# 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 — YYYY-MM-DD HH:MM`); local verify ONLY `npx 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 deploy `docker 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 1. 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. 2. 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. 3. 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. 4. 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. 5. 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 existing `lib/game/ship-costs.ts`), `lib/game/tasks.ts` (pure task model), tests `lib/game/tasks.test.ts` (colocated, matching `lib/game/crew.test.ts`) **Interfaces:** - Consumes: `CrewMember` from `lib/game/crew.ts` (`{ name, role, traitGood, traitBad }` — string fields exactly as generated by `GOOD_TRAITS`/`BAD_TRAITS` there) - 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`; from `tasks.ts` — `TaskType` and `computeDuration(type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf?: (m: CrewMember) => number): number` - [ ] Steps: TDD — write failing tests FIRST in `lib/game/tasks.test.ts` using the spec's EXACT values (table-driven; run `npx vitest run lib/game/tasks.test.ts` → red, the modules do not exist yet): ```ts 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 = { 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 }) }) ``` ```ts 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 }) }) ``` ```ts // 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 — no `lib/db` imports; the frontend imports it for the live duration preview, same server+client precedent as `ship-costs.ts`/`tech.ts`): ```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 = { JUMP: 'NAVIGATION', EXPLORE: 'SCANNING', SHIP_UPGRADE: 'ENGINEERING', SHIP_EXPAND: 'ENGINEERING', SHIP_BUILD: 'ENGINEERING', RESEARCH: 'RESEARCH', } export const PREFERRED_ROLES: Record = { 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> = { 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>> = { '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 — no `lib/db` imports): ```ts // 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, then `npx tsc --noEmit` clean - [ ] 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` (add `tasks` to the `DDL` template), `lib/db/schema.ts` (drizzle table `tasks`), `lib/game/save.ts` (`SCHEMA_VERSION` 6→7 + the idempotent v7 step in `backfillSave`), `lib/db/admin.ts` (`ADMIN_TABLES`), `lib/game/tasks.ts` (add `TaskStatus` + `Task` to the module Task 1 created) - Test: extend the `backfill (old saves)` describe in `lib/game/save.test.ts` (existing `freshFile()` + `openGameDb` + `db.raw.exec` pattern); `lib/db/admin.test.ts`'s registry test covers the new table automatically (it derives the expected list from `schema.ts`) **Interfaces:** - Consumes: `ensureSave`/`backfillSave` from `lib/game/save.ts` (currently `SCHEMA_VERSION = 6`); `TaskType` from 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'`) in `lib/game/tasks.ts`; the `tasks` table in `lib/db/schema.ts` and the matching `CREATE TABLE` in `lib/db/client.ts`'s DDL; `tasks: schema.tasks` in `ADMIN_TABLES` - [ ] Steps: TDD — add a failing test to `backfill (old saves)` in `lib/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 (`schemaVersion` stays 6, and `tasks` is not in the DDL yet): ```ts 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`): ```sql 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): ```ts // 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: ```ts 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 ``` ```ts 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): ```ts 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, then `npx tsc --noEmit` clean - [ ] 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 the `Task` interface 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) and `import 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 the `describe('queueTask')` block below (one `it` per locked case, table-driven over the small fixtures); if Tasks 1–2 left no such file, create it first with `import { describe, expect, it } from 'vitest'`, the `./tasks` import and `import type { CrewDto } from './save'`. If Task 1 put `computeDuration` in `lib/game/crew-costs.ts` instead of tasks.ts, point its import at that module — the spec §9 signature is the contract. ```ts // --- 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 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 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 } ``` ```ts 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 => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over }) const task = (over: Partial = {}): 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), in `lib/game/tasks.ts`. On failure it throws `TaskError(status, message)` — the same shape as `JumpError` / `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-boundary `id` / `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 ` | | 2 | any assigned crew (as lead or helper of one) already committed on a PENDING task | `409` — ` is already committed to a pending task` | | 3 | a helper on an ACTIVE task (as lead or helper of one) | `409` — ` 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 ` | 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). 1. - [ ] **Write the failing tests** — add the `describe('queueTask')` block to `lib/game/tasks.test.ts` (create the file with the `vitest` + `./tasks` + type-only `./save` imports if Tasks 1–2 left none). 2. - [ ] **Run — expect failure**: `npx vitest run lib/game/tasks.test.ts` → red (the seven new `it`s fail — `queueTask` is not implemented yet; pre-existing tests in the file stay green). 3. - [ ] **Implement** — append `TaskError`, `QueueTaskRequest`, `QueueState`, `QueueResult`, `queueTask` and the private `deduct` to `lib/game/tasks.ts` (code above) plus the `./ship-costs`, `./tech` and type-only `./save` imports. 4. - [ ] **Run — expect green**: `npx vitest run lib/game/tasks.test.ts` → all seven pass, then the full unit suite `npx vitest run` → green, and `npx tsc --noEmit` clean. 5. - [ ] **Commit (same PR A branch)**: `feat(game): queueTask — queue-time validation, cost deduction, ACTIVE/PENDING row (#44)` ### Task 4a: Resolver refactor (save.ts) **Files** - `lib/game/save.ts` — extract the effect bodies of `jumpToSystem` / `explorePlanet` into `resolveJumpTask` / `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 synthetic `Task` row (code below). Add to the imports: `import type { Task, TaskType } from './tasks'` (type-only — `tasks.ts` stays pure, no runtime edge back; merge with Task 2's existing type-only import from `./tasks` if present). `FoundItem` is already imported. - `lib/game/ship.ts` — add `resolveShipUpgradeTask` / `resolveShipExpandTask` / `resolveShipBuildTask` (code below); `upgradeSystem` / `expandSystem` / `buildSystem` keep their signatures, validation, `spendItems` cost and turn advance, and delegate (code below). Delete the private `bumpTurnAndEvent` — unused after the refactor (the resolvers inline the event insert without the turn update). Add the same type-only `./tasks` import. - `lib/game/research.ts` — add `resolveResearchTask` (code below); `researchTech` keeps its signature, validation, spend + `ResearchError` re-throw and turn advance, and delegates (code below). Add the same type-only `./tasks` import. - `lib/game/save.test.ts` — add the `describe('resolveJumpTask (A/B against jumpToSystem)')` and `describe('resolveExploreTask (A/B against explorePlanet)')` blocks below (code). Add `resolveJumpTask, resolveExploreTask` to the existing `./save` import and `import type { Task } from './tasks'`. - `lib/game/ship.test.ts` — add the `describe('resolveShip* (A/B against the immediate actions)')` block below (code). Add `spendItems, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask` to the existing `./ship` import; new imports `import { TECHS } from './tech'`, `import { SCHEMA_VERSION } from './save'`, `import type { Task } from './tasks'`. - `lib/game/research.test.ts` — add the `describe('resolveResearchTask (A/B against researchTech)')` block below (code). Add `resolveResearchTask` to the existing `./research` import, `spendItems` to the existing `./ship` import; new imports `import { 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` | Function | Module | Signature | |---|---|---|---| | `JUMP` | `resolveJumpTask` | `lib/game/save.ts` | `(db: GameDb, task: Task) => Promise` | | `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` | | `SHIP_EXPAND` | `resolveShipExpandTask` | `lib/game/ship.ts` | `(db: GameDb, task: Task) => Promise` | | `SHIP_BUILD` | `resolveShipBuildTask` | `lib/game/ship.ts` | `(db: GameDb, task: Task) => Promise` | | `RESEARCH` | `resolveResearchTask` | `lib/game/research.ts` | `(db: GameDb, task: Task) => Promise` | 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.turn` and 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 current `save.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 call `spendItems`. - **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`, `researchTech` keep their signatures, validation, (immediate-path) cost, turn advance and return values; they call their resolver with a private `syntheticTask(...)` stand-in row (zeroed identity fields — resolvers only read `type`/`payload`). Routes and the OpenAPI spec are unchanged. The 4b row↔Task mapping for the `tasks` table is NOT part of this task. 1. - [ ] **Write the failing tests** — add to `lib/game/save.test.ts` (below the `explorePlanet crew finds` block): ```ts // --- 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 { 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()) 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`: ```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 { 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 { 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`: ```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 { 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 { 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' }) }) }) ``` 2. - [ ] **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 because `resolveJumpTask` / `resolveExploreTask` / `resolveShip*Task` / `resolveResearchTask` are 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. 3. - [ ] **Implement** — (a) `lib/game/save.ts`: add the `./tasks` type-only import, the private `syntheticTask` and the two resolvers, and rewrite `jumpToSystem` / `explorePlanet` as shown below (code): ```ts // --- 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 { 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 { 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 { 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 `./tasks` type-only import, the private `syntheticTask`, the three resolvers, and rewrite the three public actions (same shape — validation and `spendItems` stay; the turn advance moves before the delegation; `bumpTurnAndEvent` is deleted): ```ts 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 { 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 { 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 { 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 { 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 { 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 { 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 `./tasks` type-only import, the private `syntheticTask` and the resolver, and rewrite `researchTech` (spend + re-throw and validation stay; turn advance moves before the delegation): ```ts 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 { 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 { 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) } ``` 4. - [ ] **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), and `npx tsc --noEmit` clean. 5. - [ ] **Commit (same PR A branch)**: `feat(game): per-type resolvers — jump/explore/ship/research effect extraction for task resolution (#44)` ### Task 4b: tickTurn (tasks.ts) **Files** - `lib/game/tasks.ts` — add `TickResult` and `tickTurn` after the private `deduct` (code in step 3). No new imports — the tick consumes the existing `QueueState` / `Task` and calls nothing outside the module, so it stays pure (no Date, no rng, no lib/db — global constraint). - `lib/game/save.ts` — add the orchestrator `runTurn` plus the private `rowToTask` mapping and the `TASK_RESOLVERS` dispatch (code in step 3); replace 4a's type-only `import type { Task, TaskType } from './tasks'` with `import { tickTurn, type QueueState, type Task, type TaskStatus, type TaskType } from './tasks'` and add `import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'` and `import { 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 the `describe('tickTurn')` block below (code in step 1); reuses the `queueTask` block's file-scope `CREW` / `state` / `task` fixture helpers; add `tickTurn` to the existing `./tasks` import. - `lib/game/save.test.ts` — add the `describe('runTurn (tick on the DB)')` block below (code in step 1); reuses the 4a block's file-scope `buildWorldFixture` and the existing `freshFile`, but NOT the 4a `snapshot` helper (it does not select the `tasks` table — this block asserts the queue rows directly); add `runTurn` to the existing `./save` import and a new `import { MIN_AVAILABLE } from './missions'`. - Untouched: `app/api/*` (`POST /api/turn` is Task 5), `lib/openapi.ts` (no endpoint changes yet), `lib/db/*` (the `tasks` DDL / 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). ```ts 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 `ACTIVE` task's `remaining` −= 1. `PENDING` and `DONE` rows do not age — a task's countdown starts when it starts, and `duration` is never modified after queueing. - **(b) Resolve.** Every task that reached 0 is marked `DONE` — that frees the whole team (busyness is derived from the `tasks` table; there is no crew-status column to update) — and returned in `completed` sorted by `(queuedAtTurn ASC, id ASC)` (identical to `id` order, since `queuedAtTurn` is non-decreasing in `id`). The check is `remaining <= 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). `tickTurn` itself 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 `PENDING` in the same `(queuedAtTurn ASC, id ASC)` order. A task starts (PENDING → ACTIVE, `remaining` unchanged — 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 an `ACTIVE` task and no member is on a `PENDING` task 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. Under `queueTask`'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, earliest `queuedAtTurn` wins — 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 where `checkMissionCompletion` is 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.** `tickTurn` returns `state.turn + 1`, and nothing in the pure layer increments it; the orchestrator writes it to `saves.turn` BEFORE 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` / `getTechState` all re-read their tables and carry `turn`, so they reflect the tick without new plumbing. After Task 5 retires the immediate actions, `runTurn` is the sole writer of `save.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): ```ts const TASK_RESOLVERS: Record Promise> = { 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` — 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. 1. `ensureSave(db)`; read the saves row + the crew / tasks / inventory / shipSystems / tech rows; map them into `QueueState` (the row→Task mapping lives here at the save.ts boundary — `JSON.parse` on `helpers`; the inverse, Task→row at insert, stays with the routes' queueing path). 2. `tickTurn(state)`. 3. `saves.turn = result.turn` — written BEFORE the resolvers (see (d) / (e) above). 4. 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 own `persist(db)` (4a) — harmless mid-stream, since `persist` is a full-DB export. 5. Write the tick's queue changes back to `tasks` — every row whose `status` or `remaining` changed — then a final `persist(db)` (the turn's one atomic on-disk write). 1. - [ ] **Write the failing tests** — add to `lib/game/tasks.test.ts` (below the `queueTask` block) and to `lib/game/save.test.ts` (below the 4a A/B blocks): ```ts // --- 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 } } ``` ```ts 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({ id, type: 'JUMP', payload: '{"systemId":2,"hops":1}', ...over }) const EXPLORE = (id: number, planetId: number, over: Partial = {}): 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') }) }) ``` ```ts // --- 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 Promise> = { 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 { 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 } ``` ```ts 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 { 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 { 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[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 }) }) ``` 2. - [ ] **Run — expect failure**: `npx vitest run lib/game/tasks.test.ts` → the five new `tickTurn` its red (`tickTurn` is not exported), the `queueTask` block stays green; `npx vitest run lib/game/save.test.ts` → the new `runTurn` describe red (`runTurn` is not exported), everything else — including 4a's A/B blocks — stays green. 3. - [ ] **Implement** — append `TickResult` + `byQueueOrder` + `tickTurn` to `lib/game/tasks.ts` (code above); in `lib/game/save.ts` add the two resolver imports, replace the 4a type-only `./tasks` import with `import { tickTurn, type QueueState, type Task, type TaskStatus, type TaskType } from './tasks'`, and add `TASK_RESOLVERS` / `rowToTask` / `runTurn` (code above). 4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (queueTask, 4a's A/B + guard blocks, and the new tick blocks), and `npx tsc --noEmit` clean. 5. - [ ] **Commit (same PR A branch)**: `feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)` ### 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` — rewrite `POST`. Body: `{ systemId, leadCrewId, helperCrewIds? }` — `leadCrewId` a required integer, `helperCrewIds` an 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 from `jumpToSystem` into a small route-callable helper (JC-1). Compute `hops` with the existing reachability logic (`reachableWithin` + NAV AI — what `jumpToSystem` does today). Build the `QueueState` (Task 3) via `queueStateFromSave`, call `queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId, helperCrewIds })`, persist the returned row via the new `saveTaskRow` (inventory is unchanged for JUMP — Task 3 — so no inventory write), return the same snapshot the route returns today (`getWorldState`). Map `TaskError` exactly like today's `JumpError`: `Response.json({ error: err.message }, { status: err.status })`. - `app/api/explore/route.ts` — same for EXPLORE: keep the existing pre-check (already scanned) before `queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId, helperCrewIds })`; return the same system snapshot as today (JC-2). - `lib/game/save.ts` — add `saveTaskRow(db, save, task: Omit): Promise` — the route's DB-boundary insert (the codebase's route→save pattern): fills `saveId: save.id`, returns the new row id (`QueueResult.task.payload` is already a JSON string — passed through; reuse Tasks 1–4's row-insert export, the tests' `insertTaskRow`, if one already exists). Add `queueStateFromSave(db, save): Promise` — maps schema rows into Task 3's `QueueState` (crew, tasks, inventory in id order, shipSystems, tech). `jumpToSystem` / `explorePlanet` stay untouched (4a's resolvers still use them) — only the routes stop calling them for the action itself. - `lib/openapi.ts` — add `leadCrewId` (required) and `helperCrewIds` (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 calling `POST(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 existing `JumpError` / `ExploreError` pre-checks and the snapshot readers (`getWorldState` / `getSystemState`) in `save.ts`. - Produces: `POST /api/jump` `{ systemId, leadCrewId, helperCrewIds? }` → a `JUMP {systemId, hops}` row; `POST /api/explore` `{ planetId, leadCrewId, helperCrewIds? }` → an `EXPLORE {planetId}` row; `ACTIVE` when the whole team is free, else `PENDING` (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**. 1. - [ ] **Write failing tests** — `app/api/jump/route.test.ts`: ```ts 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`: ```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) }) }) ``` 2. - [ ] **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. 3. - [ ] **Implement** — rewrite both routes per Files (pre-check extraction into the small save.ts helper, `queueStateFromSave` + `queueTask`, `saveTaskRow`, `TaskError` → `.status` mapping identical to the existing `*Error` mappings, no turn increment in either endpoint); add `saveTaskRow` + `queueStateFromSave` to `lib/game/save.ts`; update the two request schemas in `lib/openapi.ts`; update any e2e spec bodies that POST to these endpoints (JC-3). 4. - [ ] **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), and `npx tsc --noEmit` clean. 5. - [ ] **Commit (same PR branch as Task 4b)**: `feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)` ### Task 5a (ii): Action endpoints → queueing (ship, research) + mission accept **Files** - `app/api/ship/upgrade/route.ts` — keep the `systemId` body check (integer, 400); add `leadCrewId` (integer, required, 400) + `helperCrewIds` (array of integers, default `[]`) with the same 400 style as Task 5a's crew parse. Keep the current `upgradeSystem` pre-checks (not built / max level) with their existing `ShipError` codes, then `queueStateFromSave` → `queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId, helperCrewIds })` → persist the row + deducted hold via `saveTaskRow`. 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, `ShipError` codes, payload `{ systemId }`. - `app/api/ship/build/route.ts` — same treatment: `type` body check (non-empty string, 400), pre-checks already built / build gate, payload `{ type }`. - `app/api/research/route.ts` — same treatment: `techId` body check (integer, 400), pre-checks already researched / prereqs / level cap with `ResearchError` codes, 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 `*Error` codes the current functions throw (JC-5); cost deduction, turn increment and immediate effect come out of them (the cost is now `queueTask`'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.ts` `acceptMission` keeps its validation (the 400 non-integer id stays in the route; the current `MissionError` codes), its AVAILABLE→active state change and its board return, but drops the turn increment. - `lib/openapi.ts` — add `leadCrewId` + `helperCrewIds` to 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, `TaskError` 422 with the existing `spendItems` message when unaffordable; the four endpoints accept `leadCrewId` + `helperCrewIds` (openapi); mission accept costs no turn. None of the five endpoints advances the turn. 1. - [ ] **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; `seedHold` inserts into `schema.inventoryItems` (column set per the fixture suite — reuse its seeder if it has one): ```ts // 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: , ...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 }`, cost `expandCost(sys.capacity)` resources, pre-check sets `capacity` to 99. - build file: body `{ type, leadCrewId }` (`type` = an unbuilt type from the fixture), cost `BUILD_PART_COST` parts 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's `TECHS` def (`costKind`/`costQty`), pre-check marks the tech row already researched; the 422 seeds `costQty - 1`. - accept file: ```ts 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 }) ``` 2. - [ ] **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). 3. - [ ] **Implement** — rewrite the four action routes per Files (crew parse with the 400s; the pre-check helpers with unchanged codes; `queueStateFromSave` + `queueTask` + `saveTaskRow` + persisting `QueueResult.inventory`; return the same snapshot as today; the catch block maps both `*Error` and `TaskError` to `{ error: err.message }` / `err.status` like today); `acceptMission` drops the turn increment (update its existing unit tests accordingly); the four openapi schemas; the e2e spec bodies (JC-3). 4. - [ ] **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) and `npx tsc --noEmit` clean. 5. - [ ] **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)` ### 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's `runTurn` (one turn: decrement, resolve, FIFO auto-start, mission hooks — all already 4b) and return the same snapshot `GET /api/world` returns, composed with the tick's queue array. - `lib/openapi.ts` — add the `POST /api/turn` schema (no request body; response = world snapshot + `tasks` array) — the spec-in-code rule (AGENTS.md API 3). - `app/api/turn/route.test.ts` — new: the `describe('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` (`runTurn` is 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): ```ts 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` (4b) returns the pure `TickResult` unchanged: `{ turn, tasks, completed }` with `turn = before + 1` (the tick IS the turn increment) and `tasks` = every task row after the tick. - Snapshot call: `getWorldState(db)` — the exact call `GET /api/world` makes (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 new `turn`, so no new plumbing (4b Interfaces (e)). - Response = the world snapshot plus a `tasks` field composed from `tick.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 the `tasks` table, so the array comes from the TickResult, not a re-read. - An empty queue is a FREE SKIP: `runTurn` returns `turn + 1` with an empty `tasks`, 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 catches `JumpError` / `ExploreError` / `ShipError` / `ResearchError` and 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. 1. - [ ] **Write the failing tests** — new `app/api/turn/route.test.ts`: ```ts 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.) 2. - [ ] **Run — expect failure**: `npx vitest run app/api/turn/route.test.ts` → red: `POST /api/turn` does not exist yet (404 / route not found). 3. - [ ] **Implement** — the route per Interfaces (world-route import pattern + the `runTurn` call + 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); the `POST /api/turn` openapi schema in `lib/openapi.ts` (response reuses the world-snapshot shape + the `tasks` array of queue rows). 4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes; `npx tsc --noEmit` clean. 5. - [ ] **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)` ### Task 6: Snapshots gain the active + pending tasks array **Files** - `lib/game/save.ts` — add the `QueueDto` / `CrewState` interfaces and the private `taskQueue(db)` reader (code in step 3); add `tasks: QueueDto[]` to `WorldState` and `SystemState`, returned by `getWorldState` / `readSystemState`; `getCrewState` returns the new `CrewState` wrapper instead of the bare `CrewDto[]` (JC-7). Pure read — `taskQueue` selects only the `tasks` / `crew` / `solarSystems` / `planets` / `tech` tables and 4b's tick stays untouched. - `lib/game/save.test.ts` — new `describe('snapshot task queue (world / system / crew)')` block (code in step 1); hoist the 4b block's `addCrew` / `insertTaskRow` helpers 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 `.crew` off 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 as `CrewState` and render `state.crew` (JC-7) — without this the crew tab breaks the moment the builder wraps. - `components/mission-control.tsx` — the shell's own `/api/crew` consumer: change its bare-array parse to the `{crew, tasks}` `CrewState` object (JC-7's second consumer, flagged in JC-15). - `lib/openapi.ts` — new `TaskDto` + `CrewState` component schemas; `WorldState` / `SystemState` gain the `tasks` property (and required entry) — `ExploreResult` inherits it through its `SystemState` allOf; the `/api/crew` 200 response becomes the `CrewState` object instead of the `CrewMember` array (JC-7). **Interfaces** - Consumes: Task 2's `tasks` table (`schema.tasks`) and Task 1's `TaskType` (`lib/game/tasks.ts`); the existing `crew` / `solarSystems` / `planets` / `tech` tables for name resolution. - Produces: `QueueDto { id: number; type: TaskType; targetLabel: string; leadName: string; helperNames: string[]; remaining: number }` and `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` in `lib/game/save.ts`; `WorldState.tasks` / `SystemState.tasks: QueueDto[]`; `getCrewState(db): Promise`. - Array shape: ACTIVE rows first, then PENDING rows, each block ordered by (queuedAtTurn ASC, id ASC); DONE rows never appear. `remaining` is the stored row value — a pure read, no duration recomputation. `leadName` / `helperNames` resolve from the crew roster by id, falling back to `unknown` when an id references a deleted member (admin-corruption tolerance — the same spirit as 4b's `<= 0` check). - `targetLabel` is 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 }` | ` — upgrade` — e.g. `Origin — upgrade` | | SHIP_EXPAND | `{ systemId }` | ` — 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. 1. - [ ] **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's `buildWorldFixture` + the file-scope `addCrew`): ```ts 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.) 2. - [ ] **Run — expect failure**: `npx vitest run lib/game/save.test.ts` → the new block is red: `WorldState` / `SystemState` carry no `tasks` property (undefined ≠ []) and `getCrewState` still returns the bare array (no `.crew` / `.tasks`). The pre-existing blocks stay green — the shape change lands only in step 3. 3. - [ ] **Implement** — per Interfaces: the private `taskQueue(db)` (one select of the `tasks` table filtered to status ACTIVE/PENDING + one select each of `crew` / `solarSystems` / `planets` / `tech`; map every row through the table above — crew names by id, `unknown` on a miss; concat the ACTIVE block, then the PENDING block, each sorted by (queuedAtTurn, id)); wire it into `getWorldState` / `readSystemState` / `getCrewState`; hoist the test helpers and point the two existing crew-array assertions at `.crew`; the crew-panel parse; the openapi schemas. 4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (4a/4b stay green — their A/B blocks normalize the raw tables through `snapshot()`, no test asserts on a full builder object); `npx tsc --noEmit` clean. 5. - [ ] **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). 6. - [ ] **Commit (same PR branch as Tasks 4b/5a/5a-ii)**: `feat(api): world/system/crew snapshots expose the active + pending task queue (#44)` **Judgment calls (Task 6):** - **JC-7:** `getCrewState` returns a bare `CrewDto[]` today — a bare array can't carry a sibling `tasks` field, so `GET /api/crew` becomes `{ 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 `) above the `NEXT TURN` button (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 a `refreshAll` callback — same fetch → `throw` on `!res.ok` → setState pattern, same `.catch(() => {})` keep-old-state failure policy — and have the mount effect call it; then mount `` as the last child of the root div: `position: fixed` takes 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.tsx` is 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's `ShipState.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 })` in `components/next-turn-button.tsx`. Click → `POST /api/turn` → on success `onTicked()` (the shell's `refreshAll`); the transient in-flight flag makes the handler ignore further clicks while the request is out. The button never takes a `disabled` attribute — 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. 1. - [ ] **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/react` in `package.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). 2. - [ ] **Implement** — per Files / Interfaces: ```tsx '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(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 (
{error && ( {error.toUpperCase()} )} TURN {turn ?? '—'}
) } ``` 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). 3. - [ ] **Verify build**: `npx tsc --noEmit` clean — the whole unit suite stays untouched (`npx vitest run` still green, `npx playwright test --list` still lists); the button itself is exercised by the e2e task in CI. 4. - [ ] **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)` **Judgment calls** - JC-9 — the locked design says the counter reads from "the world snapshot the shell already holds"; the shell holds no `/api/world` state (WorldMapPanel owns it internally — it fetches on tab mount), and the only shell-held snapshot it already renders a turn from is `ShipState.turn` (the VESSEL METRICS TURN row). The button therefore takes `turn={ship?.turn ?? null}` and shows `TURN —` 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), so `onTicked` re-runs the four mount fetches (extracted into `refreshAll`) — a strict superset of `refreshSystem`, 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's `Button 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/jump` fetch 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/explore` body gains the team fields. - `components/skill-tree.tsx` — the RESEARCH panel: the card RESEARCH button arms the tech; same placement; the `/api/research` body gains the team fields. - `components/ship-systems.tsx` — the SHIP panel: the card UPGRADE/EXPAND/BUILD button arms that action; same placement; the local `act(url, body)` fetch gains the team fields. - `lib/game/save.ts` — `QueueDto` gains `status` / `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 openapi `TaskDto` schema gain the fields in the same commit. **Interfaces** - Consumes: Task 6's `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` from `GET /api/crew` — roster and queue in ONE response, which is why the builder takes both; Task 1's pure `computeDuration(type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf?)` from `lib/game/tasks.ts` (no DB import — client-safe, the tech.ts / ship-costs.ts precedent); the six `TaskType`s. - Produces: `TeamBuilder({ type: TaskType; hopCount?: number; roster: CrewDto[]; tasks: QueueDto[]; onQueue: (leadCrewId: number, helperCrewIds: number[]) => void })` in `components/team-builder.tsx` — no other exports. `hopCount` is JUMP-only: the hops to the armed target from the panel's `reachableWithin` (the same logic the route recomputes, #35); the other types omit it → 0, the value 5a's route passes for non-JUMP, and `computeDuration` ignores 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 of `CrewMember { name, role, traitGood, traitBad }`) and the default `powerOf` (constant 1) is what 5a's route calls, so the preview equals the stored duration exactly. A single `DISPATCH` button (EXEC variant) is enabled only when a lead is picked, that lead is not itself blocked, and the builder is not blocked; it fires `onQueue(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 with `in transit // ` (all types). - Panels: each keeps its target selection and its existing fetch/response flow (world-map's `setWorld(body)` + `onJumped`; local-system's `onChanged`; skill-tree's `onResearched`; ship-systems's `onChanged`) and adds (a) a local armed-target state set by clicking its existing action controls, and (b) a `/api/crew` fetch (the `CrewState` above) 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, … }`. 1. - [ ] **Write the failing tests — SKIPPED (no component-level test setup)** — as in Task 7: the repo has no `components/*.test.tsx` and no jsdom / `@testing-library/react` in `package.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. 2. - [ ] **QueueDto gains `status` / `leadCrewId` / `helperCrewIds` (JC-11)** — as specified, Task 6's display-oriented `QueueDto` can't drive the mirror (names only, no status, no crew ids): add the three fields to the interface, the `taskQueue` mapper and the openapi `TaskDto`, and update Task 6's snapshot `toEqual` expected objects (the two queue rows in its second test). `npx vitest run lib/game/save.test.ts` → green, `npx tsc --noEmit` clean. If Task 6 is still unimplemented when this runs, fold the fields into its implementation. 3. - [ ] **Implement** — per Files / Interfaces: ```tsx '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(null) const [helperIds, setHelperIds] = useState([]) // 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 (
PICK TEAM // {type} {blocked ? ( {inTransit ? `IN TRANSIT // ${inTransit.targetLabel}` : 'A JUMP IS ALREADY QUEUED'} ) : ( roster.map((c) => ( )) )}
{duration !== null ? `WILL TAKE ${duration} TURN(S)` : 'PICK A LEAD'}
) } ``` And the four panels per the Panels bullet: the armed-target state, the `` 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. 4. - [ ] **Verify build**: `npx tsc --noEmit` clean — the unit suite stays green (`npx vitest run`, including the updated Task 6 fixtures) and `npx playwright test --list` still lists; the panels themselves are exercised by the e2e task in CI. 5. - [ ] **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)` **Judgment calls** - JC-11 — the locked design mirrors Task 3's validation, but Task 6's `QueueDto` as 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 the `unknown` fallback. The builder therefore needs per-row `status` (lead-PENDING vs lead-ACTIVE chaining, in-transit) and `leadCrewId` / `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 `reachableWithin` map 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 `CrewState` from `GET /api/crew`, so each panel fetches it on mount and after each dispatch — self-contained like world-map's self-fetch; the shell's `refreshAll` (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's `position: fixed` + `zIndex: 60` convention. No state of its own — a pure display of the array it is handed; an empty array returns `null` (no empty box). - `components/mission-control.tsx` — import `TaskStrip` and mount it as the last child of the root div, right after the `NextTurnButton` element (Task 7): `position: fixed` takes it out of layout, and being outside the tab grid it stays visible on every tab. It receives `tasks={system?.tasks ?? []}` — the shell's existing `SystemState` already carries Task 6's `tasks` array, and Task 7's `refreshAll` re-runs the `/api/system` fetch on every run (as does #30's `refreshSystem` after 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's `status` — 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 existing `system: SystemState | null` state (the `/api/system` fetch, run on mount and by Task 7's `refreshAll`); Task 7's root-level mount convention. - Produces: `TaskStrip({ tasks: QueueDto[] })` in `components/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) left` for ACTIVE, `queued` for PENDING). PENDING rows render dimmed (45% opacity) — the order is the array's, never re-sorted. 1. - [ ] **Write the failing tests — SKIPPED (no component-level test setup)** — the repo has no `components/*.test.tsx` and no jsdom / `@testing-library/react` in `package.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). 2. - [ ] **Implement** — per Files / Interfaces: ```tsx '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 (
ACTIVE TASKS {tasks.map((t) => (
{t.type} // {t.targetLabel} {t.leadName} {t.helperNames.length > 0 ? ` +${t.helperNames.join(' +')}` : ''} {' — '} {t.status === 'ACTIVE' ? `${t.remaining} turn(s) left` : 'queued'}
))}
) } ``` And the shell: the import next to the `NextTurnButton` one, and `` appended right after the button element — position/z-index do the work; the DOM spot only keeps reading order with the button. 3. - [ ] **Verify build**: `npx tsc --noEmit` clean — the unit suite stays untouched (`npx vitest run` still green, `npx playwright test --list` still lists); the strip itself is exercised by the e2e task in CI. 4. - [ ] **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)` **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 `tasks` array — its `SystemState` (CARGO HOLD) and, via its roster-count fetch, the new `CrewState` object (JC-7). The strip takes `system?.tasks ?? []`: the shell already fetches `/api/system` on mount, and Task 7's `refreshAll` (and #30's `refreshSystem` after 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/crew` fetch is a second consumer of JC-7's object response — Task 6's parse-update names only `crew-panel.tsx`; if its bare-array cast is still there when this lands, its `.length` read silently becomes `undefined` (fix belongs to Task 6/8, not this task). ### Task 10: E2E rework — queue + tick model **Files** - `e2e/helpers.ts` — the linchpin: `jumpToFreshSystem` is called by every spec that jumps (smoke + state) and today is a single raw `POST /api/jump { systemId }` that landed instantly. Rework it to the queue+tick model (code in step 1) and add a general `resolveAction` it shares with the scan-loop tests: queue an action `POST` carrying a default one-person crew, read the new task's `remaining` (its live duration, Task 6), then `POST /api/turn` that many times and return the final snapshot. Add a minimal `CrewDto` (id + role) next to the existing `WorldState`. - `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 action `POST` now carries `leadCrewId` + `helperCrewIds` and 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-run `docker compose restart` spec (AGENTS.md). Two instant-resolution assumptions break: the raw `POST /api/explore` no longer flips `explored` in its response, and no turn bumps on queue — so the `sysExplored` baseline 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 `tickTurn` effect resolution, Task 5a/5b's reworked action endpoints (crew params) and Task 5b's `POST /api/turn`, Task 6's `tasks` array in every snapshot, Task 7's NEXT TURN button + `TURN N` counter, 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. 1. - [ ] **Rework the shared jump helper** — `e2e/helpers.ts`. `jumpToFreshSystem` keeps its target choice (prefer an undiscovered neighbour) but queues, then ticks, before returning; `resolveAction` generalizes that for the scan loops. Code: ```ts const TASK_TYPE: Record = { '/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): Promise { 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; ``` 2. - [ ] **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/world` report the target as `current`. The `#30` test's "wait for `TURN ${before.turn + 1}`" becomes "wait for `TURN ${before.turn + N}`" — the tick, not the queue, advances the counter. - *both explore tests* — the scan no longer flips `explored` in the POST response and bumps no turn; assert `explored=true` + `SCAN RESULT` only after N ticks. The *yields finds* loop reads the haul from the post-tick `/api/system` snapshot (inventory), not from the queue response's `outcome`. - *SHIP SYSTEMS* — UPGRADE/BUILD queue, then tick; the `LV n+1` / `ONLINE` card flips and the turn counter reflect the ticks, not the click. - *both SKILL TREE research tests* — RESEARCH queues; the card flips to `RESEARCHED` and the gated build unlocks only after N ticks. The `#35` two-hop test's `turn === turnBefore + 2` (flat hop-scaled cost) becomes `turnBefore + N` where 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. 3. - [ ] **Add the five new scenarios** — appended to `e2e/smoke.spec.ts` (its existing jump/explore/ship/research tests make it the natural home): 1. **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. 2. **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). 3. **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. 4. **In-transit block** — queue a JUMP and leave it active (no ticks); attempting to queue a second JUMP is refused `409` at 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. 5. **Free skip** — with an empty queue, click NEXT TURN; assert the turn counter increments by one and no task appears in the strip. 4. - [ ] **Fix `state.spec.ts`** — after its raw `POST /api/explore` baseline, tick the explore to completion via `resolveAction` before capturing `sysExplored`, so the `explored=true` and 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. 5. - [ ] **Verify (CI-only — never run locally)** — `npx tsc --noEmit` clean (the specs are TS) and `npx playwright test --list` shows 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=0` per AGENTS.md). Do NOT run the suite locally. 6. - [ ] **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)` **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/crew` shape: the specs today cast it to `CrewDto[]`, but Task 6 / JC-7 make it a `CrewState` object. `resolveAction` reads both (`Array.isArray` guard) so the helper is correct under either; the specs' inline `CrewDto[]` casts (CREW tab, state baseline) should be updated to the object shape in the same edit to keep `tsc` clean. - 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-4 `Status:` → `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) and `CHANGELOG.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. 1. - [ ] **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-4 `Status:` to `approved` and 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)"`. 2. - [ ] **Version bump** — `package.json` → `"version": "0.10.0"` (from the current `0.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 via `POST /api/turn` + the NEXT TURN button, crew teaming with live duration, trait duration modifiers, and the new `tasks` table (schema v7, idempotent backfill). 3. - [ ] **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. 4. - [ ] **Push + open the PR to master** — `git push -u origin `, then open a pull request to `master` on Gitea (repo `OpenCode/SpaceWanderer` at 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, `source` the 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. 5. - [ ] **Auto-merge on green** — trigger Gitea's auto-merge: `POST /repos/OpenCode/SpaceWanderer/pulls//merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`. The PR merges automatically once the required status check `CI / build + unit + e2e (pull request)` passes (branch protection; CI runs the full e2e suite in an isolated dind stack, ~7 min). Poll the PR until `merged_at` is 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. 6. - [ ] **Tag on master** — after pulling the merge on `master`: `git tag v0.10.0 && git push origin v0.10.0`. 7. - [ ] **Manual deploy** (CI never deploys) — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` returns the 0.10.0 payload. 8. - [ ] **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 reads `GITEA_TOKEN` from 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.