191 KiB
Crew-Driven Actions Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make crew the central resource of SpaceWanderer: every action becomes a queued task with an assigned team, variable duration from roles/skills/traits, and manual time via a NEXT TURN button; crews then specialise through skill progression (PR B) and traits gain outcome effects (PR C).
Architecture: A pure task engine (lib/game/tasks.ts + lib/game/crew-costs.ts, no DB imports — shared server+client like the tech.ts precedent) computes durations and validates queues; save.ts keeps DB orchestration and gains a turn tick that resolves completed tasks via refactored per-type resolvers. PR B adds a crew_skills table feeding power bands into the same formula; PR C hooks two trait outcome effects into the explore resolver.
Tech Stack: Next.js 16 App Router, React 19, TypeScript strict, bun, SQLite via Drizzle + sql.js (WASM), Vitest unit tests, Playwright e2e (CI-only)
Spec: docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md
Global Constraints
- AGENTS.md change process per PR: feature branch off master, no direct pushes to master; version bump in package.json + CHANGELOG entry at top (
## v<version> — YYYY-MM-DD HH:MM); local verify ONLYnpx tsc --noEmit,npx vitest run,npx playwright test --list(e2e is CI-only, never run locally); push → CI auto-merge on green (verify merged_at) → tag v on master → manual deploydocker compose up -d --build+ verify /api/version at https://space-wanderer.dev.kkhost.pl. - DDL in lib/db/client.ts must stay in sync with lib/db/schema.ts; schema changes only via idempotent backfill steps in save.ts ensureSave (SCHEMA_VERSION 6→7→8); no DB wipes ever.
- OpenAPI spec in lib/openapi.ts updated whenever an endpoint changes (and lib/openapi.test.ts pins, which must be updated with it).
- Single-player by design: NO auth, accounts, or multiplayer, ever.
- Determinism: every game outcome reproducible from seed + player actions; no wall-clock randomness.
- New pure modules (crew-costs.ts, tasks.ts, skills.ts) must not import lib/db/* — shared server+client like the tech.ts precedent (required for live duration preview in the UI).
Review Focus
- Existing save loaded after deploy has no tasks/crew_skills tables → backfill must create them without touching turn, crew, or inventory state, and be idempotent across restarts.
- Two ACTIVE tasks completing on the same tick that share a freed crew member's successor chain → FIFO auto-start must start at most one task per freed crew member (earliest queuedAtTurn wins); later successors stay PENDING until their own tick.
- Non-jump task active while a JUMP completes on the same tick → both resolve; the non-jump effect applies to its payload target, not the new current system.
- All crew busy → every queueing action is disabled/409 with a reason, but NEXT TURN always works (free skip); no dead-end because every ACTIVE task completes in finite turns.
- Seeded-stream draw order must be fixed regardless of traits: an extra haul roll for FINDS HIDDEN THINGS shifts later draws on that per-planet stream — pin the exploration outcome as a pure function of (seed, planetId, assigned crew) with determinism tests both with and without the trait.
(Pinning tests for each item live in the owning task's steps.)
PR A — Gitea issue #44 "Crew-driven actions + NEXT TURN engine" → v0.10.0
Spec sections: §3 (task model), §4 (duration), §5 (trait duration modifiers), §7 (snapshots), §8 (UI), §9 (tests), §11 (constants).
Task 1: Balance constants + duration model
Files:
- Create:
lib/game/crew-costs.ts(the §11 balance-knob module, alongside the existinglib/game/ship-costs.ts),lib/game/tasks.ts(pure task model), testslib/game/tasks.test.ts(colocated, matchinglib/game/crew.test.ts)
Interfaces:
-
Consumes:
CrewMemberfromlib/game/crew.ts({ name, role, traitGood, traitBad }— string fields exactly as generated byGOOD_TRAITS/BAD_TRAITSthere) -
Produces: from
crew-costs.ts—SkillName,WORK_POINTS,ACTION_SKILL,PREFERRED_ROLES,POWER_BANDS,XP_THRESHOLDS,STARTING_LEVELS,MISMATCH_CANCEL_TRAIT,TRAIT_DURATION_MODIFIERS,RECRUIT_CHANCE_BASE,RECRUIT_CHANCE_TALKER; fromtasks.ts—TaskTypeandcomputeDuration(type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf?: (m: CrewMember) => number): number -
Steps: TDD — write failing tests FIRST in
lib/game/tasks.test.tsusing the spec's EXACT values (table-driven; runnpx vitest run lib/game/tasks.test.ts→ red, the modules do not exist yet):
import { describe, expect, it } from 'vitest'
import type { CrewMember } from './crew'
import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, POWER_BANDS, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, TRAIT_DURATION_MODIFIERS, WORK_POINTS, XP_THRESHOLDS } from './crew-costs'
import type { TaskType } from './tasks'
import { computeDuration } from './tasks'
// Trait strings copied verbatim from lib/game/crew.ts GOOD_TRAITS/BAD_TRAITS — TRAIT_DURATION_MODIFIERS
// is keyed by exactly these strings, so a rename in crew.ts breaks these tests.
const NEUTRAL_GOOD = 'FINDS HIDDEN THINGS' // no duration entry → no effect
const NEUTRAL_BAD = 'JINXED IN A DOGFIGHT' // STANDBY combat trait → no duration effect
const crew = (role: string, traitGood = NEUTRAL_GOOD, traitBad = NEUTRAL_BAD): CrewMember => ({ name: 'TEST', role, traitGood, traitBad })
const WORK: Record<TaskType, number> = { JUMP: 3, EXPLORE: 4, SHIP_UPGRADE: 6, SHIP_EXPAND: 6, SHIP_BUILD: 6, RESEARCH: 9 }
const TYPES = Object.keys(WORK) as TaskType[]
const SHIP: TaskType[] = ['SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD']
describe('solo baselines (one crew, power 1, no modifiers)', () => {
it('work points are the duration for the preferred role: EXPLORE 4 · JUMP 3 / 6 · SHIP 6 · RESEARCH 9', () => {
for (const type of TYPES) {
expect(computeDuration(type, 1, crew(PREFERRED_ROLES[type][0]), [])).toBe(WORK[type])
}
expect(computeDuration('JUMP', 2, crew('PILOT'), [])).toBe(6) // two-hop = 2 × 3
})
})
describe('role mismatch (lead only, helpers never penalised)', () => {
it('a lead outside the preferred role adds +1; helpers contribute power only', () => {
expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [])).toBe(4) // 3 + 1 mismatch
expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [crew('PILOT')])).toBe(3) // ceil(3/2) + 1 — power 2, mismatch still on the lead
expect(computeDuration('EXPLORE', 1, crew('SCIENTIST'), [crew('PILOT', 'QUICK HANDS')])).toBe(2) // 4/2 — the helper's trait does not apply
})
it('STEADY UNDER PRESSURE cancels the mismatch penalty and adds nothing of its own', () => {
expect(computeDuration('JUMP', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(3)
expect(computeDuration('EXPLORE', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(4)
})
})
describe('floor', () => {
it('never returns below 1 turn', () => {
const lead = crew('PILOT', 'QUICK HANDS')
expect(computeDuration('JUMP', 1, lead, [crew('PILOT'), crew('PILOT')], () => 3)).toBe(1) // ceil(3/9) − 1 = 0 → floor 1
})
})
// All 15 duration traits from spec §5, each tested once in its own action context (plus its
// non-applicable types). target 'SHIP_UPGRADE' stands for the whole "SHIP work" class.
const TABLE: [trait: string, isGood: boolean, target: TaskType | 'ANY', delta: number][] = [
['RECKLESS COURAGE', true, 'JUMP', -1],
['CALM IN A CRISIS', true, 'EXPLORE', -1],
['FIXES ANYTHING', true, 'SHIP_UPGRADE', -1],
['READS STARS BLIND', true, 'JUMP', -1],
['QUICK HANDS', true, 'ANY', -1],
['IRON WILL', true, 'RESEARCH', -1],
['RIGID — SLOW TO ADAPT', false, 'RESEARCH', 1],
['OVERCAUTIOUS', false, 'ANY', 1],
['SLOWS THE CREW DOWN', false, 'ANY', 1],
['CARELESS WITH FUSES', false, 'SHIP_UPGRADE', 1],
['HOARDS RATIONS', false, 'SHIP_UPGRADE', 1],
['FORGETS TO EAT', false, 'EXPLORE', 1],
['ARGUES WITH EVERYONE', false, 'RESEARCH', 1],
['DOUBTS THE CAPTAIN', false, 'JUMP', 1],
]
describe('the 15 duration traits (spec §5, exact strings from crew.ts)', () => {
const applies = (type: TaskType, target: TaskType | 'ANY') =>
target === 'ANY' || target === type || (target !== 'ANY' && SHIP.includes(target) && SHIP.includes(type))
it('each trait shifts only its own action class, lead only, across all six types', () => {
for (const [trait, isGood, target, delta] of TABLE) {
for (const type of TYPES) {
const lead = isGood ? crew(PREFERRED_ROLES[type][0], trait) : crew(PREFERRED_ROLES[type][0], NEUTRAL_GOOD, trait)
expect(computeDuration(type, 1, lead, []), `${trait} → ${type}`).toBe(WORK[type] + (applies(type, target) ? delta : 0))
}
}
})
})
describe('crew-costs — the §11 tuning surface', () => {
it('pins the balance knobs', () => {
expect(WORK_POINTS).toEqual({ EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 })
expect(XP_THRESHOLDS).toEqual([5, 10, 15, 20, 25])
expect(POWER_BANDS).toEqual([1, 1, 1, 2, 2, 3]) // L0–L2 → 1 · L3–L4 → 2 · L5 → 3
expect(STARTING_LEVELS.PILOT).toEqual({ NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
expect(STARTING_LEVELS.SCIENTIST).toEqual({ NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 })
expect(STARTING_LEVELS.COMMANDER).toEqual({ NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
expect(RECRUIT_CHANCE_BASE).toBe(0.18)
expect(RECRUIT_CHANCE_TALKER).toBe(0.3)
expect(Object.keys(TRAIT_DURATION_MODIFIERS)).toHaveLength(15) // all 15 duration traits of spec §5, nothing else
expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier
})
})
- Implement
lib/game/crew-costs.ts(pure — nolib/dbimports; the frontend imports it for the live duration preview, same server+client precedent asship-costs.ts/tech.ts):
// Pure crew-balance module (spec §11): every tuning knob for the duration model and skill
// progression lives here, so tuning a number means editing one file. No lib/db imports —
// the frontend imports this for the live duration preview in the crew picker (same precedent
// as ship-costs.ts).
import type { TaskType } from './tasks'
export type SkillName = 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH'
export const WORK_POINTS = { EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 } as const
export const ACTION_SKILL: Record<TaskType, SkillName> = {
JUMP: 'NAVIGATION',
EXPLORE: 'SCANNING',
SHIP_UPGRADE: 'ENGINEERING',
SHIP_EXPAND: 'ENGINEERING',
SHIP_BUILD: 'ENGINEERING',
RESEARCH: 'RESEARCH',
}
export const PREFERRED_ROLES: Record<TaskType, string[]> = {
JUMP: ['PILOT', 'NAVIGATOR'],
EXPLORE: ['SCIENTIST'],
SHIP_UPGRADE: ['ENGINEER', 'TECHNICIAN'],
SHIP_EXPAND: ['ENGINEER', 'TECHNICIAN'],
SHIP_BUILD: ['ENGINEER', 'TECHNICIAN'],
RESEARCH: ['SCIENTIST'],
}
export const POWER_BANDS = [1, 1, 1, 2, 2, 3] // skill level 0..5 → power (L0–L2 → 1 · L3–L4 → 2 · L5 → 3)
export const XP_THRESHOLDS = [5, 10, 15, 20, 25] // cumulative task completions for L1..L5 (cap 5)
// Role → starting skill levels (spec §6): specialists start at L2, everyone else at L0.
export const STARTING_LEVELS: Record<string, Record<SkillName, number>> = {
COMMANDER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
PILOT: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
ENGINEER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 },
MEDIC: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
NAVIGATOR: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
SCIENTIST: { NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 },
GUNNER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
TECHNICIAN: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 },
}
// The lead's good trait that cancels the role-mismatch penalty (spec §5).
export const MISMATCH_CANCEL_TRAIT = 'STEADY UNDER PRESSURE'
// All 15 duration traits of spec §5, keyed by the EXACT trait strings from lib/game/crew.ts.
// Value = additive turn modifier keyed by action type or 'ANY'; empty object = no additive
// modifier (the trait's effect is the mismatch cancel, see MISMATCH_CANCEL_TRAIT). Traits
// with no entry (the three STANDBY combat traits and #45's outcome traits) contribute 0.
// "SHIP work" covers all three SHIP types.
export const TRAIT_DURATION_MODIFIERS: Record<string, Partial<Record<TaskType | 'ANY', number>>> = {
'STEADY UNDER PRESSURE': {},
'RECKLESS COURAGE': { JUMP: -1 },
'CALM IN A CRISIS': { EXPLORE: -1 },
'FIXES ANYTHING': { SHIP_UPGRADE: -1, SHIP_EXPAND: -1, SHIP_BUILD: -1 },
'READS STARS BLIND': { JUMP: -1 },
'QUICK HANDS': { ANY: -1 },
'IRON WILL': { RESEARCH: -1 },
'RIGID — SLOW TO ADAPT': { RESEARCH: 1 },
'OVERCAUTIOUS': { ANY: 1 },
'SLOWS THE CREW DOWN': { ANY: 1 },
'CARELESS WITH FUSES': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 },
'HOARDS RATIONS': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 },
'FORGETS TO EAT': { EXPLORE: 1 },
'ARGUES WITH EVERYONE': { RESEARCH: 1 },
'DOUBTS THE CAPTAIN': { JUMP: 1 },
}
export const RECRUIT_CHANCE_BASE = 0.18 // a scan finds a joining member (matches crew.ts's current CREW_CHANCE)
export const RECRUIT_CHANCE_TALKER = 0.3 // lead with TALKS TO ANYONE (wired in PR C)
- Implement
lib/game/tasks.ts(pure — nolib/dbimports):
// Pure task-engine model (spec §3–§4): the task types and the duration formula. No lib/db
// imports — the frontend imports this for the live duration preview in the crew picker
// (same precedent as tech.ts / ship-costs.ts).
import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, TRAIT_DURATION_MODIFIERS, WORK_POINTS } from './crew-costs'
import type { CrewMember } from './crew'
export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH'
// Work points per action (spec §4): JUMP scales with hop count, the rest are flat.
function workPoints(type: TaskType, hopCount: number): number {
switch (type) {
case 'JUMP':
return hopCount * WORK_POINTS.JUMP_PER_HOP
case 'EXPLORE':
return WORK_POINTS.EXPLORE
case 'RESEARCH':
return WORK_POINTS.RESEARCH
default:
return WORK_POINTS.SHIP_WORK // SHIP_UPGRADE | SHIP_EXPAND | SHIP_BUILD
}
}
// One trait's modifier for a lead on an action type: its per-type entry plus the 'ANY' entry.
// Traits without a map entry contribute 0.
function traitModifier(trait: string, type: TaskType): number {
const mods = TRAIT_DURATION_MODIFIERS[trait]
if (!mods) return 0
return (mods[type] ?? 0) + (mods['ANY'] ?? 0)
}
// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4.
// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the
// constant 1 until PR B feeds the skill band. The mismatch penalty and the trait modifiers
// apply to the LEAD only — helpers contribute power, are never penalised, and their traits do
// not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types).
export function computeDuration(
type: TaskType,
hopCount: number,
lead: CrewMember,
helpers: CrewMember[],
powerOf: (m: CrewMember) => number = () => 1,
): number {
const totalPower = Math.max(1, [lead, ...helpers].reduce((sum, m) => sum + powerOf(m), 0))
const mismatchPenalty = lead.traitGood === MISMATCH_CANCEL_TRAIT || PREFERRED_ROLES[type].includes(lead.role) ? 0 : 1
const traitModifierSum = traitModifier(lead.traitGood, type) + traitModifier(lead.traitBad, type)
return Math.max(1, Math.ceil(workPoints(type, hopCount) / totalPower) + mismatchPenalty + traitModifierSum)
}
- Run
npx vitest run lib/game/tasks.test.ts→ green, thennpx tsc --noEmitclean - Commit (PR A feature branch off
master; the PR-level v0.10.0 version bump + CHANGELOG entry land with the PR's final task per the global constraints):feat(game): crew-costs balance module + computeDuration duration model (#44)
Task 2: tasks table — schema v7
Files:
- Modify:
lib/db/client.ts(addtasksto theDDLtemplate),lib/db/schema.ts(drizzle tabletasks),lib/game/save.ts(SCHEMA_VERSION6→7 + the idempotent v7 step inbackfillSave),lib/db/admin.ts(ADMIN_TABLES),lib/game/tasks.ts(addTaskStatus+Taskto the module Task 1 created) - Test: extend the
backfill (old saves)describe inlib/game/save.test.ts(existingfreshFile()+openGameDb+db.raw.execpattern);lib/db/admin.test.ts's registry test covers the new table automatically (it derives the expected list fromschema.ts)
Interfaces:
-
Consumes:
ensureSave/backfillSavefromlib/game/save.ts(currentlySCHEMA_VERSION = 6);TaskTypefrom Task 1 -
Produces:
interface Task { id: number; saveId: number; type: TaskType; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: TaskStatus; queuedAtTurn: number }(+TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE') inlib/game/tasks.ts; thetaskstable inlib/db/schema.tsand the matchingCREATE TABLEinlib/db/client.ts's DDL;tasks: schema.tasksinADMIN_TABLES -
Steps: TDD — add a failing test to
backfill (old saves)inlib/game/save.test.ts. This pins Review Focus item 1 (v6→v7 backfill must create the table without touching turn/crew/inventory and be idempotent across restarts). Run → red (schemaVersionstays 6, andtasksis not in the DDL yet):
it('v7: a v6-stamped save gets an empty tasks table without touching state; idempotent across restart', async () => {
const { db, file } = await freshFile()
const world = await getWorldState(db) // fresh game, stamped at the current version
const crewBefore = await db.db.select().from(schema.crew)
const invBefore = await db.db.select().from(schema.inventoryItems)
await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12
const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7
expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1)
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore)
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
expect(tables[0].values.map((v) => v[0])).toContain('tasks')
expect(Number(db.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) // empty queue after upgrade
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
await getWorldState(reopened)
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0)
})
Realism note: the production upgrade path is a DB file written by v0.9.12 (no tasks table) → open() with the new DDL → CREATE TABLE IF NOT EXISTS creates it → ensureSave stamps v7. The test simulates the save side with the version stamp (the DDL side runs on every open, new or old file).
- Implement, each in the existing style of its file:
lib/db/client.ts — add to the DDL template after the events table (must stay in sync with schema.ts):
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
type TEXT NOT NULL, -- 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH'
payload TEXT NOT NULL, -- JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId}
lead_crew_id INTEGER NOT NULL REFERENCES crew(id),
helpers TEXT NOT NULL DEFAULT '[]', -- JSON array of crew ids
duration INTEGER NOT NULL,
remaining INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE'
queued_at_turn INTEGER NOT NULL DEFAULT 0
);
lib/db/schema.ts — append after events (mirrors the DDL above):
// Queued actions (PR A, spec §3): one row per task assigned to a team. `payload` is a JSON
// string whose shape depends on `type` (see the Task interface in lib/game/tasks.ts);
// `helpers` is a JSON array of crew ids. Upgraded saves start with an empty queue — the v7
// backfill stamps the version, the DDL in client.ts creates the table.
export const tasks = sqliteTable('tasks', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
type: text('type').notNull(), // TaskType
payload: text('payload').notNull(),
leadCrewId: integer('lead_crew_id').notNull().references(() => crew.id),
helpers: text('helpers').notNull().default('[]'), // JSON array of crew ids
duration: integer('duration').notNull(),
remaining: integer('remaining').notNull(),
status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE'
queuedAtTurn: integer('queued_at_turn').notNull().default(0),
})
lib/game/save.ts — bump the version constant and add the v7 step in backfillSave after the v6 topUpMissionBoard step:
export const SCHEMA_VERSION = 7 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table
if (version < 7) {
// The tasks table itself is created by the DDL at open (CREATE TABLE IF NOT EXISTS) for
// every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
version = 7
}
lib/db/admin.ts — register in ADMIN_TABLES after events: tasks: schema.tasks,
lib/game/tasks.ts — add the domain shape after TaskType (Task.helpers is the parsed form of the row's JSON-array column; the row↔Task mapping lives at the save.ts boundary, not in this pure module):
export type TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE'
// One queued/active/done action (spec §3). `payload` is a JSON string whose shape depends on
// the TaskType: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} ·
// RESEARCH {techId} · EXPLORE {planetId}.
export interface Task {
id: number
saveId: number
type: TaskType
payload: string
leadCrewId: number
helpers: number[]
duration: number
remaining: number
status: TaskStatus
queuedAtTurn: number
}
- Run
npx vitest run(full unit suite — the save, admin and client DB tests all touch the schema) → green, thennpx tsc --noEmitclean - Commit (same PR A branch):
feat(game): tasks table + schema v7 backfill, admin registry (#44)
Task 3: Queueing — validation + insertion
Files
lib/game/tasks.ts— append after theTaskinterface from Task 2 (code below). Add to the top-of-file imports:import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from './ship-costs',import { TECHS } from './tech'(both pure modules) andimport type { CrewDto, InventoryItemDto } from './save'— type-only, so no runtime edge into the DB module (save.ts already imports from tasks.ts for the row↔Task mapping; a runtime import back would be a cycle).lib/game/tasks.test.ts— add thedescribe('queueTask')block below (oneitper locked case, table-driven over the small fixtures); if Tasks 1–2 left no such file, create it first withimport { describe, expect, it } from 'vitest', the./tasksimport andimport type { CrewDto } from './save'. If Task 1 putcomputeDurationinlib/game/crew-costs.tsinstead of tasks.ts, point its import at that module — the spec §9 signature is the contract.
// --- Queueing: validation + row construction (pure, no DB) -------------------------------
//
// queueTask runs over a plain snapshot of the save (QueueState) and decides whether a new
// task can be queued and what row it becomes. Pure like crew.ts / ship-costs.ts — the route
// (a later task) maps schema rows into QueueState, calls queueTask, and persists the result:
// insert the task row (adding `id` + `saveId` at the DB boundary) and the deducted hold.
// Carries an HTTP status so route handlers can map failures to responses without re-parsing
// (same pattern as JumpError / ShipError / ResearchError / ExploreError).
export class TaskError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
// The queueing request — the existing endpoints' bodies plus the crew team. Payload field
// names follow the routes (the spec's names differ):
// JUMP { systemId, hops } — the route computes `hops` with jumpToSystem's logic
// (reachableWithin + NAV AI) and passes it in; the body field is `systemId`, not `toSystemId`
// EXPLORE { planetId }
// SHIP_UPGRADE { systemId } · SHIP_EXPAND { systemId } · SHIP_BUILD { type }
// RESEARCH { techId } — the tech row's id, not the slug
export interface QueueTaskRequest {
type: TaskType
payload: Record<string, unknown>
leadCrewId: number
helperCrewIds: number[]
}
// Plain per-save snapshot the decision runs over — the route maps schema rows into these
// (same row↔domain boundary as the tasks table in save.ts).
export interface QueueState {
turn: number
crew: CrewDto[]
tasks: Task[]
inventory: InventoryItemDto[] // cargo hold, id order (oldest first)
shipSystems: { id: number; type: string; level: number; capacity: number }[] // UPGRADE/EXPAND costs scale with the target's current level/capacity
tech: { id: number; slug: string }[] // RESEARCH cost comes from the matching TECHS def, like research.ts
}
// The new row minus the two values only the DB boundary knows: `id` (autoincrement) and
// `saveId` (filled in by save.ts's row↔Task mapping on insert).
export interface QueueResult {
task: Omit<Task, 'id' | 'saveId'>
inventory: InventoryItemDto[] // unchanged for JUMP/EXPLORE; deducted for SHIP_* / RESEARCH
}
// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all
// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn.
export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult {
const lead = state.crew.find((c) => c.id === req.leadCrewId)
if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`)
const helpers: CrewDto[] = []
for (const id of req.helperCrewIds) {
const helper = state.crew.find((c) => c.id === id)
if (!helper) throw new TaskError(404, `Unknown crew member ${id}`)
helpers.push(helper)
}
// Busy (409). Spec §3: one ACTIVE task per crew member, and a lead may chain one PENDING
// task behind it (FIFO — auto-starts when the current one completes). So: any crew member
// committed to a PENDING task is blocked; a helper on an ACTIVE task is blocked; a lead on
// an ACTIVE task passes and the new task is queued PENDING behind it.
const inStatus = (id: number, status: TaskStatus) => state.tasks.some((t) => t.status === status && (t.leadCrewId === id || t.helpers.includes(id)))
const checkBusy = (c: CrewDto, isLead: boolean) => {
if (inStatus(c.id, 'PENDING')) throw new TaskError(409, `${c.name} is already committed to a pending task`)
if (!isLead && inStatus(c.id, 'ACTIVE')) throw new TaskError(409, `${c.name} is already busy`)
}
checkBusy(lead, true)
for (const helper of helpers) checkBusy(helper, false)
// In transit (409): while any JUMP is ACTIVE, no new tasks of any type (spec §3).
const jumps = state.tasks.filter((t) => t.type === 'JUMP')
if (jumps.some((t) => t.status === 'ACTIVE')) throw new TaskError(409, 'A jump is in transit — no new tasks can be queued')
// Only one JUMP at a time, active or pending (two arrivals could never resolve on one tick).
// The ACTIVE half is already caught above, so this fires on a pending jump.
if (req.type === 'JUMP' && jumps.some((t) => t.status !== 'DONE')) throw new TaskError(409, 'A jump is already queued')
// Costs deduct at queue time (spec §3) — the existing cost logic from ship-costs.ts /
// tech.ts and spendItems' 422. JUMP/EXPLORE have no resource cost. Target existence and
// the 409 gates (max level / capacity, not built, already built, build gate, tech prereqs)
// stay in the routes with their existing codes — the route resolves ids before calling.
let inventory = state.inventory
if (req.type === 'SHIP_UPGRADE' || req.type === 'SHIP_EXPAND') {
const system = state.shipSystems.find((s) => s.id === req.payload.systemId)!
inventory = deduct(inventory, 'resource', req.type === 'SHIP_UPGRADE' ? upgradeCost(system.level) : expandCost(system.capacity))
} else if (req.type === 'SHIP_BUILD') {
inventory = deduct(inventory, 'part', BUILD_PART_COST)
} else if (req.type === 'RESEARCH') {
const def = TECHS.find((t) => t.slug === state.tech.find((r) => r.id === req.payload.techId)?.slug)!
inventory = deduct(inventory, def.costKind, def.costQty)
}
// Success: duration from the Task-1 formula, status from what the team is doing right now.
// A crew member is "free" when on no ACTIVE/PENDING task; after the checks above only a
// lead chaining behind its own ACTIVE can be non-free.
const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING')
const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0
const duration = computeDuration(req.type, hops, lead, helpers)
const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING'
return {
task: {
type: req.type,
payload: JSON.stringify(req.payload),
leadCrewId: req.leadCrewId,
helpers: req.helperCrewIds,
duration,
remaining: duration,
status,
queuedAtTurn: state.turn, // queueing is free — the turn advances only on the tick
},
inventory,
}
}
// Pure version of spendItems' consumption (lib/game/ship.ts): the total is checked up front
// (shortfall → the same 422 spendItems throws), then the oldest rows first (array order = id
// order); rows that hit zero are dropped. The input array is never mutated.
function deduct(items: InventoryItemDto[], kind: 'resource' | 'part', amount: number): InventoryItemDto[] {
if (totalQty(items, kind) < amount) {
throw new TaskError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
}
let remaining = amount
const out: InventoryItemDto[] = []
for (const item of items) {
if (item.kind !== kind) {
out.push(item)
continue
}
const take = Math.min(item.qty, remaining)
remaining -= take
if (take < item.qty) out.push({ ...item, qty: item.qty - take })
}
return out
}
import { describe, expect, it } from 'vitest'
import { computeDuration, queueTask, TaskError, type QueueState, type Task } from './tasks'
import type { CrewDto } from './save'
describe('queueTask', () => {
// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills
// the CrewDto shape (unused by the duration model but part of the type).
const CREW: CrewDto[] = [
{ id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' },
{ id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' },
{ id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' },
]
const state = (over: Partial<QueueState> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
const task = (over: Partial<Task> = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over })
// Sync twin of the rejects.toMatchObject house style — queueTask throws instead of rejecting.
const expectErr = (fn: () => unknown, status: number, message?: RegExp): void => {
let err: unknown
try {
fn()
} catch (e) {
err = e
}
expect(err).toBeInstanceOf(TaskError)
expect((err as TaskError).status).toBe(status)
if (message) expect((err as TaskError).message).toMatch(message)
}
it('unknown lead or helper id → 404', () => {
expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 42, helperCrewIds: [] }), 404, /Unknown crew member 42/)
expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [42] }), 404, /Unknown crew member 42/)
})
it('busy lead → 409 — already committed to a PENDING task (as lead or helper of one)', () => {
expectErr(() => queueTask(state({ tasks: [task({ status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/)
expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [1], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/)
})
it('busy helper → 409 — on an ACTIVE task, or committed to a PENDING one', () => {
expectErr(() => queueTask(state({ tasks: [task({ leadCrewId: 3, helpers: [2] })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already busy/)
expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [2], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already committed to a pending task/)
})
it('in transit (a JUMP is ACTIVE) → 409 for a non-jump action', () => {
expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3 })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /in transit/)
})
it('second jump → 409 while one is pending', () => {
expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3, status: 'PENDING' })] }), { type: 'JUMP', payload: { systemId: 2, hops: 1 }, leadCrewId: 1, helperCrewIds: [] }), 409, /A jump is already queued/)
})
it('SHIP / RESEARCH cost deducts at queue time; shortfall → the existing 422', () => {
const hold = [{ kind: 'resource' as const, name: 'IRON', qty: 5 }, { kind: 'part' as const, name: 'ALLOY', qty: 4 }]
const systems = [{ id: 7, type: 'REACTOR', level: 2, capacity: 3 }]
const techRows = [{ id: 3, slug: 'REACTOR_OVERCLOCK' }] // the def's cost: 3 resources (TECHS)
const upgraded = queueTask(state({ inventory: hold, shipSystems: systems, tech: techRows }), { type: 'SHIP_UPGRADE', payload: { systemId: 7 }, leadCrewId: 2, helperCrewIds: [] })
expect(upgraded.inventory).toEqual([{ kind: 'resource', name: 'IRON', qty: 3 }, { kind: 'part', name: 'ALLOY', qty: 4 }]) // upgradeCost(2) = 2, oldest row first
const shortHold = [{ kind: 'resource' as const, name: 'IRON', qty: 2 }]
expectErr(() => queueTask(state({ inventory: shortHold, tech: techRows }), { type: 'RESEARCH', payload: { techId: 3 }, leadCrewId: 2, helperCrewIds: [] }), 422, /Insufficient resources in the cargo hold — need 3/)
})
it('ACTIVE-vs-PENDING rule: PENDING while the lead is busy (no turn change); all free → ACTIVE', () => {
const s = state({ tasks: [task({ leadCrewId: 1 })] })
const pending = queueTask(s, { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] })
expect(pending.task.status).toBe('PENDING')
expect(pending.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
expect(pending.task.queuedAtTurn).toBe(10)
expect(s.turn).toBe(10) // queueing never advances the turn — only the tick does
const active = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] })
expect(active.task.status).toBe('ACTIVE')
expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
expect(active.task.queuedAtTurn).toBe(10)
})
})
Interfaces
queueTask(state: QueueState, req: QueueTaskRequest): QueueResult— pure (no DB / Date / rng), inlib/game/tasks.ts. On failure it throwsTaskError(status, message)— the same shape asJumpError/ShipError/ResearchError/ExploreError, so the route maps it exactly like the existing routes (Response.json({ error: err.message }, { status: err.status })). On success it returns the row to insert (everything except the DB-boundaryid/saveId) plus the (possibly deducted) hold.
Validation, in this order (all before any cost deduction):
| # | Rule | Outcome |
|---|---|---|
| 1 | leadCrewId or any helperCrewIds not in the roster (lead first, then helpers in order) |
404 — Unknown crew member <id> |
| 2 | any assigned crew (as lead or helper of one) already committed on a PENDING task | 409 — <name> is already committed to a pending task |
| 3 | a helper on an ACTIVE task (as lead or helper of one) | 409 — <name> is already busy · a lead on an ACTIVE task passes: the new task is PENDING behind it (spec §3 — a lead may chain one pending task, auto-started on completion) |
| 4 | any JUMP task ACTIVE (in transit) — blocks every type | 409 — A jump is in transit — no new tasks can be queued |
| 5 | request type JUMP and a JUMP task already ACTIVE or PENDING (the ACTIVE half is caught by 4, so this fires on a pending jump) | 409 — A jump is already queued |
| 6 | SHIP_* / RESEARCH only — deduct the existing cost: UPGRADE upgradeCost(level) / EXPAND expandCost(capacity) resources · BUILD BUILD_PART_COST parts · RESEARCH the matching TECHS def's costKind × costQty — shortfall |
422 — spendItems' exact message Insufficient resources|parts in the cargo hold — need <n> |
On success: hops = req.type === 'JUMP' ? req.payload.hops : 0 → duration = computeDuration(type, hops, lead, helpers) (Task 1's pure formula; the roster objects pass through as-is — CrewDto is a structural superset of CrewMember) → status = 'ACTIVE' when no assigned crew is on an ACTIVE/PENDING task, else 'PENDING' (after rules 2–3 only a lead chaining behind its own ACTIVE can be non-free) → remaining = duration, queuedAtTurn = state.turn — queueing never advances the turn (only the tick does) → payload stored as JSON.stringify(req.payload); the route then adds id + saveId at the DB boundary and persists the returned inventory.
Route pre-checks stay in the routes with their existing codes (this task does not move them): unknown target 404; unreachable system / not built / max level / max capacity / already built / build gate / already researched / tech prereqs 409. For JUMP the route computes hops with the same reachableWithin + NAV AI logic as jumpToSystem and passes { systemId, hops } in the payload — the body field is systemId (the spec's targetSystemId / techSlug / kind names never existed in the routes).
-
- Write the failing tests — add the
describe('queueTask')block tolib/game/tasks.test.ts(create the file with thevitest+./tasks+ type-only./saveimports if Tasks 1–2 left none).
- Write the failing tests — add the
-
- Run — expect failure:
npx vitest run lib/game/tasks.test.ts→ red (the seven newits fail —queueTaskis not implemented yet; pre-existing tests in the file stay green).
- Run — expect failure:
-
- Implement — append
TaskError,QueueTaskRequest,QueueState,QueueResult,queueTaskand the privatededucttolib/game/tasks.ts(code above) plus the./ship-costs,./techand type-only./saveimports.
- Implement — append
-
- Run — expect green:
npx vitest run lib/game/tasks.test.ts→ all seven pass, then the full unit suitenpx vitest run→ green, andnpx tsc --noEmitclean.
- Run — expect green:
-
- Commit (same PR A branch):
feat(game): queueTask — queue-time validation, cost deduction, ACTIVE/PENDING row (#44)
- Commit (same PR A branch):
Task 4a: Resolver refactor (save.ts)
Files
lib/game/save.ts— extract the effect bodies ofjumpToSystem/explorePlanetintoresolveJumpTask/resolveExploreTask(code below); the two public functions keep their signatures, validation, turn advance and return values, and delegate the effect part to the resolvers through a syntheticTaskrow (code below). Add to the imports:import type { Task, TaskType } from './tasks'(type-only —tasks.tsstays pure, no runtime edge back; merge with Task 2's existing type-only import from./tasksif present).FoundItemis already imported.lib/game/ship.ts— addresolveShipUpgradeTask/resolveShipExpandTask/resolveShipBuildTask(code below);upgradeSystem/expandSystem/buildSystemkeep their signatures, validation,spendItemscost and turn advance, and delegate (code below). Delete the privatebumpTurnAndEvent— unused after the refactor (the resolvers inline the event insert without the turn update). Add the same type-only./tasksimport.lib/game/research.ts— addresolveResearchTask(code below);researchTechkeeps its signature, validation, spend +ResearchErrorre-throw and turn advance, and delegates (code below). Add the same type-only./tasksimport.lib/game/save.test.ts— add thedescribe('resolveJumpTask (A/B against jumpToSystem)')anddescribe('resolveExploreTask (A/B against explorePlanet)')blocks below (code). AddresolveJumpTask, resolveExploreTaskto the existing./saveimport andimport type { Task } from './tasks'.lib/game/ship.test.ts— add thedescribe('resolveShip* (A/B against the immediate actions)')block below (code). AddspendItems, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTaskto the existing./shipimport; new importsimport { TECHS } from './tech',import { SCHEMA_VERSION } from './save',import type { Task } from './tasks'.lib/game/research.test.ts— add thedescribe('resolveResearchTask (A/B against researchTech)')block below (code). AddresolveResearchTaskto the existing./researchimport,spendItemsto the existing./shipimport; new importsimport { SCHEMA_VERSION } from './save',import type { Task } from './tasks'.- Untouched:
app/api/*routes,lib/openapi.ts,lib/game/tasks.ts,lib/db/*.
Interfaces
New resolvers — one per TaskType value, named 1:1 so Task 4b's tick can dispatch through a Record<TaskType, resolver>:
TaskType |
Function | Module | Signature |
|---|---|---|---|
JUMP |
resolveJumpTask |
lib/game/save.ts |
(db: GameDb, task: Task) => Promise<void> |
EXPLORE |
resolveExploreTask |
lib/game/save.ts |
(db: GameDb, task: Task) => Promise<{ outcome: FoundItem[]; crewFound: CrewDto | null }> |
SHIP_UPGRADE |
resolveShipUpgradeTask |
lib/game/ship.ts |
(db: GameDb, task: Task) => Promise<void> |
SHIP_EXPAND |
resolveShipExpandTask |
lib/game/ship.ts |
(db: GameDb, task: Task) => Promise<void> |
SHIP_BUILD |
resolveShipBuildTask |
lib/game/ship.ts |
(db: GameDb, task: Task) => Promise<void> |
RESEARCH |
resolveResearchTask |
lib/game/research.ts |
(db: GameDb, task: Task) => Promise<void> |
State shape they consume: (db: GameDb, task: Task) — the same save-like state the current action functions use (GameDb; the save row is read inside, exactly like today — each resolver starts with ensureSave(db)) plus the domain Task row from lib/game/tasks.ts. Only task.type and task.payload are read; payload is the JSON string queueTask stored (Task 3): JUMP {systemId, hops} · EXPLORE {planetId} · SHIP_UPGRADE/SHIP_EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId}.
Common contract (all six — what Task 4b may rely on):
- Payload target, not current location. Effects land on the target recorded in the payload. A queued EXPLORE resolves against its planet wherever it is (the ship stays in its current system; a scan never discovers a system) — this is what makes a queued scan resolve correctly while the ship is elsewhere. JUMP moves the ship to the payload system.
- No turn advance. Resolvers read
save.turnand never write it — the caller owns the turn (the 4b tick for queued tasks; the immediate public function advances it before delegating). Event rows are written at the currentsave.turn. - No cost deduction. SHIP_*/RESEARCH costs are deducted at queue time (
queueTask, Task 3); the immediate public functions deduct inside themselves before delegating. Resolvers never callspendItems. - Guards, not re-validation. Each resolver keeps a minimal guard against a stale or double-resolved target: unknown target → 404, already-done target → 409, using the existing error class and exact message (
JumpError/ExploreError/ShipError/ResearchError). Reachability/cost/prerequisite checks are NOT repeated (they belong to queue time; a resolver throw is the signal for the 4b tick's re-validation, which fails the task). - Mission hooks unchanged. JUMP →
checkMissionCompletion(db, saveId, turn, { jumpedTo: target.id })+topUpMissionBoard(db); EXPLORE →checkMissionCompletion(db, saveId, turn, { outcome })+topUpMissionBoard(db); SHIP_* / RESEARCH → none (the current actions have none). - Event strings. The exact existing message formats, values computed from the resolution-time state.
- Persist. Each resolver ends with
persist(db)— the existing route→module pattern (jump/explore in save.ts, ship in ship.ts, research in research.ts). - Delegation keeps the legacy path intact.
jumpToSystem,explorePlanet,upgradeSystem,expandSystem,buildSystem,researchTechkeep their signatures, validation, (immediate-path) cost, turn advance and return values; they call their resolver with a privatesyntheticTask(...)stand-in row (zeroed identity fields — resolvers only readtype/payload). Routes and the OpenAPI spec are unchanged. The 4b row↔Task mapping for thetaskstable is NOT part of this task.
-
- Write the failing tests — add to
lib/game/save.test.ts(below theexplorePlanet crew findsblock):
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) ------------------------- // // Each test builds the identical deterministic fixture in two fresh files (seed '42', // stamped save → no backfill; ids line up across worlds) and compares their full state: // A = the legacy immediate action (validates, costs a turn, applies the effect); // B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver // applies the effect (ship/research rows deduct the cost up front, as queueTask did at // queue time — Task 3). snapshot() normalizes every game table (wall-clock columns // excluded), so the two end states must be byte-identical. const T0 = '2026-10-01T00:00:00.000Z' async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> { const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id }) const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id }) await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0, toSystem: s1 }) await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1, toSystem: s0 }) const [p0] = await db.db.insert(schema.planets).values({ systemId: s0, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id }) const [p1] = await db.db.insert(schema.planets).values({ systemId: s1, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id }) return { sys1: s1, p0, p1 } } // The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed // identity fields, the same way the public functions' delegation does. const task = (type: 'JUMP' | 'EXPLORE', payload: object): Task => ({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) async function snapshot(db: GameDb): Promise<string> { const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), db.db.select().from(schema.solarSystems), db.db.select().from(schema.jumpRoutes), db.db.select().from(schema.planets), db.db.select().from(schema.inventoryItems), db.db.select().from(schema.crew), db.db.select().from(schema.shipSystems), db.db.select().from(schema.tech), db.db.select().from(schema.missions), db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), ]) return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) } describe('resolveJumpTask (A/B against jumpToSystem)', () => { it('undiscovered target: lands exactly what jumpToSystem lands — move, discover, ring, event', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() const fa = await buildWorldFixture(a) const fb = await buildWorldFixture(b) await jumpToSystem(a, fa.sys1) // legacy: validates, +1 turn, effect, event at turn 1 await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) // the tick's job (Task 4b) await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 })) expect(await snapshot(b)).toEqual(await snapshot(a)) }) it('discovered target: no ring — still byte-identical to jumpToSystem', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() const fa = await buildWorldFixture(a, true) const fb = await buildWorldFixture(b, true) await jumpToSystem(a, fa.sys1) await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 })) expect(await snapshot(b)).toEqual(await snapshot(a)) expect(await b.db.select().from(schema.solarSystems)).toHaveLength(2) // no ring added }) it('guards: 404 unknown system, 409 already-current', async () => { const { db } = await freshFile() const f = await buildWorldFixture(db) await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await expect(resolveJumpTask(db, task('JUMP', { systemId: 999, hops: 1 }))).rejects.toMatchObject({ status: 404, message: 'Unknown system 999' }) await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, 1)) await db.db.update(schema.solarSystems).set({ isCurrent: true }).where(eq(schema.solarSystems.id, f.sys1)) await expect(resolveJumpTask(db, task('JUMP', { systemId: f.sys1, hops: 1 }))).rejects.toMatchObject({ status: 409, message: 'Already at this system' }) }) }) describe('resolveExploreTask (A/B against explorePlanet)', () => { it('planet in the current system: lands exactly what explorePlanet lands — haul, explored, event', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() const fa = await buildWorldFixture(a) const fb = await buildWorldFixture(b) await explorePlanet(a, fa.p0) await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p0 })) expect(await snapshot(b)).toEqual(await snapshot(a)) }) it('payload planet wins over the current system — the ship stays put (elsewhere semantics)', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() const fa = await buildWorldFixture(a) const fb = await buildWorldFixture(b) await explorePlanet(a, fa.p1) // the current function already scans any planet, not just the current system's await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p1 })) expect(await snapshot(b)).toEqual(await snapshot(a)) const sysB = await b.db.select().from(schema.solarSystems) expect(sysB.filter((s) => s.isCurrent).map((s) => s.name)).toEqual(['Origin']) // the ship never moved expect(sysB.find((s) => s.name === 'Outpost')!.discovered).toBe(false) // a scan never discovers a system const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'explore')! expect(ev.turn).toBe(1) // Exact existing format, recomputed the way save.ts computes it: haul first, crew roll on the same stream. const rng = explorationRng('42', fb.p1) const haul = rollExploration(rng) const member = rollCrewMember(rng, new Set<string>()) const foundText = haul.length > 0 ? haul.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' expect(ev.message).toBe(`Explored Basalt — found ${foundText}${member ? ` — ${member.name} joined the crew` : ''}`) }) it('guards: 404 unknown planet, 409 already explored (double-resolution safe)', async () => { const { db } = await freshFile() const f = await buildWorldFixture(db) await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await expect(resolveExploreTask(db, task('EXPLORE', { planetId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown planet 999' }) await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 })) await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' }) }) })Add to
lib/game/ship.test.ts:// --- A/B: resolvers vs the legacy immediate ship actions (Task 4a) --------------------- const T0 = '2026-10-01T00:00:00.000Z' // Stamped save (no backfill), the full ship per SHIP_TYPES, the full tech tree with // WEAPONS_SYSTEMS researched (unlocks the BUILD row). Identical build per world → ids line up. async function buildShipFixture(db: GameDb): Promise<void> { const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() for (const def of SHIP_TYPES) { await db.db.insert(schema.shipSystems).values({ saveId: save.id, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 }) } for (const def of TECHS) { await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: def.slug === 'WEAPONS_SYSTEMS' }) } } const task = (type: 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD', payload: object): Task => ({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) async function snapshot(db: GameDb): Promise<string> { const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), db.db.select().from(schema.solarSystems), db.db.select().from(schema.jumpRoutes), db.db.select().from(schema.planets), db.db.select().from(schema.inventoryItems), db.db.select().from(schema.crew), db.db.select().from(schema.shipSystems), db.db.select().from(schema.tech), db.db.select().from(schema.missions), db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), ]) return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) } describe('resolveShip* (A/B against the immediate actions)', () => { // A = legacy immediate action (validates, deducts the cost, +1 turn, effect, event). // B = the queued shape: the cost is deducted up front (queueTask did it at queue time — // Task 3), the tick advanced the turn (Task 4b), then the resolver applies the effect. // Same starting world → byte-identical end states. it('UPGRADE: lands exactly what upgradeSystem lands', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() await buildShipFixture(a) await buildShipFixture(b) await addItem(a, 'resource', 'IRON ORE', 1) // level 1 → cost 1 await addItem(b, 'resource', 'IRON ORE', 1) const reactorId = systemOf(await getShipState(a), 'REACTOR').id await upgradeSystem(a, reactorId) await spendItems(b, 1, 'resource', 1) // the queue-time deduction await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveShipUpgradeTask(b, task('SHIP_UPGRADE', { systemId: reactorId })) expect(await snapshot(b)).toEqual(await snapshot(a)) const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'upgrade')! expect(ev.turn).toBe(1) expect(ev.message).toBe('Upgraded REACTOR to level 2 (cost: 1× resources)') }) it('EXPAND: lands exactly what expandSystem lands', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() await buildShipFixture(a) await buildShipFixture(b) await addItem(a, 'resource', 'CRYSTAL DUST', 2) // capacity 2 → cost 2 await addItem(b, 'resource', 'CRYSTAL DUST', 2) const hullId = systemOf(await getShipState(a), 'HULL').id await expandSystem(a, hullId) await spendItems(b, 1, 'resource', 2) await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveShipExpandTask(b, task('SHIP_EXPAND', { systemId: hullId })) expect(await snapshot(b)).toEqual(await snapshot(a)) const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'expand')! expect(ev.turn).toBe(1) expect(ev.message).toBe('Expanded HULL capacity to 3 (cost: 2× resources)') }) it('BUILD: lands exactly what buildSystem lands', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() await buildShipFixture(a) await buildShipFixture(b) await addItem(a, 'part', 'SERVO ACTUATOR', 3) await addItem(b, 'part', 'SERVO ACTUATOR', 3) await buildSystem(a, 'WEAPONS') await spendItems(b, 1, 'part', 3) await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveShipBuildTask(b, task('SHIP_BUILD', { type: 'WEAPONS' })) expect(await snapshot(b)).toEqual(await snapshot(a)) const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'build')! expect(ev.turn).toBe(1) expect(ev.message).toBe('Built WEAPONS at base values (cost: 3× parts)') }) it('guards: 404 unknown, 409 unbuilt / max level / max capacity / gate / already built', async () => { const { db } = await freshFile() await buildShipFixture(db) const state = await getShipState(db) const reactorId = systemOf(state, 'REACTOR').id const hullId = systemOf(state, 'HULL').id const weaponsId = systemOf(state, 'WEAPONS').id await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' }) await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: weaponsId }))).rejects.toMatchObject({ status: 409, message: 'WEAPONS is not built' }) await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: reactorId }))).rejects.toMatchObject({ status: 409, message: `REACTOR is at max level (${MAX_LEVEL})` }) await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' }) await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId)) await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: hullId }))).rejects.toMatchObject({ status: 409, message: `HULL is at max capacity (${MAX_CAPACITY})` }) await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'SHIELDS' }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system type SHIELDS' }) await db.db.update(schema.tech).set({ researched: false }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS')) await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'WEAPONS' }))).rejects.toMatchObject({ status: 409, message: 'Building WEAPONS requires the WEAPONS SYSTEMS tech' }) await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'HULL' }))).rejects.toMatchObject({ status: 409, message: 'HULL is already built' }) }) })Add to
lib/game/research.test.ts:// --- A/B: resolver vs the legacy immediate action (Task 4a) --------------------------- const T0 = '2026-10-01T00:00:00.000Z' // Stamped save (no backfill), the full tech tree, nothing researched yet. Identical build // per world → ids line up. async function buildResearchFixture(db: GameDb): Promise<void> { const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() for (const def of TECHS) { await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect }) } } const task = (payload: object): Task => ({ id: 0, saveId: 0, type: 'RESEARCH', payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) async function snapshot(db: GameDb): Promise<string> { const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), db.db.select().from(schema.solarSystems), db.db.select().from(schema.jumpRoutes), db.db.select().from(schema.planets), db.db.select().from(schema.inventoryItems), db.db.select().from(schema.crew), db.db.select().from(schema.shipSystems), db.db.select().from(schema.tech), db.db.select().from(schema.missions), db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), ]) return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) } describe('resolveResearchTask (A/B against researchTech)', () => { // A = legacy immediate action (validates, deducts the cost, +1 turn, marks researched, // event). B = the queued shape: the cost was deducted at queue time (Task 3), the tick // advanced the turn (Task 4b), then the resolver marks the tech. Same starting world → // byte-identical end states. it('lands exactly what researchTech lands', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() await buildResearchFixture(a) await buildResearchFixture(b) await addItem(a, 'resource', 'IRON ORE', 2) // NAVIGATION AI costs 2 resources await addItem(b, 'resource', 'IRON ORE', 2) const [navA] = await a.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) const [navB] = await b.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) await researchTech(a, navA.id) await spendItems(b, 1, 'resource', 2) // the queue-time deduction await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveResearchTask(b, task({ techId: navB.id })) expect(await snapshot(b)).toEqual(await snapshot(a)) expect((await b.db.select().from(schema.tech)).find((t) => t.id === navB.id)?.researched).toBe(true) const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'research')! expect(ev.turn).toBe(1) expect(ev.message).toBe('Researched NAVIGATION AI (cost: 2× resources)') }) it('guards: 404 unknown tech, 409 already researched (prereqs are monotone — no guard needed)', async () => { const { db } = await freshFile() await buildResearchFixture(db) const [nav] = await db.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await expect(resolveResearchTask(db, task({ techId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown tech 999' }) await resolveResearchTask(db, task({ techId: nav.id })) await expect(resolveResearchTask(db, task({ techId: nav.id }))).rejects.toMatchObject({ status: 409, message: 'NAVIGATION AI is already researched' }) }) }) - Write the failing tests — add to
-
- Run — expect failure:
npx vitest run lib/game/save.test.ts lib/game/ship.test.ts lib/game/research.test.ts→ red: all three files fail at module load becauseresolveJumpTask/resolveExploreTask/resolveShip*Task/resolveResearchTaskare not exported yet. No pre-existing test logic is touched — the old describes fail only on the import, and stay green once step 3 lands.
- Run — expect failure:
-
- Implement — (a)
lib/game/save.ts: add the./taskstype-only import, the privatesyntheticTaskand the two resolvers, and rewritejumpToSystem/explorePlanetas shown below (code):
// --- Per-type resolvers (PR A, spec §3) ------------------------------------------- // // The effect bodies of the instant actions, extracted so the turn tick (Task 4b) can // resolve a completed task row against whatever the world looks like at resolution time. // Contract (all six resolvers across save.ts / ship.ts / research.ts — see the Interfaces // block of this plan): effects land on the PAYLOAD target, never on the current location; // no turn advance (the caller owns the turn); no cost deduction (queueTask deducted at // queue time — Task 3; the immediate public functions deduct before delegating); a minimal // guard against a stale or double-resolved target (existing error class + exact message — // a throw is the signal for the 4b tick's re-validation); the existing event text formats // at the current turn; persist at the end. // A stand-in task row for the immediate public functions delegating to a resolver: the // resolvers only read `type` + `payload`, so the identity fields are zeroed. function syntheticTask(type: TaskType, payload: object): Task { return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } } // JUMP — move the ship to the payload's system: flip isCurrent, discover the target, and // when it was undiscovered grow the chart ring off the same (seed, turn) stream as before. export async function resolveJumpTask(db: GameDb, task: Task): Promise<void> { await ensureSave(db) const { systemId } = JSON.parse(task.payload) as { systemId: number } const [save] = await db.db.select().from(schema.saves).limit(1) const systems = await db.db.select().from(schema.solarSystems) const target = systems.find((s) => s.id === systemId) if (!target) throw new JumpError(404, `Unknown system ${systemId}`) if (target.isCurrent) throw new JumpError(409, 'Already at this system') const current = systems.find((s) => s.isCurrent)! await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id)) await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id)) if (!target.discovered) { // Growing the chart: a ring of new (undiscovered) neighbours around the newly reached // system, so there is always somewhere to jump next and a route back. const rng = expansionRng(save.seed, save.turn) const takenNames = new Set(systems.map((s) => s.name)) const ring = generateRing(rng, target.x, target.y, takenNames, systems) const ringIds: number[] = [] for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed)) const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1]) await insertRoutes(db, ring.edges, resolve) } await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() }) await checkMissionCompletion(db, save.id, save.turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination await topUpMissionBoard(db) await persist(db) } // EXPLORE — scan the payload's planet (any system — the UI only offered the current // system's rows; a queued scan may resolve while the ship is elsewhere): deterministic // haul into the inventory, planet marked explored, crew-join roll on the same // (seed, planet) stream. export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outcome: FoundItem[]; crewFound: CrewDto | null }> { await ensureSave(db) const { planetId } = JSON.parse(task.payload) as { planetId: number } const [save] = await db.db.select().from(schema.saves).limit(1) const planets = await db.db.select().from(schema.planets) const target = planets.find((p) => p.id === planetId) if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`) if (target.explored) throw new ExploreError(409, `${target.name} was already explored`) const rng = explorationRng(save.seed, target.id) const outcome = rollExploration(rng) // resource/part haul first… const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name)) const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id)) for (const item of outcome) { // Accumulate: the same kind+name on this save bumps qty instead of adding a row. const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === item.kind && i.name === item.name) if (existing) { await db.db.update(schema.inventoryItems).set({ qty: existing.qty + item.qty }).where(eq(schema.inventoryItems.id, existing.id)) } else { await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty }) } } let crewFound: CrewDto | null = null if (foundMember) { const systems = await db.db.select().from(schema.solarSystems) const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system' const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning() crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin } } const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : '' await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() }) await checkMissionCompletion(db, save.id, save.turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed await topUpMissionBoard(db) await persist(db) return { outcome, crewFound } } export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldState> { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const systems = await db.db.select().from(schema.solarSystems) const target = systems.find((s) => s.id === systemId) if (!target) throw new JumpError(404, `Unknown system ${systemId}`) const current = systems.find((s) => s.isCurrent)! if (current.id === target.id) throw new JumpError(409, 'Already at this system') const routes = await db.db.select().from(schema.jumpRoutes) // #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop // jump costs one turn per hop, so single-hop jumps keep their existing cost. const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched) const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id) if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`) // The immediate jump keeps its own turn cost (the tick owns turn advances for queued // tasks): advance it before delegating so the resolver's event lands at exactly the // turn the old code wrote. const turn = save.turn + hopCount await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveJumpTask(db, syntheticTask('JUMP', { systemId, hops: hopCount })) return getWorldState(db) } export async function explorePlanet(db: GameDb, planetId: number): Promise<ExploreResult> { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const planets = await db.db.select().from(schema.planets) const target = planets.find((p) => p.id === planetId) if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`) if (target.explored) throw new ExploreError(409, `${target.name} was already explored`) // The immediate scan keeps its one-turn cost; the resolver applies everything else at that turn. const turn = save.turn + 1 await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) const { outcome, crewFound } = await resolveExploreTask(db, syntheticTask('EXPLORE', { planetId })) return { ...(await readSystemState(db)), outcome, crewFound } }(b)
lib/game/ship.ts: add the./taskstype-only import, the privatesyntheticTask, the three resolvers, and rewrite the three public actions (same shape — validation andspendItemsstay; the turn advance moves before the delegation;bumpTurnAndEventis deleted):function syntheticTask(type: TaskType, payload: object): Task { return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } } // SHIP_UPGRADE — level a built system by one, at whatever level it sits at resolution // time. No cost here (deducted at queue time — Task 3 — or by upgradeSystem before // delegating); no mission hooks (the current action has none). export async function resolveShipUpgradeTask(db: GameDb, task: Task): Promise<void> { await ensureSave(db) const { systemId } = JSON.parse(task.payload) as { systemId: number } const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) const cost = upgradeCost(target.level) await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'upgrade', message: `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() }) await persist(db) } // SHIP_EXPAND — same shape for capacity (cost: `capacity` units of any resource, deducted). export async function resolveShipExpandTask(db: GameDb, task: Task): Promise<void> { await ensureSave(db) const { systemId } = JSON.parse(task.payload) as { systemId: number } const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) const cost = expandCost(target.capacity) await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'expand', message: `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() }) await persist(db) } // SHIP_BUILD — build an unbuilt type at base values. The flat parts cost is already // deducted; some types are gated behind a researched tech (see EFFECTS in lib/game/tech.ts). export async function resolveShipBuildTask(db: GameDb, task: Task): Promise<void> { await ensureSave(db) const { type } = JSON.parse(task.payload) as { type: string } const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type) if (!target) throw new ShipError(404, `Unknown ship system type ${type}`) if (target.level > 0) throw new ShipError(409, `${target.type} is already built`) const gate = buildGate(type, await researchedTechSlugs(db)) if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'build', message: `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`, createdAt: new Date().toISOString() }) await persist(db) } // UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) await spendItems(db, save.id, 'resource', upgradeCost(target.level)) const turn = save.turn + 1 await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveShipUpgradeTask(db, syntheticTask('SHIP_UPGRADE', { systemId })) return readShipState(db) } // EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) await spendItems(db, save.id, 'resource', expandCost(target.capacity)) const turn = save.turn + 1 await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveShipExpandTask(db, syntheticTask('SHIP_EXPAND', { systemId })) return readShipState(db) } // BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. export async function buildSystem(db: GameDb, type: string): Promise<ShipState> { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type) if (!target) throw new ShipError(404, `Unknown ship system type ${type}`) if (target.level > 0) throw new ShipError(409, `${target.type} is already built`) const gate = buildGate(type, await researchedTechSlugs(db)) if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) await spendItems(db, save.id, 'part', BUILD_PART_COST) const turn = save.turn + 1 await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveShipBuildTask(db, syntheticTask('SHIP_BUILD', { type })) return readShipState(db) }(c)
lib/game/research.ts: add the./taskstype-only import, the privatesyntheticTaskand the resolver, and rewriteresearchTech(spend + re-throw and validation stay; turn advance moves before the delegation):function syntheticTask(type: TaskType, payload: object): Task { return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } } // RESEARCH — mark the payload's tech researched at the current turn. No cost here (deducted // at queue time — Task 3 — or by researchTech before delegating); no mission hooks (the // current action has none). export async function resolveResearchTask(db: GameDb, task: Task): Promise<void> { await ensureSave(db) const { techId } = JSON.parse(task.payload) as { techId: number } const [save] = await db.db.select().from(schema.saves).limit(1) const rows = await db.db.select().from(schema.tech) const target = rows.find((t) => t.id === techId) if (!target) throw new ResearchError(404, `Unknown tech ${techId}`) const def = TECHS.find((t) => t.slug === target.slug)! if (target.researched) throw new ResearchError(409, `${def.name} is already researched`) await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() }) await persist(db) } // Research one tech: validates prerequisites (409, naming what is missing) and the cost (422), // spends it from the cargo hold, then delegates the unlock to the resolver at the new turn. export async function researchTech(db: GameDb, techId: number): Promise<TechDto[]> { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const rows = await db.db.select().from(schema.tech) const target = rows.find((t) => t.id === techId) if (!target) throw new ResearchError(404, `Unknown tech ${techId}`) const def = TECHS.find((t) => t.slug === target.slug)! const researched = await researchedSlugs(db) if (researched.has(def.slug)) throw new ResearchError(409, `${def.name} is already researched`) const missing = def.prereqs.filter((p) => !researched.has(p)).map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug) if (missing.length > 0) throw new ResearchError(409, `${def.name} requires ${missing.join(' and ')} first`) try { await spendItems(db, save.id, def.costKind, def.costQty) } catch (err) { // spendItems throws ShipError(422) on a short hold — re-throw as this domain's error. if (err instanceof ShipError) throw new ResearchError(err.status, err.message) throw err } const turn = save.turn + 1 await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveResearchTask(db, syntheticTask('RESEARCH', { techId })) return getTechState(db) } - Implement — (a)
-
- Run — expect green:
npx vitest run→ the whole unit suite passes (all six pre-existing action describes in save/ship/research stay green — the delegated public functions are behaviour-identical — plus the new A/B + guard blocks), andnpx tsc --noEmitclean.
- Run — expect green:
-
- Commit (same PR A branch):
feat(game): per-type resolvers — jump/explore/ship/research effect extraction for task resolution (#44)
- Commit (same PR A branch):
Task 4b: tickTurn (tasks.ts)
Files
lib/game/tasks.ts— addTickResultandtickTurnafter the privatededuct(code in step 3). No new imports — the tick consumes the existingQueueState/Taskand calls nothing outside the module, so it stays pure (no Date, no rng, no lib/db — global constraint).lib/game/save.ts— add the orchestratorrunTurnplus the privaterowToTaskmapping and theTASK_RESOLVERSdispatch (code in step 3); replace 4a's type-onlyimport type { Task, TaskType } from './tasks'withimport { tickTurn, type QueueState, type Task, type TaskStatus, type TaskType } from './tasks'and addimport { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'andimport { resolveResearchTask, ResearchError } from './research'. Import-graph note: ship.ts and research.ts already import save.ts at runtime (ensureSave,InventoryItemDto), so this adds the reverse edge — cycles save↔ship and save→research→ship. Safe here: every cross-module reference is a function call made at request time, none at module-evaluation time (each file's top level only declares functions/classes) — the standard function-only cycle shape ESM / webpack / Turbopack / Vitest handle.lib/game/tasks.test.ts— add thedescribe('tickTurn')block below (code in step 1); reuses thequeueTaskblock's file-scopeCREW/state/taskfixture helpers; addtickTurnto the existing./tasksimport.lib/game/save.test.ts— add thedescribe('runTurn (tick on the DB)')block below (code in step 1); reuses the 4a block's file-scopebuildWorldFixtureand the existingfreshFile, but NOT the 4asnapshothelper (it does not select thetaskstable — this block asserts the queue rows directly); addrunTurnto the existing./saveimport and a newimport { MIN_AVAILABLE } from './missions'.- Untouched:
app/api/*(POST /api/turnis Task 5),lib/openapi.ts(no endpoint changes yet),lib/db/*(thetasksDDL / registry is Task 2's), the 4a resolvers and public functions.
Interfaces
tickTurn(state: QueueState): TickResult — the pure tick over the same plain state queueTask consumes (Task 3's QueueState, reused as-is — the tick needs no fields beyond it). It reads only state.turn and state.tasks (it never parses task.payload — the target is the resolver's business — and never consults the roster — busyness is derived entirely from the task rows); the other QueueState fields pass through untouched, so one row→state mapping at the save.ts boundary serves both queueing and the tick. Never mutates its input (house style — deduct sets the pattern).
export interface TickResult {
turn: number // state.turn + 1 — the tick IS the turn increment
tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE
completed: Task[] // the tasks that hit 0 this tick, in resolution order
}
export function tickTurn(state: QueueState): TickResult
The tick does exactly one turn per call, in this order:
- (a) Decrement. Every
ACTIVEtask'sremaining−= 1.PENDINGandDONErows do not age — a task's countdown starts when it starts, anddurationis never modified after queueing. - (b) Resolve. Every task that reached 0 is marked
DONE— that frees the whole team (busyness is derived from thetaskstable; there is no crew-status column to update) — and returned incompletedsorted by(queuedAtTurn ASC, id ASC)(identical toidorder, sincequeuedAtTurnis non-decreasing inid). The check isremaining <= 0, not=== 0, so a corrupt row at or below zero still completes on the next tick instead of dead-ending the queue (spec focus 4).tickTurnitself applies no effect: it returns WHICH tasks completed, and the orchestrator calls each type's 4a resolver in that order. - (c) Auto-start. ONE forward pass over
PENDINGin the same(queuedAtTurn ASC, id ASC)order. A task starts (PENDING → ACTIVE,remainingunchanged — it keeps its full duration; its first decrement lands on the next tick) when its FULL team (lead + all helpers) is free — no member of it is on anACTIVEtask and no member is on aPENDINGtask other than this one — evaluated after this tick's completions and any earlier start in this pass. One pass is complete: starting a task only makes its team busier, never frees anyone, so a task that cannot start at its position cannot start later in the same pass. UnderqueueTask's invariants (one PENDING per member; no one on an ACTIVE holds a PENDING unless they lead both) a freed member has at most one successor, so at most ONE of their PENDINGs starts per tick — the FIFO chain of spec §3, earliestqueuedAtTurnwins — and the "no other PENDING" clause only bites on admin-corrupted rows, where it keeps the pass deterministic anyway. - (d) Mission hooks. NOT in
tickTurn— they stay wherecheckMissionCompletionis called today: inside the save.ts action machinery, at the new turn, before persist. In the task world that machinery IS the 4a resolvers (their locked contract: JUMP →checkMissionCompletion(db, saveId, turn, { jumpedTo })+topUpMissionBoard(db); EXPLORE →checkMissionCompletion(db, saveId, turn, { outcome })+topUpMissionBoard(db); SHIP_* / RESEARCH → none), so the orchestrator gets the hooks for free by calling the resolvers, at the tick's new turn. A tick with no resolution checks no missions and tops up nothing. - (e) The tick IS the turn.
tickTurnreturnsstate.turn + 1, and nothing in the pure layer increments it; the orchestrator writes it tosaves.turnBEFORE the resolvers run (so the resolvers' events and mission hooks land at the new turn — the same placement as today's actions) and persists the outcome. The full state the snapshot endpoints need is exactly the DB after that persist:getWorldState/getSystemState/getCrewState/getShipState/getMissions/getEvents/getTechStateall re-read their tables and carryturn, so they reflect the tick without new plumbing. After Task 5 retires the immediate actions,runTurnis the sole writer ofsave.turn; until then the legacy immediate functions keep their existing (unchanged) turn advance.
Resolver dispatch — the 4a contract names the six resolvers 1:1 exactly for this; the record is exhaustive by type (a TaskType without a resolver is a compile error):
const TASK_RESOLVERS: Record<TaskType, (db: GameDb, task: Task) => Promise<unknown>> = {
JUMP: resolveJumpTask,
EXPLORE: resolveExploreTask,
SHIP_UPGRADE: resolveShipUpgradeTask,
SHIP_EXPAND: resolveShipExpandTask,
SHIP_BUILD: resolveShipBuildTask,
RESEARCH: resolveResearchTask,
}
The resolvers receive the real Task rows from the tick (they only read type + payload — 4a).
Failure semantics (from the 4a contract — "a resolver throw is the signal for the 4b tick's re-validation, which fails the task"): a resolver throw is its minimal guard (unknown target → 404, already-resolved target → 409 — a queue-time check can go stale between queueing and resolution). The orchestrator catches exactly the four domain error classes (JumpError / ExploreError / ShipError / ResearchError) and fails the task: it stays DONE (the tick already freed the team — the turn is spent), the effect simply does not land, no event is written for the failure, and the remaining completed tasks still resolve. Non-domain errors rethrow (a crash is a real bug).
runTurn(db: GameDb): Promise<TickResult> — the orchestrator in save.ts, matching the existing route→save pattern (ensureSave first, unscoped single-player reads, persist last). It returns the pure TickResult unchanged — the full mutated state the snapshot endpoints need; Task 5's route responds with it, composing spec §7's response (the active+pending task array) from it and the existing readers.
-
ensureSave(db); read the saves row + the crew / tasks / inventory / shipSystems / tech rows; map them intoQueueState(the row→Task mapping lives here at the save.ts boundary —JSON.parseonhelpers; the inverse, Task→row at insert, stays with the routes' queueing path). -
tickTurn(state). -
saves.turn = result.turn— written BEFORE the resolvers (see (d) / (e) above). -
For each task of
result.completed(in the returned order):TASK_RESOLVERS[task.type](db, task)with the failure semantics above. Each resolver ends in its ownpersist(db)(4a) — harmless mid-stream, sincepersistis a full-DB export. -
Write the tick's queue changes back to
tasks— every row whosestatusorremainingchanged — then a finalpersist(db)(the turn's one atomic on-disk write). -
- Write the failing tests — add to
lib/game/tasks.test.ts(below thequeueTaskblock) and tolib/game/save.test.ts(below the 4a A/B blocks):
// --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) ------------------- // // tickTurn is the whole §3 tick over the same plain QueueState queueTask consumes. It owns // the turn increment and nothing else: it applies no effect and runs no mission logic — // the save.ts orchestrator (runTurn) calls the 4a resolvers for the returned `completed` // tasks, in the order returned. export interface TickResult { turn: number // state.turn + 1 — the tick IS the turn increment tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE completed: Task[] // the tasks that hit 0 this tick, in resolution order } const byQueueOrder = (a: Task, b: Task) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id export function tickTurn(state: QueueState): TickResult { const turn = state.turn + 1 const tasks = state.tasks.map((t) => ({ ...t })) // never mutate the input (house style — deduct) // (a) every ACTIVE task ages by one; PENDING/DONE rows do not age. for (const t of tasks) if (t.status === 'ACTIVE') t.remaining -= 1 // (b) everything that hit 0 resolves this tick. Marking DONE frees the whole team // (busyness is derived from the tasks table — there is no crew status column to update). // <= 0, not === 0: a corrupt row at or below zero still completes on the next tick // instead of dead-ending the queue (spec focus 4: no dead ends). const completed = tasks .filter((t) => t.status === 'ACTIVE' && t.remaining <= 0) .sort(byQueueOrder) for (const t of completed) t.status = 'DONE' // (c) auto-start: one forward pass over PENDING in (queuedAtTurn ASC, id ASC). Starting a // task only makes its team busier — never frees anyone — so a task that cannot start at // its position cannot start later in the same pass: one pass is complete. A just-started // task keeps its full duration; its first decrement lands on the next tick. for (const t of tasks.filter((t) => t.status === 'PENDING').sort(byQueueOrder)) { const free = (id: number) => !tasks.some((o) => o.status === 'ACTIVE' && (o.leadCrewId === id || o.helpers.includes(id))) && !tasks.some((o) => o.status === 'PENDING' && o.id !== t.id && (o.leadCrewId === id || o.helpers.includes(id))) if ([t.leadCrewId, ...t.helpers].every(free)) t.status = 'ACTIVE' } return { turn, tasks, completed } }describe('tickTurn', () => { // The queueTask block's file-scope fixture helpers (above): CREW = [1 AVA PILOT, // 2 BEN ENGINEER, 3 CASS SCIENTIST]; state() → { turn: 10, crew: CREW, tasks: [], … }; // task() → a one-turn row. The tick never parses payloads — the targets below are inert. const JUMP = (id: number, over: Partial<Task> = {}): Task => task({ id, type: 'JUMP', payload: '{"systemId":2,"hops":1}', ...over }) const EXPLORE = (id: number, planetId: number, over: Partial<Task> = {}): Task => task({ id, type: 'EXPLORE', payload: `{"planetId":${planetId}}`, ...over }) it('decrements every ACTIVE task by one; a task that hits 0 is DONE (its team freed); PENDING rows do not age; the input is never mutated', () => { const s = state({ tasks: [ JUMP(1, { duration: 3, remaining: 3, leadCrewId: 1, queuedAtTurn: 8 }), EXPLORE(2, 5, { leadCrewId: 3, helpers: [2], duration: 1, remaining: 1, queuedAtTurn: 9 }), EXPLORE(3, 6, { leadCrewId: 1, duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 10 }), // member 1 is on ACTIVE 1 ], }) const r = tickTurn(s) expect(r.turn).toBe(11) // the tick is the increment expect(r.tasks.find((t) => t.id === 1)!.remaining).toBe(2) expect(r.tasks.find((t) => t.id === 1)!.status).toBe('ACTIVE') expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE') expect(r.completed.map((t) => t.id)).toEqual([2]) expect(r.tasks.find((t) => t.id === 3)!.status).toBe('PENDING') expect(r.tasks.find((t) => t.id === 3)!.remaining).toBe(4) // pending does not age expect(s.tasks.find((t) => t.id === 1)!.remaining).toBe(3) // input state untouched }) it('a 3-deep PENDING chain on one lead: when the lead task completes, exactly ONE successor starts — earliest queuedAtTurn wins (Review Focus 2)', () => { let s = state({ tasks: [ JUMP(9, { duration: 3, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }), EXPLORE(10, 5, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), EXPLORE(11, 6, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 9 }), EXPLORE(12, 7, { leadCrewId: 1, duration: 1, remaining: 1, status: 'PENDING', queuedAtTurn: 10 }), ], }) let r = tickTurn(s) expect(r.completed.map((t) => t.id)).toEqual([9]) expect(r.tasks.find((t) => t.id === 9)!.status).toBe('DONE') expect(r.tasks.find((t) => t.id === 10)!.status).toBe('ACTIVE') // earliest queuedAtTurn starts expect(r.tasks.find((t) => t.id === 10)!.remaining).toBe(2) // full duration — first decrement next tick expect(r.tasks.find((t) => t.id === 11)!.status).toBe('PENDING') // the others wait for their own tick expect(r.tasks.find((t) => t.id === 12)!.status).toBe('PENDING') // Walk the chain to the end: each successor starts on exactly the tick its predecessor completes. const EXPECT: [completed: number[], active: number[]][] = [ [[], [10]], [[10], [11]], [[], [11]], [[11], [12]], [[12], []], ] for (const [done, active] of EXPECT) { s = { ...s, turn: r.turn, tasks: r.tasks } r = tickTurn(s) expect(r.completed.map((t) => t.id)).toEqual(done) expect(r.tasks.filter((t) => t.status === 'ACTIVE').map((t) => t.id)).toEqual(active) } expect(r.turn).toBe(16) // 10 + 1 (the first tick) + 5 (the walk) expect(r.tasks.every((t) => t.status === 'DONE')).toBe(true) }) it('same queuedAtTurn ties break by id, and a started task blocks a later one sharing a member — at most one start per freed member (Review Focus 2, corrupt-row half)', () => { // queueTask forbids a member committed to two PENDINGs (rule 2), so this shape only // exists on admin-corrupted rows — the tick must still be deterministic about it. const s = state({ tasks: [ JUMP(19, { duration: 1, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }), EXPLORE(20, 5, { leadCrewId: 2, helpers: [3], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), EXPLORE(21, 6, { leadCrewId: 3, helpers: [2], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), ], }) const r = tickTurn(s) expect(r.tasks.find((t) => t.id === 20)!.status).toBe('ACTIVE') // (8, 20) < (8, 21) — starts first and takes members 2 and 3 expect(r.tasks.find((t) => t.id === 21)!.status).toBe('PENDING') }) it('a JUMP and an EXPLORE can both resolve on the same tick — completion is location-independent (Review Focus 3, tick half)', () => { const s = state({ tasks: [ EXPLORE(1, 5, { leadCrewId: 1, duration: 1, remaining: 1, queuedAtTurn: 8 }), JUMP(2, { leadCrewId: 2, duration: 1, remaining: 1, queuedAtTurn: 8 }), ], }) const r = tickTurn(s) expect(r.completed.map((t) => t.id)).toEqual([1, 2]) // both this tick, queue order (explore first) expect(r.tasks.find((t) => t.id === 1)!.status).toBe('DONE') expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE') }) it('free skip: an empty queue advances exactly one turn and changes nothing; a fully-free PENDING still starts (Review Focus 4)', () => { const r = tickTurn(state()) expect(r.turn).toBe(11) expect(r.tasks).toEqual([]) expect(r.completed).toEqual([]) // No ACTIVE tasks and a startable PENDING (a corrupt-row shape — a legal PENDING always // has a busy member): the pass still starts it. Skipping means "no resolution", not // "no starts". const r2 = tickTurn(state({ tasks: [EXPLORE(30, 5, { leadCrewId: 1, helpers: [2, 3], duration: 3, remaining: 3, status: 'PENDING', queuedAtTurn: 9 })] })) expect(r2.turn).toBe(11) expect(r2.tasks.find((t) => t.id === 30)!.status).toBe('ACTIVE') }) })// --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) ----------------- // // The route→save pattern: ensureSave first, unscoped single-player reads, persist last. // The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the // effects and the mission hooks (their contract — invoked here, at the tick's new turn, // before the final persist); this function owns the table writes. // The 4a contract names the resolvers 1:1 exactly for this dispatch; exhaustive by type — // a TaskType without a resolver is a compile error. const TASK_RESOLVERS: Record<TaskType, (db: GameDb, task: Task) => Promise<unknown>> = { JUMP: resolveJumpTask, EXPLORE: resolveExploreTask, SHIP_UPGRADE: resolveShipUpgradeTask, SHIP_EXPAND: resolveShipExpandTask, SHIP_BUILD: resolveShipBuildTask, RESEARCH: resolveResearchTask, } // Row→domain at the save.ts boundary (the inverse — Task→row at insert — stays with the // routes' queueing path). function rowToTask(row: { id: number; saveId: number; type: string; payload: string; leadCrewId: number; helpers: string; duration: number; remaining: number; status: string; queuedAtTurn: number }): Task { return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn } } export async function runTurn(db: GameDb): Promise<TickResult> { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([ db.db.select().from(schema.crew), db.db.select().from(schema.tasks), db.db.select().from(schema.inventoryItems), db.db.select().from(schema.shipSystems), db.db.select().from(schema.tech), ]) const state: QueueState = { turn: save.turn, crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })), tasks: taskRows.map(rowToTask), inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })), shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })), tech: tech.map((t) => ({ id: t.id, slug: t.slug })), } const result = tickTurn(state) // (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events // and the checkMissionCompletion / topUpMissionBoard hooks (4a contract) land at the new // turn, exactly where today's actions place them. await db.db.update(schema.saves).set({ turn: result.turn }).where(eq(schema.saves.id, save.id)) // (b) + (d) resolve in the order tickTurn returned. A resolver throw is its 4a guard // (stale or double-resolved target): the task fails — it is already DONE (the tick freed // the team; the turn is spent), the effect simply does not land, and no event is written // for it. Only the four domain error classes fail a task; anything else rethrows. for (const task of result.completed) { try { await TASK_RESOLVERS[task.type](db, task) } catch (err) { if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue throw err } } // Persist the tick's queue changes (the resolvers already persisted their own effects) — // one final whole-DB write makes the turn atomic on disk. for (const before of state.tasks) { const after = result.tasks.find((t) => t.id === before.id) if (after && (after.status !== before.status || after.remaining !== before.remaining)) { await db.db.update(schema.tasks).set({ status: after.status, remaining: after.remaining }).where(eq(schema.tasks.id, after.id)) } } await persist(db) return result }describe('runTurn (tick on the DB)', () => { // buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no // backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) / // Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so // this block adds its own rows. async function addCrew(db: GameDb, count: number): Promise<number[]> { const [save] = await db.db.select().from(schema.saves).limit(1) const ids: number[] = [] for (let i = 0; i < count; i++) { const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id }) ids.push(row.id) } return ids } async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise<number> { const [save] = await db.db.select().from(schema.saves).limit(1) const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id }) return row.id } it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => { const { db } = await freshFile() await buildWorldFixture(db) const count = (t: Parameters<typeof db.db.select>[0]) => db.db.select().from(t).then((rows) => rows.length) const before = { systems: await count(schema.solarSystems), planets: await count(schema.planets), inventory: await count(schema.inventoryItems), missions: await count(schema.missions), events: await count(schema.events), } const r = await runTurn(db) expect(r.turn).toBe(1) expect(r.tasks).toEqual([]) expect(r.completed).toEqual([]) const [save] = await db.db.select().from(schema.saves).limit(1) expect(save.turn).toBe(1) // the tick is the increment — the only write of the call expect(await count(schema.solarSystems)).toBe(before.systems) expect(await count(schema.planets)).toBe(before.planets) expect(await count(schema.inventoryItems)).toBe(before.inventory) expect(await count(schema.missions)).toBe(before.missions) // no top-up without a resolution expect(await count(schema.events)).toBe(before.events) // no event for a skip expect(await count(schema.tasks)).toBe(0) }) it('a resolved JUMP completes the matching DELIVER mission, grants the reward, and tops the board up — the 4a hooks, at the tick\'s new turn', async () => { const { db } = await freshFile() const f = await buildWorldFixture(db) // s0 current; s1 'Outpost' undiscovered const [c1] = await addCrew(db, 1) const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 4 }) const [save] = await db.db.select().from(schema.saves).limit(1) await db.db.update(schema.saves).set({ turn: 5 }).where(eq(schema.saves.id, save.id)) const [missionId] = await db.db.insert(schema.missions).values({ saveId: save.id, type: 'DELIVER', description: 'Jump to Outpost', targetSystemId: f.sys1, progressTarget: 1, rewardKind: 'resource', rewardQty: 2, state: 'ACTIVE' }).returning({ id: schema.missions.id }) const r = await runTurn(db) expect(r.turn).toBe(6) expect(r.completed.map((t) => t.id)).toEqual([jumpId]) expect((await db.db.select().from(schema.tasks)).find((t) => t.id === jumpId)!.status).toBe('DONE') // the mission resolved at the new turn: DONE + reward in the hold + both events at turn 6 expect((await db.db.select().from(schema.missions)).find((m) => m.id === missionId)).toMatchObject({ state: 'DONE', progress: 1 }) expect(await db.db.select().from(schema.inventoryItems)).toEqual([{ id: expect.any(Number), saveId: save.id, kind: 'resource', name: 'MISSION REWARD', qty: 2 }]) const events = (await db.db.select().from(schema.events)).map((e) => [e.turn, e.type] as const) expect(events).toHaveLength(2) expect(events).toContainEqual([6, 'jump']) expect(events).toContainEqual([6, 'mission']) // the board is topped up after the arrival (the 4a jump-resolver hook) to exactly the floor const available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length expect(available).toBe(MIN_AVAILABLE) // the effect half: the ship is at the payload system expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true) }) it('a JUMP and an EXPLORE both resolving on one tick: the explore effect lands on its payload planet, not the newly arrived system (Review Focus 3)', async () => { const { db } = await freshFile() const f = await buildWorldFixture(db) // p0 'Alder' in s0 (Origin); p1 'Basalt' in s1 (Outpost) const [c1, c2] = await addCrew(db, 2) await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) const [save] = await db.db.select().from(schema.saves).limit(1) await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id)) const r = await runTurn(db) expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE', 'JUMP']) // both this tick — the resolvers are payload-targeted (4a), so resolution order cannot change what lands const planets = await db.db.select().from(schema.planets) expect(planets.find((p) => p.id === f.p0)!.explored).toBe(true) // the payload planet — in the OLD system expect(planets.find((p) => p.id === f.p1)!.explored).toBe(false) // nothing in the new system expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true) // the jump still landed // the haul is the payload planet's deterministic stream (seed '42', p0) — never the new system's expect((await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind, name: i.name, qty: i.qty }))).toEqual(rollExploration(explorationRng('42', f.p0))) // the ~18% crew roll rides the same stream — at most one member joins (PR C pins the stream) expect((await db.db.select().from(schema.crew)).length).toBeGreaterThanOrEqual(2) }) it('a resolver guard failure fails just that task — the turn still lands, the sibling still resolves, no event for the failure', async () => { const { db } = await freshFile() const f = await buildWorldFixture(db) const [c1, c2] = await addCrew(db, 2) const badJumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: 999, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) const [save] = await db.db.select().from(schema.saves).limit(1) await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id)) const r = await runTurn(db) // must not throw — the 4a guard throw is a failure signal, not an error expect(r.turn).toBe(5) expect(r.completed.map((t) => t.id)).toEqual([badJumpId, exploreId]) // both reported; only the sibling's effect lands expect((await db.db.select().from(schema.tasks)).find((t) => t.id === badJumpId)!.status).toBe('DONE') // failed task: done — the turn is spent, the crew freed expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(true) // the sibling resolved const types = (await db.db.select().from(schema.events)).map((e) => e.type) expect(types.filter((t) => t === 'jump')).toHaveLength(0) // the 404 guard threw before the jump effect/event expect(types.filter((t) => t === 'explore')).toHaveLength(1) expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move }) }) - Write the failing tests — add to
-
- Run — expect failure:
npx vitest run lib/game/tasks.test.ts→ the five newtickTurnits red (tickTurnis not exported), thequeueTaskblock stays green;npx vitest run lib/game/save.test.ts→ the newrunTurndescribe red (runTurnis not exported), everything else — including 4a's A/B blocks — stays green.
- Run — expect failure:
-
- Implement — append
TickResult+byQueueOrder+tickTurntolib/game/tasks.ts(code above); inlib/game/save.tsadd the two resolver imports, replace the 4a type-only./tasksimport withimport { tickTurn, type QueueState, type Task, type TaskStatus, type TaskType } from './tasks', and addTASK_RESOLVERS/rowToTask/runTurn(code above).
- Implement — append
-
- Run — expect green:
npx vitest run→ the whole unit suite passes (queueTask, 4a's A/B + guard blocks, and the new tick blocks), andnpx tsc --noEmitclean.
- Run — expect green:
-
- Commit (same PR A branch):
feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)
- Commit (same PR A branch):
Task 5a: Action endpoints → queueing (jump, explore)
The two instant-action endpoints become queueing endpoints: same body fields plus the crew team, same pre-checks with the same codes, then a queueTask and a row insert. Effects move to the tick (4a's resolvers), and these two endpoints no longer advance the turn — from this task on, runTurn is the sole writer of saves.turn (tick section).
Files
app/api/jump/route.ts— rewritePOST. Body:{ systemId, leadCrewId, helperCrewIds? }—leadCrewIda required integer,helperCrewIdsan optional integer array (default[]), each validated in the existing 400 style. Keep the target pre-checks in the route with their existing codes (unknown system, unreachable) — extracted fromjumpToSysteminto a small route-callable helper (JC-1). Computehopswith the existing reachability logic (reachableWithin+ NAV AI — whatjumpToSystemdoes today). Build theQueueState(Task 3) viaqueueStateFromSave, callqueueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId, helperCrewIds }), persist the returned row via the newsaveTaskRow(inventory is unchanged for JUMP — Task 3 — so no inventory write), return the same snapshot the route returns today (getWorldState). MapTaskErrorexactly like today'sJumpError:Response.json({ error: err.message }, { status: err.status }).app/api/explore/route.ts— same for EXPLORE: keep the existing pre-check (already scanned) beforequeueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId, helperCrewIds }); return the same system snapshot as today (JC-2).lib/game/save.ts— addsaveTaskRow(db, save, task: Omit<Task, 'id' | 'saveId'>): Promise<number>— the route's DB-boundary insert (the codebase's route→save pattern): fillssaveId: save.id, returns the new row id (QueueResult.task.payloadis already a JSON string — passed through; reuse Tasks 1–4's row-insert export, the tests'insertTaskRow, if one already exists). AddqueueStateFromSave(db, save): Promise<QueueState>— maps schema rows into Task 3'sQueueState(crew, tasks, inventory in id order, shipSystems, tech).jumpToSystem/explorePlanetstay untouched (4a's resolvers still use them) — only the routes stop calling them for the action itself.lib/openapi.ts— addleadCrewId(required) andhelperCrewIds(optional array) to the jump + explore request schemas (AGENTS.md: the spec must track endpoint changes).app/api/jump/route.test.ts,app/api/explore/route.test.ts— handler-level unit tests callingPOST(new Request(...))against the same fresh-file fixture helpers as the 4b tests (freshFile,addCrew,buildWorldFixture,insertTaskRow). (JC-3: if the CI-only e2e specs POST to these endpoints, update those bodies to carry the crew fields in the same commit — not verifiable from the route files.)
Interfaces
- Consumes: Task 3's
queueTask/QueueState/QueueResult/TaskError; the existingJumpError/ExploreErrorpre-checks and the snapshot readers (getWorldState/getSystemState) insave.ts. - Produces:
POST /api/jump{ systemId, leadCrewId, helperCrewIds? }→ aJUMP {systemId, hops}row;POST /api/explore{ planetId, leadCrewId, helperCrewIds? }→ anEXPLORE {planetId}row;ACTIVEwhen the whole team is free, elsePENDING(lead chaining behind its own ACTIVE — Task 3). Same snapshot response shape as today; statuses: today's plus TaskError's 404/409; no turn increment in either endpoint.
-
- Write failing tests —
app/api/jump/route.test.ts:
- Write failing tests —
import { describe, expect, it } from 'vitest'
import { schema } from '@/lib/db/schema'
import { getWorldState } from '@/lib/game/save'
import { POST } from './route'
// freshFile / addCrew / buildWorldFixture / insertTaskRow — the 4b tests' fixture helpers
const post = (body: unknown) => POST(new Request('http://localhost/api/jump', { method: 'POST', body: JSON.stringify(body) }))
describe('POST /api/jump (5a)', () => {
it('queues a JUMP: row with type/payload/status, turn unchanged, snapshot preserved, ship not moved', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
const [before] = await db.db.select().from(schema.saves)
const res = await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(await getWorldState(db)) // the same fresh snapshot as today — queueing changes nothing else
const [row] = await db.db.select().from(schema.tasks)
expect(row).toMatchObject({ type: 'JUMP', leadCrewId: c1, helpers: [c2], status: 'ACTIVE', queuedAtTurn: before.turn })
expect(JSON.parse(row.payload)).toEqual({ systemId: f.sys1, hops: 1 }) // route-computed via the existing reachability logic
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the effect waits for the tick
})
it('unknown crew → 404 (queueTask)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
expect((await post({ systemId: f.sys1, leadCrewId: 999 })).status).toBe(404)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('busy helper → 409, nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
expect((await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
})
it('a jump in transit → 409 (blocks any new task)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 3, remaining: 3, status: 'ACTIVE', queuedAtTurn: 1 })
expect((await post({ systemId: f.sys1, leadCrewId: c1 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
})
it('a jump already pending → second jump 409', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 1 }) // lead chains behind its own ACTIVE
expect((await post({ systemId: f.sys1, leadCrewId: c2 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(2)
})
it('unreachable target → 409 (existing pre-check, existing code)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
expect((await post({ systemId: f.farSystem, leadCrewId: c1 })).status).toBe(409) // JC-4: a beyond-reach system in the fixture; add it to buildWorldFixture if absent
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
})
and app/api/explore/route.test.ts:
import { describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { schema } from '@/lib/db/schema'
import { getSystemState } from '@/lib/game/save'
import { POST } from './route'
// same fixture helpers
const post = (body: unknown) => POST(new Request('http://localhost/api/explore', { method: 'POST', body: JSON.stringify(body) }))
describe('POST /api/explore (5a)', () => {
it('queues an EXPLORE: row with type/payload, turn unchanged, planet not yet scanned, no haul', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
const [before] = await db.db.select().from(schema.saves)
const invBefore = (await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)
const res = await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })
expect(res.status).toBe(200)
const body = await res.json()
const sys = await getSystemState(db)
for (const k of Object.keys(sys)) expect(body[k]).toEqual(sys[k]) // today's system snapshot part unchanged; the findings key stays, empty (JC-2)
const [row] = await db.db.select().from(schema.tasks)
expect(row).toMatchObject({ type: 'EXPLORE', leadCrewId: c1, helpers: [c2], status: 'ACTIVE', queuedAtTurn: before.turn })
expect(JSON.parse(row.payload)).toEqual({ planetId: f.p0 })
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(false) // the effect waits for the tick
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)).toEqual(invBefore) // no haul yet
})
it('unknown crew → 404 (queueTask)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
expect((await post({ planetId: f.p0, leadCrewId: 999 })).status).toBe(404)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('busy helper → 409, nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1, c2] = await addCrew(db, 2)
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
expect((await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
})
it('already scanned → 409 (existing pre-check, existing code)', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, f.p0))
expect((await post({ planetId: f.p0, leadCrewId: c1 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
})
-
- Run — expect failure:
npx vitest run app/api/jump/route.test.ts app/api/explore/route.test.ts→ the new blocks red: the current routes ignore the crew fields and resolve instantly (happy paths fail — the turn advances, no row, snapshot differs) and the 404/409 crew cases return 200 today; the unreachable / already-scanned pre-check cases may already pass — expected, those codes do not move.
- Run — expect failure:
-
- Implement — rewrite both routes per Files (pre-check extraction into the small save.ts helper,
queueStateFromSave+queueTask,saveTaskRow,TaskError→.statusmapping identical to the existing*Errormappings, no turn increment in either endpoint); addsaveTaskRow+queueStateFromSavetolib/game/save.ts; update the two request schemas inlib/openapi.ts; update any e2e spec bodies that POST to these endpoints (JC-3).
- Implement — rewrite both routes per Files (pre-check extraction into the small save.ts helper,
-
- Run — expect green:
npx vitest run→ the whole unit suite passes (5a's route tests plus 4a/4b's tick and resolver blocks — the resolvers are untouched), andnpx tsc --noEmitclean.
- Run — expect green:
-
- Commit (same PR branch as Task 4b):
feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)
- Commit (same PR branch as Task 4b):
Task 5a (ii): Action endpoints → queueing (ship, research) + mission accept
Files
app/api/ship/upgrade/route.ts— keep thesystemIdbody check (integer, 400); addleadCrewId(integer, required, 400) +helperCrewIds(array of integers, default[]) with the same 400 style as Task 5a's crew parse. Keep the currentupgradeSystempre-checks (not built / max level) with their existingShipErrorcodes, thenqueueStateFromSave→queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId, helperCrewIds })→ persist the row + deducted hold viasaveTaskRow. Returns the same ship-state shape as today. No turn increment, no immediate effect.app/api/ship/expand/route.ts— same treatment: pre-checks not built / max capacity,ShipErrorcodes, payload{ systemId }.app/api/ship/build/route.ts— same treatment:typebody check (non-empty string, 400), pre-checks already built / build gate, payload{ type }.app/api/research/route.ts— same treatment:techIdbody check (integer, 400), pre-checks already researched / prereqs / level cap withResearchErrorcodes, payload{ techId }.lib/game/ship.ts+lib/game/research.ts— split the four functions: the pre-checks move into small pre-check-only helpers (e.g.checkUpgradePreconditions(save, systemId)) that throw exactly the same*Errorcodes the current functions throw (JC-5); cost deduction, turn increment and immediate effect come out of them (the cost is nowqueueTask's, at queue time; the effect waits for the tick).app/api/missions/[id]/accept/route.ts— becomes instant and free per the approved spec: no body (no crew params);lib/game/missions.tsacceptMissionkeeps its validation (the 400 non-integer id stays in the route; the currentMissionErrorcodes), its AVAILABLE→active state change and its board return, but drops the turn increment.lib/openapi.ts— addleadCrewId+helperCrewIdsto the four action request schemas (Task 5a's pattern); accept's schema unchanged (no body).- Tests —
app/api/ship/{upgrade,expand,build}/route.test.ts,app/api/research/route.test.ts(append to the existing file, create it if absent — the same note as Task 3),app/api/missions/[id]/accept/route.test.ts— the blocks below; update any e2e specs that POST to these endpoints (JC-3).
Interfaces
- Consumes:
queueTask/TaskError/QueueState(Task 3);queueStateFromSave+saveTaskRow(Task 5a); the 4a/5a test fixture suite (freshFile,buildWorldFixture,addCrew,insertTaskRow). - Produces: SHIP_UPGRADE / SHIP_EXPAND / SHIP_BUILD / RESEARCH queueing — costs deducted at queue time,
TaskError422 with the existingspendItemsmessage when unaffordable; the four endpoints acceptleadCrewId+helperCrewIds(openapi); mission accept costs no turn. None of the five endpoints advances the turn.
-
- Write failing tests — three blocks per action file (upgrade in full below; expand/build/research the same shape, deltas in the note after the code block) + one block for accept. Fixtures from the 4a/5a suite;
seedHoldinserts intoschema.inventoryItems(column set per the fixture suite — reuse its seeder if it has one):
- Write failing tests — three blocks per action file (upgrade in full below; expand/build/research the same shape, deltas in the note after the code block) + one block for accept. Fixtures from the 4a/5a suite;
// app/api/ship/upgrade/route.test.ts — imports/helpers per Task 5a's route tests
import { upgradeCost } from '@/lib/game/ship-costs'
const post = (body: object) =>
fetch('/api/ship/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
const seedHold = (db: Db, rows: { kind: string; name: string; qty: number }[]) =>
rows.forEach((r) => db.db.insert(schema.inventoryItems).values({ saveId: <fixture save id>, ...r })) // JC-6
describe('POST /api/ship/upgrade', () => {
it('happy: row with type/payload, turn unchanged, cost deducted, no level change yet', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
const sys = /* built, not-maxed system row from f (JC-6) */
const cost = upgradeCost(sys.level) // exactly the existing cost logic
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: cost }])
const [before] = await db.db.select().from(schema.saves)
const res = await post({ systemId: sys.id, leadCrewId: c1 })
expect(res.status).toBe(200)
const [row] = await db.db.select().from(schema.tasks)
expect(row).toMatchObject({ type: 'SHIP_UPGRADE', leadCrewId: c1, helpers: [], status: 'ACTIVE', queuedAtTurn: before.turn })
expect(JSON.parse(row.payload)).toEqual({ systemId: sys.id })
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted (if the fixture seeds a hold, assert the resource total drops by exactly cost)
expect((await db.db.select().from(schema.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(sys.level) // the effect waits for the tick
})
it('unaffordable → 422 (existing spendItems message), nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
const sys = /* the same built system (JC-6) */
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: upgradeCost(sys.level) - 1 }])
const res = await post({ systemId: sys.id, leadCrewId: c1 })
expect(res.status).toBe(422)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
it('max level → 409 (existing pre-check, existing code), nothing queued', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
await db.db.update(schema.shipSystems).set({ level: 99 }) // above any max
expect((await post({ systemId: /* built system id (JC-6) */, leadCrewId: c1 })).status).toBe(409)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
})
})
- expand file: same three blocks — payload
{ systemId }, costexpandCost(sys.capacity)resources, pre-check setscapacityto 99. - build file: body
{ type, leadCrewId }(type= an unbuilt type from the fixture), costBUILD_PART_COSTparts seeded exactly, pre-check marks a system row already built. - research file: body
{ techId, leadCrewId }(techId= an unresearched tech row's id), cost = that tech'sTECHSdef (costKind/costQty), pre-check marks the tech row already researched; the 422 seedscostQty - 1. - accept file:
describe('POST /api/missions/[id]/accept', () => {
it('happy: mission goes active, board returned, turn unchanged', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [before] = await db.db.select().from(schema.saves)
const res = await fetch(`/api/missions/${f.mission0}/accept`, { method: 'POST' }) // f.mission0 = an AVAILABLE mission (JC-6)
expect(res.status).toBe(200)
expect(await res.json()).toEqual(await getMissionBoard(db)) // same board shape as today (JC-6)
// existing state change: the mission row goes AVAILABLE → active (assert with the schema's status value, as the existing tests do)
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // free — no turn cost
})
// the existing validation cases (400 non-integer id, each current MissionError case) stay as-is,
// except expectations asserting a turn increment — those now expect the turn unchanged
})
-
- Run — expect failure:
npx vitest run app/api/ship/upgrade/route.test.ts app/api/ship/expand/route.test.ts app/api/ship/build/route.test.ts app/api/research/route.test.ts "app/api/missions/[id]/accept/route.test.ts"→ red: the action happy paths (today the routes resolve instantly — the turn advances, no task row) and accept's no-turn assertion (it costs a turn today); the 422 / pre-check 409 cases may already pass — expected, those codes do not move (queueTask's own busy / in-transit / second-jump rules are NOT re-tested here — Task 3 owns them).
- Run — expect failure:
-
- Implement — rewrite the four action routes per Files (crew parse with the 400s; the pre-check helpers with unchanged codes;
queueStateFromSave+queueTask+saveTaskRow+ persistingQueueResult.inventory; return the same snapshot as today; the catch block maps both*ErrorandTaskErrorto{ error: err.message }/err.statuslike today);acceptMissiondrops the turn increment (update its existing unit tests accordingly); the four openapi schemas; the e2e spec bodies (JC-3).
- Implement — rewrite the four action routes per Files (crew parse with the 400s; the pre-check helpers with unchanged codes;
-
- Run — expect green:
npx vitest run→ the whole unit suite passes (including the existing ship/research/missions lib tests with their turn-related expectations updated) andnpx tsc --noEmitclean.
- Run — expect green:
-
- Commit (same PR branch as Tasks 4b/5a):
feat(api): ship + research endpoints queue tasks with lead + helpers; mission accept is instant and free (#44)
- Commit (same PR branch as Tasks 4b/5a):
Task 5b: POST /api/turn — the manual tick
Files
app/api/turn/route.ts— new:POST /api/turn(code in step 3). The route is the world-route one-liner plus a tick: call 4b'srunTurn(one turn: decrement, resolve, FIFO auto-start, mission hooks — all already 4b) and return the same snapshotGET /api/worldreturns, composed with the tick's queue array.lib/openapi.ts— add thePOST /api/turnschema (no request body; response = world snapshot +tasksarray) — the spec-in-code rule (AGENTS.md API 3).app/api/turn/route.test.ts— new: thedescribe('POST /api/turn')block in step 1; the same helpers as the 5a route tests (freshFile,buildWorldFixture,addCrew— imported from the same source they use).- Untouched:
lib/game/save.ts(runTurnis 4b's; this route only calls it),app/api/world/route.ts, the 5a routes.
Interfaces
POST /api/turn — no body (the handler never reads one):
const db = await getGameDb()
const tick = await runTurn(db) // 4b's orchestrator: decrement → resolve → FIFO auto-start → mission hooks; persists the turn
return Response.json({ ...(await getWorldState(db)), tasks: tick.tasks })
runTurn(db: GameDb): Promise<TickResult>(4b) returns the pureTickResultunchanged:{ turn, tasks, completed }withturn = before + 1(the tick IS the turn increment) andtasks= every task row after the tick.- Snapshot call:
getWorldState(db)— the exact callGET /api/worldmakes (its route file is this one's template); the snapshot readers all re-read their tables after the tick's final persist and carry the newturn, so no new plumbing (4b Interfaces (e)). - Response = the world snapshot plus a
tasksfield composed fromtick.tasks— spec §7's active+pending queue array: the 4b contract pins that the route "responds with it, composing spec §7's response (the active+pending task array) from it and the existing readers", and the snapshot readers do not select thetaskstable, so the array comes from the TickResult, not a re-read. - An empty queue is a FREE SKIP:
runTurnreturnsturn + 1with an emptytasks, and the route returns 200 with nothing else changed — the route never rejects an empty tick (the UI tick button is never disabled). - Errors: no domain errors escape
runTurn— 4b already catchesJumpError/ExploreError/ShipError/ResearchErrorand fails the task instead; only a non-domain rethrow (a real bug) reaches the route, and the existing error-mapping pattern applies ({ error: err.message }/ status, 500 when absent). The route adds no 4xx paths of its own.
-
- Write the failing tests — new
app/api/turn/route.test.ts:
- Write the failing tests — new
describe('POST /api/turn', () => {
it('empty queue → free skip: 200, turn +1, nothing else changes', async () => {
const { db } = await freshFile()
await buildWorldFixture(db)
const [before] = await db.db.select().from(schema.saves)
const res = await fetch('/api/turn', { method: 'POST' })
expect(res.status).toBe(200)
const snap = await res.json()
expect(snap.turn).toBe(before.turn + 1)
expect(await db.db.select().from(schema.tasks)).toHaveLength(0) // the tick creates nothing
expect(snap.tasks).toEqual([]) // the composed queue array is empty
})
it('one ACTIVE task → 200, remaining decrements by exactly 1 in the returned snapshot', async () => {
const { db } = await freshFile()
const f = await buildWorldFixture(db)
const [c1] = await addCrew(db, 1)
const [before] = await db.db.select().from(schema.saves)
// Fixture task: one ACTIVE row, mid-countdown — a direct insert on the queue table
// (the 4b save.test pattern: it works with the queue rows directly); it never reaches 0
// this tick, so no resolver and no mission hook runs
await db.db.insert(schema.tasks).values({
id: /* fresh id, per the fixture's convention (JC-6) */,
type: 'JUMP',
payload: /* a valid JUMP payload at a fixture system (JC-6) */,
leadCrewId: c1,
helpers: '[]', // JSON string — the save.ts boundary JSON.parses it
duration: 5,
remaining: 3,
queuedAtTurn: before.turn,
status: 'ACTIVE',
})
const res = await fetch('/api/turn', { method: 'POST' })
expect(res.status).toBe(200)
const snap = await res.json()
expect(snap.turn).toBe(before.turn + 1)
expect(snap.tasks).toHaveLength(1)
expect(snap.tasks[0].remaining).toBe(2) // 3 − 1: exactly one decrement
expect((await db.db.select().from(schema.tasks))[0].remaining).toBe(2) // the persisted row matches the snapshot
})
})
(Row field names per Task 2's tasks DDL / the Task row type — only helpers is a JSON-encoded column; id / payload are fixture-dependent, JC-6.)
-
- Run — expect failure:
npx vitest run app/api/turn/route.test.ts→ red:POST /api/turndoes not exist yet (404 / route not found).
- Run — expect failure:
-
- Implement — the route per Interfaces (world-route import pattern + the
runTurncall + the composed response; a catch mapping any thrown error to{ error: err.message }/(err as { status?: number }).status ?? 500— only 4b's non-domain rethrow can reach it); thePOST /api/turnopenapi schema inlib/openapi.ts(response reuses the world-snapshot shape + thetasksarray of queue rows).
- Implement — the route per Interfaces (world-route import pattern + the
-
- Run — expect green:
npx vitest run→ the whole unit suite passes;npx tsc --noEmitclean.
- Run — expect green:
-
- Commit (same PR branch as Tasks 4b/5a):
feat(api): POST /api/turn ticks the queue one turn; an empty queue is a free skip (#44)
- Commit (same PR branch as Tasks 4b/5a):
Task 6: Snapshots gain the active + pending tasks array
Files
lib/game/save.ts— add theQueueDto/CrewStateinterfaces and the privatetaskQueue(db)reader (code in step 3); addtasks: QueueDto[]toWorldStateandSystemState, returned bygetWorldState/readSystemState;getCrewStatereturns the newCrewStatewrapper instead of the bareCrewDto[](JC-7). Pure read —taskQueueselects only thetasks/crew/solarSystems/planets/techtables and 4b's tick stays untouched.lib/game/save.test.ts— newdescribe('snapshot task queue (world / system / crew)')block (code in step 1); hoist the 4b block'saddCrew/insertTaskRowhelpers to file scope so both blocks share them; update the two existing blocks that assert the old crew-array shape (getCrewState,explorePlanet crew finds) to read.crewoff the wrapper.app/api/crew/route.ts— no code change (it forwards whatever the builder returns); update its header comment to the object shape.app/api/world/route.ts/app/api/system/route.ts— unchanged (they already forward their builders).app/api/turn/route.ts— unchanged (JC-8).components/crew-panel.tsx— parse the response asCrewStateand renderstate.crew(JC-7) — without this the crew tab breaks the moment the builder wraps.components/mission-control.tsx— the shell's own/api/crewconsumer: change its bare-array parse to the{crew, tasks}CrewStateobject (JC-7's second consumer, flagged in JC-15).lib/openapi.ts— newTaskDto+CrewStatecomponent schemas;WorldState/SystemStategain thetasksproperty (and required entry) —ExploreResultinherits it through itsSystemStateallOf; the/api/crew200 response becomes theCrewStateobject instead of theCrewMemberarray (JC-7).
Interfaces
- Consumes: Task 2's
taskstable (schema.tasks) and Task 1'sTaskType(lib/game/tasks.ts); the existingcrew/solarSystems/planets/techtables for name resolution. - Produces:
QueueDto { id: number; type: TaskType; targetLabel: string; leadName: string; helperNames: string[]; remaining: number }andCrewState { crew: CrewDto[]; tasks: QueueDto[] }inlib/game/save.ts;WorldState.tasks/SystemState.tasks: QueueDto[];getCrewState(db): Promise<CrewState>. - Array shape: ACTIVE rows first, then PENDING rows, each block ordered by (queuedAtTurn ASC, id ASC); DONE rows never appear.
remainingis the stored row value — a pure read, no duration recomputation.leadName/helperNamesresolve from the crew roster by id, falling back tounknownwhen an id references a deleted member (admin-corruption tolerance — the same spirit as 4b's<= 0check). targetLabelis resolved from the 4a payload shapes, per type:
| TaskType | payload (4a) | targetLabel |
|---|---|---|
| JUMP | { systemId, hops } |
the solarSystems.name of payload.systemId — e.g. Outpost |
| EXPLORE | { planetId } |
the planets.name of payload.planetId — e.g. Basalt |
| SHIP_UPGRADE | { systemId } |
<system name> — upgrade — e.g. Origin — upgrade |
| SHIP_EXPAND | { systemId } |
<system name> — expand — e.g. Origin — expand |
| SHIP_BUILD | { type } |
payload.type as-is — the SHIP_TYPES strings are display names (e.g. WEAPONS) |
| RESEARCH | { techId } |
the tech.name display column of payload.techId — e.g. NAVIGATION AI |
A lookup miss (a target id deleted by the admin) falls back to unknown; SHIP_BUILD never misses, its label lives in the payload.
-
- Write the failing tests — add the block below to
lib/game/save.test.ts(insertTaskRow= the 4b helper hoisted to file scope in this task; fixtures = the 4a block'sbuildWorldFixture+ the file-scopeaddCrew):
- Write the failing tests — add the block below to
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
})
})
(Test 2's two rows share queuedAtTurn 3 — the ACTIVE→PENDING block order, not (queuedAtTurn, id), is what puts the jump first; test 3's DONE row sorts first by (queuedAtTurn, id), so its absence proves the filter.)
-
- Run — expect failure:
npx vitest run lib/game/save.test.ts→ the new block is red:WorldState/SystemStatecarry notasksproperty (undefined ≠ []) andgetCrewStatestill returns the bare array (no.crew/.tasks). The pre-existing blocks stay green — the shape change lands only in step 3.
- Run — expect failure:
-
- Implement — per Interfaces: the private
taskQueue(db)(one select of thetaskstable filtered to status ACTIVE/PENDING + one select each ofcrew/solarSystems/planets/tech; map every row through the table above — crew names by id,unknownon a miss; concat the ACTIVE block, then the PENDING block, each sorted by (queuedAtTurn, id)); wire it intogetWorldState/readSystemState/getCrewState; hoist the test helpers and point the two existing crew-array assertions at.crew; the crew-panel parse; the openapi schemas.
- Implement — per Interfaces: the private
-
- Run — expect green:
npx vitest run→ the whole unit suite passes (4a/4b stay green — their A/B blocks normalize the raw tables throughsnapshot(), no test asserts on a full builder object);npx tsc --noEmitclean.
- Run — expect green:
-
- The shell's own /api/crew consumer: change its bare-array parse (it casts the response to CrewDto[] and reads .length for the roster count) to the new object shape — parse as the {crew, tasks} CrewState and read crew.length (JC-7's second consumer, flagged in JC-15).
-
- Commit (same PR branch as Tasks 4b/5a/5a-ii):
feat(api): world/system/crew snapshots expose the active + pending task queue (#44)
- Commit (same PR branch as Tasks 4b/5a/5a-ii):
Judgment calls (Task 6):
- JC-7:
getCrewStatereturns a bareCrewDto[]today — a bare array can't carry a siblingtasksfield, soGET /api/crewbecomes{ crew: CrewDto[]; tasks: QueueDto[] }; the one UI consumer (components/crew-panel.tsx) and the two existing save.test crew-array blocks update in the same commit. - JC-8: 5b's route composes from
tick.tasks(all statuses per 4b) vs the GET builders' ACTIVE+PENDING filter; they agree on every suite fixture (5b tests never create a DONE row) and 5b is locked, so it stays as-is — noted as a possible 5b-level follow-up.
Task 7: UI — NEXT TURN button + turn counter
Files
components/next-turn-button.tsx— new: the fixed bottom-right control, the current turn (TURN <n>) above theNEXT TURNbutton (code in step 3). House style throughout:Button variant="EXEC"(the green/glow variant), the panels' inline CSS-variable styling (--surface,--border,--text-muted,--color-red, mono letter-spaced uppercase labels) and event-log.tsx's inline error line.components/mission-control.tsx— extract the four mount fetches (the effect at lines 65–106:/api/ship,/api/system,/api/tech,/api/crew) into arefreshAllcallback — same fetch →throwon!res.ok→ setState pattern, same.catch(() => {})keep-old-state failure policy — and have the mount effect call it; then mount<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />as the last child of the root div:position: fixedtakes it out of layout, and being outside the tab grid it stays visible on every tab.- Untouched: the tab panels — Radix unmounts inactive tabs and each panel refetches on mount, so each re-reads its own endpoint when it opens again; the button only refreshes the snapshots the shell owns.
components/ui/button.tsxis consumed as-is.
Interfaces
- Consumes: 5b's
POST /api/turn(no body; always 200 — an empty queue is a FREE SKIP, the route never rejects); the shell'sShipState.turn— the same value the VESSEL METRICS TURN row already renders, so the counter has a single source of truth and no new fetch. - Produces:
NextTurnButton({ turn: number | null; onTicked: () => void })incomponents/next-turn-button.tsx. Click →POST /api/turn→ on successonTicked()(the shell'srefreshAll); the transient in-flight flag makes the handler ignore further clicks while the request is out. The button never takes adisabledattribute — Review Focus 4: it is NEVER disabled by game state (an empty queue is a free skip); a failed request only shows the inline error and clears in-flight.
-
- Write the failing tests — SKIPPED (no component-level test setup) — the repo has no
components/*.test.tsx(verified by glob) and no jsdom /@testing-library/reactinpackage.json; its unit suite is pure-TS only. No test file in this task — the button's behaviour (the click, the counter, the free skip on an empty queue) is covered by the e2e task later in the plan (CI-only per AGENTS.md).
- Write the failing tests — SKIPPED (no component-level test setup) — the repo has no
-
- Implement — per Files / Interfaces:
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from
// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell
// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an
// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an
// in-flight request, which the handler ignores. `disabled` is never set, so the cva
// disabled:opacity-40 styling never applies either.
export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
const [inFlight, setInFlight] = useState(false)
const [error, setError] = useState<string | null>(null)
const tick = () => {
if (inFlight) return // the only guard — a transient, never a game-state disable
setInFlight(true)
setError(null)
fetch('/api/turn', { method: 'POST' })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
onTicked() // the same app-wide snapshot refresh the shell uses after every other action
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setInFlight(false))
}
return (
<div
style={{
position: 'fixed',
right: '1rem',
bottom: '1rem',
zIndex: 60, // above the header/footer's 50
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '0.375rem',
}}
>
{error && (
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
)}
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
TURN {turn ?? '—'}
</span>
<Button variant="EXEC" size="SM" onClick={tick}>
NEXT TURN
</Button>
</div>
)
}
And the shell: refreshAll = the extracted four-fetch block as a useCallback (the per-fetch cancelled flag moves into it, so unmount still cancels setStates), the mount effect becomes useEffect(() => { refreshAll() }, [refreshAll]), and the NextTurnButton element appended after the footer (position/z-index do the work; the DOM spot is only for reading order).
-
- Verify build:
npx tsc --noEmitclean — the whole unit suite stays untouched (npx vitest runstill green,npx playwright test --liststill lists); the button itself is exercised by the e2e task in CI.
- Verify build:
-
- Commit (same PR A branch as Tasks 4b/5a/5b/6):
feat(ui): NEXT TURN button (bottom-right) + turn counter; empty queue is a free skip (#44)
- Commit (same PR A branch as Tasks 4b/5a/5b/6):
Judgment calls
- JC-9 — the locked design says the counter reads from "the world snapshot the shell already holds"; the shell holds no
/api/worldstate (WorldMapPanel owns it internally — it fetches on tab mount), and the only shell-held snapshot it already renders a turn from isShipState.turn(the VESSEL METRICS TURN row). The button therefore takesturn={ship?.turn ?? null}and showsTURN —while the ship fetch is in flight — the shell's own—loading convention, and the same value the shell already displays, so the two counters can never disagree. - JC-10 — "the same app-wide snapshot refresh the shell uses after every other action": the shell's only explicit post-action refresh today is
refreshSystem(#30, jump only), while its full snapshot set is the four mount fetches. A tick can change all four (the turn always; inventory on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research completion), soonTickedre-runs the four mount fetches (extracted intorefreshAll) — a strict superset ofrefreshSystem, which stays for the jump callback.
Task 8: UI — team builder (lead + helpers, live duration)
Files
components/team-builder.tsx— new: a self-contained crew picker shared by the four action panels (code in step 3). House style: the panels' inline CSS-variable look (--surface,--border,--text-muted,--color-red, mono letter-spaced uppercase) and next-turn-button'sButton variant="EXEC"for the dispatch control.components/world-map.tsx— the JUMP panel: a node click ARMS the target instead of POSTing; the builder is mounted while armed (a floating card over the full-bleed chart, #36); the existing/api/jumpfetch gains the team fields in its body.components/local-system.tsx— the EXPLORE panel: the per-planet EXPLORE button arms the planet; the builder is the first child of the column; the/api/explorebody gains the team fields.components/skill-tree.tsx— the RESEARCH panel: the card RESEARCH button arms the tech; same placement; the/api/researchbody gains the team fields.components/ship-systems.tsx— the SHIP panel: the card UPGRADE/EXPAND/BUILD button arms that action; same placement; the localact(url, body)fetch gains the team fields.lib/game/save.ts—QueueDtogainsstatus/leadCrewId/helperCrewIds(JC-11) — Task 6's reader already selects all three (it filters + sorts on status and resolves the names from the ids); its snapshot fixtures and the openapiTaskDtoschema gain the fields in the same commit.
Interfaces
-
Consumes: Task 6's
CrewState { crew: CrewDto[]; tasks: QueueDto[] }fromGET /api/crew— roster and queue in ONE response, which is why the builder takes both; Task 1's purecomputeDuration(type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf?)fromlib/game/tasks.ts(no DB import — client-safe, the tech.ts / ship-costs.ts precedent); the sixTaskTypes. -
Produces:
TeamBuilder({ type: TaskType; hopCount?: number; roster: CrewDto[]; tasks: QueueDto[]; onQueue: (leadCrewId: number, helperCrewIds: number[]) => void })incomponents/team-builder.tsx— no other exports.hopCountis JUMP-only: the hops to the armed target from the panel'sreachableWithin(the same logic the route recomputes, #35); the other types omit it → 0, the value 5a's route passes for non-JUMP, andcomputeDurationignores it off-JUMP (Task 1's doc comment). -
One row per roster member: a LEAD radio + a HELPER checkbox. The checkbox is disabled while that member is the selected lead — a lead is never also a helper of the same task (picking a new lead drops the member from the helper selection). With a lead picked, the LIVE preview reads
will take N turn(s)=computeDuration(type, hopCount ?? 0, lead, helpers)— the roster objects pass through as-is (CrewDto { id, name, role, traitGood, traitBad, origin }is a structural superset ofCrewMember { name, role, traitGood, traitBad }) and the defaultpowerOf(constant 1) is what 5a's route calls, so the preview equals the stored duration exactly. A singleDISPATCHbutton (EXEC variant) is enabled only when a lead is picked, that lead is not itself blocked, and the builder is not blocked; it firesonQueue(leadId, helperIds). -
Availability (mirrors Task 3's queueTask — the UI never offers a 409): a member is selectable as LEAD unless they LEAD a PENDING row (an ACTIVE lead may chain one PENDING behind it — stays enabled); a member is selectable as HELPER unless they are lead or helper on any ACTIVE or PENDING row — the disabled state shows the reason, e.g.
busy: EXPLORE(first matching row in array order, the ACTIVE block first); an ACTIVE JUMP row blocks the whole builder within transit // <targetLabel>(all types). -
Panels: each keeps its target selection and its existing fetch/response flow (world-map's
setWorld(body)+onJumped; local-system'sonChanged; skill-tree'sonResearched; ship-systems'sonChanged) and adds (a) a local armed-target state set by clicking its existing action controls, and (b) a/api/crewfetch (theCrewStateabove) on mount and after every successful dispatch — the action response updates the panel's own state, not the roster, so the crew refetch keeps the builder current (keep-old-on-failure, the house.catch(() => {})pattern). Dispatching fires the panel's existing fetch with the body extended — the routes already accept both fields (Task 5a):/api/jump{ systemId, leadCrewId, helperCrewIds }·/api/explore{ planetId, … }·/api/research{ techId, … }·/api/ship/{upgrade|expand|build}{ systemId | type, … }.
-
- Write the failing tests — SKIPPED (no component-level test setup) — as in Task 7: the repo has no
components/*.test.tsxand no jsdom /@testing-library/reactinpackage.json; its unit suite is pure-TS only. The builder's row states, the live preview, the 409-mirror blocking and the four wired panels are covered by the e2e task later in the plan (the one Task 7's note points at), CI-only per AGENTS.md.
- Write the failing tests — SKIPPED (no component-level test setup) — as in Task 7: the repo has no
-
- QueueDto gains
status/leadCrewId/helperCrewIds(JC-11) — as specified, Task 6's display-orientedQueueDtocan't drive the mirror (names only, no status, no crew ids): add the three fields to the interface, thetaskQueuemapper and the openapiTaskDto, and update Task 6's snapshottoEqualexpected objects (the two queue rows in its second test).npx vitest run lib/game/save.test.ts→ green,npx tsc --noEmitclean. If Task 6 is still unimplemented when this runs, fold the fields into its implementation.
- QueueDto gains
-
- Implement — per Files / Interfaces:
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import type { CrewDto, QueueDto } from '@/lib/game/save'
import { computeDuration, type TaskType } from '@/lib/game/tasks'
// Shared crew picker for the four action panels — PR A: an action is queued as a lead +
// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState
// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel
// performs its own fetch with the team fields in the body (Task 5a). The live preview is
// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call
// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask
// so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
type: TaskType
hopCount?: number
roster: CrewDto[]
tasks: QueueDto[]
onQueue: (leadCrewId: number, helperCrewIds: number[]) => void
}) {
const [leadId, setLeadId] = useState<number | null>(null)
const [helperIds, setHelperIds] = useState<number[]>([])
// Whole-builder blocks: an ACTIVE jump is in transit (all types); in the jump panel any
// queued jump is — the second-jump 409 (JC-12).
const inTransit = tasks.find((t) => t.type === 'JUMP' && t.status === 'ACTIVE')
const blocked = inTransit ?? (type === 'JUMP' ? tasks.find((t) => t.type === 'JUMP') : null)
const lead = roster.find((c) => c.id === leadId) ?? null
const helpers = roster.filter((c) => helperIds.includes(c.id))
const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember
const leadReason = (c: CrewDto) => {
const t = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
return t ? `${t.type} ALREADY QUEUED` : null
}
const helperReason = (c: CrewDto) => {
const t = tasks.find((x) => (x.leadCrewId === c.id || x.helperCrewIds.includes(c.id)) && (x.status === 'ACTIVE' || x.status === 'PENDING'))
return t ? `BUSY: ${t.type}` : null
}
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>PICK TEAM // {type}</span>
{blocked ? (
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
{inTransit ? `IN TRANSIT // ${inTransit.targetLabel}` : 'A JUMP IS ALREADY QUEUED'}
</span>
) : (
roster.map((c) => (
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="radio"
name="lead"
checked={leadId === c.id}
disabled={leadReason(c) !== null}
onChange={() => {
setLeadId(c.id)
setHelperIds((h) => h.filter((x) => x !== c.id)) // a lead is never also a helper of the same task
}}
/>
<input type="checkbox" checked={helperIds.includes(c.id)} disabled={leadId === c.id || helperReason(c) !== null} onChange={() => setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} />
<span style={{ fontSize: '0.65rem', color: leadId === c.id ? 'var(--text-secondary)' : 'var(--text-muted)', letterSpacing: '0.08em' }}>
{c.name}
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
</span>
</label>
))
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
{duration !== null ? `WILL TAKE ${duration} TURN(S)` : 'PICK A LEAD'}
</span>
<Button variant="EXEC" size="SM" className="ml-auto" disabled={!lead || blocked !== null || leadReason(lead) !== null} onClick={() => lead && onQueue(lead.id, helperIds)}>
DISPATCH
</Button>
</div>
</div>
)
}
And the four panels per the Panels bullet: the armed-target state, the <TeamBuilder> mount (world-map: a floating card over the chart; the others: first child of the column), the /api/crew mount + post-dispatch fetches and the extended bodies — each panel's existing response handling stays as-is.
-
- Verify build:
npx tsc --noEmitclean — the unit suite stays green (npx vitest run, including the updated Task 6 fixtures) andnpx playwright test --liststill lists; the panels themselves are exercised by the e2e task in CI.
- Verify build:
-
- Commit (same PR A branch as Tasks 4b/5a/5b/6/7):
feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)
- Commit (same PR A branch as Tasks 4b/5a/5b/6/7):
Judgment calls
- JC-11 — the locked design mirrors Task 3's validation, but Task 6's
QueueDtoas specified is display-oriented (names only, no status, no crew ids): the ACTIVE→PENDING array order doesn't expose the block boundary and name-matching breaks on theunknownfallback. The builder therefore needs per-rowstatus(lead-PENDING vs lead-ACTIVE chaining, in-transit) andleadCrewId/helperCrewIds(exact member↔row matching) — all three already selected by Task 6's reader, added in step 2 with the fixture + schema updates. - JC-12 — the locked design blocks the whole builder on an ACTIVE (in-transit) JUMP; Task 3's second-jump 409 also rejects a new JUMP while one is merely PENDING, so the JUMP panel's builder disables on ANY queued jump (reason
A JUMP IS ALREADY QUEUED) while the other three panels are unaffected by a PENDING jump. - JC-13 — "pick a team → the existing fetch gains the team fields" needs a target first: the one JUMP hopCount is per-target (the
reachableWithinmap the panel already computes) and the explore / research / ship payloads are per-row. Each existing action control therefore becomes the armer (a click selects the target and shows the builder) and the builder's DISPATCH performs the fetch; the panels' in-flight locks stay as-is. - JC-14 — the locked design says the roster + tasks come "from the snapshot"; the only response carrying both is Task 6's
CrewStatefromGET /api/crew, so each panel fetches it on mount and after each dispatch — self-contained like world-map's self-fetch; the shell'srefreshAll(Task 7) covers the shell-owned snapshots, and Radix unmounts the panels, which refetch on reopen.
Task 9: UI — active-tasks strip
Files
components/task-strip.tsx— new: the fixed bottom-left strip rendering the save's task queue (code in step 3). House style throughout: the panels' inline CSS-variable look (--surface,--border,--text-muted,--text-secondary, mono letter-spaced small type) and next-turn-button'sposition: fixed+zIndex: 60convention. No state of its own — a pure display of the array it is handed; an empty array returnsnull(no empty box).components/mission-control.tsx— importTaskStripand mount it as the last child of the root div, right after theNextTurnButtonelement (Task 7):position: fixedtakes it out of layout, and being outside the tab grid it stays visible on every tab. It receivestasks={system?.tasks ?? []}— the shell's existingSystemStatealready carries Task 6'stasksarray, and Task 7'srefreshAllre-runs the/api/systemfetch on every run (as does #30'srefreshSystemafter a jump), so the strip updates on every shell refresh with no new state and no fetch of its own.
Interfaces
- Consumes: Task 6's
QueueDto(with JC-11'sstatus— the only JC-11 field this component reads; the array arrives pre-ordered: ACTIVE block, then PENDING block, each by (queuedAtTurn, id)) and the shell's existingsystem: SystemState | nullstate (the/api/systemfetch, run on mount and by Task 7'srefreshAll); Task 7's root-level mount convention. - Produces:
TaskStrip({ tasks: QueueDto[] })incomponents/task-strip.tsx— no other exports. Each row, in array order: type label +targetLabel(left); lead name,+-joined helper names (if any), and the status (right:N turn(s) leftfor ACTIVE,queuedfor PENDING). PENDING rows render dimmed (45% opacity) — the order is the array's, never re-sorted.
-
- Write the failing tests — SKIPPED (no component-level test setup) — the repo has no
components/*.test.tsxand no jsdom /@testing-library/reactinpackage.json(confirmed in Task 7); its unit suite is pure-TS only. No test file in this task — the strip's behaviour (active first, pending dimmed, hidden on an empty queue, updates on every refresh) is covered by the e2e task later in the plan (CI-only per AGENTS.md).
- Write the failing tests — SKIPPED (no component-level test setup) — the repo has no
-
- Implement — per Files / Interfaces:
'use client'
import type { QueueDto } from '@/lib/game/save'
// TASK STRIP — the save's task queue, fixed bottom-left next to the NEXT TURN button (#44).
// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING
// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows,
// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on
// every refreshAll (Task 7) — a pure display of the array it is handed.
export function TaskStrip({ tasks }: { tasks: QueueDto[] }) {
if (tasks.length === 0) return null
return (
<div
style={{
position: 'fixed',
left: '1rem',
bottom: '1rem',
zIndex: 60, // above the header/footer's 50
background: 'var(--surface)',
border: '1px solid var(--border)',
padding: '0.5rem 0.75rem',
maxWidth: '26rem',
}}
>
<span style={{ display: 'block', fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.12em', marginBottom: '0.3rem' }}>
ACTIVE TASKS
</span>
{tasks.map((t) => (
<div
key={t.id}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '0.75rem',
padding: '0.2rem 0',
opacity: t.status === 'PENDING' ? 0.45 : 1, // dim, don't reorder — the array is the order
}}
>
<span style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', letterSpacing: '0.06em' }}>
{t.type} // {t.targetLabel}
</span>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.04em', whiteSpace: 'nowrap' }}>
{t.leadName}
{t.helperNames.length > 0 ? ` +${t.helperNames.join(' +')}` : ''}
{' — '}
{t.status === 'ACTIVE' ? `${t.remaining} turn(s) left` : 'queued'}
</span>
</div>
))}
</div>
)
}
And the shell: the import next to the NextTurnButton one, and <TaskStrip tasks={system?.tasks ?? []} /> appended right after the button element — position/z-index do the work; the DOM spot only keeps reading order with the button.
-
- Verify build:
npx tsc --noEmitclean — the unit suite stays untouched (npx vitest runstill green,npx playwright test --liststill lists); the strip itself is exercised by the e2e task in CI.
- Verify build:
-
- Commit (same PR A branch as Tasks 4b/5a/5b/6/7/8):
feat(ui): active-tasks strip bottom-left — active first, pending dimmed (#44)
- Commit (same PR A branch as Tasks 4b/5a/5b/6/7/8):
Judgment calls
- JC-15 — "the shell passes the array down" fixes the data source but not which snapshot the shell hands over: after Task 6 the shell holds two that carry a
tasksarray — itsSystemState(CARGO HOLD) and, via its roster-count fetch, the newCrewStateobject (JC-7). The strip takessystem?.tasks ?? []: the shell already fetches/api/systemon mount, and Task 7'srefreshAll(and #30'srefreshSystemafter a jump) re-run it, so "updates whenever the shell's refresh runs" holds with zero new state and zero new fetches, and the crew fetch stays count-only. Reality note: the shell's own/api/crewfetch is a second consumer of JC-7's object response — Task 6's parse-update names onlycrew-panel.tsx; if its bare-array cast is still there when this lands, its.lengthread silently becomesundefined(fix belongs to Task 6/8, not this task).
Task 10: E2E rework — queue + tick model
Files
e2e/helpers.ts— the linchpin:jumpToFreshSystemis called by every spec that jumps (smoke + state) and today is a single rawPOST /api/jump { systemId }that landed instantly. Rework it to the queue+tick model (code in step 1) and add a generalresolveActionit shares with the scan-loop tests: queue an actionPOSTcarrying a default one-person crew, read the new task'sremaining(its live duration, Task 6), thenPOST /api/turnthat many times and return the final snapshot. Add a minimalCrewDto(id + role) next to the existingWorldState.e2e/smoke.spec.ts— the action tests reworked from instant-resolution to queue+tick. This is where the jump / explore / ship / research flow tests already live, so it is the natural home for the new scenarios too (step 3). Uniform recipe for every affected test: each actionPOSTnow carriesleadCrewId+helperCrewIdsand returns a queued state (200, no turn bump, effect not yet visible); the effect is observed only after the NEXT TURN button (Task 7, bottom-right) is clicked N times, N = the team builder's (Task 8) live duration for that team. Affected tests: both jump tests, both explore tests, the SHIP SYSTEMS test, both SKILL TREE research tests, the MISSIONS test, the EVENT LOG test.e2e/state.spec.ts— the mid-rundocker compose restartspec (AGENTS.md). Two instant-resolution assumptions break: the rawPOST /api/exploreno longer flipsexploredin its response, and no turn bumps on queue — so thesysExploredbaseline it captures (then compares byte-identical after the restart) must be re-derived after ticking the explore to completion, not straight off the queue response (step 4). The other baselines (crew / ship / tech / missions / events) don't depend on that explore resolving and stay as-is.
Interfaces
- Consumes: Task 3's queue-time validation (busy / in-transit / second-jump → 409 — the API half of the in-transit-block scenario), Task 4b's
tickTurneffect resolution, Task 5a/5b's reworked action endpoints (crew params) and Task 5b'sPOST /api/turn, Task 6'stasksarray in every snapshot, Task 7's NEXT TURN button +TURN Ncounter, Task 8's team builder (source of N), Task 9's task strip (dimmed PENDING rows the FIFO scenario reads). - Produces: a reworked
e2e/suite that compiles and lists cleanly (npx playwright test --list); the new scenarios assert the queue+tick model end-to-end. No unit tests are added or changed (the 409 validation and the duration formula are unit-covered in Tasks 1/3) and no app code is touched — test-only, landing in the same PR A batch as Tasks 4b–9.
-
- Rework the shared jump helper —
e2e/helpers.ts.jumpToFreshSystemkeeps its target choice (prefer an undiscovered neighbour) but queues, then ticks, before returning;resolveActiongeneralizes that for the scan loops. Code:
- Rework the shared jump helper —
const TASK_TYPE: Record<string, string> = {
'/api/jump': 'JUMP', '/api/explore': 'EXPLORE',
'/api/research': 'RESEARCH', '/api/ship/upgrade': 'SHIP',
'/api/ship/expand': 'SHIP', '/api/ship/build': 'SHIP',
};
// Queue an action POST with a default one-person crew, then tick until that task resolves.
export async function resolveAction(page: Page, path: string, data: Record<string, unknown>): Promise<unknown> {
const roster = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[] } | CrewDto[];
const members = Array.isArray(roster) ? roster : roster.crew; // Task 6 / JC-7 CrewState shape
const res = await page.request.post(path, { data: { ...data, leadCrewId: members[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200); // queued — a 409 here is a busy / in-transit rejection (Task 3)
const snap = (await res.json()) as { tasks?: { type: string; remaining: number }[] };
const n = snap.tasks?.find((t) => t.type === TASK_TYPE[path])?.remaining ?? 1;
for (let i = 0; i < n; i++) expect((await page.request.post('/api/turn')).status()).toBe(200);
return (await page.request.get(path === '/api/jump' ? '/api/world' : path)).json();
}
// jumpToFreshSystem: pick the target as before, then:
// const after = (await resolveAction(page, '/api/jump', { systemId: target.id })) as WorldState;
// expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued
// return after;
-
- Rework the existing action tests — apply the recipe to each; the per-test assertion deltas are the whole of the rework:
- both jump tests — after queueing, the current system is still the origin (no instant landing); only after N ticks does
/api/worldreport the target ascurrent. The#30test's "wait forTURN ${before.turn + 1}" becomes "wait forTURN ${before.turn + N}" — the tick, not the queue, advances the counter. - both explore tests — the scan no longer flips
exploredin the POST response and bumps no turn; assertexplored=true+SCAN RESULTonly after N ticks. The yields finds loop reads the haul from the post-tick/api/systemsnapshot (inventory), not from the queue response'soutcome. - SHIP SYSTEMS — UPGRADE/BUILD queue, then tick; the
LV n+1/ONLINEcard flips and the turn counter reflect the ticks, not the click. - both SKILL TREE research tests — RESEARCH queues; the card flips to
RESEARCHEDand the gated build unlocks only after N ticks. The#35two-hop test'sturn === turnBefore + 2(flat hop-scaled cost) becomesturnBefore + Nwhere N is that jump's team-builder duration — the hop count now feeds the duration model, not a flat turn delta. - MISSIONS — accepting a contract is the one action in the suite §7 does not list as a queueing op (JC-19); completion still rides queue+tick through the explore/jump calls in its loop.
- EVENT LOG — the jump + scan it logs now carry the tick's turn (when they resolved), not the queue's turn; the "newer first" ordering assertion still holds.
-
- Add the five new scenarios — appended to
e2e/smoke.spec.ts(its existing jump/explore/ship/research tests make it the natural home):
- Assign → tick → effect — queue a one-person JUMP; read N from the new task's
remaining(= the team builder's live duration, Task 8). Assert the current system is unchanged immediately after the queue POST (never instant), unchanged after N-1 ticks, and changed to the target exactly on the Nth tick. - Parallel tasks — queue a JUMP (crew A) and an EXPLORE (disjoint crew B); tick max(Na, Nb) times; assert both resolved at their respective durations (jump landed + planet marked) with no interleaving clicks (JC-17: a non-jump action is legal while a jump is in transit).
- FIFO pre-queueing — queue two tasks for the same crew member (both accepted while it holds no ACTIVE task — JC-16); the strip (Task 9) shows the first and the second dimmed (PENDING); tick until the first resolves and assert the second auto-starts (ACTIVE) on that tick with no new click.
- In-transit block — queue a JUMP and leave it active (no ticks); attempting to queue a second JUMP is refused
409at the API (Task 3's "in-transit or second jump" rule). The UI variant (the jump builder/target disabled while in transit) and the full 409 matrix are unit-covered by Task 3, so this single API case stays in e2e. - Free skip — with an empty queue, click NEXT TURN; assert the turn counter increments by one and no task appears in the strip.
- Add the five new scenarios — appended to
-
- Fix
state.spec.ts— after its rawPOST /api/explorebaseline, tick the explore to completion viaresolveActionbefore capturingsysExplored, so theexplored=trueand the turn it pins are the resolved values; the byte-identical post-restart comparisons (crew / ship / tech / missions / events) are otherwise unaffected and stay as-is.
- Fix
-
- Verify (CI-only — never run locally) —
npx tsc --noEmitclean (the specs are TS) andnpx playwright test --listshows every reworked + new test compiles and lists. The actual run happens in CI at this PR's Ship task against the dind stack (E2E_BASE_URL/E2E_DEPLOY_CMD/PW_REMOTE_BROWSER=0per AGENTS.md). Do NOT run the suite locally.
- Verify (CI-only — never run locally) —
-
- Commit (same PR A branch as Tasks 4b/5a/5b/6/7/8/9):
test(e2e): rework the suite to the queue + NEXT TURN model (#44)
- Commit (same PR A branch as Tasks 4b/5a/5b/6/7/8/9):
Judgment calls
- JC-16 — Scenario 3 (FIFO) vs §7's "busy/committed crew → 409": read "busy" as "has an ACTIVE task." A member holding only a PENDING (queued, not yet started) task can still be given a second queued task, so the second shows dimmed in the strip (Task 9's PENDING row) and auto-starts when the predecessor resolves on a tick. If Task 3 instead 409s on any queued task, this scenario must queue the second task before the first tick — reconcile with Task 3's validation, don't re-decide here.
- JC-17 — The in-transit block is scoped to jump actions: §7's "in-transit or second jump → 409" blocks a second JUMP while one is in transit, not a non-jump action. That is why scenario 2's parallel JUMP + EXPLORE is legal — the 409 doesn't reach a different-crew, different-type action.
- JC-18 —
GET /api/crewshape: the specs today cast it toCrewDto[], but Task 6 / JC-7 make it aCrewStateobject.resolveActionreads both (Array.isArrayguard) so the helper is correct under either; the specs' inlineCrewDto[]casts (CREW tab, state baseline) should be updated to the object shape in the same edit to keeptscclean. - JC-19 — mission-accept is a free, non-queued action per Task 5a (ii) and spec §3 — no crew params, no turn increment; e2e asserts it resolves on the next interaction, not on a NEXT TURN click.
Task 11: Ship v0.10.0 (PR A — Gitea issue #44)
Files touched (this ship step only — no app code, no failing-test step):
docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md— line-4Status:→approved; §10 issue-table renumbered to reality (#44 = crew-driven actions v0.10.0, #45 = skill progression v0.11.0, #43 = trait outcome effects v0.12.0).docs/superpowers/plans/2026-10-01-crew-driven-actions.md— this plan.package.json(version bump) andCHANGELOG.md(new top entry).
This is a process task that ships the PR A already built by Tasks 1–10 (all code + tests already live on the same branch). It has no failing-test step and ends in a verification gate.
-
- Commit the docs on the PR A branch (the branch created in Task 1, e.g.
feat/crew-driven-actions) — in the spec file, first flip line-4Status:toapprovedand correct its §10 issue-table numbers to match reality:#44= crew-driven actions engine (v0.10.0),#45= skill progression (v0.11.0),#43= trait outcome effects (v0.12.0). Then commit the spec and this plan:git add ... && git commit -m "docs: approved crew-driven-actions spec + implementation plan (#44)".
- Commit the docs on the PR A branch (the branch created in Task 1, e.g.
-
- Version bump —
package.json→"version": "0.10.0"(from the current0.9.12).CHANGELOG.md: new entry at the very top in the repo's format## v0.10.0 — YYYY-MM-DD HH:MM(fill the date/time at execution) with a bullet summary of the PR: queue-based actions (every action becomes a queued task with a lead + helpers), manual time viaPOST /api/turn+ the NEXT TURN button, crew teaming with live duration, trait duration modifiers, and the newtaskstable (schema v7, idempotent backfill).
- Version bump —
-
- Local verify — ONLY these three (e2e is CI-only, never run locally):
npx tsc --noEmit(clean),npx vitest run(all pass),npx playwright test --list(every spec compiles + lists). All three must pass. Do NOT run the e2e suite (npx playwright test) — CI-only per AGENTS.md.
- Local verify — ONLY these three (e2e is CI-only, never run locally):
-
- Push + open the PR to master —
git push -u origin <branch>, then open a pull request tomasteron Gitea (repoOpenCode/SpaceWandererat git.kkhost.pl). Gitea API access: the CI (.gitea/workflows/ci.yml) carries no credentials — it only runs build + unit + e2e. Per ORCHESTRATOR.md, prefer the pre-configured gitea MCP tools to create the PR; if you must use raw REST/curl,sourcethe repo-root.env(GITEA_TOKEN, gitignored) — but a fresh checkout has no.env, so in that case use the gitea MCP tools or your own Gitea API token from the environment.
- Push + open the PR to master —
-
- Auto-merge on green — trigger Gitea's auto-merge:
POST /repos/OpenCode/SpaceWanderer/pulls/<n>/mergewith body{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}. The PR merges automatically once the required status checkCI / build + unit + e2e (pull request)passes (branch protection; CI runs the full e2e suite in an isolated dind stack, ~7 min). Poll the PR untilmerged_atis set (max ~15 min; poll the API every ~30s — do not read files or re-check code while waiting). If CI is red, STOP and report the failure instead of fixing it.
- Auto-merge on green — trigger Gitea's auto-merge:
-
- Tag on master — after pulling the merge on
master:git tag v0.10.0 && git push origin v0.10.0.
- Tag on master — after pulling the merge on
-
- Manual deploy (CI never deploys) —
docker compose up -d --buildin the repo root, then verifycurl -s https://space-wanderer.dev.kkhost.pl/api/versionreturns the 0.10.0 payload.
- Manual deploy (CI never deploys) —
-
- Close the issue — Gitea API: close issue #44 with a comment summarizing the shipped state (version 0.10.0, tag v0.10.0, live deploy verified).
Verification gate (whole task): /api/version on the live URL (https://space-wanderer.dev.kkhost.pl) reports 0.10.0 AND Gitea issue #44 is closed.
Judgment calls
- JC-20 — Gitea credentials: the CI (
.gitea/workflows/ci.yml) contains no Gitea API token — it is only a test runner (checkout → build → unit → e2e), so "the way the CI does it" yields nothing. The repo's documented source is ORCHESTRATOR.md: prefer the pre-configured gitea MCP tools; otherwise raw REST/curl readsGITEA_TOKENfrom the repo-root.env(gitignored, never committed). A fresh checkout has no.env, so when absent the path is the gitea MCP tools or your own Gitea API token from the environment. This step states that fallback explicitly rather than assuming CI carries a token.