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
+5
View File
@@ -40,6 +40,11 @@ const STARTING_CREW_MIN = 2
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)
// 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 {
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 { TECHS } from './tech'
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 { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
@@ -308,25 +309,14 @@ describe('explorePlanet crew finds', () => {
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
//
// Each test builds the identical deterministic fixture in two fresh files (seed '42',
// stamped save → no backfill; ids line up across worlds) and compares their full state:
// Each test builds the identical deterministic fixture (buildWorldFixture — the shared
// 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);
// 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
// queue time — Task 3). snapshot() normalizes every game table (wall-clock columns
// 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
// 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 }))
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 () => {
@@ -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,
// before the final persist); this function owns the table writes.
describe('runTurn (tick on the DB)', () => {
// buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no
// backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) /
// Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so
// 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
}
// buildWorldFixture (the shared suite in ./test-fixtures) seeds the save + Origin /
// Outpost / Farpoint + Alder / Basalt + the route chain — but no crew or tasks; the tick
// needs both, so each test here mints its own rows via addCrew / insertTaskRow.
it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => {
const { db } = await freshFile()
await buildWorldFixture(db)
+57 -2
View File
@@ -293,6 +293,27 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
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 ---------------------------------------------
export interface PlanetDto {
@@ -438,6 +459,16 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
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 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 }
}
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)
const [save] = await db.db.select().from(schema.saves).limit(1)
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.tech),
])
const state: QueueState = {
return {
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 })),
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 })),
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)
+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) {
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', () => {
@@ -134,6 +140,12 @@ describe('OpenAPI spec', () => {
for (const status of ['400', '404', '409'] as const) {
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', () => {
+33 -20
View File
@@ -1,4 +1,5 @@
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
// served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated
@@ -8,7 +9,7 @@ export const openApiSpec = {
info: {
title: 'SpaceWanderer API',
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.
version: pkg.version,
},
@@ -52,9 +53,9 @@ export const openApiSpec = {
},
'/api/jump': {
post: {
summary: 'Jump to a solar system within range',
summary: 'Queue a jump to a solar system within range',
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',
requestBody: {
required: true,
@@ -66,7 +67,7 @@ export const openApiSpec = {
},
responses: {
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: {
'application/json': {
schema: { $ref: '#/components/schemas/WorldState' },
@@ -74,7 +75,7 @@ export const openApiSpec = {
},
},
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: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
@@ -82,7 +83,7 @@ export const openApiSpec = {
},
},
404: {
description: 'Unknown system id',
description: 'Unknown system id or unknown crew member',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
@@ -90,7 +91,7 @@ export const openApiSpec = {
},
},
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: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
@@ -120,9 +121,9 @@ export const openApiSpec = {
},
'/api/explore': {
post: {
summary: 'Explore a planet or moon',
summary: 'Queue a scan of a planet or moon',
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',
requestBody: {
required: true,
@@ -134,7 +135,7 @@ export const openApiSpec = {
},
responses: {
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: {
'application/json': {
schema: { $ref: '#/components/schemas/ExploreResult' },
@@ -142,7 +143,7 @@ export const openApiSpec = {
},
},
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: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
@@ -150,7 +151,7 @@ export const openApiSpec = {
},
},
404: {
description: 'Unknown planet id',
description: 'Unknown planet id or unknown crew member',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
@@ -158,7 +159,7 @@ export const openApiSpec = {
},
},
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: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
@@ -827,7 +828,7 @@ export const openApiSpec = {
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'],
},
@@ -835,8 +836,14 @@ export const openApiSpec = {
type: 'object',
properties: {
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: {
type: 'object',
@@ -900,7 +907,7 @@ export const openApiSpec = {
items: { $ref: '#/components/schemas/ShipSystem' },
},
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'],
},
@@ -993,7 +1000,7 @@ export const openApiSpec = {
items: { $ref: '#/components/schemas/Planet' },
},
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'],
},
@@ -1001,8 +1008,14 @@ export const openApiSpec = {
type: 'object',
properties: {
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: {
allOf: [
@@ -1012,11 +1025,11 @@ export const openApiSpec = {
properties: {
outcome: {
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' },
},
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' }],
},
},