feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)

This commit is contained in:
2026-10-02 15:47:31 +00:00
parent d7faf739a9
commit 4e312d054f
10 changed files with 455 additions and 66 deletions
+84
View File
@@ -0,0 +1,84 @@
// Handler-level tests for the queueing explore route (Task 5a): POST /api/explore with a crew
// team queues an EXPLORE row instead of resolving instantly — same system snapshot (findings key
// kept, empty), same pre-check codes, no turn spent at queue time.
import { eq } from 'drizzle-orm'
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 { getSystemState, type SystemState } from '@/lib/game/save'
import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures'
import { POST } from './route'
const dirs: string[] = []
// The route talks to the app-process singleton (getGameDb), so each test points it at a
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
// sql.js copies of one file would never see each other's writes.
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
async function freshFile(): Promise<{ db: GameDb; file: string }> {
const dir = await mkdtemp(path.join(tmpdir(), 'sw-explore-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/explore', { method: 'POST', body: JSON.stringify(body) }))
describe('POST /api/explore (5a)', () => {
it('queues an EXPLORE: row with type/payload, turn unchanged, planet not yet scanned, no haul', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
const [before] = await db.db.select().from(schema.saves)
const invBefore = (await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)
const res = await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })
expect(res.status).toBe(200)
const body = await res.json()
const sys = await getSystemState(db)
for (const k of Object.keys(sys) as (keyof SystemState)[]) expect(body[k]).toEqual(sys[k]) // today's system snapshot part unchanged; the findings key stays, empty (JC-2)
const [row] = await db.db.select().from(schema.tasks)
expect(row).toMatchObject({ type: 'EXPLORE', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column
expect(JSON.parse(row.payload)).toEqual({ planetId: f.p0 })
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(false) // the effect waits for the tick
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)).toEqual(invBefore) // no haul yet
})
it('unknown crew → 404 (queueTask)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
expect((await post({ planetId: f.p0, leadCrewId: 999 })).status).toBe(404)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('busy helper → 409, nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
expect((await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
})
it('already scanned → 409 (existing pre-check, existing code)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, f.p0))
expect((await post({ planetId: f.p0, leadCrewId: c1 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
})
+34 -5
View File
@@ -1,8 +1,13 @@
import { getGameDb } from '@/lib/db/client' import { getGameDb } from '@/lib/db/client'
import { ExploreError, explorePlanet } from '@/lib/game/save' import { MAX_TEAM_SIZE } from '@/lib/game/crew'
import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
import { queueTask, TaskError } from '@/lib/game/tasks'
// Scan one body of the current system. Body: { "planetId": number }. Returns the fresh // Scan one body of the current system: assign the crew team (lead + helpers) and queue the
// system state plus what this scan found. Costs a turn; each body can only be scanned once. // EXPLORE task — the haul, the scanned flag, the crew roll and the event land when the task
// completes on the turn tick (runTurn). Body: { planetId, leadCrewId, helperCrewIds? }.
// Queueing spends no turn; returns the same system snapshot as the instant scan, with the
// findings key empty until the scan resolves (JC-2).
export async function POST(req: Request) { export async function POST(req: Request) {
let body: unknown let body: unknown
try { try {
@@ -15,12 +20,36 @@ 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
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 })
}
try { try {
const db = await getGameDb() const db = await getGameDb()
return Response.json(await explorePlanet(db, planetId)) 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 })
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) { } catch (err) {
if (err instanceof ExploreError) { if (err instanceof ExploreError || 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
+109
View File
@@ -0,0 +1,109 @@
// Handler-level tests for the queueing jump route (Task 5a): POST /api/jump with a crew
// team queues a JUMP row instead of resolving instantly — same snapshots, 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 { getWorldState } from '@/lib/game/save'
import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures'
import { POST } from './route'
const dirs: string[] = []
// The route talks to the app-process singleton (getGameDb), so each test points it at a
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
// sql.js copies of one file would never see each other's writes.
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
async function freshFile(): Promise<{ db: GameDb; file: string }> {
const dir = await mkdtemp(path.join(tmpdir(), 'sw-jump-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/jump', { method: 'POST', body: JSON.stringify(body) }))
describe('POST /api/jump (5a)', () => {
it('queues a JUMP: row with type/payload/status, turn unchanged, snapshot preserved, ship not moved', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
const [before] = await db.db.select().from(schema.saves)
const res = await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(await getWorldState(db)) // the same fresh snapshot as today — queueing changes nothing else
const [row] = await db.db.select().from(schema.tasks)
expect(row).toMatchObject({ type: 'JUMP', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column
expect(JSON.parse(row.payload)).toEqual({ systemId: f.sys1, hops: 1 }) // route-computed via the existing reachability logic
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the effect waits for the tick
})
it('unknown crew → 404 (queueTask)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
expect((await post({ systemId: f.sys1, leadCrewId: 999 })).status).toBe(404)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('busy helper → 409, nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
expect((await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
})
it('a jump in transit → 409 (blocks any new task)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 3, remaining: 3, status: 'ACTIVE', queuedAtTurn: 1 })
expect((await post({ systemId: f.sys1, leadCrewId: c1 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
})
it('a jump already pending → second jump 409', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 1 }) // lead chains behind its own ACTIVE
expect((await post({ systemId: f.sys1, leadCrewId: c2 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(2)
})
it('unreachable target → 409 (existing pre-check, existing code)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
expect((await post({ systemId: f.farSystem, leadCrewId: c1 })).status).toBe(409) // JC-4: a beyond-reach system in the fixture; add it to buildWorldFixture if absent
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('malformed crew fields → 400, nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
expect((await post({ systemId: f.sys1 })).status).toBe(400) // leadCrewId is required
expect((await post({ systemId: f.sys1, leadCrewId: 'x' })).status).toBe(400)
expect((await post({ systemId: f.sys1, leadCrewId: 1.5 })).status).toBe(400)
expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: 'nope' })).status).toBe(400)
expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: [1, 'x'] })).status).toBe(400)
expect((await post('not json at all' as unknown)).status).toBe(400)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
})
+33 -4
View File
@@ -1,7 +1,12 @@
import { getGameDb } from '@/lib/db/client' import { getGameDb } from '@/lib/db/client'
import { JumpError, jumpToSystem } from '@/lib/game/save' import { MAX_TEAM_SIZE } from '@/lib/game/crew'
import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
import { queueTask, TaskError } from '@/lib/game/tasks'
// Jump to an adjacent solar system. Body: { "systemId": number }. Returns the fresh world state. // Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP
// task — the move lands when the task completes on the turn tick (runTurn). Body:
// { systemId, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh
// world state as the instant jump did.
export async function POST(req: Request) { export async function POST(req: Request) {
let body: unknown let body: unknown
try { try {
@@ -14,12 +19,36 @@ 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
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 })
}
try { try {
const db = await getGameDb() const db = await getGameDb()
return Response.json(await jumpToSystem(db, systemId)) 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 })
await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write
return Response.json(await getWorldState(db))
} catch (err) { } catch (err) {
if (err instanceof JumpError) { if (err instanceof JumpError || 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
+5
View File
@@ -40,6 +40,11 @@ const STARTING_CREW_MIN = 2
const STARTING_CREW_SPREAD = 2 // 2..3 starting members const STARTING_CREW_SPREAD = 2 // 2..3 starting members
export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans) export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans)
// A task team is one lead plus up to this many members total — the queueing routes reject
// a bigger team with a 400 (spec §3 leaves the cap to balance; 4 lets a full crew bring
// RESEARCH's 9 work points down to 3 turns, matching a solo L5 specialist).
export const MAX_TEAM_SIZE = 4
function title(word: string): string { function title(word: string): string {
return word.charAt(0) + word.slice(1).toLowerCase() return word.charAt(0) + word.slice(1).toLowerCase()
} }
+8 -35
View File
@@ -10,6 +10,7 @@ import { MIN_AVAILABLE } from './missions'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { TECHS } from './tech' import { TECHS } from './tech'
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save' import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save'
import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures'
import type { Task } from './tasks' import type { Task } from './tasks'
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
@@ -308,25 +309,14 @@ describe('explorePlanet crew finds', () => {
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) ------------------------- // --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
// //
// Each test builds the identical deterministic fixture in two fresh files (seed '42', // Each test builds the identical deterministic fixture (buildWorldFixture — the shared
// stamped save → no backfill; ids line up across worlds) and compares their full state: // fixture suite in ./test-fixtures: seed '42', stamped save → no backfill; ids line up
// across worlds) in two fresh files and compares their full state:
// A = the legacy immediate action (validates, costs a turn, applies the effect); // A = the legacy immediate action (validates, costs a turn, applies the effect);
// B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver // B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver
// applies the effect (ship/research rows deduct the cost up front, as queueTask did at // applies the effect (ship/research rows deduct the cost up front, as queueTask did at
// queue time — Task 3). snapshot() normalizes every game table (wall-clock columns // queue time — Task 3). snapshot() normalizes every game table (wall-clock columns
// excluded), so the two end states must be byte-identical. // excluded), so the two end states must be byte-identical.
const T0 = '2026-10-01T00:00:00.000Z'
async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id })
const [p0] = await db.db.insert(schema.planets).values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id })
const [p1] = await db.db.insert(schema.planets).values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id })
return { sys1: s1.id, p0: p0.id, p1: p1.id }
}
// The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed // The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed
// identity fields, the same way the public functions' delegation does. // identity fields, the same way the public functions' delegation does.
@@ -374,7 +364,7 @@ describe('resolveJumpTask (A/B against jumpToSystem)', () => {
await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 })) await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
expect(await snapshot(b)).toEqual(await snapshot(a)) expect(await snapshot(b)).toEqual(await snapshot(a))
expect(await b.db.select().from(schema.solarSystems)).toHaveLength(2) // no ring added expect(await b.db.select().from(schema.solarSystems)).toHaveLength(3) // the fixture's three systems, no ring added
}) })
it('guards: 404 unknown system, 409 already-current', async () => { it('guards: 404 unknown system, 409 already-current', async () => {
@@ -447,26 +437,9 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => {
// effects and the mission hooks (their contract — invoked here, at the tick's new turn, // effects and the mission hooks (their contract — invoked here, at the tick's new turn,
// before the final persist); this function owns the table writes. // before the final persist); this function owns the table writes.
describe('runTurn (tick on the DB)', () => { describe('runTurn (tick on the DB)', () => {
// buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no // buildWorldFixture (the shared suite in ./test-fixtures) seeds the save + Origin /
// backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) / // Outpost / Farpoint + Alder / Basalt + the route chain — but no crew or tasks; the tick
// Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so // needs both, so each test here mints its own rows via addCrew / insertTaskRow.
// this block adds its own rows.
async function addCrew(db: GameDb, count: number): Promise<number[]> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const ids: number[] = []
for (let i = 0; i < count; i++) {
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
ids.push(row.id)
}
return ids
}
async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise<number> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id })
return row.id
}
it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => { it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => {
const { db } = await freshFile() const { db } = await freshFile()
await buildWorldFixture(db) await buildWorldFixture(db)
+57 -2
View File
@@ -293,6 +293,27 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
return getWorldState(db) return getWorldState(db)
} }
// The jump pre-checks, extracted for the queueing route (5a): the same codes and messages
// as jumpToSystem — unknown system (404), already there (409), outside range (409) — and
// the hop count (reachableWithin + NAV AI, the existing reachability logic) the route puts
// in the queued JUMP payload.
export async function checkJumpTarget(db: GameDb, systemId: number): Promise<number> {
await ensureSave(db)
const systems = await db.db.select().from(schema.solarSystems)
const target = systems.find((s) => s.id === systemId)
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
const current = systems.find((s) => s.isCurrent)!
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
const routes = await db.db.select().from(schema.jumpRoutes)
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops.
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
return hopCount
}
// --- Local system + exploration --------------------------------------------- // --- Local system + exploration ---------------------------------------------
export interface PlanetDto { export interface PlanetDto {
@@ -438,6 +459,16 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
return { ...(await readSystemState(db)), outcome, crewFound } return { ...(await readSystemState(db)), outcome, crewFound }
} }
// The explore pre-checks, extracted for the queueing route (5a): the same codes and messages
// as explorePlanet — unknown body (404), already scanned (409).
export async function checkExploreTarget(db: GameDb, planetId: number): Promise<void> {
await ensureSave(db)
const planets = await db.db.select().from(schema.planets)
const target = planets.find((p) => p.id === planetId)
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
}
// --- The turn tick on the DB (POST /api/turn — Task 5 wires the route) --------------------- // --- The turn tick on the DB (POST /api/turn — Task 5 wires the route) ---------------------
// //
// The route→save pattern: ensureSave first, unscoped single-player reads, persist last. // The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
@@ -462,7 +493,10 @@ function rowToTask(row: { id: number; saveId: number; type: string; payload: str
return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn } return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn }
} }
export async function runTurn(db: GameDb): Promise<TickResult> { // The plain queue snapshot Task 3's engine runs over, mapped from the schema's rows: crew,
// tasks, inventory, shipSystems and tech, all in id order (single save, unscoped). The route
// never touches lib/db/schema, so the single save row is resolved here, not passed in.
export async function queueStateFromSave(db: GameDb): Promise<QueueState> {
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)
const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([ const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([
@@ -472,7 +506,7 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
db.db.select().from(schema.shipSystems), db.db.select().from(schema.shipSystems),
db.db.select().from(schema.tech), db.db.select().from(schema.tech),
]) ])
const state: QueueState = { return {
turn: save.turn, turn: save.turn,
crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })), crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
tasks: taskRows.map(rowToTask), tasks: taskRows.map(rowToTask),
@@ -480,6 +514,27 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })), shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
tech: tech.map((t) => ({ id: t.id, slug: t.slug })), tech: tech.map((t) => ({ id: t.id, slug: t.slug })),
} }
}
// The route's DB-boundary insert (the route→save pattern): queueTask's row minus the two
// values only the DB boundary knows (id autoincrement, saveId = the single save's id).
// payload is already a JSON string from queueTask (passed through); the crew ids serialize
// to the JSON column. Persists so the queue survives a crash; returns the new row id.
export async function saveTaskRow(db: GameDb, task: Omit<Task, 'id' | 'saveId'>): Promise<number> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const [row] = await db.db
.insert(schema.tasks)
.values({ saveId: save.id, type: task.type, payload: task.payload, leadCrewId: task.leadCrewId, helpers: JSON.stringify(task.helpers), duration: task.duration, remaining: task.remaining, status: task.status, queuedAtTurn: task.queuedAtTurn })
.returning({ id: schema.tasks.id })
await persist(db)
return row.id
}
export async function runTurn(db: GameDb): Promise<TickResult> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const state = await queueStateFromSave(db)
const result = tickTurn(state) const result = tickTurn(state)
+80
View File
@@ -0,0 +1,80 @@
// Shared fixture suite for the game tests (save.test.ts, the 5a route tests, and the later
// ship/research route tests): one deterministic world — seed '42', save stamped at the
// current schema version so no backfill runs — plus the crew and task-row minters the
// queueing tests use. Ids line up across worlds built in fresh files (same insert order),
// so two fixture worlds are comparable row for row.
import type { GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { SCHEMA_VERSION } from './save'
// Fixed so a fixture's event rows carry a stable created_at (tests never assert on it, but
// a wall-clock Date would make two fixture worlds differ byte-for-byte).
export const T0 = '2026-10-01T00:00:00.000Z'
// Origin (s0, id 1, current) — Outpost (s1, one hop: the jump target) — Farpoint (s2, two
// hops: beyond one-hop range, so the unreachable pre-check has a target to 409 on; with
// NAVIGATION AI researched it would be reachable). p0 'Alder' in s0, p1 'Basalt' in s1.
// No crew, tasks, ship, or tech rows — the caller mints exactly what a test needs.
export async function buildWorldFixture(
db: GameDb,
outpostDiscovered = false,
): Promise<{ sys1: number; p0: number; p1: number; farSystem: number }> {
const [save] = await db.db
.insert(schema.saves)
.values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION })
.returning()
const [s0] = await db.db
.insert(schema.solarSystems)
.values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true })
.returning({ id: schema.solarSystems.id })
const [s1] = await db.db
.insert(schema.solarSystems)
.values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered })
.returning({ id: schema.solarSystems.id })
const [s2] = await db.db
.insert(schema.solarSystems)
.values({ saveId: save.id, name: 'Farpoint', x: 60, y: 50, discovered: false })
.returning({ id: schema.solarSystems.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s2.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s2.id, toSystem: s1.id })
const [p0] = await db.db
.insert(schema.planets)
.values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false })
.returning({ id: schema.planets.id })
const [p1] = await db.db
.insert(schema.planets)
.values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false })
.returning({ id: schema.planets.id })
return { sys1: s1.id, p0: p0.id, p1: p1.id, farSystem: s2.id }
}
// `count` identical crew members, in insertion order — the queueing tests need real crew
// ids to lead and help with.
export async function addCrew(db: GameDb, count: number): Promise<number[]> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const ids: number[] = []
for (let i = 0; i < count; i++) {
const [row] = await db.db
.insert(schema.crew)
.values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' })
.returning({ id: schema.crew.id })
ids.push(row.id)
}
return ids
}
// Mint one tasks-table row with a chosen state — the queueing/tick tests seed the queue
// directly instead of going through the routes (which would run the same validation).
export async function insertTaskRow(
db: GameDb,
over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number },
): Promise<number> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const [row] = await db.db
.insert(schema.tasks)
.values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn })
.returning({ id: schema.tasks.id })
return row.id
}
+12
View File
@@ -122,6 +122,12 @@ describe('OpenAPI spec', () => {
for (const status of ['400', '404', '409'] as const) { for (const status of ['400', '404', '409'] as const) {
expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
} }
// The queued-scan request: the scan target plus the crew team (lead required, helpers optional).
const exploreReq = openApiSpec.components.schemas.ExploreRequest as { properties: Record<string, unknown>; required: string[] };
expect(Object.keys(exploreReq.properties)).toEqual(['planetId', 'leadCrewId', 'helperCrewIds']);
expect(exploreReq.required).toEqual(['planetId', 'leadCrewId']);
expect((exploreReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
}); });
it('describes the world endpoints as WorldState in and out', () => { it('describes the world endpoints as WorldState in and out', () => {
@@ -134,6 +140,12 @@ describe('OpenAPI spec', () => {
for (const status of ['400', '404', '409'] as const) { for (const status of ['400', '404', '409'] as const) {
expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
} }
// The queued-jump request: the jump target plus the crew team (lead required, helpers optional).
const jumpReq = openApiSpec.components.schemas.JumpRequest as { properties: Record<string, unknown>; required: string[] };
expect(Object.keys(jumpReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']);
expect(jumpReq.required).toEqual(['systemId', 'leadCrewId']);
expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
}); });
it('keeps info.version in sync with package.json', () => { it('keeps info.version in sync with package.json', () => {
+33 -20
View File
@@ -1,4 +1,5 @@
import pkg from '@/package.json'; import pkg from '@/package.json';
import { MAX_TEAM_SIZE } from './game/crew';
// OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be // OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be
// served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated // served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated
@@ -8,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 and jump between solar systems within range (adjacent by default; two hops once NAVIGATION AI is researched).', '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.',
// 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,
}, },
@@ -52,9 +53,9 @@ export const openApiSpec = {
}, },
'/api/jump': { '/api/jump': {
post: { post: {
summary: 'Jump to a solar system within range', summary: 'Queue a jump to a solar system within range',
description: description:
'Validates that the target is within jump range of the current system — one hop away, or two hops once NAVIGATION AI is researched (#35) — sets it as current (discovering it if new and generating its neighbours), advances the turn counter by the hop count and writes a jump event. Returns the fresh world state.', 'Queues a JUMP task with the given crew team (lead + helpers): validates that the target is within jump range of the current system (one hop away, or two hops once NAVIGATION AI is researched — #35) and that the team is free, then the move — target becomes current, discovered if new with its neighbours generated, jump event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh world state.',
operationId: 'jumpToSystem', operationId: 'jumpToSystem',
requestBody: { requestBody: {
required: true, required: true,
@@ -66,7 +67,7 @@ export const openApiSpec = {
}, },
responses: { responses: {
200: { 200: {
description: 'Fresh world state after the jump', description: 'Fresh world state — the jump itself lands when the task completes on the turn tick',
content: { content: {
'application/json': { 'application/json': {
schema: { $ref: '#/components/schemas/WorldState' }, schema: { $ref: '#/components/schemas/WorldState' },
@@ -74,7 +75,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' },
@@ -82,7 +83,7 @@ export const openApiSpec = {
}, },
}, },
404: { 404: {
description: 'Unknown system id', description: 'Unknown system id or unknown crew member',
content: { content: {
'application/json': { 'application/json': {
schema: { $ref: '#/components/schemas/Error' }, schema: { $ref: '#/components/schemas/Error' },
@@ -90,7 +91,7 @@ export const openApiSpec = {
}, },
}, },
409: { 409: {
description: 'Target is outside jump range of the current system — more than one hop away, or more than two hops without NAVIGATION AI researched (or is the current system itself)', description: 'Target is outside jump range of the current system (more than one hop away, or more than two without NAVIGATION AI; or is the current system itself) — 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' },
@@ -120,9 +121,9 @@ export const openApiSpec = {
}, },
'/api/explore': { '/api/explore': {
post: { post: {
summary: 'Explore a planet or moon', summary: 'Queue a scan of a planet or moon',
description: description:
'Scans one body of the save with a deterministic haul (a resource and/or a part, or nothing), marks it explored, increments the turn counter, accumulates finds into inventory and writes an explore event. Returns the fresh system state plus this scan outcome.', 'Queues an EXPLORE task with the given crew team (lead + helpers): validates that the body exists and has not been scanned yet, and that the team is free, then the scan — a deterministic haul into the cargo hold, the explored flag, the ~18% crew roll and the explore event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh system state, with the outcome key empty and crewFound null until the scan resolves.',
operationId: 'explorePlanet', operationId: 'explorePlanet',
requestBody: { requestBody: {
required: true, required: true,
@@ -134,7 +135,7 @@ export const openApiSpec = {
}, },
responses: { responses: {
200: { 200: {
description: 'Fresh system state plus the scan outcome', description: 'Fresh system state — the scan itself lands when the task completes on the turn tick (outcome empty, crewFound null)',
content: { content: {
'application/json': { 'application/json': {
schema: { $ref: '#/components/schemas/ExploreResult' }, schema: { $ref: '#/components/schemas/ExploreResult' },
@@ -142,7 +143,7 @@ export const openApiSpec = {
}, },
}, },
400: { 400: {
description: 'Malformed body or non-integer planetId', description: 'Malformed body, a non-integer planetId/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' },
@@ -150,7 +151,7 @@ export const openApiSpec = {
}, },
}, },
404: { 404: {
description: 'Unknown planet id', description: 'Unknown planet id or unknown crew member',
content: { content: {
'application/json': { 'application/json': {
schema: { $ref: '#/components/schemas/Error' }, schema: { $ref: '#/components/schemas/Error' },
@@ -158,7 +159,7 @@ export const openApiSpec = {
}, },
}, },
409: { 409: {
description: 'Body was already explored (each can only be scanned once)', description: 'Body was already explored (each can only be scanned once) — 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' },
@@ -827,7 +828,7 @@ export const openApiSpec = {
maxItems: 2, maxItems: 2,
}, },
}, },
turn: { type: 'integer', description: 'Turn counter — one per jump' }, 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' },
}, },
required: ['systems', 'edges', 'turn'], required: ['systems', 'edges', 'turn'],
}, },
@@ -835,8 +836,14 @@ export const openApiSpec = {
type: 'object', type: 'object',
properties: { properties: {
systemId: { type: 'integer', description: 'solar_systems.id of the jump target' }, systemId: { type: 'integer', description: 'solar_systems.id of the jump target' },
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'],
}, },
Planet: { Planet: {
type: 'object', type: 'object',
@@ -900,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 jump, exploration or ship action' }, 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)' },
}, },
required: ['systems', 'inventory', 'turn'], required: ['systems', 'inventory', 'turn'],
}, },
@@ -993,7 +1000,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 — one per jump or exploration' }, 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' },
}, },
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'], required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'],
}, },
@@ -1001,8 +1008,14 @@ export const openApiSpec = {
type: 'object', type: 'object',
properties: { properties: {
planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' }, planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' },
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: ['planetId'], required: ['planetId', 'leadCrewId'],
}, },
ExploreResult: { ExploreResult: {
allOf: [ allOf: [
@@ -1012,11 +1025,11 @@ export const openApiSpec = {
properties: { properties: {
outcome: { outcome: {
type: 'array', type: 'array',
description: 'What this scan found — may be empty (some scans find nothing)', description: 'What this scan found — always empty in the queueing response; populated only once the scan resolves on the turn tick (a resolved scan may still find nothing)',
items: { $ref: '#/components/schemas/FoundItem' }, items: { $ref: '#/components/schemas/FoundItem' },
}, },
crewFound: { crewFound: {
description: 'A crew member who joined the ship on this scan (~18% of scans), null when none', description: 'A crew member who joined the ship on this scan (~18% of scans), null when none — always null in the queueing response',
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }], anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
}, },
}, },