feat(api): world/system/crew snapshots expose the active + pending task queue (#44)
This commit is contained in:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user