feat(api): world/system/crew snapshots expose the active + pending task queue (#44)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { getCrewState } from '@/lib/game/save'
|
||||
|
||||
// The ship's crew roster: starting crew plus members found while exploring. Auto-initializes on first hit.
|
||||
// The ship's crew roster wrapped with the snapshot task queue: { crew, tasks } — the roster
|
||||
// (starting crew plus members found while exploring) and the ACTIVE + PENDING queue. Auto-initializes on first hit.
|
||||
export async function GET() {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await getCrewState(db))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { CrewDto } from '@/lib/game/save'
|
||||
import type { CrewDto, CrewState } from '@/lib/game/save'
|
||||
|
||||
// CREW tab — the ship's roster: starting crew plus members found while exploring. Fetches
|
||||
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
|
||||
@@ -22,10 +22,10 @@ export function CrewPanel({ narrow }: CrewPanelProps) {
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as CrewDto[]
|
||||
return (await res.json()) as CrewState // { crew, tasks } — the endpoint wraps the roster (JC-7)
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setRoster(r)
|
||||
if (!cancelled) setRoster(r.crew)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||
import { SkillTreePanel } from '@/components/skill-tree'
|
||||
import { WorldMapPanel } from '@/components/world-map'
|
||||
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
||||
import type { CrewDto, SystemState } from '@/lib/game/save'
|
||||
import type { CrewState, SystemState } from '@/lib/game/save'
|
||||
import type { ShipState } from '@/lib/game/ship'
|
||||
import type { TechDto } from '@/lib/game/tech'
|
||||
import pkg from '@/package.json'
|
||||
@@ -94,10 +94,10 @@ export function MissionControl() {
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as CrewDto[]
|
||||
return (await res.json()) as CrewState // { crew, tasks } — the endpoint wraps the roster (JC-7)
|
||||
})
|
||||
.then((c) => {
|
||||
if (!cancelled) setCrewCount(c.length)
|
||||
if (!cancelled) setCrewCount(c.crew.length)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
|
||||
+51
-4
@@ -239,7 +239,7 @@ describe('explorePlanet', () => {
|
||||
describe('getCrewState', () => {
|
||||
it('seeds a starting crew of 2–3 members derived from the save seed', async () => {
|
||||
const { db } = await freshFile()
|
||||
const crew = await getCrewState(db) // ensureSave runs inside, so this also seeds the crew
|
||||
const crew = (await getCrewState(db)).crew // ensureSave runs inside, so this also seeds the crew (the roster moved under .crew — JC-7)
|
||||
|
||||
expect(crew.length).toBeGreaterThanOrEqual(2)
|
||||
expect(crew.length).toBeLessThanOrEqual(3)
|
||||
@@ -285,7 +285,7 @@ describe('explorePlanet crew finds', () => {
|
||||
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
|
||||
}
|
||||
|
||||
const before = await getCrewState(db) // empty — this save was hand-built, no starting crew seeded
|
||||
const before = (await getCrewState(db)).crew // empty — this save was hand-built, no starting crew seeded
|
||||
const result = await explorePlanet(db, crewPlanetId)
|
||||
|
||||
expect(result.crewFound).not.toBeNull()
|
||||
@@ -297,13 +297,13 @@ describe('explorePlanet crew finds', () => {
|
||||
expect([result.crewFound!.traitGood, result.crewFound!.traitBad]).toEqual([expectedMember.traitGood, expectedMember.traitBad])
|
||||
expect(result.crewFound!.origin).toBe(`${planetName}, Test System`)
|
||||
|
||||
const after = await getCrewState(db)
|
||||
const after = (await getCrewState(db)).crew
|
||||
expect(after.length).toBe(before.length + 1) // exactly one join
|
||||
expect(after.find((c) => c.id === result.crewFound!.id)).toMatchObject({ name: expectedMember.name, origin: `${planetName}, Test System` })
|
||||
|
||||
// The body is already explored → a second scan is rejected and no duplicate joins.
|
||||
await expect(explorePlanet(db, crewPlanetId)).rejects.toMatchObject({ status: 409 })
|
||||
expect((await getCrewState(db)).length).toBe(before.length + 1)
|
||||
expect((await getCrewState(db)).crew.length).toBe(before.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -540,6 +540,53 @@ describe('runTurn (tick on the DB)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- The GET snapshots' task queue (Task 6) ---------------------------------------------
|
||||
//
|
||||
// Every GET snapshot (world / system / crew) carries the active + pending queue: ACTIVE rows
|
||||
// first, then PENDING, each block ordered by (queuedAtTurn, id); DONE rows never appear. A
|
||||
// pure read — `remaining` is the stored value, never recomputed; crew names resolve from the
|
||||
// roster by id.
|
||||
describe('snapshot task queue (world / system / crew)', () => {
|
||||
// buildWorldFixture (4a): a save (seed '42') + Origin (s0, current) / Outpost (s1) +
|
||||
// Alder (p0) / Basalt (p1) + the route pair — no crew, no tasks; this block adds its own.
|
||||
|
||||
it('empty queue → every GET snapshot carries tasks: []', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
expect((await getWorldState(db)).tasks).toEqual([])
|
||||
expect((await getSystemState(db)).tasks).toEqual([])
|
||||
const crew = await getCrewState(db)
|
||||
expect(crew.tasks).toEqual([])
|
||||
expect(crew.crew).toEqual([]) // the new wrapper: the roster moves under .crew (JC-7)
|
||||
})
|
||||
|
||||
it('one ACTIVE + one PENDING row → exactly those two, ACTIVE first, in all three snapshots', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
const [c1, c2, c3] = await addCrew(db, 3)
|
||||
const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||||
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [c3], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 3 })
|
||||
const expected = [
|
||||
{ id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 },
|
||||
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4 },
|
||||
]
|
||||
expect((await getWorldState(db)).tasks).toEqual(expected)
|
||||
expect((await getSystemState(db)).tasks).toEqual(expected)
|
||||
expect((await getCrewState(db)).tasks).toEqual(expected)
|
||||
})
|
||||
|
||||
it('DONE rows never appear — even when they sort first by (queuedAtTurn, id)', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
const [c1] = await addCrew(db, 1)
|
||||
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 0, status: 'DONE', queuedAtTurn: 1 }) // oldest, lowest id in the queue
|
||||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 2 })
|
||||
const snap = await getWorldState(db)
|
||||
expect(snap.tasks).toHaveLength(1)
|
||||
expect(snap.tasks[0].type).toBe('EXPLORE') // the DONE row is filtered out, not reordered to the back
|
||||
})
|
||||
})
|
||||
|
||||
describe('backfill (old saves)', () => {
|
||||
// Planets/moons of one system in insertion order, nested by parent — ids are excluded so a
|
||||
// regenerated set compares equal to the original even though autoincrement moved on.
|
||||
|
||||
+81
-5
@@ -1,4 +1,4 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import type { GameDb } from '@/lib/db/client'
|
||||
import { persist } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
@@ -24,10 +24,23 @@ export interface WorldSystemDto {
|
||||
current: boolean
|
||||
}
|
||||
|
||||
// One row of the snapshot task queue — what the GET builders expose of the tasks table
|
||||
// (distinct from the tick's TickResult, which reports every row including DONE): the display
|
||||
// fields the UI needs, names resolved, no raw payload.
|
||||
export interface QueueDto {
|
||||
id: number
|
||||
type: TaskType
|
||||
targetLabel: string
|
||||
leadName: string
|
||||
helperNames: string[]
|
||||
remaining: number
|
||||
}
|
||||
|
||||
export interface WorldState {
|
||||
systems: WorldSystemDto[]
|
||||
edges: [number, number][] // undirected system-id pairs, deduped + sorted for stable output
|
||||
turn: number
|
||||
tasks: QueueDto[] // the active + pending queue — DONE rows never appear
|
||||
}
|
||||
|
||||
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||
@@ -188,6 +201,58 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema
|
||||
}
|
||||
}
|
||||
|
||||
// The snapshot task queue — the GET builders' read of the tasks table (distinct from the
|
||||
// tick's TickResult, which reports every row): one select of the tasks table filtered to
|
||||
// ACTIVE + PENDING (DONE rows never appear) plus one select each of the name-resolution
|
||||
// tables. The ACTIVE block comes first, then the PENDING block, each ordered by
|
||||
// (queuedAtTurn, id). Pure read: `remaining` is the stored row value, never recomputed;
|
||||
// crew names resolve from the roster by id, `unknown` tolerating a deleted member (the same
|
||||
// spirit as 4b's <= 0 check). Private — every caller is a public read that already ran
|
||||
// ensureSave.
|
||||
async function taskQueue(db: GameDb): Promise<QueueDto[]> {
|
||||
const [taskRows, crew, systems, planets, tech] = await Promise.all([
|
||||
db.db.select().from(schema.tasks).where(inArray(schema.tasks.status, ['ACTIVE', 'PENDING'])),
|
||||
db.db.select().from(schema.crew),
|
||||
db.db.select().from(schema.solarSystems),
|
||||
db.db.select().from(schema.planets),
|
||||
db.db.select().from(schema.tech),
|
||||
])
|
||||
|
||||
const systemName = new Map(systems.map((s): [number, string] => [s.id, s.name]))
|
||||
const planetName = new Map(planets.map((p): [number, string] => [p.id, p.name]))
|
||||
const techName = new Map(tech.map((t): [number, string] => [t.id, t.name]))
|
||||
const crewName = (id: number) => crew.find((c) => c.id === id)?.name ?? 'unknown'
|
||||
|
||||
// targetLabel from the 4a payload shapes, per type (the Task interface in tasks.ts): the
|
||||
// target's display name (the jump destination, the scanned body, the researched tech), the
|
||||
// ship system's name with the verb suffixed for upgrade/expand, and the SHIP_TYPES string
|
||||
// as-is for a build (its label lives in the payload, so it never misses). A deleted lookup
|
||||
// target falls back to `unknown`.
|
||||
const labelFor: Record<TaskType, (payload: string) => string> = {
|
||||
JUMP: (payload) => systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown',
|
||||
EXPLORE: (payload) => planetName.get((JSON.parse(payload) as { planetId: number }).planetId) ?? 'unknown',
|
||||
SHIP_UPGRADE: (payload) => `${systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown'} — upgrade`,
|
||||
SHIP_EXPAND: (payload) => `${systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown'} — expand`,
|
||||
SHIP_BUILD: (payload) => (JSON.parse(payload) as { type: string }).type,
|
||||
RESEARCH: (payload) => techName.get((JSON.parse(payload) as { techId: number }).techId) ?? 'unknown',
|
||||
}
|
||||
|
||||
const queue = taskRows.map((t) => ({
|
||||
id: t.id,
|
||||
type: t.type as TaskType,
|
||||
targetLabel: labelFor[t.type as TaskType](t.payload),
|
||||
leadName: crewName(t.leadCrewId),
|
||||
helperNames: (JSON.parse(t.helpers) as number[]).map(crewName),
|
||||
remaining: t.remaining,
|
||||
status: t.status as TaskStatus,
|
||||
queuedAtTurn: t.queuedAtTurn,
|
||||
}))
|
||||
const byQueueOrder = (rows: typeof queue) => rows.sort((a, b) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id)
|
||||
const ordered = [...byQueueOrder(queue.filter((t) => t.status === 'ACTIVE')), ...byQueueOrder(queue.filter((t) => t.status === 'PENDING'))]
|
||||
// The sort keys are only for ordering — the exposed shape is exactly QueueDto.
|
||||
return ordered.map(({ status, queuedAtTurn, ...dto }) => dto)
|
||||
}
|
||||
|
||||
export async function getWorldState(db: GameDb): Promise<WorldState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
@@ -211,6 +276,7 @@ export async function getWorldState(db: GameDb): Promise<WorldState> {
|
||||
systems: systems.map((s) => ({ id: s.id, name: s.name, x: s.x, y: s.y, discovered: s.discovered, current: s.isCurrent })),
|
||||
edges,
|
||||
turn: save.turn,
|
||||
tasks: await taskQueue(db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +403,7 @@ export interface SystemState {
|
||||
planets: PlanetDto[] // insertion order = display order (a moon follows its parent row)
|
||||
inventory: InventoryItemDto[]
|
||||
turn: number
|
||||
tasks: QueueDto[] // the active + pending queue — DONE rows never appear
|
||||
}
|
||||
|
||||
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||
@@ -362,7 +429,7 @@ async function readSystemState(db: GameDb): Promise<SystemState> {
|
||||
parent: p.parentId,
|
||||
}))
|
||||
const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty }))
|
||||
return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn }
|
||||
return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn, tasks: await taskQueue(db) }
|
||||
}
|
||||
|
||||
export async function getSystemState(db: GameDb): Promise<SystemState> {
|
||||
@@ -381,10 +448,19 @@ export interface CrewDto {
|
||||
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring
|
||||
}
|
||||
|
||||
// The full roster in join order (starting crew first, then finds). Auto-initializes like the other reads.
|
||||
export async function getCrewState(db: GameDb): Promise<CrewDto[]> {
|
||||
// GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot
|
||||
// carry a sibling tasks field, so the endpoint returns this object.
|
||||
export interface CrewState {
|
||||
crew: CrewDto[]
|
||||
tasks: QueueDto[]
|
||||
}
|
||||
|
||||
// The full roster in join order (starting crew first, then finds), wrapped with the queue.
|
||||
// Auto-initializes like the other reads.
|
||||
export async function getCrewState(db: GameDb): Promise<CrewState> {
|
||||
await ensureSave(db)
|
||||
return (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
|
||||
const crew = (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
|
||||
return { crew, tasks: await taskQueue(db) }
|
||||
}
|
||||
|
||||
export interface ExploreResult extends SystemState {
|
||||
|
||||
+23
-3
@@ -120,10 +120,20 @@ describe('OpenAPI spec', () => {
|
||||
expect(get.responses['400'].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||
});
|
||||
|
||||
it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => {
|
||||
it('describes /api/crew as the CrewState { crew, tasks } object and crewFound on ExploreResult', () => {
|
||||
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
||||
expect(crew.type).toBe('array');
|
||||
expect((crew.items as { $ref: string }).$ref).toBe('#/components/schemas/CrewMember');
|
||||
expect(crew.$ref).toBe('#/components/schemas/CrewState');
|
||||
|
||||
const crewState = openApiSpec.components.schemas.CrewState as { properties: Record<string, unknown>; required: string[] };
|
||||
expect(Object.keys(crewState.properties)).toEqual(['crew', 'tasks']);
|
||||
expect(crewState.required).toEqual(['crew', 'tasks']);
|
||||
expect((crewState.properties.crew as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/CrewMember');
|
||||
expect((crewState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto');
|
||||
|
||||
const taskDto = openApiSpec.components.schemas.TaskDto as { properties: Record<string, unknown>; required: string[] };
|
||||
expect(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining']);
|
||||
expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining']);
|
||||
expect((taskDto.properties.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']);
|
||||
|
||||
const member = openApiSpec.components.schemas.CrewMember.properties;
|
||||
expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']);
|
||||
@@ -137,6 +147,11 @@ describe('OpenAPI spec', () => {
|
||||
const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema;
|
||||
expect(system.$ref).toBe('#/components/schemas/SystemState');
|
||||
|
||||
// The snapshot carries the active + pending task queue.
|
||||
const systemState = openApiSpec.components.schemas.SystemState as { properties: Record<string, unknown>; required: string[] };
|
||||
expect(systemState.required).toEqual(['systemId', 'systemName', 'planets', 'inventory', 'turn', 'tasks']);
|
||||
expect((systemState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto');
|
||||
|
||||
const explore = openApiSpec.paths['/api/explore'].post;
|
||||
expect(explore.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreRequest');
|
||||
expect(explore.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreResult');
|
||||
@@ -158,6 +173,11 @@ describe('OpenAPI spec', () => {
|
||||
const world = openApiSpec.paths['/api/world'].get.responses[200].content['application/json'].schema;
|
||||
expect(world.$ref).toBe('#/components/schemas/WorldState');
|
||||
|
||||
// The snapshot carries the active + pending task queue.
|
||||
const worldState = openApiSpec.components.schemas.WorldState as { properties: Record<string, unknown>; required: string[] };
|
||||
expect(worldState.required).toEqual(['systems', 'edges', 'turn', 'tasks']);
|
||||
expect((worldState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto');
|
||||
|
||||
const jump = openApiSpec.paths['/api/jump'].post;
|
||||
expect(jump.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/JumpRequest');
|
||||
expect(jump.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/WorldState');
|
||||
|
||||
+49
-5
@@ -199,14 +199,14 @@ export const openApiSpec = {
|
||||
get: {
|
||||
summary: 'Crew roster',
|
||||
description:
|
||||
'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring planets/moons. Each member has one good and one bad trait and an origin. Auto-initializes a new game when no save exists yet.',
|
||||
'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring planets/moons — returned as the { crew, tasks } object, wrapped with the active + pending task queue. Each member has one good and one bad trait and an origin. Auto-initializes a new game when no save exists yet.',
|
||||
operationId: 'getCrew',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Crew roster',
|
||||
description: 'Crew roster plus the active + pending task queue',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/CrewMember' } },
|
||||
schema: { $ref: '#/components/schemas/CrewState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -855,8 +855,13 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' },
|
||||
tasks: {
|
||||
type: 'array',
|
||||
description: 'The active + pending task queue — the ACTIVE block first, then the PENDING block, each ordered by (queuedAtTurn, id); DONE rows never appear',
|
||||
items: { $ref: '#/components/schemas/TaskDto' },
|
||||
},
|
||||
},
|
||||
required: ['systems', 'edges', 'turn'],
|
||||
required: ['systems', 'edges', 'turn', 'tasks'],
|
||||
},
|
||||
TurnResult: {
|
||||
allOf: [
|
||||
@@ -894,6 +899,23 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn'],
|
||||
},
|
||||
TaskDto: {
|
||||
type: 'object',
|
||||
description: 'One row of the snapshot task queue — the ACTIVE + PENDING rows of the tasks table as the GET snapshots expose them (DONE rows never appear)',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
type: { type: 'string', enum: ['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH'] },
|
||||
targetLabel: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The task target by display name — the jump destination, the scanned body, the ship system with the verb suffixed (e.g. Origin — upgrade), the built system type, or the researched tech; `unknown` when the target row no longer exists',
|
||||
},
|
||||
leadName: { type: 'string', description: 'The team lead by name; `unknown` when the crew row no longer exists' },
|
||||
helperNames: { type: 'array', description: 'The supporting crew by name (empty when none)', items: { type: 'string' } },
|
||||
remaining: { type: 'integer', description: 'Turns left — the stored row value (a pure read, not recomputed)' },
|
||||
},
|
||||
required: ['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining'],
|
||||
},
|
||||
JumpRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -949,6 +971,23 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'],
|
||||
},
|
||||
CrewState: {
|
||||
type: 'object',
|
||||
description: 'GET /api/crew response — the roster wrapped with the snapshot task queue (a bare array cannot carry a sibling tasks field)',
|
||||
properties: {
|
||||
crew: {
|
||||
type: 'array',
|
||||
description: 'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring',
|
||||
items: { $ref: '#/components/schemas/CrewMember' },
|
||||
},
|
||||
tasks: {
|
||||
type: 'array',
|
||||
description: 'The active + pending task queue — the ACTIVE block first, then the PENDING block, each ordered by (queuedAtTurn, id); DONE rows never appear',
|
||||
items: { $ref: '#/components/schemas/TaskDto' },
|
||||
},
|
||||
},
|
||||
required: ['crew', 'tasks'],
|
||||
},
|
||||
ShipSystem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1087,8 +1126,13 @@ export const openApiSpec = {
|
||||
},
|
||||
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
||||
turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' },
|
||||
tasks: {
|
||||
type: 'array',
|
||||
description: 'The active + pending task queue — the ACTIVE block first, then the PENDING block, each ordered by (queuedAtTurn, id); DONE rows never appear',
|
||||
items: { $ref: '#/components/schemas/TaskDto' },
|
||||
},
|
||||
},
|
||||
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'],
|
||||
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn', 'tasks'],
|
||||
},
|
||||
ExploreRequest: {
|
||||
type: 'object',
|
||||
|
||||
Reference in New Issue
Block a user