feat(api): ship + research endpoints queue tasks with lead + helpers; mission accept is instant and free (#44)
CI / build + unit + e2e (pull_request) Failing after 3m58s

This commit is contained in:
2026-10-02 18:36:14 +00:00
parent 4e312d054f
commit 03b8b661f6
20 changed files with 772 additions and 103 deletions
+103
View File
@@ -0,0 +1,103 @@
// Handler-level tests for the queueing research route (Task 5a(ii)): POST /api/research with
// a crew team queues a RESEARCH row instead of resolving instantly — the tech stays
// unresearched until the task completes on the turn tick, the cost comes out of the cargo
// hold at queue time, same snapshot shape, 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 { getTechState } from '@/lib/game/research'
import { TECHS, type TechDef } from '@/lib/game/tech'
import { addCrew, buildWorldFixture } 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-research-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/research', { method: 'POST', body: JSON.stringify(body) }))
// The fixture suite mints no tech rows (JC-6) — insert exactly what the test needs from the
// pure TECHS definitions (WEAPONS_SYSTEMS: part cost, no prerequisites). The happy path seeds
// every row, like the new-game backfill: the snapshot read (getTechState) maps every TECHS def.
async function insertTech(db: GameDb, def: TechDef, over: { researched?: boolean } = {}) {
const [save] = await db.db.select().from(schema.saves).limit(1)
const [row] = await db.db
.insert(schema.tech)
.values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: over.researched ?? false })
.returning()
return row
}
// The fixture suite mints no cargo hold (JC-6) — seed rows directly.
async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) {
const [save] = await db.db.select().from(schema.saves).limit(1)
for (const r of rows) {
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r })
}
}
describe('POST /api/research (5a(ii))', () => {
it('happy: row with type/payload, turn unchanged, cost deducted, tech not researched yet', async () => {
const { db } = await freshFile()
await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
const def = TECHS[0] // WEAPONS_SYSTEMS — the tech row's cost comes from its TECHS def
for (const d of TECHS) await insertTech(db, d) // every row, like the new-game backfill
const tech = (await db.db.select().from(schema.tech)).find((t) => t.slug === def.slug)!
await seedHold(db, [{ kind: def.costKind, name: def.costKind === 'resource' ? 'resource' : 'part', qty: def.costQty }])
const [before] = await db.db.select().from(schema.saves)
const res = await post({ techId: tech.id, leadCrewId: c1 })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(await getTechState(db)) // the same snapshot shape the instant action returned
const [row] = await db.db.select().from(schema.tasks)
expect(row).toMatchObject({ type: 'RESEARCH', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column
expect(JSON.parse(row.payload)).toEqual({ techId: tech.id })
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted at queue time
expect((await db.db.select().from(schema.tech)).find((t) => t.id === tech.id)!.researched).toBe(false) // the effect waits for the tick
})
it('unaffordable → 422 (existing spendItems message), nothing queued', async () => {
const { db } = await freshFile()
await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
const def = TECHS[0]
const tech = await insertTech(db, def)
await seedHold(db, [{ kind: def.costKind, name: def.costKind === 'resource' ? 'resource' : 'part', qty: def.costQty - 1 }])
const res = await post({ techId: tech.id, leadCrewId: c1 })
expect(res.status).toBe(422)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('already researched → 409 (existing pre-check, existing code), nothing queued', async () => {
const { db } = await freshFile()
await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
const def = TECHS[0]
const tech = await insertTech(db, def, { researched: true })
expect((await post({ techId: tech.id, leadCrewId: c1 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
})
+20 -4
View File
@@ -1,7 +1,14 @@
import { getGameDb } from '@/lib/db/client'
import { ResearchError, researchTech } from '@/lib/game/research'
import { parseCrewTeam } from '@/app/api/crew-team'
import { getTechState, checkResearchPreconditions, ResearchError } from '@/lib/game/research'
import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
import { queueTask, TaskError } from '@/lib/game/tasks'
// Research one tech. Body: { "techId": number }. Costs inventory items + a turn; returns the fresh tech tree state.
// Research one tech: assign the crew team (lead + helpers) and queue the RESEARCH task — the
// tech cost is deducted at queue time and the tech is marked researched (unlock effect
// included) when the task completes on the turn tick (runTurn). Body:
// { techId, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh
// tech tree state the instant action returned.
export async function POST(req: Request) {
let body: unknown
try {
@@ -14,12 +21,21 @@ export async function POST(req: Request) {
if (typeof techId !== 'number' || !Number.isInteger(techId)) {
return Response.json({ error: 'techId must be an integer' }, { status: 400 })
}
const team = parseCrewTeam(body)
if ('error' in team) {
return Response.json({ error: team.error }, { status: team.status })
}
try {
const db = await getGameDb()
return Response.json(await researchTech(db, techId))
await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq
const state = await queueStateFromSave(db)
const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds })
await saveTaskRow(db, task)
await saveInventory(db, inventory) // the cost was deducted at queue time
return Response.json(await getTechState(db))
} catch (err) {
if (err instanceof ResearchError) {
if (err instanceof ResearchError || err instanceof TaskError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err