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
+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 {