feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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' }],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user