feat(api): world/system/crew snapshots expose the active + pending task queue (#44)

This commit is contained in:
2026-10-02 19:42:19 +00:00
parent 178de6e4fb
commit 84d1aa8122
7 changed files with 212 additions and 24 deletions
+2 -1
View File
@@ -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 -3
View File
@@ -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))
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',