diff --git a/app/api/explore/route.test.ts b/app/api/explore/route.test.ts new file mode 100644 index 0000000..881bc0b --- /dev/null +++ b/app/api/explore/route.test.ts @@ -0,0 +1,84 @@ +// Handler-level tests for the queueing explore route (Task 5a): POST /api/explore with a crew +// team queues an EXPLORE row instead of resolving instantly — same system snapshot (findings key +// kept, empty), same pre-check codes, no turn spent at queue time. +import { eq } from 'drizzle-orm' +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 { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getSystemState, type SystemState } from '@/lib/game/save' +import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-explore-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +const post = (body: unknown) => POST(new Request('http://localhost/api/explore', { method: 'POST', body: JSON.stringify(body) })) + +describe('POST /api/explore (5a)', () => { + it('queues an EXPLORE: row with type/payload, turn unchanged, planet not yet scanned, no haul', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + const [before] = await db.db.select().from(schema.saves) + const invBefore = (await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const) + + const res = await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] }) + expect(res.status).toBe(200) + const body = await res.json() + const sys = await getSystemState(db) + for (const k of Object.keys(sys) as (keyof SystemState)[]) expect(body[k]).toEqual(sys[k]) // today's system snapshot part unchanged; the findings key stays, empty (JC-2) + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'EXPLORE', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column + expect(JSON.parse(row.payload)).toEqual({ planetId: f.p0 }) + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) + expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(false) // the effect waits for the tick + expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)).toEqual(invBefore) // no haul yet + }) + + it('unknown crew → 404 (queueTask)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + expect((await post({ planetId: f.p0, leadCrewId: 999 })).status).toBe(404) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) + + it('busy helper → 409, nothing queued', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 }) + expect((await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(1) + }) + + it('already scanned → 409 (existing pre-check, existing code)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, f.p0)) + expect((await post({ planetId: f.p0, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts index a8e644c..0fe51df 100644 --- a/app/api/explore/route.ts +++ b/app/api/explore/route.ts @@ -1,8 +1,13 @@ import { getGameDb } from '@/lib/db/client' -import { ExploreError, explorePlanet } from '@/lib/game/save' +import { MAX_TEAM_SIZE } from '@/lib/game/crew' +import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save' +import { queueTask, TaskError } from '@/lib/game/tasks' -// Scan one body of the current system. Body: { "planetId": number }. Returns the fresh -// system state plus what this scan found. Costs a turn; each body can only be scanned once. +// Scan one body of the current system: assign the crew team (lead + helpers) and queue the +// EXPLORE task — the haul, the scanned flag, the crew roll and the event land when the task +// completes on the turn tick (runTurn). Body: { planetId, leadCrewId, helperCrewIds? }. +// Queueing spends no turn; returns the same system snapshot as the instant scan, with the +// findings key empty until the scan resolves (JC-2). export async function POST(req: Request) { let body: unknown try { @@ -15,12 +20,36 @@ export async function POST(req: Request) { if (typeof planetId !== 'number' || !Number.isInteger(planetId)) { return Response.json({ error: 'planetId must be an integer' }, { status: 400 }) } + const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId + if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) { + return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 }) + } + let helperCrewIds: number[] = [] + const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds + if (rawHelpers !== undefined) { + if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) { + return Response.json({ error: 'helperCrewIds must be an array of integers' }, { status: 400 }) + } + helperCrewIds = rawHelpers + } + // Team rules (route layer, not queueTask's): one task team, distinct members, size cap. + const team = [leadCrewId, ...helperCrewIds] + if (new Set(team).size !== team.length) { + return Response.json({ error: 'crew members must be unique' }, { status: 400 }) + } + if (team.length > MAX_TEAM_SIZE) { + return Response.json({ error: `a task team holds at most ${MAX_TEAM_SIZE} crew members` }, { status: 400 }) + } try { const db = await getGameDb() - return Response.json(await explorePlanet(db, planetId)) + await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned + const state = await queueStateFromSave(db) + const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId, helperCrewIds }) + await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write + return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null }) } catch (err) { - if (err instanceof ExploreError) { + if (err instanceof ExploreError || err instanceof TaskError) { return Response.json({ error: err.message }, { status: err.status }) } throw err diff --git a/app/api/jump/route.test.ts b/app/api/jump/route.test.ts new file mode 100644 index 0000000..f0129dd --- /dev/null +++ b/app/api/jump/route.test.ts @@ -0,0 +1,109 @@ +// Handler-level tests for the queueing jump route (Task 5a): POST /api/jump with a crew +// team queues a JUMP row instead of resolving instantly — same snapshots, same pre-check +// codes, no turn spent at queue time. +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 { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getWorldState } from '@/lib/game/save' +import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-jump-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +const post = (body: unknown) => POST(new Request('http://localhost/api/jump', { method: 'POST', body: JSON.stringify(body) })) + +describe('POST /api/jump (5a)', () => { + it('queues a JUMP: row with type/payload/status, turn unchanged, snapshot preserved, ship not moved', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + const [before] = await db.db.select().from(schema.saves) + + const res = await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(await getWorldState(db)) // the same fresh snapshot as today — queueing changes nothing else + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'JUMP', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column + expect(JSON.parse(row.payload)).toEqual({ systemId: f.sys1, hops: 1 }) // route-computed via the existing reachability logic + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment + expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the effect waits for the tick + }) + + it('unknown crew → 404 (queueTask)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + expect((await post({ systemId: f.sys1, leadCrewId: 999 })).status).toBe(404) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) + + it('busy helper → 409, nothing queued', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 }) + expect((await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(1) + }) + + it('a jump in transit → 409 (blocks any new task)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 3, remaining: 3, status: 'ACTIVE', queuedAtTurn: 1 }) + expect((await post({ systemId: f.sys1, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(1) + }) + + it('a jump already pending → second jump 409', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 }) + await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 1 }) // lead chains behind its own ACTIVE + expect((await post({ systemId: f.sys1, leadCrewId: c2 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(2) + }) + + it('unreachable target → 409 (existing pre-check, existing code)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + expect((await post({ systemId: f.farSystem, leadCrewId: c1 })).status).toBe(409) // JC-4: a beyond-reach system in the fixture; add it to buildWorldFixture if absent + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) + + it('malformed crew fields → 400, nothing queued', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + expect((await post({ systemId: f.sys1 })).status).toBe(400) // leadCrewId is required + expect((await post({ systemId: f.sys1, leadCrewId: 'x' })).status).toBe(400) + expect((await post({ systemId: f.sys1, leadCrewId: 1.5 })).status).toBe(400) + expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: 'nope' })).status).toBe(400) + expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: [1, 'x'] })).status).toBe(400) + expect((await post('not json at all' as unknown)).status).toBe(400) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) diff --git a/app/api/jump/route.ts b/app/api/jump/route.ts index 68bfd59..e6226d7 100644 --- a/app/api/jump/route.ts +++ b/app/api/jump/route.ts @@ -1,7 +1,12 @@ import { getGameDb } from '@/lib/db/client' -import { JumpError, jumpToSystem } from '@/lib/game/save' +import { MAX_TEAM_SIZE } from '@/lib/game/crew' +import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save' +import { queueTask, TaskError } from '@/lib/game/tasks' -// Jump to an adjacent solar system. Body: { "systemId": number }. Returns the fresh world state. +// Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP +// task — the move lands when the task completes on the turn tick (runTurn). Body: +// { systemId, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh +// world state as the instant jump did. export async function POST(req: Request) { let body: unknown try { @@ -14,12 +19,36 @@ export async function POST(req: Request) { if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) } + const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId + if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) { + return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 }) + } + let helperCrewIds: number[] = [] + const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds + if (rawHelpers !== undefined) { + if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) { + return Response.json({ error: 'helperCrewIds must be an array of integers' }, { status: 400 }) + } + helperCrewIds = rawHelpers + } + // Team rules (route layer, not queueTask's): one task team, distinct members, size cap. + const team = [leadCrewId, ...helperCrewIds] + if (new Set(team).size !== team.length) { + return Response.json({ error: 'crew members must be unique' }, { status: 400 }) + } + if (team.length > MAX_TEAM_SIZE) { + return Response.json({ error: `a task team holds at most ${MAX_TEAM_SIZE} crew members` }, { status: 400 }) + } try { const db = await getGameDb() - return Response.json(await jumpToSystem(db, systemId)) + const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range + const state = await queueStateFromSave(db) + const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId, helperCrewIds }) + await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write + return Response.json(await getWorldState(db)) } catch (err) { - if (err instanceof JumpError) { + if (err instanceof JumpError || err instanceof TaskError) { return Response.json({ error: err.message }, { status: err.status }) } throw err diff --git a/lib/game/crew.ts b/lib/game/crew.ts index 20da79a..a39f9c6 100644 --- a/lib/game/crew.ts +++ b/lib/game/crew.ts @@ -40,6 +40,11 @@ const STARTING_CREW_MIN = 2 const STARTING_CREW_SPREAD = 2 // 2..3 starting members export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans) +// A task team is one lead plus up to this many members total — the queueing routes reject +// a bigger team with a 400 (spec §3 leaves the cap to balance; 4 lets a full crew bring +// RESEARCH's 9 work points down to 3 turns, matching a solo L5 specialist). +export const MAX_TEAM_SIZE = 4 + function title(word: string): string { return word.charAt(0) + word.slice(1).toLowerCase() } diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index f7c0148..31091ac 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -10,6 +10,7 @@ import { MIN_AVAILABLE } from './missions' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { TECHS } from './tech' import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save' +import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures' import type { Task } from './tasks' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' @@ -308,25 +309,14 @@ describe('explorePlanet crew finds', () => { // --- A/B: resolvers vs the legacy immediate actions (Task 4a) ------------------------- // -// Each test builds the identical deterministic fixture in two fresh files (seed '42', -// stamped save → no backfill; ids line up across worlds) and compares their full state: +// Each test builds the identical deterministic fixture (buildWorldFixture — the shared +// fixture suite in ./test-fixtures: seed '42', stamped save → no backfill; ids line up +// across worlds) in two fresh files and compares their full state: // A = the legacy immediate action (validates, costs a turn, applies the effect); // B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver // applies the effect (ship/research rows deduct the cost up front, as queueTask did at // queue time — Task 3). snapshot() normalizes every game table (wall-clock columns // excluded), so the two end states must be byte-identical. -const T0 = '2026-10-01T00:00:00.000Z' - -async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> { - const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() - const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id }) - const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id }) - await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id }) - await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id }) - const [p0] = await db.db.insert(schema.planets).values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id }) - const [p1] = await db.db.insert(schema.planets).values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id }) - return { sys1: s1.id, p0: p0.id, p1: p1.id } -} // The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed // identity fields, the same way the public functions' delegation does. @@ -374,7 +364,7 @@ describe('resolveJumpTask (A/B against jumpToSystem)', () => { await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 })) expect(await snapshot(b)).toEqual(await snapshot(a)) - expect(await b.db.select().from(schema.solarSystems)).toHaveLength(2) // no ring added + expect(await b.db.select().from(schema.solarSystems)).toHaveLength(3) // the fixture's three systems, no ring added }) it('guards: 404 unknown system, 409 already-current', async () => { @@ -447,26 +437,9 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => { // effects and the mission hooks (their contract — invoked here, at the tick's new turn, // before the final persist); this function owns the table writes. describe('runTurn (tick on the DB)', () => { - // buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no - // backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) / - // Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so - // this block adds its own rows. - async function addCrew(db: GameDb, count: number): Promise { - const [save] = await db.db.select().from(schema.saves).limit(1) - const ids: number[] = [] - for (let i = 0; i < count; i++) { - const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id }) - ids.push(row.id) - } - return ids - } - - async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise { - const [save] = await db.db.select().from(schema.saves).limit(1) - const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id }) - return row.id - } - + // buildWorldFixture (the shared suite in ./test-fixtures) seeds the save + Origin / + // Outpost / Farpoint + Alder / Basalt + the route chain — but no crew or tasks; the tick + // needs both, so each test here mints its own rows via addCrew / insertTaskRow. it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => { const { db } = await freshFile() await buildWorldFixture(db) diff --git a/lib/game/save.ts b/lib/game/save.ts index 2ae6cf2..de89d56 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -293,6 +293,27 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + const systems = await db.db.select().from(schema.solarSystems) + + const target = systems.find((s) => s.id === systemId) + if (!target) throw new JumpError(404, `Unknown system ${systemId}`) + const current = systems.find((s) => s.isCurrent)! + if (current.id === target.id) throw new JumpError(409, 'Already at this system') + + const routes = await db.db.select().from(schema.jumpRoutes) + // #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops. + const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched) + const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id) + if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`) + return hopCount +} + // --- Local system + exploration --------------------------------------------- export interface PlanetDto { @@ -438,6 +459,16 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise { + await ensureSave(db) + const planets = await db.db.select().from(schema.planets) + const target = planets.find((p) => p.id === planetId) + if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`) + if (target.explored) throw new ExploreError(409, `${target.name} was already explored`) +} + // --- The turn tick on the DB (POST /api/turn — Task 5 wires the route) --------------------- // // The route→save pattern: ensureSave first, unscoped single-player reads, persist last. @@ -462,7 +493,10 @@ function rowToTask(row: { id: number; saveId: number; type: string; payload: str return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn } } -export async function runTurn(db: GameDb): Promise { +// The plain queue snapshot Task 3's engine runs over, mapped from the schema's rows: crew, +// tasks, inventory, shipSystems and tech, all in id order (single save, unscoped). The route +// never touches lib/db/schema, so the single save row is resolved here, not passed in. +export async function queueStateFromSave(db: GameDb): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([ @@ -472,7 +506,7 @@ export async function runTurn(db: GameDb): Promise { db.db.select().from(schema.shipSystems), db.db.select().from(schema.tech), ]) - const state: QueueState = { + return { turn: save.turn, crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })), tasks: taskRows.map(rowToTask), @@ -480,6 +514,27 @@ export async function runTurn(db: GameDb): Promise { shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })), tech: tech.map((t) => ({ id: t.id, slug: t.slug })), } +} + +// The route's DB-boundary insert (the route→save pattern): queueTask's row minus the two +// values only the DB boundary knows (id autoincrement, saveId = the single save's id). +// payload is already a JSON string from queueTask (passed through); the crew ids serialize +// to the JSON column. Persists so the queue survives a crash; returns the new row id. +export async function saveTaskRow(db: GameDb, task: Omit): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db + .insert(schema.tasks) + .values({ saveId: save.id, type: task.type, payload: task.payload, leadCrewId: task.leadCrewId, helpers: JSON.stringify(task.helpers), duration: task.duration, remaining: task.remaining, status: task.status, queuedAtTurn: task.queuedAtTurn }) + .returning({ id: schema.tasks.id }) + await persist(db) + return row.id +} + +export async function runTurn(db: GameDb): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const state = await queueStateFromSave(db) const result = tickTurn(state) diff --git a/lib/game/test-fixtures.ts b/lib/game/test-fixtures.ts new file mode 100644 index 0000000..20f8f89 --- /dev/null +++ b/lib/game/test-fixtures.ts @@ -0,0 +1,80 @@ +// Shared fixture suite for the game tests (save.test.ts, the 5a route tests, and the later +// ship/research route tests): one deterministic world — seed '42', save stamped at the +// current schema version so no backfill runs — plus the crew and task-row minters the +// queueing tests use. Ids line up across worlds built in fresh files (same insert order), +// so two fixture worlds are comparable row for row. +import type { GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { SCHEMA_VERSION } from './save' + +// Fixed so a fixture's event rows carry a stable created_at (tests never assert on it, but +// a wall-clock Date would make two fixture worlds differ byte-for-byte). +export const T0 = '2026-10-01T00:00:00.000Z' + +// Origin (s0, id 1, current) — Outpost (s1, one hop: the jump target) — Farpoint (s2, two +// hops: beyond one-hop range, so the unreachable pre-check has a target to 409 on; with +// NAVIGATION AI researched it would be reachable). p0 'Alder' in s0, p1 'Basalt' in s1. +// No crew, tasks, ship, or tech rows — the caller mints exactly what a test needs. +export async function buildWorldFixture( + db: GameDb, + outpostDiscovered = false, +): Promise<{ sys1: number; p0: number; p1: number; farSystem: number }> { + const [save] = await db.db + .insert(schema.saves) + .values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }) + .returning() + const [s0] = await db.db + .insert(schema.solarSystems) + .values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }) + .returning({ id: schema.solarSystems.id }) + const [s1] = await db.db + .insert(schema.solarSystems) + .values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }) + .returning({ id: schema.solarSystems.id }) + const [s2] = await db.db + .insert(schema.solarSystems) + .values({ saveId: save.id, name: 'Farpoint', x: 60, y: 50, discovered: false }) + .returning({ id: schema.solarSystems.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s2.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s2.id, toSystem: s1.id }) + const [p0] = await db.db + .insert(schema.planets) + .values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }) + .returning({ id: schema.planets.id }) + const [p1] = await db.db + .insert(schema.planets) + .values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }) + .returning({ id: schema.planets.id }) + return { sys1: s1.id, p0: p0.id, p1: p1.id, farSystem: s2.id } +} + +// `count` identical crew members, in insertion order — the queueing tests need real crew +// ids to lead and help with. +export async function addCrew(db: GameDb, count: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const ids: number[] = [] + for (let i = 0; i < count; i++) { + const [row] = await db.db + .insert(schema.crew) + .values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }) + .returning({ id: schema.crew.id }) + ids.push(row.id) + } + return ids +} + +// Mint one tasks-table row with a chosen state — the queueing/tick tests seed the queue +// directly instead of going through the routes (which would run the same validation). +export async function insertTaskRow( + db: GameDb, + over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }, +): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db + .insert(schema.tasks) + .values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }) + .returning({ id: schema.tasks.id }) + return row.id +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 9cb3ff1..e781c79 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -122,6 +122,12 @@ describe('OpenAPI spec', () => { for (const status of ['400', '404', '409'] as const) { expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } + + // The queued-scan request: the scan target plus the crew team (lead required, helpers optional). + const exploreReq = openApiSpec.components.schemas.ExploreRequest as { properties: Record; required: string[] }; + expect(Object.keys(exploreReq.properties)).toEqual(['planetId', 'leadCrewId', 'helperCrewIds']); + expect(exploreReq.required).toEqual(['planetId', 'leadCrewId']); + expect((exploreReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('describes the world endpoints as WorldState in and out', () => { @@ -134,6 +140,12 @@ describe('OpenAPI spec', () => { for (const status of ['400', '404', '409'] as const) { expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } + + // The queued-jump request: the jump target plus the crew team (lead required, helpers optional). + const jumpReq = openApiSpec.components.schemas.JumpRequest as { properties: Record; required: string[] }; + expect(Object.keys(jumpReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']); + expect(jumpReq.required).toEqual(['systemId', 'leadCrewId']); + expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('keeps info.version in sync with package.json', () => { diff --git a/lib/openapi.ts b/lib/openapi.ts index b22746e..92cb366 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -1,4 +1,5 @@ import pkg from '@/package.json'; +import { MAX_TEAM_SIZE } from './game/crew'; // OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be // served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated @@ -8,7 +9,7 @@ export const openApiSpec = { info: { title: 'SpaceWanderer API', description: - 'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between solar systems within range (adjacent by default; two hops once NAVIGATION AI is researched).', + 'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state. Jump and exploration queue a crew team (lead + helpers) and their effects land when the task completes on the turn tick — queueing spends no turn (jump range is adjacent by default, two hops once NAVIGATION AI is researched); ship work, research and mission accept still resolve instantly, each costing a turn.', // Sourced from package.json so it stays in sync with every version bump. version: pkg.version, }, @@ -52,9 +53,9 @@ export const openApiSpec = { }, '/api/jump': { post: { - summary: 'Jump to a solar system within range', + summary: 'Queue a jump to a solar system within range', description: - 'Validates that the target is within jump range of the current system — one hop away, or two hops once NAVIGATION AI is researched (#35) — sets it as current (discovering it if new and generating its neighbours), advances the turn counter by the hop count and writes a jump event. Returns the fresh world state.', + 'Queues a JUMP task with the given crew team (lead + helpers): validates that the target is within jump range of the current system (one hop away, or two hops once NAVIGATION AI is researched — #35) and that the team is free, then the move — target becomes current, discovered if new with its neighbours generated, jump event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh world state.', operationId: 'jumpToSystem', requestBody: { required: true, @@ -66,7 +67,7 @@ export const openApiSpec = { }, responses: { 200: { - description: 'Fresh world state after the jump', + description: 'Fresh world state — the jump itself lands when the task completes on the turn tick', content: { 'application/json': { schema: { $ref: '#/components/schemas/WorldState' }, @@ -74,7 +75,7 @@ export const openApiSpec = { }, }, 400: { - description: 'Malformed body or non-integer systemId', + description: 'Malformed body, a non-integer systemId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -82,7 +83,7 @@ export const openApiSpec = { }, }, 404: { - description: 'Unknown system id', + description: 'Unknown system id or unknown crew member', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -90,7 +91,7 @@ export const openApiSpec = { }, }, 409: { - description: 'Target is outside jump range of the current system — more than one hop away, or more than two hops without NAVIGATION AI researched (or is the current system itself)', + description: 'Target is outside jump range of the current system (more than one hop away, or more than two without NAVIGATION AI; or is the current system itself) — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -120,9 +121,9 @@ export const openApiSpec = { }, '/api/explore': { post: { - summary: 'Explore a planet or moon', + summary: 'Queue a scan of a planet or moon', description: - 'Scans one body of the save with a deterministic haul (a resource and/or a part, or nothing), marks it explored, increments the turn counter, accumulates finds into inventory and writes an explore event. Returns the fresh system state plus this scan outcome.', + 'Queues an EXPLORE task with the given crew team (lead + helpers): validates that the body exists and has not been scanned yet, and that the team is free, then the scan — a deterministic haul into the cargo hold, the explored flag, the ~18% crew roll and the explore event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh system state, with the outcome key empty and crewFound null until the scan resolves.', operationId: 'explorePlanet', requestBody: { required: true, @@ -134,7 +135,7 @@ export const openApiSpec = { }, responses: { 200: { - description: 'Fresh system state plus the scan outcome', + description: 'Fresh system state — the scan itself lands when the task completes on the turn tick (outcome empty, crewFound null)', content: { 'application/json': { schema: { $ref: '#/components/schemas/ExploreResult' }, @@ -142,7 +143,7 @@ export const openApiSpec = { }, }, 400: { - description: 'Malformed body or non-integer planetId', + description: 'Malformed body, a non-integer planetId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -150,7 +151,7 @@ export const openApiSpec = { }, }, 404: { - description: 'Unknown planet id', + description: 'Unknown planet id or unknown crew member', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -158,7 +159,7 @@ export const openApiSpec = { }, }, 409: { - description: 'Body was already explored (each can only be scanned once)', + description: 'Body was already explored (each can only be scanned once) — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -827,7 +828,7 @@ export const openApiSpec = { maxItems: 2, }, }, - turn: { type: 'integer', description: 'Turn counter — one per jump' }, + turn: { type: 'integer', description: 'Turn counter — advances on the turn tick and on the still-instant actions (ship work, research, mission accept); a queued jump or exploration costs no turn' }, }, required: ['systems', 'edges', 'turn'], }, @@ -835,8 +836,14 @@ export const openApiSpec = { type: 'object', properties: { systemId: { type: 'integer', description: 'solar_systems.id of the jump target' }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' }, + helperCrewIds: { + type: 'array', + description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`, + items: { type: 'integer' }, + }, }, - required: ['systemId'], + required: ['systemId', 'leadCrewId'], }, Planet: { type: 'object', @@ -900,7 +907,7 @@ export const openApiSpec = { items: { $ref: '#/components/schemas/ShipSystem' }, }, inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, - turn: { type: 'integer', description: 'Turn counter — one per jump, exploration or ship action' }, + turn: { type: 'integer', description: 'Turn counter — one per ship action (research and mission accept cost one too; a queued jump or exploration costs no turn)' }, }, required: ['systems', 'inventory', 'turn'], }, @@ -993,7 +1000,7 @@ export const openApiSpec = { items: { $ref: '#/components/schemas/Planet' }, }, inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, - turn: { type: 'integer', description: 'Turn counter — one per jump or exploration' }, + turn: { type: 'integer', description: 'Turn counter — advances on the turn tick and on the still-instant actions (ship work, research, mission accept); a queued jump or exploration costs no turn' }, }, required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'], }, @@ -1001,8 +1008,14 @@ export const openApiSpec = { type: 'object', properties: { planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' }, + helperCrewIds: { + type: 'array', + description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`, + items: { type: 'integer' }, + }, }, - required: ['planetId'], + required: ['planetId', 'leadCrewId'], }, ExploreResult: { allOf: [ @@ -1012,11 +1025,11 @@ export const openApiSpec = { properties: { outcome: { type: 'array', - description: 'What this scan found — may be empty (some scans find nothing)', + description: 'What this scan found — always empty in the queueing response; populated only once the scan resolves on the turn tick (a resolved scan may still find nothing)', items: { $ref: '#/components/schemas/FoundItem' }, }, crewFound: { - description: 'A crew member who joined the ship on this scan (~18% of scans), null when none', + description: 'A crew member who joined the ship on this scan (~18% of scans), null when none — always null in the queueing response', anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }], }, },