From 84d1aa8122e2cc01e140088cb5b12e4e928120d9 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 19:42:19 +0000 Subject: [PATCH] feat(api): world/system/crew snapshots expose the active + pending task queue (#44) --- app/api/crew/route.ts | 3 +- components/crew-panel.tsx | 6 +-- components/mission-control.tsx | 6 +-- lib/game/save.test.ts | 55 ++++++++++++++++++++-- lib/game/save.ts | 86 ++++++++++++++++++++++++++++++++-- lib/openapi.test.ts | 26 ++++++++-- lib/openapi.ts | 54 +++++++++++++++++++-- 7 files changed, 212 insertions(+), 24 deletions(-) diff --git a/app/api/crew/route.ts b/app/api/crew/route.ts index 6ca4457..acd12f7 100644 --- a/app/api/crew/route.ts +++ b/app/api/crew/route.ts @@ -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)) diff --git a/components/crew-panel.tsx b/components/crew-panel.tsx index 19580a0..f848c43 100644 --- a/components/crew-panel.tsx +++ b/components/crew-panel.tsx @@ -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)) diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 9d57581..18e2944 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -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 () => { diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 31091ac..35d702c 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -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. diff --git a/lib/game/save.ts b/lib/game/save.ts index add17e1..2170838 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -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 { + 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 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 { 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 { 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 { 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 { @@ -381,10 +448,19 @@ export interface CrewDto { origin: string // 'STARTING CREW' or ', ' 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 { +// 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 { 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 { diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 2fb4f2b..6dc1616 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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; 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; 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; 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; 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'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 64eaa8d..38c58d5 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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',