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
CI / build + unit + e2e (pull_request) Failing after 3m58s
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { MAX_TEAM_SIZE } from '@/lib/game/crew'
|
||||
|
||||
// The crew-team half of a queueing-action body (leadCrewId + helperCrewIds) plus the route-layer
|
||||
// team rules: one task team, distinct members, the size cap. queueTask (lib) deliberately does
|
||||
// NOT validate teams, so this shared parse is the single place the team 400s live — extracted
|
||||
// verbatim from the 5a jump/explore crew-parse blocks so the six queueing routes can't drift.
|
||||
// Returns a 400-shaped error object instead of throwing, so the caller maps it to a response.
|
||||
|
||||
export type CrewTeam =
|
||||
| { leadCrewId: number; helperCrewIds: number[] }
|
||||
| { error: string; status: number }
|
||||
|
||||
export function parseCrewTeam(body: unknown): CrewTeam {
|
||||
const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId
|
||||
if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) {
|
||||
return { 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 { 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 { error: 'crew members must be unique', status: 400 }
|
||||
}
|
||||
if (team.length > MAX_TEAM_SIZE) {
|
||||
return { error: `a task team holds at most ${MAX_TEAM_SIZE} crew members`, status: 400 }
|
||||
}
|
||||
return { leadCrewId, helperCrewIds }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { MAX_TEAM_SIZE } from '@/lib/game/crew'
|
||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||
import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||
|
||||
@@ -20,32 +20,16 @@ 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 })
|
||||
const team = parseCrewTeam(body)
|
||||
if ('error' in team) {
|
||||
return Response.json({ error: team.error }, { status: team.status })
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getGameDb()
|
||||
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 })
|
||||
const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.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) {
|
||||
|
||||
+5
-21
@@ -1,5 +1,5 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { MAX_TEAM_SIZE } from '@/lib/game/crew'
|
||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||
import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||
|
||||
@@ -19,32 +19,16 @@ 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 })
|
||||
const team = parseCrewTeam(body)
|
||||
if ('error' in team) {
|
||||
return Response.json({ error: team.error }, { status: team.status })
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getGameDb()
|
||||
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 })
|
||||
const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds })
|
||||
await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write
|
||||
return Response.json(await getWorldState(db))
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Handler-level tests for mission accept (Task 5a(ii)): instant and free per the approved
|
||||
// spec (JC-19) — the AVAILABLE → ACTIVE flip and the event land at the current turn, no
|
||||
// turn is spent, no crew params; the board response shape is unchanged.
|
||||
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 { getMissions } from '@/lib/game/missions'
|
||||
import { 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-mission-accept-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 = (id: string) => POST(new Request(`http://localhost/api/missions/${id}/accept`, { method: 'POST' }), { params: Promise.resolve({ id }) })
|
||||
|
||||
// The fixture suite mints no mission rows (JC-6) — insert exactly what the test needs (same
|
||||
// shape as the insertMission helper in lib/game/missions.test.ts, plus a state override).
|
||||
async function insertMission(
|
||||
db: GameDb,
|
||||
fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number; state?: string },
|
||||
): Promise<number> {
|
||||
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, state: fields.state ?? 'AVAILABLE' })
|
||||
.returning()
|
||||
return row.id
|
||||
}
|
||||
|
||||
describe('POST /api/missions/[id]/accept (5a(ii))', () => {
|
||||
it('happy: mission goes active, board returned, turn unchanged (free)', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const id = await insertMission(db, { type: 'DELIVER', description: 'Jump to Outpost', targetSystemId: 2 })
|
||||
const [before] = await db.db.select().from(schema.saves)
|
||||
|
||||
const res = await post(String(id))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(await getMissions(db)) // same board shape as today
|
||||
expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE') // AVAILABLE → active
|
||||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // free — no turn cost
|
||||
})
|
||||
|
||||
it('non-integer id → 400, nothing changes', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const [before] = await db.db.select().from(schema.saves)
|
||||
expect((await post('abc')).status).toBe(400)
|
||||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||
})
|
||||
|
||||
it('unknown id → 404, nothing changes', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const [before] = await db.db.select().from(schema.saves)
|
||||
expect((await post('999')).status).toBe(404)
|
||||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||
})
|
||||
|
||||
it('not AVAILABLE → 409 (existing code), nothing changes', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, state: 'ACTIVE' })
|
||||
const [before] = await db.db.select().from(schema.saves)
|
||||
expect((await post(String(id))).status).toBe(409)
|
||||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||
})
|
||||
|
||||
it('a mission already active → 409 (existing code), the target stays AVAILABLE', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
await insertMission(db, { type: 'FIND', description: 'Find a part while scanning', conditionKind: 'part', state: 'ACTIVE' })
|
||||
const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2 })
|
||||
const [before] = await db.db.select().from(schema.saves)
|
||||
expect((await post(String(id))).status).toBe(409)
|
||||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||
expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('AVAILABLE')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,8 @@ 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.
|
||||
// Accept an AVAILABLE mission — it becomes the single active one. Instant and free (no turn,
|
||||
// no body); 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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Handler-level tests for the queueing ship-build route (Task 5a(ii)): POST /api/ship/build
|
||||
// with a crew team queues a SHIP_BUILD row instead of resolving instantly — the build at
|
||||
// base values waits for the turn tick, the flat parts 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 { getShipState } from '@/lib/game/ship'
|
||||
import { BUILD_PART_COST } from '@/lib/game/ship-costs'
|
||||
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-ship-build-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/ship/build', { method: 'POST', body: JSON.stringify(body) }))
|
||||
|
||||
// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs. HULL is
|
||||
// the only type that is unbuilt by default AND has no build gate (WEAPONS is gated behind
|
||||
// the WEAPONS SYSTEMS tech, which the fixture leaves unresearched).
|
||||
async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).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/ship/build (5a(ii))', () => {
|
||||
it('happy: row with type/payload, turn unchanged, parts deducted, not built yet', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const [c1] = await addCrew(db, 1)
|
||||
const sys = await insertSystem(db, { type: 'HULL', level: 0, capacity: 0 }) // unbuilt, no gate
|
||||
await seedHold(db, [{ kind: 'part', name: 'part', qty: BUILD_PART_COST }]) // exactly the existing flat cost
|
||||
const [before] = await db.db.select().from(schema.saves)
|
||||
|
||||
const res = await post({ type: sys.type, leadCrewId: c1 })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned
|
||||
const [row] = await db.db.select().from(schema.tasks)
|
||||
expect(row).toMatchObject({ type: 'SHIP_BUILD', 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({ type: sys.type })
|
||||
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.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(0) // 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)
|
||||
await insertSystem(db, { type: 'HULL', level: 0, capacity: 0 })
|
||||
await seedHold(db, [{ kind: 'part', name: 'part', qty: BUILD_PART_COST - 1 }])
|
||||
const res = await post({ type: 'HULL', leadCrewId: c1 })
|
||||
expect(res.status).toBe(422)
|
||||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('already built → 409 (existing pre-check, existing code), nothing queued', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const [c1] = await addCrew(db, 1)
|
||||
const sys = await insertSystem(db, { type: 'HULL', level: 1, capacity: 2 }) // built at base values
|
||||
expect((await post({ type: sys.type, leadCrewId: c1 })).status).toBe(409)
|
||||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, buildSystem } from '@/lib/game/ship'
|
||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||
import { checkBuildPreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||
import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||
|
||||
// Build an unbuilt ship system type at base values. Body: { "type": string }.
|
||||
// Costs inventory items + a turn; returns the fresh ship state.
|
||||
// Build an unbuilt ship system type at base values: assign the crew team (lead + helpers)
|
||||
// and queue the SHIP_BUILD task — the flat parts cost is deducted at queue time and the
|
||||
// build lands when the task completes on the turn tick (runTurn). Body:
|
||||
// { type, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh
|
||||
// ship state the instant action returned.
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown
|
||||
try {
|
||||
@@ -15,12 +21,21 @@ export async function POST(req: Request) {
|
||||
if (typeof type !== 'string' || type.length === 0) {
|
||||
return Response.json({ error: 'type must be a non-empty string' }, { 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 buildSystem(db, type))
|
||||
await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate
|
||||
const state = await queueStateFromSave(db)
|
||||
const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, 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 getShipState(db))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
if (err instanceof ShipError || err instanceof TaskError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Handler-level tests for the queueing ship-expand route (Task 5a(ii)): POST /api/ship/expand
|
||||
// with a crew team queues a SHIP_EXPAND row instead of resolving instantly — the capacity
|
||||
// bump waits for 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 { getShipState } from '@/lib/game/ship'
|
||||
import { expandCost } from '@/lib/game/ship-costs'
|
||||
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-ship-expand-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/ship/expand', { method: 'POST', body: JSON.stringify(body) }))
|
||||
|
||||
// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs.
|
||||
async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).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/ship/expand (5a(ii))', () => {
|
||||
it('happy: row with type/payload, turn unchanged, cost deducted, no capacity change yet', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const [c1] = await addCrew(db, 1)
|
||||
const sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 2 }) // built, not maxed (base cap 10)
|
||||
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: expandCost(sys.capacity) }]) // exactly the existing cost logic
|
||||
const [before] = await db.db.select().from(schema.saves)
|
||||
|
||||
const res = await post({ systemId: sys.id, leadCrewId: c1 })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned
|
||||
const [row] = await db.db.select().from(schema.tasks)
|
||||
expect(row).toMatchObject({ type: 'SHIP_EXPAND', 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({ systemId: sys.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.shipSystems)).find((s) => s.id === sys.id)!.capacity).toBe(sys.capacity) // 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 sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 2 })
|
||||
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: expandCost(sys.capacity) - 1 }])
|
||||
const res = await post({ systemId: sys.id, leadCrewId: c1 })
|
||||
expect(res.status).toBe(422)
|
||||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('max capacity → 409 (existing pre-check, existing code), nothing queued', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const [c1] = await addCrew(db, 1)
|
||||
const sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 99 }) // above any max
|
||||
expect((await post({ systemId: sys.id, leadCrewId: c1 })).status).toBe(409)
|
||||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, expandSystem } from '@/lib/game/ship'
|
||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||
import { checkExpandPreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||
import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||
|
||||
// Raise a built ship system's capacity by one (capped at max capacity). Body: { "systemId": number }.
|
||||
// Costs inventory items + a turn; returns the fresh ship state.
|
||||
// Raise a built ship system's capacity by one (capped at max capacity): assign the crew team
|
||||
// (lead + helpers) and queue the SHIP_EXPAND task — the cost (`capacity` units of any
|
||||
// resource) is deducted at queue time and the capacity bump lands when the task completes on
|
||||
// the turn tick (runTurn). Body: { systemId, leadCrewId, helperCrewIds? }. Queueing spends no
|
||||
// turn; returns the same fresh ship state the instant action returned.
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown
|
||||
try {
|
||||
@@ -15,12 +21,21 @@ 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 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 expandSystem(db, systemId))
|
||||
await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity
|
||||
const state = await queueStateFromSave(db)
|
||||
const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, 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 getShipState(db))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
if (err instanceof ShipError || err instanceof TaskError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Handler-level tests for the queueing ship-upgrade route (Task 5a(ii)): POST /api/ship/upgrade
|
||||
// with a crew team queues a SHIP_UPGRADE row instead of resolving instantly — the level-up
|
||||
// waits for 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 { getShipState } from '@/lib/game/ship'
|
||||
import { upgradeCost } from '@/lib/game/ship-costs'
|
||||
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-ship-upgrade-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/ship/upgrade', { method: 'POST', body: JSON.stringify(body) }))
|
||||
|
||||
// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs.
|
||||
async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).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/ship/upgrade (5a(ii))', () => {
|
||||
it('happy: row with type/payload, turn unchanged, cost deducted, no level change yet', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const [c1] = await addCrew(db, 1)
|
||||
const sys = await insertSystem(db, { type: 'REACTOR', level: 2, capacity: 2 }) // built, not maxed (base cap 5)
|
||||
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: upgradeCost(sys.level) }]) // exactly the existing cost logic
|
||||
const [before] = await db.db.select().from(schema.saves)
|
||||
|
||||
const res = await post({ systemId: sys.id, leadCrewId: c1 })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned
|
||||
const [row] = await db.db.select().from(schema.tasks)
|
||||
expect(row).toMatchObject({ type: 'SHIP_UPGRADE', 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({ systemId: sys.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.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(sys.level) // 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 sys = await insertSystem(db, { type: 'REACTOR', level: 2, capacity: 2 })
|
||||
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: upgradeCost(sys.level) - 1 }])
|
||||
const res = await post({ systemId: sys.id, leadCrewId: c1 })
|
||||
expect(res.status).toBe(422)
|
||||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('max level → 409 (existing pre-check, existing code), nothing queued', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const [c1] = await addCrew(db, 1)
|
||||
const sys = await insertSystem(db, { type: 'REACTOR', level: 99, capacity: 2 }) // above any max
|
||||
expect((await post({ systemId: sys.id, leadCrewId: c1 })).status).toBe(409)
|
||||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, upgradeSystem } from '@/lib/game/ship'
|
||||
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||
import { checkUpgradePreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||
import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||
|
||||
// Level up a built ship system by one (capped at max level). Body: { "systemId": number }.
|
||||
// Costs inventory items + a turn; returns the fresh ship state.
|
||||
// Level up a built ship system by one (capped at max level): assign the crew team (lead +
|
||||
// helpers) and queue the SHIP_UPGRADE task — the cost (`level` units of any resource) is
|
||||
// deducted at queue time and the level-up lands when the task completes on the turn tick
|
||||
// (runTurn). Body: { systemId, leadCrewId, helperCrewIds? }. Queueing spends no turn;
|
||||
// returns the same fresh ship state the instant action returned.
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown
|
||||
try {
|
||||
@@ -15,12 +21,21 @@ 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 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 upgradeSystem(db, systemId))
|
||||
await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level
|
||||
const state = await queueStateFromSave(db)
|
||||
const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, 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 getShipState(db))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
if (err instanceof ShipError || err instanceof TaskError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
|
||||
Reference in New Issue
Block a user