diff --git a/CHANGELOG.md b/CHANGELOG.md index 9df36f5..0ed0f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.8.0 — 2026-09-29 01:56 +- MISSIONS tab — contract generation, acceptance and completion (issue #10): the first objective loop on top of issue #9's research. The board is generated deterministically from the save seed; one mission is ACTIVE at a time; completion is checked after every jump and scan and grants its reward into the cargo hold. +- New table (`lib/db/schema.ts` + DDL): `missions` (id/save_id/type/description/target_system_id/condition_kind/progress_target/progress/reward_kind/reward_qty/state). Seeded on new game AND as backfill step v6 — same pattern as tech; saves created by older code get their board on first read. `SCHEMA_VERSION` 5 → 6. +- Pure mission model (`lib/game/missions.ts`, no save.ts import — the completion hooks are called FROM save.ts, one-way): three types — DELIVER (jump to a target system), EXPLORE (scan N = 2–4 bodies), FIND (haul in an item of one kind). `missionRng(seed, turn)` anchors generation exactly like world expansion does; every action costs exactly one turn, so the stream never repeats. The board is always topped up to ≥3 AVAILABLE after every action (`topUpMissionBoard`, idempotent); DELIVER targets only discovered non-current systems (none on a fresh game → initial contracts are EXPLORE/FIND). +- Acceptance & completion: `acceptMission` — 409 while another mission is ACTIVE or the contract isn't AVAILABLE, costs one turn like every other action and writes an event. `checkMissionCompletion` runs inside jumpToSystem/explorePlanet before persist (one atomic write): DELIVER completes on arrival at its target system, EXPLORE counts scans (capped), FIND completes when a haul contains its kind; completion marks DONE, grants the reward as one neutral 'MISSION REWARD' row of the mission's kind (spenders consume by kind only) and writes an event. +- New endpoints: `GET /api/missions` → `[{id,type,description,targetSystemId,conditionKind,progressTarget,progress,rewardKind,rewardQty,state}]`; `POST /api/missions/{id}/accept` → fresh board (400 non-integer id, 404 unknown, 409 wrong state / active conflict). OpenAPI spec in code updated (12 → 14 paths) with the Mission schema. +- MISSIONS tab now renders REAL data: one card per contract — type title + state badge (AVAILABLE/ACTIVE/DONE), CONTRACT // #id line, objective, reward line and an EXPLORE-only progress line; ACCEPT is enabled only when the contract is AVAILABLE and nothing else is ACTIVE, each response applied directly (no polling). Placeholder encounter content removed. +- Tests: unit — deterministic generation per type + reward range over 200 draws, same-stream determinism, DELIVER target selection / null on a fresh game, top-up idempotency + MIN_AVAILABLE floor, v6 backfill on pre-mission saves (idempotent), accept validation (404/409) + turn + event + re-top-up, per-type completion through real jump/explore actions incl. wrong-system non-completion and reward accumulation into one row; e2e — MISSIONS tab accepts a contract via the UI (card flips ACTIVE, button disables), completes it via API (scan loop for EXPLORE/FIND, BFS jump path for DELIVER) and asserts DONE + the reward landed in the hold, plus the mission board survives the mid-run container restart. + ## v0.7.0 — 2026-09-29 00:38 - SKILL TREE tab — technology research unlocks (issue #9): the second spend-side loop on top of issue #8's ship systems. Researching a tech costs inventory items + a turn and its unlock effect then gates or raises ship actions, so the two tabs interlock: researching WEAPONS SYSTEMS is what makes building WEAPONS possible at all. - New table (`lib/db/schema.ts` + DDL): `tech` (id/save_id/slug/name/cost_kind/cost_qty/prerequisites/effect/researched, UNIQUE(save_id,slug)). Definitions are seeded from the pure module on new game AND as backfill step v5 — same pattern as ship_systems; saves created by older code get the tree on first read. `SCHEMA_VERSION` 4 → 5. diff --git a/app/api/missions/[id]/accept/route.ts b/app/api/missions/[id]/accept/route.ts new file mode 100644 index 0000000..6e9e2b0 --- /dev/null +++ b/app/api/missions/[id]/accept/route.ts @@ -0,0 +1,24 @@ +import { getGameDb } from '@/lib/db/client' +import { ensureSave } from '@/lib/game/save' +import { acceptMission, MissionError } from '@/lib/game/missions' + +// Accept an AVAILABLE mission — it becomes the single active one. Costs a turn; returns the fresh board. +export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { + const db = await getGameDb() + await ensureSave(db) // acceptMission assumes a save exists + + const { id } = await ctx.params + const missionId = Number(id) + if (!Number.isInteger(missionId)) { + return Response.json({ error: 'mission id must be an integer' }, { status: 400 }) + } + + try { + return Response.json(await acceptMission(db, missionId)) + } catch (err) { + if (err instanceof MissionError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/app/api/missions/route.ts b/app/api/missions/route.ts new file mode 100644 index 0000000..b1915b1 --- /dev/null +++ b/app/api/missions/route.ts @@ -0,0 +1,10 @@ +import { getGameDb } from '@/lib/db/client' +import { ensureSave } from '@/lib/game/save' +import { getMissions } from '@/lib/game/missions' + +// The full mission board for the single active save; auto-initializes on first hit. +export async function GET() { + const db = await getGameDb() + await ensureSave(db) // top-up inside getMissions assumes a save exists + return Response.json(await getMissions(db)) +} diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 4e72910..db87c58 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel' import { Progress } from '@/components/ui/progress' import { StatCard } from '@/components/ui/stat-card' @@ -11,6 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Terminal, type TerminalLine } from '@/components/ui/terminal' import { CrewPanel } from '@/components/crew-panel' import { LocalSystemPanel } from '@/components/local-system' +import { MissionPanel } from '@/components/missions' import { ShipSystemsPanel } from '@/components/ship-systems' import { SkillTreePanel } from '@/components/skill-tree' import { WorldMapPanel } from '@/components/world-map' @@ -21,9 +21,9 @@ import type { TechDto } from '@/lib/game/tech' // Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control // showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The -// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS and SKILL TREE tabs render live data (/api/world, -// /api/system, /api/crew, /api/ship, /api/tech); the other tabs keep placeholder content until -// their own issues land. Single player, no auth, by design. +// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE and MISSIONS tabs render live data +// (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions); the EVENT LOG tab +// keeps placeholder content until its own issue lands. Single player, no auth, by design. function useNarrow(threshold = 900): boolean { const [narrow, setNarrow] = useState( @@ -386,62 +386,9 @@ export function MissionControl() { - {/* MISSIONS TAB */} + {/* MISSIONS TAB — live data from /api/missions */} -
- - ENCOUNTER #E-102 — DERELICT FREIGHTER - - Placeholder briefing. Real encounter resolution lands with the turn engine. - - - -
- {[ - { label: 'DESTINATION', value: 'SECTOR 7G // DRIFT LANE' }, - { label: 'ETA', value: 'T-07:14 (PLACEHOLDER)' }, - { label: 'THREAT LEVEL', value: 'LOW — UNMANNED' }, - { label: 'CARGO MANIFEST', value: 'UNKNOWN' }, - ].map((item) => ( -
-
- {item.label} -
-
- {item.value} -
-
- ))} -
-
+
{/* EVENT LOG TAB */} diff --git a/components/missions.tsx b/components/missions.tsx new file mode 100644 index 0000000..9edbdf6 --- /dev/null +++ b/components/missions.tsx @@ -0,0 +1,120 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import type { MissionDto } from '@/lib/game/missions' + +// MISSIONS tab — live data. Fetches /api/missions on mount (Radix Tabs unmounts inactive tabs, so it +// refetches whenever the tab is opened); accept responses are applied directly (no polling). ACCEPT is +// enabled only when the contract is AVAILABLE and no other mission is ACTIVE — mirroring what +// POST /api/missions/{id}/accept enforces. + +export function MissionPanel() { + const [missions, setMissions] = useState(null) + const [error, setError] = useState(null) + const [busyId, setBusyId] = useState(null) // one action at a time — turn-based game + + useEffect(() => { + let cancelled = false + fetch('/api/missions') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as MissionDto[] + }) + .then((m) => { + if (!cancelled) setMissions(m) + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, []) + + const accept = useCallback(async (mission: MissionDto) => { + setBusyId(mission.id) + setError(null) + try { + const res = await fetch(`/api/missions/${mission.id}/accept`, { method: 'POST' }) + const data = (await res.json()) as MissionDto[] & { error?: string } + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`) + setMissions(data) // response is the fresh board — apply directly + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusyId(null) + } + }, []) + + if (!missions && !error) { + return ( +
+ LOADING MISSIONS… +
+ ) + } + + const active = (missions ?? []).find((m) => m.state === 'ACTIVE') + + return ( +
+ {error && ( +
+ {error.toUpperCase()} +
+ )} +
+ {(missions ?? []).map((mission) => ( + + ))} +
+
+ ) +} + +function MissionCard({ + mission, + busy, + blockedByActive, + onAccept, +}: { + mission: MissionDto + busy: boolean + blockedByActive: boolean + onAccept: (mission: MissionDto) => void +}) { + const acceptDisabled = busy || mission.state !== 'AVAILABLE' || blockedByActive + + return ( + + +
+ {mission.type} + + {mission.state} + +
+
+ +

CONTRACT // #{mission.id}

+

{mission.description.toUpperCase()}

+

+ REWARD {mission.rewardQty}× {mission.rewardKind === 'resource' ? 'RESOURCES' : 'PARTS'} +

+ {mission.type === 'EXPLORE' && ( +

+ PROGRESS // {mission.progress}/{mission.progressTarget} +

+ )} +
+ + + +
+ ) +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 5ab2610..7e0c260 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -12,9 +12,40 @@ interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: u interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number } +interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string } const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0); +// BFS over the undirected jump routes — [next, ..., target], empty when already there. +const shortestJumpPath = (fromId: number, toId: number, edges: [number, number][]): number[] => { + const adj = new Map(); + for (const [a, b] of edges) { + if (!adj.has(a)) adj.set(a, []); + if (!adj.has(b)) adj.set(b, []); + adj.get(a)!.push(b); + adj.get(b)!.push(a); + } + const prev = new Map(); + const seen = new Set([fromId]); + const queue = [fromId]; + while (queue.length > 0 && !seen.has(toId)) { + const cur = queue.shift()!; + for (const next of adj.get(cur) ?? []) { + if (!seen.has(next)) { + seen.add(next); + prev.set(next, cur); + queue.push(next); + } + } + } + expect(seen.has(toId)).toBe(true); // the world graph is connected by construction + const path: number[] = []; + for (let cur = toId; cur !== fromId; cur = prev.get(cur)!) { + path.unshift(cur); + } + return path; +}; + test('landing page loads with the SpaceWanderer title', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); @@ -255,6 +286,82 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu await expect(weaponsCard.getByText('LV 1')).toBeVisible(); }); +test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => { + const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; + expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up + + await page.goto('/'); + await page.getByRole('tab', { name: 'MISSIONS' }).click(); + const acceptButtons = page.getByRole('button', { name: 'ACCEPT' }); + const panel = page.getByRole('tabpanel'); + + // Idempotent across retries sharing a DB: if a previous attempt already accepted a mission, it's still + // ACTIVE and every ACCEPT button is disabled — complete that one instead of accepting again. + const leftover = board.find((m) => m.state === 'ACTIVE'); + let picked: MissionDto; + + if (leftover) { + picked = leftover; + // No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE". + await expect(panel.getByText('ACTIVE')).toBeVisible(); + for (const button of await acceptButtons.all()) { + await expect(button).toBeDisabled(); + } + } else { + // Prefer a scan-completable contract — fresh boards are all EXPLORE/FIND (DELIVER only appears once systems are discovered). + picked = board.find((m) => m.state === 'AVAILABLE' && m.type !== 'DELIVER') ?? board.find((m) => m.state === 'AVAILABLE')!; + const idx = board.indexOf(picked); + + // One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active). + await expect(acceptButtons.nth(idx)).toBeEnabled(); + await acceptButtons.nth(idx).click(); + + // UI flipped: exactly one ACTIVE badge on the board and the accepted card's button is now disabled. + // No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE". + await expect(panel.getByText('ACTIVE')).toBeVisible(); + await expect(acceptButtons.nth(idx)).toBeDisabled(); + + // API confirms it: exactly one ACTIVE mission, the picked one (accepting cost a turn). + const boardAfterAccept = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; + expect(boardAfterAccept.filter((m) => m.state === 'ACTIVE')).toEqual([boardAfterAccept.find((m) => m.id === picked.id)!]); + } + + // Baseline of the reward kind in the hold — scans during completion may add more, so assert >=. + const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState; + const baseline = totalQty(shipBefore.inventory, picked.rewardKind); + + // Complete it. EXPLORE/FIND: scan until done, jumping when a system exhausts (parts drop on ~30% of + // scans, so P(a FIND-part misses in 80 scans) ≈ 0.7^80 — negligible). DELIVER: follow the shortest + // jump path to its target (edges only ever get added, so the precomputed path stays valid). + let deliverPath: number[] = []; + if (picked.type === 'DELIVER') { + const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState; + deliverPath = shortestJumpPath(worldNow.systems.find((s) => s.current)!.id, picked.targetSystemId!, worldNow.edges); + } + + let state = 'ACTIVE'; + let iterations = 0; + while (state !== 'DONE' && iterations < 80) { + iterations++; + if (deliverPath.length > 0) { + expect((await page.request.post('/api/jump', { data: { systemId: deliverPath.shift()! } })).status()).toBe(200); + } else { + const sys = (await (await page.request.get('/api/system')).json()) as SystemState; + const unexplored = sys.planets.filter((p) => !p.explored); + if (unexplored.length > 0) { + expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200); + } else { + await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems + } + } + state = ((await (await page.request.get('/api/missions')).json()) as MissionDto[]).find((m) => m.id === picked.id)?.state ?? 'ACTIVE'; + } + expect(state).toBe('DONE'); // the bounded loop always completes: scans are plentiful, jump paths finite + + const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState; + expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty); +}); + test('GET /api/version reports the app version as JSON', async ({ page }) => { const res = await page.request.get('/api/version'); expect(res.status()).toBe(200); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index af817cf..6ba6d17 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -16,6 +16,7 @@ interface CrewDto { id: number; name: string; role: string; traitGood: string; t interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] } +interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string } test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); @@ -53,6 +54,11 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[]; expect(techBefore.length).toBeGreaterThan(0); // the tech tree is always seeded + // Mission board baseline: contracts, states and progress must survive too. The GET tops the board + // up first — a no-op here because every action leaves at least MIN_AVAILABLE AVAILABLE contracts. + const missionsBefore = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; + expect(missionsBefore.length).toBeGreaterThan(0); // the board is always seeded on new game + // Restart the app container mid-run — the named DB volume must survive it. execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); @@ -95,6 +101,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[]; expect(techAfter).toEqual(techBefore); + // Mission board survived too: same contracts, states and progress (no re-seed or re-top-up on restart). + const missionsAfter = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; + expect(missionsAfter).toEqual(missionsBefore); + // The page itself serves again with the landing title. await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); diff --git a/lib/db/client.ts b/lib/db/client.ts index 1b4149a..5158809 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -94,6 +94,20 @@ CREATE TABLE IF NOT EXISTS tech ( UNIQUE (save_id, slug) ); +CREATE TABLE IF NOT EXISTS missions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + save_id INTEGER NOT NULL REFERENCES saves(id), + type TEXT NOT NULL, -- 'DELIVER' | 'EXPLORE' | 'FIND' + description TEXT NOT NULL, -- human-readable objective, e.g. "Jump to Kepler Drift" + target_system_id INTEGER, -- DELIVER only — destination system (NULL otherwise) + condition_kind TEXT, -- FIND only — 'resource' | 'part' (NULL otherwise) + progress_target INTEGER NOT NULL DEFAULT 1, -- EXPLORE: scans required; 1 for the others + progress INTEGER NOT NULL DEFAULT 0, -- scans counted since acceptance; 0/1 for DELIVER/FIND + reward_kind TEXT NOT NULL, -- 'resource' | 'part' + reward_qty INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL DEFAULT 'AVAILABLE' -- 'AVAILABLE' | 'ACTIVE' | 'DONE' +); + CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), diff --git a/lib/db/schema.ts b/lib/db/schema.ts index b657a79..fbc8018 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -108,6 +108,24 @@ export const tech = sqliteTable( (t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)], ) +// The mission board (#10): contracts generated deterministically from the save seed, one ACTIVE +// at a time. `type` drives completion semantics (see advanceMission in lib/game/missions.ts): +// DELIVER jumps to target_system_id, EXPLORE counts scans up to progress_target, FIND completes +// when a scan's haul contains condition_kind. The reward lands in inventory_items on completion. +export const missions = sqliteTable('missions', { + id: integer('id').primaryKey({ autoIncrement: true }), + saveId: integer('save_id').notNull().references(() => saves.id), + type: text('type').notNull(), // 'DELIVER' | 'EXPLORE' | 'FIND' + description: text('description').notNull(), // human-readable objective, e.g. "Jump to Kepler Drift" + targetSystemId: integer('target_system_id'), // DELIVER only — destination system (NULL otherwise) + conditionKind: text('condition_kind'), // FIND only — 'resource' | 'part' (NULL otherwise) + progressTarget: integer('progress_target').notNull().default(1), // EXPLORE: scans required; 1 for the others + progress: integer('progress').notNull().default(0), // scans counted since acceptance; 0/1 for DELIVER/FIND + rewardKind: text('reward_kind').notNull(), // 'resource' | 'part' + rewardQty: integer('reward_qty').notNull().default(1), + state: text('state').notNull().default('AVAILABLE'), // 'AVAILABLE' | 'ACTIVE' | 'DONE' +}) + // Append-only game log. Writers so far: new-game + jump events; later issues add more // writers and a read endpoint. export const events = sqliteTable('events', { diff --git a/lib/game/missions.test.ts b/lib/game/missions.test.ts new file mode 100644 index 0000000..4ac7d6b --- /dev/null +++ b/lib/game/missions.test.ts @@ -0,0 +1,294 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { openGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { explorationRng, mulberry32, rollExploration } from './world' +import { SCHEMA_VERSION, explorePlanet, getWorldState, jumpToSystem } from './save' +import { acceptMission, MIN_AVAILABLE, REWARD_ITEM_NAME, generateMission, missionRng, pickMissionType, topUpMissionBoard, type MissionDef, type MissionSystem } from './missions' + +const dirs: string[] = [] + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-missions-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + return { db: await openGameDb(file), file } +} + +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +// A snapshot with one discovered non-current system to deliver to. +const SYSTEMS: MissionSystem[] = [ + { id: 1, name: 'Start', discovered: true, isCurrent: true }, + { id: 2, name: 'Alpha', discovered: true, isCurrent: false }, + { id: 3, name: 'Beta', discovered: false, isCurrent: false }, +] + +describe('generateMission (pure)', () => { + it('DELIVER targets a discovered non-current system and returns null when none exists', () => { + const def = generateMission('DELIVER', mulberry32(7), SYSTEMS)! + expect(def.type).toBe('DELIVER') + expect(def.targetSystemId).toBe(2) // the only candidate + expect(def.description).toContain('Alpha') + + expect(generateMission('DELIVER', mulberry32(7), [SYSTEMS[0]])).toBeNull() // only the current system is discovered + }) + + it('EXPLORE targets 2–4 scans, FIND one item kind; rewards are always 1–3', () => { + for (let i = 0; i < 200; i++) { + const e = generateMission('EXPLORE', mulberry32(i), SYSTEMS)! + expect(e.progressTarget).toBeGreaterThanOrEqual(2) + expect(e.progressTarget).toBeLessThanOrEqual(4) + expect(e.description).toMatch(/^Scan [2-4] bodies$/) + + const f = generateMission('FIND', mulberry32(i), SYSTEMS)! + expect(['resource', 'part']).toContain(f.conditionKind) + expect(f.description).toContain(f.conditionKind!) + + for (const d of [e, f]) { + expect(d.rewardQty).toBeGreaterThanOrEqual(1) + expect(d.rewardQty).toBeLessThanOrEqual(3) + } + } + }) + + it('is deterministic — the same (seed, turn) stream generates identical missions', () => { + const a = generateMission('DELIVER', missionRng('42', 5), SYSTEMS) + const b = generateMission('DELIVER', missionRng('42', 5), SYSTEMS) + expect(a).toEqual(b) + }) +}) + +describe('pickMissionType (pure)', () => { + it('never picks DELIVER without a target, and does pick it when one exists', () => { + for (let i = 0; i < 200; i++) expect(pickMissionType(mulberry32(i), false)).not.toBe('DELIVER') + + const types = new Set(Array.from({ length: 200 }, (_, i) => pickMissionType(mulberry32(i), true))) + expect(types.has('DELIVER')).toBe(true) + for (const t of types) expect(['DELIVER', 'EXPLORE', 'FIND']).toContain(t) + }) +}) + +describe('topUpMissionBoard', () => { + it('seeds a fresh game with MIN_AVAILABLE AVAILABLE non-DELIVER missions and is idempotent', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game — createNewGame already topped the board up + + const rows = await db.db.select().from(schema.missions) + expect(rows).toHaveLength(MIN_AVAILABLE) + for (const r of rows) { + expect(r.state).toBe('AVAILABLE') + expect(r.type).not.toBe('DELIVER') // only the start system is discovered yet — no targets exist + } + + await topUpMissionBoard(db) // a second pass inserts nothing + expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE) + }) + + it('generates deterministically from (seed, turn) on a hand-crafted save', async () => { + const { db } = await freshFile() + // A v6 save with start (current), one discovered neighbour and one undiscovered. + await db.db.insert(schema.saves).values({ seed: '42', turn: 5, createdAt: new Date().toISOString(), schemaVersion: SCHEMA_VERSION }) + const [start] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Start', x: 50, y: 50, discovered: true, isCurrent: true }).returning() + const [alpha] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Alpha', x: 60, y: 50, discovered: true, isCurrent: false }).returning() + const [beta] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Beta', x: 40, y: 50 }).returning() + + await topUpMissionBoard(db) + + // Replay the same pure pipeline with the same (seed, turn) stream — must match row for row. + const snapshot: MissionSystem[] = [ + { id: start.id, name: 'Start', discovered: true, isCurrent: true }, + { id: alpha.id, name: 'Alpha', discovered: true, isCurrent: false }, + { id: beta.id, name: 'Beta', discovered: false, isCurrent: false }, + ] + const rng = missionRng('42', 5) + const expected: MissionDef[] = [] + while (expected.length < MIN_AVAILABLE) { + const type = pickMissionType(rng, snapshot.some((s) => s.discovered && !s.isCurrent)) + expected.push(generateMission(type, rng, snapshot)!) + } + + const rows = await db.db.select().from(schema.missions) + expect(rows.map((r) => ({ type: r.type, description: r.description, targetSystemId: r.targetSystemId, conditionKind: r.conditionKind, progressTarget: r.progressTarget, rewardKind: r.rewardKind, rewardQty: r.rewardQty }))).toEqual(expected) + }) +}) + +// Inserts a mission row directly so tests can control exactly what the board holds. +async function insertMission(db: GameDb, fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number }): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db.insert(schema.missions).values({ saveId: save.id, type: fields.type, description: fields.description, targetSystemId: fields.targetSystemId ?? null, conditionKind: fields.conditionKind ?? null, progressTarget: fields.progressTarget ?? 1, rewardKind: fields.rewardKind ?? 'resource', rewardQty: fields.rewardQty ?? 1 }).returning() + return row.id +} + +describe('acceptMission', () => { + it('rejects unknown ids with 404 and a second accept while one is active with 409 naming the active mission', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game — board seeded + + await expect(acceptMission(db, 999)).rejects.toMatchObject({ status: 404 }) + + const [a, b] = (await db.db.select().from(schema.missions)) + await acceptMission(db, a.id) + await expect(acceptMission(db, a.id)).rejects.toMatchObject({ status: 409, message: /already active/i }) // re-accepting the active one + await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ }) + }) + + it('activates an AVAILABLE contract, costs a turn, writes an event and keeps the board topped up', async () => { + const { db } = await freshFile() + await getWorldState(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const [a] = await db.db.select().from(schema.missions) + + const board = await acceptMission(db, a.id) + + expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE') + expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up + const [after] = await db.db.select().from(schema.saves).limit(1) + expect(after.turn).toBe(save.turn + 1) + + const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')! + expect(event.message).toContain('Accepted mission') + expect(event.turn).toBe(save.turn + 1) + }) +}) + +describe('completion', () => { + it('EXPLORE counts scans, completes at the target and grants the reward into the hold', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game — empty inventory, all bodies unexplored + const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 3 bodies', progressTarget: 3, rewardKind: 'part', rewardQty: 2 }) + + await acceptMission(db, id) + const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null) + await explorePlanet(db, planets[0].id) + let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('ACTIVE') + expect(mission.progress).toBe(1) + + await explorePlanet(db, planets[1].id) + mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('ACTIVE') + expect(mission.progress).toBe(2) + + await explorePlanet(db, planets[2].id) // third scan — completes + mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('DONE') + expect(mission.progress).toBe(3) + + const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! + expect(reward.kind).toBe('part') + expect(reward.qty).toBe(2) + + const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))! + expect(event.message).toContain('Scan 3 bodies') + }) + + it('FIND completes when a haul contains its kind and ignores scans that do not', async () => { + const { db } = await freshFile() + await getWorldState(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const id = await insertMission(db, { type: 'FIND', description: 'Find at least one part while scanning', conditionKind: 'part', rewardKind: 'resource', rewardQty: 3 }) + + // Pick bodies by their deterministic per-planet rolls — one without parts first (no progress), + // then one with (completes). + const planets = await db.db.select().from(schema.planets) + const haulOf = (p: { id: number }) => rollExploration(explorationRng(save.seed, p.id)) + const noPart = planets.find((p) => !haulOf(p).some((i) => i.kind === 'part'))! + expect(noPart).toBeDefined() + const withPart = planets.find((p) => haulOf(p).some((i) => i.kind === 'part'))! + expect(withPart).toBeDefined() + + await acceptMission(db, id) + await explorePlanet(db, noPart.id) // haul has no parts — the mission does not advance + let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('ACTIVE') + expect(mission.progress).toBe(0) + + await explorePlanet(db, withPart.id) // haul has parts — completes + mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('DONE') + + const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! + expect(reward.kind).toBe('resource') + expect(reward.qty).toBe(3) + }) + + it('DELIVER completes on arrival at its target system', async () => { + const { db } = await freshFile() + const world = await getWorldState(db) // new game — start is current, six undiscovered neighbours + const [start] = await db.db.select().from(schema.solarSystems).where(eq(schema.solarSystems.isCurrent, true)) + const neighbor = world.systems.find((s) => !s.discovered)! + + await jumpToSystem(db, neighbor.id) // start is now discovered + non-current — a valid target + const id = await insertMission(db, { type: 'DELIVER', description: `Jump to ${start.name}`, targetSystemId: start.id, rewardKind: 'resource', rewardQty: 1 }) + + await acceptMission(db, id) + + // Wrong destination: one of the ring neighbours generated around `neighbor` on the first jump. + const world2 = await getWorldState(db) + const wrongEdge = world2.edges.find(([a, b]) => (a === neighbor.id || b === neighbor.id) && a !== start.id && b !== start.id)! + const wrongId = wrongEdge[0] === neighbor.id ? wrongEdge[1] : wrongEdge[0] + await jumpToSystem(db, wrongId) // not the target — no completion + expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE') + + await jumpToSystem(db, neighbor.id) // back through `neighbor`… + const result = await jumpToSystem(db, start.id) // …to the target — completes + const mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('DONE') + expect(result.systems.find((s) => s.current)?.id).toBe(start.id) + + const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! + expect(reward.kind).toBe('resource') + expect(reward.qty).toBe(1) + }) + + it('rewards of the same kind accumulate into one hold row across missions', async () => { + const { db } = await freshFile() + await getWorldState(db) + const a = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 2 }) + const b = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 more bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 3 }) + const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null) + + await acceptMission(db, a) + await explorePlanet(db, planets[0].id) + await explorePlanet(db, planets[1].id) // completes A — 2× resource reward + expect((await db.db.select().from(schema.missions)).find((m) => m.id === a)!.state).toBe('DONE') + + await acceptMission(db, b) + await explorePlanet(db, planets[2].id) + await explorePlanet(db, planets[3].id) // completes B — 3× more of the same kind+name + expect((await db.db.select().from(schema.missions)).find((m) => m.id === b)!.state).toBe('DONE') + + const rewards = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.name === REWARD_ITEM_NAME) + expect(rewards).toHaveLength(1) // accumulated, not a second row + expect(rewards[0].qty).toBe(5) + }) +}) + +describe('backfill v6', () => { + it('seeds the board on a pre-#10 save when it opens — deterministic and idempotent', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game at the current version + + // Simulate a save created by pre-v6 code: no mission rows, stamped v5. + await db.db.delete(schema.missions) + await db.db.update(schema.saves).set({ schemaVersion: 5 }) + + await getWorldState(db) // ensureSave → backfill runs the v6 step + + const rows = await db.db.select().from(schema.missions) + expect(rows).toHaveLength(MIN_AVAILABLE) + for (const r of rows) expect(r.state).toBe('AVAILABLE') + const [save] = await db.db.select().from(schema.saves) + expect(save.schemaVersion).toBe(SCHEMA_VERSION) + + // Idempotent — a second pass changes nothing. + await getWorldState(db) + expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE) + }) +}) diff --git a/lib/game/missions.ts b/lib/game/missions.ts new file mode 100644 index 0000000..5fbdca0 --- /dev/null +++ b/lib/game/missions.ts @@ -0,0 +1,201 @@ +import { eq } from 'drizzle-orm' +import * as schema from '@/lib/db/schema' +import { persist, type GameDb } from '@/lib/db/client' +import { mulberry32, type FoundItem } from './world' + +// Mission board (#10): contracts generated deterministically from the save seed. Three types — +// DELIVER (jump to a target system), EXPLORE (scan N bodies), FIND (haul in an item of one kind). +// One mission is ACTIVE at a time; the board always holds MIN_AVAILABLE AVAILABLE contracts. +// The pure helpers below are DB-free so generation/completion rules stay unit-testable alone. + +export type MissionType = 'DELIVER' | 'EXPLORE' | 'FIND' +export type ItemKind = 'resource' | 'part' +export type MissionStateName = 'AVAILABLE' | 'ACTIVE' | 'DONE' + +// The board never drops below this many AVAILABLE contracts (topped up after every action). +export const MIN_AVAILABLE = 3 + +// What generation produces and the DB stores — one row per mission. +export interface MissionDef { + type: MissionType + description: string + targetSystemId: number | null // DELIVER only + conditionKind: ItemKind | null // FIND only + progressTarget: number // EXPLORE: scans required; 1 for the others + rewardKind: ItemKind + rewardQty: number // 1–3, consistent with exploration hauls +} + +// A system as far as generation cares about. +export interface MissionSystem { + id: number + name: string + discovered: boolean + isCurrent: boolean +} + +// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts. Every +// action costs exactly one turn, so the stream never repeats for two different top-ups. +export function missionRng(seed: string, turn: number): () => number { + return mulberry32((Number(seed) + Math.imul(turn, 0x9e3779b1)) >>> 0) +} + +// Which type to generate next: DELIVER when a target exists and the coin says so, else EXPLORE/FIND. +export function pickMissionType(rng: () => number, canDeliver: boolean): MissionType { + if (canDeliver && rng() < 0.5) return 'DELIVER' + return rng() < 0.5 ? 'EXPLORE' : 'FIND' +} + +// One mission of the given type from the current world snapshot. DELIVER returns null when no +// discovered non-current system exists to target (e.g. a brand-new game). +export function generateMission(type: MissionType, rng: () => number, systems: MissionSystem[]): MissionDef | null { + const rewardKind: ItemKind = rng() < 0.5 ? 'resource' : 'part' + const rewardQty = 1 + Math.floor(rng() * 3) // 1–3 + + if (type === 'DELIVER') { + const candidates = systems.filter((s) => s.discovered && !s.isCurrent) + if (candidates.length === 0) return null + const target = candidates[Math.floor(rng() * candidates.length)] + return { type, description: `Jump to ${target.name}`, targetSystemId: target.id, conditionKind: null, progressTarget: 1, rewardKind, rewardQty } + } + if (type === 'EXPLORE') { + const scans = 2 + Math.floor(rng() * 3) // 2–4 + return { type, description: `Scan ${scans} bodies`, targetSystemId: null, conditionKind: null, progressTarget: scans, rewardKind, rewardQty } + } + const kind: ItemKind = rng() < 0.5 ? 'resource' : 'part' + return { type, description: `Find at least one ${kind} while scanning`, targetSystemId: null, conditionKind: kind, progressTarget: 1, rewardKind, rewardQty } +} + +// What an action did to the world — what completion checks run against. +export interface MissionAction { + jumpedTo?: number // system id reached by a jump + outcome?: FoundItem[] // haul from a scan +} + +// How far an action advances a mission: new progress, or null when the action doesn't touch it. +// DELIVER completes on arrival at its target; EXPLORE counts every scan (capped); FIND completes +// when a haul contains its kind. +export function advanceMission( + mission: { type: MissionType; targetSystemId: number | null; conditionKind: ItemKind | null; progressTarget: number; progress: number }, + action: MissionAction, +): number | null { + if (action.jumpedTo !== undefined) { + return mission.type === 'DELIVER' && mission.targetSystemId === action.jumpedTo ? 1 : null + } + if (mission.type === 'EXPLORE') return Math.min(mission.progressTarget, mission.progress + 1) + if (mission.type === 'FIND' && mission.conditionKind !== null && (action.outcome ?? []).some((i) => i.kind === mission.conditionKind)) return 1 + return null +} + +// --- DB layer ----------------------------------------------------------------- + +export interface MissionDto extends MissionDef { + id: number + progress: number + state: MissionStateName +} + +// Carries an HTTP status so route handlers can map failures to responses without re-parsing. +export class MissionError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + +async function readMissions(db: GameDb): Promise { + return (await db.db.select().from(schema.missions)).map((m) => ({ + id: m.id, + type: m.type as MissionType, + description: m.description, + targetSystemId: m.targetSystemId, + conditionKind: m.conditionKind as ItemKind | null, + progressTarget: m.progressTarget, + progress: m.progress, + rewardKind: m.rewardKind as ItemKind, + rewardQty: m.rewardQty, + state: m.state as MissionStateName, + })) +} + +// The board for the MISSIONS tab. Assumes a save exists (routes call ensureSave first) and tops +// the AVAILABLE list up before reading so the tab always has contracts to offer. +export async function getMissions(db: GameDb): Promise { + await topUpMissionBoard(db) + return readMissions(db) +} + +// Keeps at least MIN_AVAILABLE AVAILABLE missions on the board, generating deterministically from +// (seed, turn). Idempotent — a second call with a full list inserts nothing. +export async function topUpMissionBoard(db: GameDb): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + if (!save) return + const systems = (await db.db.select().from(schema.solarSystems)).map((s) => ({ id: s.id, name: s.name, discovered: s.discovered, isCurrent: s.isCurrent })) + + let available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length + if (available >= MIN_AVAILABLE) return + + const rng = missionRng(save.seed, save.turn) + while (available < MIN_AVAILABLE) { + const type = pickMissionType(rng, systems.some((s) => s.discovered && !s.isCurrent)) + const def = generateMission(type, rng, systems) + if (!def) break // no DELIVER target — the other types always generate + await db.db.insert(schema.missions).values({ saveId: save.id, type: def.type, description: def.description, targetSystemId: def.targetSystemId, conditionKind: def.conditionKind, progressTarget: def.progressTarget, rewardKind: def.rewardKind, rewardQty: def.rewardQty }) + available += 1 + } +} + +// Mission rewards are granted under one neutral name — spenders (ship actions, research) consume by kind only. +export const REWARD_ITEM_NAME = 'MISSION REWARD' + +const kindLabel = (kind: ItemKind): string => (kind === 'resource' ? 'RESOURCES' : 'PARTS') + +// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time), costs a turn like +// every other action and writes an event. Returns the fresh board. +export async function acceptMission(db: GameDb, missionId: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + if (!save) throw new MissionError(404, 'No save yet') + const rows = await readMissions(db) + const target = rows.find((m) => m.id === missionId) + if (!target) throw new MissionError(404, `Unknown mission ${missionId}`) + if (target.state !== 'AVAILABLE') throw new MissionError(409, `${target.description} is already ${target.state.toLowerCase()}`) + const active = rows.find((m) => m.state === 'ACTIVE') + if (active) throw new MissionError(409, `One mission at a time — finish "${active.description}" first`) + + const turn = save.turn + 1 + await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id)) + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() }) + await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up + await persist(db) + + return readMissions(db) +} + +// Runs after a jump or scan: advances the ACTIVE mission (if any) and, on completion, marks it +// DONE, grants its reward into the cargo hold and writes an event. Called from save.ts inside the +// action that triggered it, before persist — so everything lands in one atomic write. +export async function checkMissionCompletion(db: GameDb, saveId: number, turn: number, action: MissionAction): Promise { + const row = (await db.db.select().from(schema.missions)).find((m) => m.state === 'ACTIVE') + if (!row) return + + const active = { type: row.type as MissionType, targetSystemId: row.targetSystemId, conditionKind: row.conditionKind as ItemKind | null, progressTarget: row.progressTarget, progress: row.progress } + const newProgress = advanceMission(active, action) + if (newProgress === null) return + + if (newProgress >= active.progressTarget) { + await db.db.update(schema.missions).set({ state: 'DONE', progress: newProgress }).where(eq(schema.missions.id, row.id)) + // The reward lands in the hold like any haul — same kind+name accumulates into one row. + const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === row.rewardKind && i.name === REWARD_ITEM_NAME) + if (existing) { + await db.db.update(schema.inventoryItems).set({ qty: existing.qty + row.rewardQty }).where(eq(schema.inventoryItems.id, existing.id)) + } else { + await db.db.insert(schema.inventoryItems).values({ saveId, kind: row.rewardKind, name: REWARD_ITEM_NAME, qty: row.rewardQty }) + } + await db.db.insert(schema.events).values({ saveId, turn, type: 'mission', message: `Mission complete — ${row.description} (+${row.rewardQty}× ${kindLabel(row.rewardKind as ItemKind)})`, createdAt: new Date().toISOString() }) + } else { + await db.db.update(schema.missions).set({ progress: newProgress }).where(eq(schema.missions.id, row.id)) + } +} diff --git a/lib/game/save.ts b/lib/game/save.ts index c3f4ac0..f901601 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -4,6 +4,7 @@ import { persist } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' +import { checkMissionCompletion, topUpMissionBoard } from './missions' import { TECHS } from './tech' import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world' @@ -95,9 +96,8 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi // What each schema version added to a save. Saves created by older code lack the data newer // versions seed at new-game time, so ensureSave runs these steps in order for anything below -// current — each step is idempotent (a no-op when its data already exists). When #10 adds -// seeding, bump SCHEMA_VERSION and append a step here. -export const SCHEMA_VERSION = 5 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree +// current — each step is idempotent (a no-op when its data already exists). +export const SCHEMA_VERSION = 6 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board // Seeds the starting ship: one row per known type — core types built at base values, the rest // unbuilt (level/capacity 0) until a BUILD action. @@ -142,6 +142,7 @@ async function createNewGame(db: GameDb): Promise { await seedStartingCrew(db, save.id, seed) await seedStartingShip(db, save.id) await seedStartingTech(db, save.id) + await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso }) await persist(db) @@ -168,6 +169,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id) version = 5 } + if (version < 6) { + await topUpMissionBoard(db) // idempotent — only inserts when fewer than MIN_AVAILABLE AVAILABLE contracts exist + version = 6 + } if (from !== SCHEMA_VERSION) { await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id)) @@ -233,6 +238,8 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise { '/api/crew', '/api/explore', '/api/jump', + '/api/missions', + '/api/missions/{id}/accept', '/api/research', '/api/ship', '/api/ship/build', @@ -58,6 +60,23 @@ describe('OpenAPI spec', () => { } }); + it('describes the mission endpoints: GET /api/missions → Mission[], POST accept with error responses', () => { + const missions = openApiSpec.paths['/api/missions'].get.responses[200].content['application/json'].schema; + expect(missions.type).toBe('array'); + expect((missions.items as { $ref: string }).$ref).toBe('#/components/schemas/Mission'); + + const props = openApiSpec.components.schemas.Mission.properties; + expect(Object.keys(props)).toEqual(['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state']); + expect((props.type as { enum: string[] }).enum).toEqual(['DELIVER', 'EXPLORE', 'FIND']); + expect((props.state as { enum: string[] }).enum).toEqual(['AVAILABLE', 'ACTIVE', 'DONE']); + + const post = openApiSpec.paths['/api/missions/{id}/accept'].post; + expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Mission'); + for (const status of ['400', '404', '409'] as const) { + expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); + } + }); + it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => { const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema; expect(crew.type).toBe('array'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 42c54fc..6e01b2e 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -454,6 +454,75 @@ export const openApiSpec = { }, }, }, + '/api/missions': { + get: { + summary: 'Mission board', + description: + "The save's mission contracts — generated deterministically from the seed, always topped up to at least three AVAILABLE. One mission is ACTIVE at a time; completion is checked after jumps and scans (DELIVER on arrival at its target system, EXPLORE after N scans, FIND when a haul contains its kind) and grants the reward into the cargo hold. Auto-initializes a new game when no save exists yet.", + operationId: 'getMissions', + responses: { + 200: { + description: 'The mission board', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Mission' } }, + }, + }, + }, + }, + }, + }, + '/api/missions/{id}/accept': { + post: { + summary: 'Accept a mission', + description: + "Makes an AVAILABLE contract the single ACTIVE one (409 while another is active, or when it isn't AVAILABLE). Costs a turn and writes a mission event. Returns the fresh board.", + operationId: 'acceptMission', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'integer' }, + description: 'missions.id of the contract to accept', + }, + ], + responses: { + 200: { + description: 'Fresh mission board after the acceptance', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Mission' } }, + }, + }, + }, + 400: { + description: 'Non-integer id in the path', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown mission id', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: "Mission isn't AVAILABLE, or another mission is already ACTIVE (one at a time)", + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -597,6 +666,22 @@ export const openApiSpec = { }, required: ['techId'], }, + Mission: { + type: 'object', + properties: { + id: { type: 'integer' }, + type: { type: 'string', enum: ['DELIVER', 'EXPLORE', 'FIND'] }, + description: { type: 'string', description: "Human-readable objective, e.g. 'Jump to Kepler Drift'" }, + targetSystemId: { type: ['integer', 'null'], nullable: true, description: 'DELIVER only — destination system (NULL otherwise)' }, + conditionKind: { type: ['string', 'null'], enum: ['resource', 'part', null], description: 'FIND only — item kind to haul in (NULL otherwise)' }, + progressTarget: { type: 'integer', description: 'EXPLORE: scans required; 1 for the others' }, + progress: { type: 'integer', description: 'Scans counted since acceptance; 0/1 for DELIVER/FIND' }, + rewardKind: { type: 'string', enum: ['resource', 'part'] }, + rewardQty: { type: 'integer', description: 'Reward quantity (1–3), granted into the cargo hold on completion' }, + state: { type: 'string', enum: ['AVAILABLE', 'ACTIVE', 'DONE'] }, + }, + required: ['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state'], + }, UpgradeRequest: { type: 'object', properties: { diff --git a/package.json b/package.json index 5445a2e..3080782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.7.0", + "version": "0.8.0", "private": true, "scripts": { "dev": "next dev -p 3000",