diff --git a/app/api/turn/route.test.ts b/app/api/turn/route.test.ts new file mode 100644 index 0000000..755ab44 --- /dev/null +++ b/app/api/turn/route.test.ts @@ -0,0 +1,70 @@ +// Handler-level tests for the manual tick route (Task 5b): POST /api/turn (no body) advances +// exactly one turn — an empty queue is a FREE SKIP (200, turn +1, nothing else changes, the +// route never rejects), and a mid-countdown ACTIVE task decrements by exactly one in both the +// returned snapshot and the persisted row (no resolution, no mission hook — it never hits 0). +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 { 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-turn-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 }) +}) + +// No request body — the handler never reads one. +const post = () => POST() + +describe('POST /api/turn', () => { + it('empty queue → free skip: 200, turn +1, nothing else changes', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [before] = await db.db.select().from(schema.saves) + + const res = await post() + expect(res.status).toBe(200) + const snap = await res.json() + expect(snap.turn).toBe(before.turn + 1) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) // the tick creates nothing + expect(snap.tasks).toEqual([]) // the composed queue array is empty + }) + + it('one ACTIVE task → 200, remaining decrements by exactly 1 in the returned snapshot', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const [before] = await db.db.select().from(schema.saves) + // Fixture task: one ACTIVE row, mid-countdown — a direct insert on the queue table + // (the 4b save.test pattern: it works with the queue rows directly); it never reaches 0 + // this tick, so no resolver and no mission hook runs + await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 5, remaining: 3, status: 'ACTIVE', queuedAtTurn: before.turn }) + + const res = await post() + expect(res.status).toBe(200) + const snap = await res.json() + expect(snap.turn).toBe(before.turn + 1) + expect(snap.tasks).toHaveLength(1) + expect(snap.tasks[0].remaining).toBe(2) // 3 − 1: exactly one decrement + expect((await db.db.select().from(schema.tasks))[0].remaining).toBe(2) // the persisted row matches the snapshot + }) +}) diff --git a/app/api/turn/route.ts b/app/api/turn/route.ts new file mode 100644 index 0000000..02b4fef --- /dev/null +++ b/app/api/turn/route.ts @@ -0,0 +1,21 @@ +import { getGameDb } from '@/lib/db/client' +import { getWorldState, runTurn } from '@/lib/game/save' + +// The manual tick (spec §3): advance exactly one turn — every ACTIVE task ages by one, tasks +// that reach 0 resolve (the 4a resolvers + their mission hooks, 4b), and PENDING successors +// auto-start in FIFO order. An empty queue is a FREE SKIP: turn +1 with nothing else changed — +// the route never rejects a tick, so the UI's NEXT TURN button is never disabled. No request +// body. Returns the same world snapshot GET /api/world returns, composed with the tick's +// task array (the snapshot readers do not select the tasks table, so it comes from the TickResult). +export async function POST() { + try { + const db = await getGameDb() + const tick = await runTurn(db) + return Response.json({ ...(await getWorldState(db)), tasks: tick.tasks }) + } catch (err) { + // No domain errors escape runTurn (4b fails the task instead) — anything reaching here is + // a non-domain rethrow, i.e. a real bug: the error-mapping pattern, 500 when no status. + const e = err as { status?: number; message: string } + return Response.json({ error: e.message }, { status: e.status ?? 500 }) + } +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index d58128e..2fb4f2b 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -20,6 +20,7 @@ describe('OpenAPI spec', () => { '/api/ship/upgrade', '/api/system', '/api/tech', + '/api/turn', '/api/version', '/api/world', ]); @@ -171,6 +172,25 @@ describe('OpenAPI spec', () => { expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); + it('describes the turn endpoint: POST /api/turn with no body → WorldState + tasks, 500 on internal error', () => { + const post = openApiSpec.paths['/api/turn'].post; + expect(post).not.toHaveProperty('requestBody'); + expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/TurnResult'); + expect(post.responses[500].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); + + // TurnResult extends WorldState with the task queue after the tick. + const turnResult = openApiSpec.components.schemas.TurnResult as { allOf: unknown[] }; + expect((turnResult.allOf[0] as { $ref: string }).$ref).toBe('#/components/schemas/WorldState'); + const extra = (turnResult.allOf[1] as { properties: Record }).properties; + expect(Object.keys(extra)).toEqual(['tasks']); + + const props = openApiSpec.components.schemas.Task.properties; + expect(Object.keys(props)).toEqual(['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn']); + expect((props.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']); + expect((props.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING', 'DONE']); + expect((props.helpers as { items: { type: string } }).items.type).toBe('integer'); + }); + it('keeps info.version in sync with package.json', () => { expect(openApiSpec.info.title).toBe('SpaceWanderer API'); expect(openApiSpec.info.version).toBe(pkg.version); diff --git a/lib/openapi.ts b/lib/openapi.ts index 8f1376d..64eaa8d 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -51,6 +51,32 @@ export const openApiSpec = { }, }, }, + '/api/turn': { + post: { + summary: 'Advance exactly one turn (manual tick)', + description: + "Ticks the queue one turn — no request body: every ACTIVE task ages by one, tasks that reach 0 resolve (jump, scan, ship-work or research effects land, mission checks run for what resolved) and PENDING successors auto-start in FIFO order. An empty queue is a FREE SKIP — turn +1 with nothing else changed, always 200 (the UI's NEXT TURN button is never disabled; a task that fails to resolve simply does not land its effect). Returns the same world snapshot as GET /api/world, plus the task queue after the tick.", + operationId: 'nextTurn', + responses: { + 200: { + description: 'World state after the tick, plus the task queue (every task row: ACTIVE, PENDING, DONE)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/TurnResult' }, + }, + }, + }, + 500: { + description: 'Internal error — no domain failures escape the tick (a failed task is silently dropped, not an error)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, '/api/jump': { post: { summary: 'Queue a jump to a solar system within range', @@ -832,6 +858,42 @@ export const openApiSpec = { }, required: ['systems', 'edges', 'turn'], }, + TurnResult: { + allOf: [ + { $ref: '#/components/schemas/WorldState' }, + { + type: 'object', + properties: { + tasks: { + type: 'array', + description: + 'Every task row after the tick — ACTIVE (decremented this turn, or auto-started this turn), PENDING (waiting for its team to free) and DONE (resolved this tick or earlier)', + items: { $ref: '#/components/schemas/Task' }, + }, + }, + required: ['tasks'], + }, + ], + }, + Task: { + type: 'object', + properties: { + id: { type: 'integer' }, + saveId: { type: 'integer' }, + type: { type: 'string', enum: ['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH'] }, + payload: { + type: 'string', + description: 'JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId}', + }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead' }, + helpers: { type: 'array', description: 'crew.id of the supporting crew (empty when none)', items: { type: 'integer' } }, + duration: { type: 'integer' }, + remaining: { type: 'integer' }, + status: { type: 'string', enum: ['ACTIVE', 'PENDING', 'DONE'] }, + queuedAtTurn: { type: 'integer' }, + }, + required: ['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn'], + }, JumpRequest: { type: 'object', properties: {