feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)

This commit is contained in:
2026-10-02 20:34:14 +00:00
parent 07c1508999
commit a199b64348
9 changed files with 374 additions and 62 deletions
+6 -6
View File
@@ -581,8 +581,8 @@ describe('snapshot task queue (world / system / crew)', () => {
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 },
{ id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4, status: 'PENDING', leadCrewId: c2, helperCrewIds: [c3] },
]
expect((await getWorldState(db)).tasks).toEqual(expected)
expect((await getSystemState(db)).tasks).toEqual(expected)
@@ -608,8 +608,8 @@ describe('snapshot task queue (world / system / crew)', () => {
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
const snap = await getWorldState(db) // must not throw out of the read
expect(snap.tasks).toEqual([
{ id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 },
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1 }, // the well-formed sibling still resolves
{ id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c2, helperCrewIds: [] }, // the well-formed sibling still resolves
])
})
@@ -620,7 +620,7 @@ describe('snapshot task queue (world / system / crew)', () => {
const badId = await insertRawTaskRow(db, { type: 'JUMP', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[not-json', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
const snap = await getWorldState(db)
expect(snap.tasks).toEqual([
{ id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 },
{ id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
])
})
@@ -631,7 +631,7 @@ describe('snapshot task queue (world / system / crew)', () => {
const badId = await insertRawTaskRow(db, { type: 'TELEPORT', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
const snap = await getWorldState(db)
expect(snap.tasks).toEqual([
{ id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 },
{ id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
])
})
})
+11 -3
View File
@@ -26,7 +26,10 @@ export interface WorldSystemDto {
// 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.
// fields the UI needs, names resolved, no raw payload — plus the raw row state (JC-11):
// `status` (the array's ACTIVE-block-first order isn't readable back from it) and the crew
// ids, which the team builder needs for exact member↔row matching (name matching breaks on
// the `unknown` fallback).
export interface QueueDto {
id: number
type: TaskType
@@ -34,6 +37,9 @@ export interface QueueDto {
leadName: string
helperNames: string[]
remaining: number
status: TaskStatus // ACTIVE block first in the array, then PENDING
leadCrewId: number // crew.id of the team lead
helperCrewIds: number[] // crew.id of the supporting crew (degrades to [] on a corrupted blob, like helperNames)
}
export interface WorldState {
@@ -251,12 +257,14 @@ async function taskQueue(db: GameDb): Promise<QueueDto[]> {
helperNames: parseHelpers(t.helpers).map(crewName),
remaining: t.remaining,
status: t.status as TaskStatus,
leadCrewId: t.leadCrewId,
helperCrewIds: parseHelpers(t.helpers),
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)
// queuedAtTurn is only for ordering — the exposed shape is exactly QueueDto (status stays: JC-11).
return ordered.map(({ queuedAtTurn, ...dto }) => dto)
}
export async function getWorldState(db: GameDb): Promise<WorldState> {
+4 -2
View File
@@ -131,9 +131,11 @@ describe('OpenAPI spec', () => {
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(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']);
expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']);
expect((taskDto.properties.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']);
expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']);
expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
const member = openApiSpec.components.schemas.CrewMember.properties;
expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']);
+12 -1
View File
@@ -913,8 +913,19 @@ export const openApiSpec = {
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)' },
status: {
type: 'string',
enum: ['ACTIVE', 'PENDING'],
description: 'The row state — the ACTIVE block precedes the PENDING block in the array',
},
leadCrewId: { type: 'integer', description: 'crew.id of the team lead' },
helperCrewIds: {
type: 'array',
description: 'crew.id of the supporting crew (empty when none)',
items: { type: 'integer' },
},
},
required: ['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining'],
required: ['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds'],
},
JumpRequest: {
type: 'object',