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 { 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 { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
@@ -20,32 +20,16 @@ export async function POST(req: Request) {
|
|||||||
if (typeof planetId !== 'number' || !Number.isInteger(planetId)) {
|
if (typeof planetId !== 'number' || !Number.isInteger(planetId)) {
|
||||||
return Response.json({ error: 'planetId must be an integer' }, { status: 400 })
|
return Response.json({ error: 'planetId must be an integer' }, { status: 400 })
|
||||||
}
|
}
|
||||||
const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId
|
const team = parseCrewTeam(body)
|
||||||
if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) {
|
if ('error' in team) {
|
||||||
return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 })
|
return Response.json({ error: team.error }, { status: team.status })
|
||||||
}
|
|
||||||
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 {
|
try {
|
||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned
|
await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned
|
||||||
const state = await queueStateFromSave(db)
|
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
|
await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write
|
||||||
return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null })
|
return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+5
-21
@@ -1,5 +1,5 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
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 { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||||
import { queueTask, TaskError } from '@/lib/game/tasks'
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
@@ -19,32 +19,16 @@ export async function POST(req: Request) {
|
|||||||
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||||
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
||||||
}
|
}
|
||||||
const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId
|
const team = parseCrewTeam(body)
|
||||||
if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) {
|
if ('error' in team) {
|
||||||
return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 })
|
return Response.json({ error: team.error }, { status: team.status })
|
||||||
}
|
|
||||||
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 {
|
try {
|
||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range
|
const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range
|
||||||
const state = await queueStateFromSave(db)
|
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
|
await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write
|
||||||
return Response.json(await getWorldState(db))
|
return Response.json(await getWorldState(db))
|
||||||
} catch (err) {
|
} 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 { ensureSave } from '@/lib/game/save'
|
||||||
import { acceptMission, MissionError } from '@/lib/game/missions'
|
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 }> }) {
|
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
await ensureSave(db) // acceptMission assumes a save exists
|
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 { 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) {
|
export async function POST(req: Request) {
|
||||||
let body: unknown
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
@@ -14,12 +21,21 @@ export async function POST(req: Request) {
|
|||||||
if (typeof techId !== 'number' || !Number.isInteger(techId)) {
|
if (typeof techId !== 'number' || !Number.isInteger(techId)) {
|
||||||
return Response.json({ error: 'techId must be an integer' }, { status: 400 })
|
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 {
|
try {
|
||||||
const db = await getGameDb()
|
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) {
|
} catch (err) {
|
||||||
if (err instanceof ResearchError) {
|
if (err instanceof ResearchError || err instanceof TaskError) {
|
||||||
return Response.json({ error: err.message }, { status: err.status })
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
}
|
}
|
||||||
throw err
|
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 { 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 }.
|
// Build an unbuilt ship system type at base values: assign the crew team (lead + helpers)
|
||||||
// Costs inventory items + a turn; returns the fresh ship state.
|
// 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) {
|
export async function POST(req: Request) {
|
||||||
let body: unknown
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
@@ -15,12 +21,21 @@ export async function POST(req: Request) {
|
|||||||
if (typeof type !== 'string' || type.length === 0) {
|
if (typeof type !== 'string' || type.length === 0) {
|
||||||
return Response.json({ error: 'type must be a non-empty string' }, { status: 400 })
|
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 {
|
try {
|
||||||
const db = await getGameDb()
|
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) {
|
} catch (err) {
|
||||||
if (err instanceof ShipError) {
|
if (err instanceof ShipError || err instanceof TaskError) {
|
||||||
return Response.json({ error: err.message }, { status: err.status })
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
}
|
}
|
||||||
throw err
|
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 { 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 }.
|
// Raise a built ship system's capacity by one (capped at max capacity): assign the crew team
|
||||||
// Costs inventory items + a turn; returns the fresh ship state.
|
// (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) {
|
export async function POST(req: Request) {
|
||||||
let body: unknown
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
@@ -15,12 +21,21 @@ export async function POST(req: Request) {
|
|||||||
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||||
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
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 {
|
try {
|
||||||
const db = await getGameDb()
|
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) {
|
} catch (err) {
|
||||||
if (err instanceof ShipError) {
|
if (err instanceof ShipError || err instanceof TaskError) {
|
||||||
return Response.json({ error: err.message }, { status: err.status })
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
}
|
}
|
||||||
throw err
|
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 { 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 }.
|
// Level up a built ship system by one (capped at max level): assign the crew team (lead +
|
||||||
// Costs inventory items + a turn; returns the fresh ship state.
|
// 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) {
|
export async function POST(req: Request) {
|
||||||
let body: unknown
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
@@ -15,12 +21,21 @@ export async function POST(req: Request) {
|
|||||||
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||||
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
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 {
|
try {
|
||||||
const db = await getGameDb()
|
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) {
|
} catch (err) {
|
||||||
if (err instanceof ShipError) {
|
if (err instanceof ShipError || err instanceof TaskError) {
|
||||||
return Response.json({ error: err.message }, { status: err.status })
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ describe('acceptMission', () => {
|
|||||||
await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ })
|
await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('activates an AVAILABLE contract, costs a turn, writes an event and keeps the board topped up', async () => {
|
it('activates an AVAILABLE contract, spends no turn, writes an event and keeps the board topped up', async () => {
|
||||||
const { db } = await freshFile()
|
const { db } = await freshFile()
|
||||||
await getWorldState(db)
|
await getWorldState(db)
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
@@ -149,11 +149,11 @@ describe('acceptMission', () => {
|
|||||||
expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE')
|
expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE')
|
||||||
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up
|
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up
|
||||||
const [after] = await db.db.select().from(schema.saves).limit(1)
|
const [after] = await db.db.select().from(schema.saves).limit(1)
|
||||||
expect(after.turn).toBe(save.turn + 1)
|
expect(after.turn).toBe(save.turn) // free — no turn cost
|
||||||
|
|
||||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')!
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')!
|
||||||
expect(event.message).toContain('Accepted mission')
|
expect(event.message).toContain('Accepted mission')
|
||||||
expect(event.turn).toBe(save.turn + 1)
|
expect(event.turn).toBe(save.turn) // stamped at the current turn — accept spends none
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ describe('completion', () => {
|
|||||||
expect(reward.qty).toBe(2)
|
expect(reward.qty).toBe(2)
|
||||||
|
|
||||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))!
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))!
|
||||||
expect(event.turn).toBe(4) // accept (1) + three scans (2–4); completion is written by the third scan
|
expect(event.turn).toBe(3) // free accept (turn 0) + three scans (1–3); completion is written by the third scan
|
||||||
expect(event.message).toContain('Scan 3 bodies')
|
expect(event.message).toContain('Scan 3 bodies')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+10
-7
@@ -34,8 +34,12 @@ export interface MissionSystem {
|
|||||||
isCurrent: boolean
|
isCurrent: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts. Every
|
// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts.
|
||||||
// action costs exactly one turn, so the stream never repeats for two different top-ups.
|
// Turn-advancing actions re-anchor the stream, so top-ups on different turns generate
|
||||||
|
// different contracts. Mission accept is free and tops up at the current turn, so it may
|
||||||
|
// share an anchor with a top-up that already ran at that turn (e.g. the new-game seed at
|
||||||
|
// turn 0) — the generated contract can then mirror an existing one: cosmetic, never a
|
||||||
|
// double reward (rewards are per row).
|
||||||
export function missionRng(seed: string, turn: number): () => number {
|
export function missionRng(seed: string, turn: number): () => number {
|
||||||
return mulberry32((Number(seed) + Math.imul(turn, 0x9e3779b1)) >>> 0)
|
return mulberry32((Number(seed) + Math.imul(turn, 0x9e3779b1)) >>> 0)
|
||||||
}
|
}
|
||||||
@@ -152,8 +156,9 @@ export const REWARD_ITEM_NAME = 'MISSION REWARD'
|
|||||||
|
|
||||||
const kindLabel = (kind: ItemKind): string => (kind === 'resource' ? 'RESOURCES' : 'PARTS')
|
const kindLabel = (kind: ItemKind): string => (kind === 'resource' ? 'RESOURCES' : 'PARTS')
|
||||||
|
|
||||||
// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time), costs a turn like
|
// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time). Instant and
|
||||||
// every other action and writes an event. Returns the fresh board.
|
// free (JC-19) — no turn, no cost; the event is stamped at the current turn. Returns the fresh
|
||||||
|
// board.
|
||||||
export async function acceptMission(db: GameDb, missionId: number): Promise<MissionDto[]> {
|
export async function acceptMission(db: GameDb, missionId: number): Promise<MissionDto[]> {
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
if (!save) throw new MissionError(404, 'No save yet')
|
if (!save) throw new MissionError(404, 'No save yet')
|
||||||
@@ -164,10 +169,8 @@ export async function acceptMission(db: GameDb, missionId: number): Promise<Miss
|
|||||||
const active = rows.find((m) => m.state === 'ACTIVE')
|
const active = rows.find((m) => m.state === 'ACTIVE')
|
||||||
if (active) throw new MissionError(409, `One mission at a time — finish "${active.description}" first`)
|
if (active) throw new MissionError(409, `One mission at a time — finish "${active.description}" first`)
|
||||||
|
|
||||||
const turn = save.turn + 1
|
|
||||||
await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id))
|
await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id))
|
||||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() })
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() })
|
|
||||||
await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up
|
await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up
|
||||||
await persist(db)
|
await persist(db)
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,23 @@ export async function resolveResearchTask(db: GameDb, task: Task): Promise<void>
|
|||||||
await persist(db)
|
await persist(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The research pre-checks, extracted for the queueing route (5a(ii)): the same codes and
|
||||||
|
// messages as researchTech — unknown tech (404), already researched (409), missing prereqs
|
||||||
|
// (409, naming what is missing). The cost check stays out: the queueing path deducts at
|
||||||
|
// queue time (queueTask) and the research lands when the task completes on the tick.
|
||||||
|
export async function checkResearchPreconditions(db: GameDb, techId: number): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const rows = await db.db.select().from(schema.tech)
|
||||||
|
const target = rows.find((t) => t.id === techId)
|
||||||
|
if (!target) throw new ResearchError(404, `Unknown tech ${techId}`)
|
||||||
|
|
||||||
|
const def = TECHS.find((t) => t.slug === target.slug)!
|
||||||
|
const researched = await researchedSlugs(db)
|
||||||
|
if (researched.has(def.slug)) throw new ResearchError(409, `${def.name} is already researched`)
|
||||||
|
const missing = def.prereqs.filter((p) => !researched.has(p)).map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug)
|
||||||
|
if (missing.length > 0) throw new ResearchError(409, `${def.name} requires ${missing.join(' and ')} first`)
|
||||||
|
}
|
||||||
|
|
||||||
// Research one tech: validates prerequisites (409, naming what is missing) and the cost (422),
|
// Research one tech: validates prerequisites (409, naming what is missing) and the cost (422),
|
||||||
// spends it from the cargo hold, then delegates the unlock to the resolver at the new turn.
|
// spends it from the cargo hold, then delegates the unlock to the resolver at the new turn.
|
||||||
export async function researchTech(db: GameDb, techId: number): Promise<TechDto[]> {
|
export async function researchTech(db: GameDb, techId: number): Promise<TechDto[]> {
|
||||||
|
|||||||
@@ -531,6 +531,20 @@ export async function saveTaskRow(db: GameDb, task: Omit<Task, 'id' | 'saveId'>)
|
|||||||
return row.id
|
return row.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The route's DB-boundary hold write (the route→save pattern): queueTask's deducted inventory
|
||||||
|
// replaces the save's rows — array order = oldest first = id order, so spendItems' oldest-row-
|
||||||
|
// first consumption and the id-ordered reads stay consistent after the rewrite. Persists so
|
||||||
|
// the deduction survives a crash; the queueing routes call it with queueTask's result.
|
||||||
|
export async function saveInventory(db: GameDb, items: InventoryItemDto[]): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.saveId, save.id))
|
||||||
|
for (const item of items) {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty })
|
||||||
|
}
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
|
||||||
export async function runTurn(db: GameDb): Promise<TickResult> {
|
export async function runTurn(db: GameDb): Promise<TickResult> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
|||||||
@@ -131,6 +131,19 @@ export async function resolveShipBuildTask(db: GameDb, task: Task): Promise<void
|
|||||||
await persist(db)
|
await persist(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The upgrade pre-checks, extracted for the queueing route (5a(ii)): the same codes and
|
||||||
|
// messages as upgradeSystem — unknown system (404), not built (409), at max level (409).
|
||||||
|
// Cost, turn and the level-up effect stay out: the queueing path deducts at queue time
|
||||||
|
// (queueTask) and the effect lands when the task completes on the tick.
|
||||||
|
export async function checkUpgradePreconditions(db: GameDb, systemId: number): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
||||||
|
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
||||||
|
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
||||||
|
const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db))
|
||||||
|
if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`)
|
||||||
|
}
|
||||||
|
|
||||||
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs).
|
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs).
|
||||||
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
@@ -148,6 +161,17 @@ export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipS
|
|||||||
return readShipState(db)
|
return readShipState(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The expand pre-checks, extracted for the queueing route (5a(ii)): the same codes and
|
||||||
|
// messages as expandSystem — unknown system (404), not built (409), at max capacity (409).
|
||||||
|
export async function checkExpandPreconditions(db: GameDb, systemId: number): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
||||||
|
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
||||||
|
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
||||||
|
const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db))
|
||||||
|
if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`)
|
||||||
|
}
|
||||||
|
|
||||||
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs).
|
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs).
|
||||||
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
@@ -165,6 +189,18 @@ export async function expandSystem(db: GameDb, systemId: number): Promise<ShipSt
|
|||||||
return readShipState(db)
|
return readShipState(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The build pre-checks, extracted for the queueing route (5a(ii)): the same codes and
|
||||||
|
// messages as buildSystem — unknown type (404), already built (409), build-gate tech
|
||||||
|
// missing (409). The parts cost stays out: the queueing path deducts it at queue time.
|
||||||
|
export async function checkBuildPreconditions(db: GameDb, type: string): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
|
||||||
|
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
|
||||||
|
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
|
||||||
|
const gate = buildGate(type, await researchedTechSlugs(db))
|
||||||
|
if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`)
|
||||||
|
}
|
||||||
|
|
||||||
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
|
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
|
||||||
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queued-ship-work requests: the action target plus the crew team (lead required,
|
||||||
|
// helpers optional) — the same team fields as JumpRequest / ExploreRequest.
|
||||||
|
const upgradeReq = openApiSpec.components.schemas.UpgradeRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(upgradeReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(upgradeReq.required).toEqual(['systemId', 'leadCrewId']);
|
||||||
|
expect((upgradeReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
|
||||||
|
const expandReq = openApiSpec.components.schemas.ExpandRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(expandReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(expandReq.required).toEqual(['systemId', 'leadCrewId']);
|
||||||
|
expect((expandReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
|
||||||
|
const buildReq = openApiSpec.components.schemas.BuildRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(buildReq.properties)).toEqual(['type', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(buildReq.required).toEqual(['type', 'leadCrewId']);
|
||||||
|
expect((buildReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('describes the tech endpoints: GET /api/tech → Tech[], POST /api/research with error responses', () => {
|
it('describes the tech endpoints: GET /api/tech → Tech[], POST /api/research with error responses', () => {
|
||||||
@@ -61,6 +78,12 @@ describe('OpenAPI spec', () => {
|
|||||||
for (const status of ['400', '404', '409', '422'] as const) {
|
for (const status of ['400', '404', '409', '422'] as const) {
|
||||||
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queued-research request: the tech target plus the crew team (lead required, helpers optional).
|
||||||
|
const researchReq = openApiSpec.components.schemas.ResearchRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(researchReq.properties)).toEqual(['techId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(researchReq.required).toEqual(['techId', 'leadCrewId']);
|
||||||
|
expect((researchReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('describes the mission endpoints: GET /api/missions → Mission[], POST accept with error responses', () => {
|
it('describes the mission endpoints: GET /api/missions → Mission[], POST accept with error responses', () => {
|
||||||
|
|||||||
+54
-30
@@ -9,7 +9,7 @@ export const openApiSpec = {
|
|||||||
info: {
|
info: {
|
||||||
title: 'SpaceWanderer API',
|
title: 'SpaceWanderer API',
|
||||||
description:
|
description:
|
||||||
'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.',
|
'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state. Jump, exploration, ship work and research 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); mission accept is instant and free (no turn).',
|
||||||
// Sourced from package.json so it stays in sync with every version bump.
|
// Sourced from package.json so it stays in sync with every version bump.
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
},
|
},
|
||||||
@@ -209,7 +209,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Upgrade a ship system',
|
summary: 'Upgrade a ship system',
|
||||||
description:
|
description:
|
||||||
"Level up a built ship system by one (capped at max level). Costs `level` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.",
|
"Level up a built ship system by one (capped at max level). Assign the crew team 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; queueing spends no turn. Returns the fresh ship state.",
|
||||||
operationId: 'upgradeShipSystem',
|
operationId: 'upgradeShipSystem',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -221,7 +221,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Fresh ship state after the upgrade',
|
description: 'Fresh ship state — the upgrade lands when the task completes on the turn tick',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/ShipState' },
|
schema: { $ref: '#/components/schemas/ShipState' },
|
||||||
@@ -229,7 +229,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
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: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -237,7 +237,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown ship system id',
|
description: 'Unknown ship system id or unknown crew member',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -245,7 +245,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description: 'System is not built or already at max level',
|
description: 'System is not built or already at max level — 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: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -267,7 +267,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Expand a ship system',
|
summary: 'Expand a ship system',
|
||||||
description:
|
description:
|
||||||
"Raise a built ship system's capacity by one (capped at max capacity). Costs `capacity` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.",
|
"Raise a built ship system's capacity by one (capped at max capacity). Assign the crew team 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; queueing spends no turn. Returns the fresh ship state.",
|
||||||
operationId: 'expandShipSystem',
|
operationId: 'expandShipSystem',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -279,7 +279,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Fresh ship state after the expansion',
|
description: 'Fresh ship state — the expansion lands when the task completes on the turn tick',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/ShipState' },
|
schema: { $ref: '#/components/schemas/ShipState' },
|
||||||
@@ -287,7 +287,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
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: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -295,7 +295,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown ship system id',
|
description: 'Unknown ship system id or unknown crew member',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -303,7 +303,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description: 'System is not built or already at max capacity',
|
description: 'System is not built or already at max capacity — 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: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -325,7 +325,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Build a ship system',
|
summary: 'Build a ship system',
|
||||||
description:
|
description:
|
||||||
'Build an unbuilt ship system type at base values. Costs a flat amount of parts, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.',
|
'Build an unbuilt ship system type at base values. Assign the crew team 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; queueing spends no turn. Returns the fresh ship state.',
|
||||||
operationId: 'buildShipSystem',
|
operationId: 'buildShipSystem',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -337,7 +337,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Fresh ship state after the build',
|
description: 'Fresh ship state — the build lands when the task completes on the turn tick',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/ShipState' },
|
schema: { $ref: '#/components/schemas/ShipState' },
|
||||||
@@ -345,7 +345,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
400: {
|
||||||
description: 'Malformed body or empty type',
|
description: 'Malformed body, a non-string or empty type, a non-integer leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -353,7 +353,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown ship system type',
|
description: 'Unknown ship system type or unknown crew member',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -361,7 +361,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description: 'System is already built',
|
description: 'System is already built, or the build-gate tech is not researched yet — 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: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -401,7 +401,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Research a tech',
|
summary: 'Research a tech',
|
||||||
description:
|
description:
|
||||||
"Validates the prerequisites (409, naming what is missing) and the cost against the cargo hold (422), spends the cost from the oldest row first, marks the tech researched, increments the turn counter and writes a research event. Returns the fresh tech tree state.",
|
"Validates the prerequisites (409, naming what is missing) and assigns the crew team, queueing 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; queueing spends no turn. Returns the fresh tech tree state.",
|
||||||
operationId: 'researchTech',
|
operationId: 'researchTech',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -413,7 +413,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Fresh tech tree state after the research',
|
description: 'Fresh tech tree state — the research lands when the task completes on the turn tick',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } },
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } },
|
||||||
@@ -421,7 +421,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
400: {
|
||||||
description: 'Malformed body or non-integer techId',
|
description: 'Malformed body, a non-integer techId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -429,7 +429,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown tech id',
|
description: 'Unknown tech id or unknown crew member',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -437,7 +437,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description: 'Tech is already researched or a prerequisite is not researched yet (message names the missing prereq)',
|
description: 'Tech is already researched or a prerequisite is not researched yet (message names the missing prereq) — 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: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -477,7 +477,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Accept a mission',
|
summary: 'Accept a mission',
|
||||||
description:
|
description:
|
||||||
"Makes an AVAILABLE contract the single ACTIVE one (409 while another is active, or when it isn't AVAILABLE). Costs a turn and writes a mission event. Returns the fresh board.",
|
"Makes an AVAILABLE contract the single ACTIVE one (409 while another is active, or when it isn't AVAILABLE). Instant and free — no turn, no body — and writes a mission event at the current turn. Returns the fresh board.",
|
||||||
operationId: 'acceptMission',
|
operationId: 'acceptMission',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
@@ -828,7 +828,7 @@ export const openApiSpec = {
|
|||||||
maxItems: 2,
|
maxItems: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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' },
|
turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' },
|
||||||
},
|
},
|
||||||
required: ['systems', 'edges', 'turn'],
|
required: ['systems', 'edges', 'turn'],
|
||||||
},
|
},
|
||||||
@@ -907,7 +907,7 @@ export const openApiSpec = {
|
|||||||
items: { $ref: '#/components/schemas/ShipSystem' },
|
items: { $ref: '#/components/schemas/ShipSystem' },
|
||||||
},
|
},
|
||||||
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
||||||
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)' },
|
turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' },
|
||||||
},
|
},
|
||||||
required: ['systems', 'inventory', 'turn'],
|
required: ['systems', 'inventory', 'turn'],
|
||||||
},
|
},
|
||||||
@@ -938,8 +938,14 @@ export const openApiSpec = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
techId: { type: 'integer', description: 'tech.id of the tech to research' },
|
techId: { type: 'integer', description: 'tech.id of the tech to research' },
|
||||||
|
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: ['techId'],
|
required: ['techId', 'leadCrewId'],
|
||||||
},
|
},
|
||||||
Mission: {
|
Mission: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -961,7 +967,7 @@ export const openApiSpec = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'integer' },
|
id: { type: 'integer' },
|
||||||
turn: { type: 'integer', description: 'The save turn the action cost (new_game is 0)' },
|
turn: { type: 'integer', description: 'The save turn the event was written (new_game is 0)' },
|
||||||
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] },
|
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] },
|
||||||
message: { type: 'string' },
|
message: { type: 'string' },
|
||||||
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
|
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
|
||||||
@@ -972,22 +978,40 @@ export const openApiSpec = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
systemId: { type: 'integer', description: 'ship_systems.id of the built system to level up' },
|
systemId: { type: 'integer', description: 'ship_systems.id of the built system to level up' },
|
||||||
|
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'],
|
||||||
},
|
},
|
||||||
ExpandRequest: {
|
ExpandRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
systemId: { type: 'integer', description: 'ship_systems.id of the built system to expand' },
|
systemId: { type: 'integer', description: 'ship_systems.id of the built system to expand' },
|
||||||
|
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'],
|
||||||
},
|
},
|
||||||
BuildRequest: {
|
BuildRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
type: { type: 'string', description: "Ship system type to build, e.g. WEAPONS (see ShipSystem.type)" },
|
type: { type: 'string', description: "Ship system type to build, e.g. WEAPONS (see ShipSystem.type)" },
|
||||||
|
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: ['type'],
|
required: ['type', 'leadCrewId'],
|
||||||
},
|
},
|
||||||
SystemState: {
|
SystemState: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1000,7 +1024,7 @@ export const openApiSpec = {
|
|||||||
items: { $ref: '#/components/schemas/Planet' },
|
items: { $ref: '#/components/schemas/Planet' },
|
||||||
},
|
},
|
||||||
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
||||||
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' },
|
turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' },
|
||||||
},
|
},
|
||||||
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'],
|
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user