feat(api): POST /api/turn ticks the queue one turn; an empty queue is a free skip (#44)
This commit is contained in:
@@ -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<GameDb> }
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user