Files
SpaceWanderer/docs/superpowers/plans/2026-10-01-crew-driven-actions.md

191 KiB
Raw Permalink Blame History

Crew-Driven Actions Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make crew the central resource of SpaceWanderer: every action becomes a queued task with an assigned team, variable duration from roles/skills/traits, and manual time via a NEXT TURN button; crews then specialise through skill progression (PR B) and traits gain outcome effects (PR C).

Architecture: A pure task engine (lib/game/tasks.ts + lib/game/crew-costs.ts, no DB imports — shared server+client like the tech.ts precedent) computes durations and validates queues; save.ts keeps DB orchestration and gains a turn tick that resolves completed tasks via refactored per-type resolvers. PR B adds a crew_skills table feeding power bands into the same formula; PR C hooks two trait outcome effects into the explore resolver.

Tech Stack: Next.js 16 App Router, React 19, TypeScript strict, bun, SQLite via Drizzle + sql.js (WASM), Vitest unit tests, Playwright e2e (CI-only)

Spec: docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md

Global Constraints

  • AGENTS.md change process per PR: feature branch off master, no direct pushes to master; version bump in package.json + CHANGELOG entry at top (## v<version> — YYYY-MM-DD HH:MM); local verify 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):

import { describe, expect, it } from 'vitest'
import type { CrewMember } from './crew'
import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, POWER_BANDS, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, TRAIT_DURATION_MODIFIERS, WORK_POINTS, XP_THRESHOLDS } from './crew-costs'
import type { TaskType } from './tasks'
import { computeDuration } from './tasks'

// Trait strings copied verbatim from lib/game/crew.ts GOOD_TRAITS/BAD_TRAITS — TRAIT_DURATION_MODIFIERS
// is keyed by exactly these strings, so a rename in crew.ts breaks these tests.
const NEUTRAL_GOOD = 'FINDS HIDDEN THINGS' // no duration entry → no effect
const NEUTRAL_BAD = 'JINXED IN A DOGFIGHT' // STANDBY combat trait → no duration effect
const crew = (role: string, traitGood = NEUTRAL_GOOD, traitBad = NEUTRAL_BAD): CrewMember => ({ name: 'TEST', role, traitGood, traitBad })
const WORK: Record<TaskType, number> = { JUMP: 3, EXPLORE: 4, SHIP_UPGRADE: 6, SHIP_EXPAND: 6, SHIP_BUILD: 6, RESEARCH: 9 }
const TYPES = Object.keys(WORK) as TaskType[]
const SHIP: TaskType[] = ['SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD']

describe('solo baselines (one crew, power 1, no modifiers)', () => {
  it('work points are the duration for the preferred role: EXPLORE 4 · JUMP 3 / 6 · SHIP 6 · RESEARCH 9', () => {
    for (const type of TYPES) {
      expect(computeDuration(type, 1, crew(PREFERRED_ROLES[type][0]), [])).toBe(WORK[type])
    }
    expect(computeDuration('JUMP', 2, crew('PILOT'), [])).toBe(6) // two-hop = 2 × 3
  })
})
describe('role mismatch (lead only, helpers never penalised)', () => {
  it('a lead outside the preferred role adds +1; helpers contribute power only', () => {
    expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [])).toBe(4) // 3 + 1 mismatch
    expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [crew('PILOT')])).toBe(3) // ceil(3/2) + 1 — power 2, mismatch still on the lead
    expect(computeDuration('EXPLORE', 1, crew('SCIENTIST'), [crew('PILOT', 'QUICK HANDS')])).toBe(2) // 4/2 — the helper's trait does not apply
  })
  it('STEADY UNDER PRESSURE cancels the mismatch penalty and adds nothing of its own', () => {
    expect(computeDuration('JUMP', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(3)
    expect(computeDuration('EXPLORE', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(4)
  })
})

describe('floor', () => {
  it('never returns below 1 turn', () => {
    const lead = crew('PILOT', 'QUICK HANDS')
    expect(computeDuration('JUMP', 1, lead, [crew('PILOT'), crew('PILOT')], () => 3)).toBe(1) // ceil(3/9) − 1 = 0 → floor 1
  })
})
// All 15 duration traits from spec §5, each tested once in its own action context (plus its
// non-applicable types). target 'SHIP_UPGRADE' stands for the whole "SHIP work" class.
const TABLE: [trait: string, isGood: boolean, target: TaskType | 'ANY', delta: number][] = [
  ['RECKLESS COURAGE', true, 'JUMP', -1],
  ['CALM IN A CRISIS', true, 'EXPLORE', -1],
  ['FIXES ANYTHING', true, 'SHIP_UPGRADE', -1],
  ['READS STARS BLIND', true, 'JUMP', -1],
  ['QUICK HANDS', true, 'ANY', -1],
  ['IRON WILL', true, 'RESEARCH', -1],
  ['RIGID — SLOW TO ADAPT', false, 'RESEARCH', 1],
  ['OVERCAUTIOUS', false, 'ANY', 1],
  ['SLOWS THE CREW DOWN', false, 'ANY', 1],
  ['CARELESS WITH FUSES', false, 'SHIP_UPGRADE', 1],
  ['HOARDS RATIONS', false, 'SHIP_UPGRADE', 1],
  ['FORGETS TO EAT', false, 'EXPLORE', 1],
  ['ARGUES WITH EVERYONE', false, 'RESEARCH', 1],
  ['DOUBTS THE CAPTAIN', false, 'JUMP', 1],
]

describe('the 15 duration traits (spec §5, exact strings from crew.ts)', () => {
  const applies = (type: TaskType, target: TaskType | 'ANY') =>
    target === 'ANY' || target === type || (target !== 'ANY' && SHIP.includes(target) && SHIP.includes(type))
  it('each trait shifts only its own action class, lead only, across all six types', () => {
    for (const [trait, isGood, target, delta] of TABLE) {
      for (const type of TYPES) {
        const lead = isGood ? crew(PREFERRED_ROLES[type][0], trait) : crew(PREFERRED_ROLES[type][0], NEUTRAL_GOOD, trait)
        expect(computeDuration(type, 1, lead, []), `${trait} → ${type}`).toBe(WORK[type] + (applies(type, target) ? delta : 0))
      }
    }
  })
})

describe('crew-costs — the §11 tuning surface', () => {
  it('pins the balance knobs', () => {
    expect(WORK_POINTS).toEqual({ EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 })
    expect(XP_THRESHOLDS).toEqual([5, 10, 15, 20, 25])
    expect(POWER_BANDS).toEqual([1, 1, 1, 2, 2, 3]) // L0–L2 → 1 · L3–L4 → 2 · L5 → 3
    expect(STARTING_LEVELS.PILOT).toEqual({ NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
    expect(STARTING_LEVELS.SCIENTIST).toEqual({ NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 })
    expect(STARTING_LEVELS.COMMANDER).toEqual({ NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
    expect(RECRUIT_CHANCE_BASE).toBe(0.18)
    expect(RECRUIT_CHANCE_TALKER).toBe(0.3)
    expect(Object.keys(TRAIT_DURATION_MODIFIERS)).toHaveLength(15) // all 15 duration traits of spec §5, nothing else
    expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier
  })
})
  • Implement lib/game/crew-costs.ts (pure — no lib/db imports; the frontend imports it for the live duration preview, same server+client precedent as ship-costs.ts/tech.ts):
// Pure crew-balance module (spec §11): every tuning knob for the duration model and skill
// progression lives here, so tuning a number means editing one file. No lib/db imports —
// the frontend imports this for the live duration preview in the crew picker (same precedent
// as ship-costs.ts).
import type { TaskType } from './tasks'

export type SkillName = 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH'

export const WORK_POINTS = { EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 } as const

export const ACTION_SKILL: Record<TaskType, SkillName> = {
  JUMP: 'NAVIGATION',
  EXPLORE: 'SCANNING',
  SHIP_UPGRADE: 'ENGINEERING',
  SHIP_EXPAND: 'ENGINEERING',
  SHIP_BUILD: 'ENGINEERING',
  RESEARCH: 'RESEARCH',
}

export const PREFERRED_ROLES: Record<TaskType, string[]> = {
  JUMP: ['PILOT', 'NAVIGATOR'],
  EXPLORE: ['SCIENTIST'],
  SHIP_UPGRADE: ['ENGINEER', 'TECHNICIAN'],
  SHIP_EXPAND: ['ENGINEER', 'TECHNICIAN'],
  SHIP_BUILD: ['ENGINEER', 'TECHNICIAN'],
  RESEARCH: ['SCIENTIST'],
}

export const POWER_BANDS = [1, 1, 1, 2, 2, 3] // skill level 0..5 → power (L0–L2 → 1 · L3–L4 → 2 · L5 → 3)
export const XP_THRESHOLDS = [5, 10, 15, 20, 25] // cumulative task completions for L1..L5 (cap 5)

// Role → starting skill levels (spec §6): specialists start at L2, everyone else at L0.
export const STARTING_LEVELS: Record<string, Record<SkillName, number>> = {
  COMMANDER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
  PILOT: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
  ENGINEER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 },
  MEDIC: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
  NAVIGATOR: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
  SCIENTIST: { NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 },
  GUNNER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
  TECHNICIAN: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 },
}

// The lead's good trait that cancels the role-mismatch penalty (spec §5).
export const MISMATCH_CANCEL_TRAIT = 'STEADY UNDER PRESSURE'

// All 15 duration traits of spec §5, keyed by the EXACT trait strings from lib/game/crew.ts.
// Value = additive turn modifier keyed by action type or 'ANY'; empty object = no additive
// modifier (the trait's effect is the mismatch cancel, see MISMATCH_CANCEL_TRAIT). Traits
// with no entry (the three STANDBY combat traits and #45's outcome traits) contribute 0.
// "SHIP work" covers all three SHIP types.
export const TRAIT_DURATION_MODIFIERS: Record<string, Partial<Record<TaskType | 'ANY', number>>> = {
  'STEADY UNDER PRESSURE': {},
  'RECKLESS COURAGE': { JUMP: -1 },
  'CALM IN A CRISIS': { EXPLORE: -1 },
  'FIXES ANYTHING': { SHIP_UPGRADE: -1, SHIP_EXPAND: -1, SHIP_BUILD: -1 },
  'READS STARS BLIND': { JUMP: -1 },
  'QUICK HANDS': { ANY: -1 },
  'IRON WILL': { RESEARCH: -1 },
  'RIGID — SLOW TO ADAPT': { RESEARCH: 1 },
  'OVERCAUTIOUS': { ANY: 1 },
  'SLOWS THE CREW DOWN': { ANY: 1 },
  'CARELESS WITH FUSES': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 },
  'HOARDS RATIONS': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 },
  'FORGETS TO EAT': { EXPLORE: 1 },
  'ARGUES WITH EVERYONE': { RESEARCH: 1 },
  'DOUBTS THE CAPTAIN': { JUMP: 1 },
}

export const RECRUIT_CHANCE_BASE = 0.18 // a scan finds a joining member (matches crew.ts's current CREW_CHANCE)
export const RECRUIT_CHANCE_TALKER = 0.3 // lead with TALKS TO ANYONE (wired in PR C)
  • Implement lib/game/tasks.ts (pure — no lib/db imports):
// 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):

it('v7: a v6-stamped save gets an empty tasks table without touching state; idempotent across restart', async () => {
  const { db, file } = await freshFile()
  const world = await getWorldState(db) // fresh game, stamped at the current version
  const crewBefore = await db.db.select().from(schema.crew)
  const invBefore = await db.db.select().from(schema.inventoryItems)

  await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12
  const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7

  expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1)
  expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
  expect(await db.db.select().from(schema.crew)).toEqual(crewBefore)
  expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
  const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
  expect(tables[0].values.map((v) => v[0])).toContain('tasks')
  expect(Number(db.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) // empty queue after upgrade

  const reopened = await openGameDb(file) // restart: the backfill must be a no-op
  await getWorldState(reopened)
  expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
  expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0)
})

Realism note: the production upgrade path is a DB file written by v0.9.12 (no tasks table) → open() with the new DDL → CREATE TABLE IF NOT EXISTS creates it → ensureSave stamps v7. The test simulates the save side with the version stamp (the DDL side runs on every open, new or old file).

  • Implement, each in the existing style of its file:

lib/db/client.ts — add to the DDL template after the events table (must stay in sync with schema.ts):

CREATE TABLE IF NOT EXISTS tasks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  save_id INTEGER NOT NULL REFERENCES saves(id),
  type TEXT NOT NULL, -- 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH'
  payload TEXT NOT NULL, -- JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId}
  lead_crew_id INTEGER NOT NULL REFERENCES crew(id),
  helpers TEXT NOT NULL DEFAULT '[]', -- JSON array of crew ids
  duration INTEGER NOT NULL,
  remaining INTEGER NOT NULL,
  status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE'
  queued_at_turn INTEGER NOT NULL DEFAULT 0
);

lib/db/schema.ts — append after events (mirrors the DDL above):

// Queued actions (PR A, spec §3): one row per task assigned to a team. `payload` is a JSON
// string whose shape depends on `type` (see the Task interface in lib/game/tasks.ts);
// `helpers` is a JSON array of crew ids. Upgraded saves start with an empty queue — the v7
// backfill stamps the version, the DDL in client.ts creates the table.
export const tasks = sqliteTable('tasks', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  saveId: integer('save_id').notNull().references(() => saves.id),
  type: text('type').notNull(), // TaskType
  payload: text('payload').notNull(),
  leadCrewId: integer('lead_crew_id').notNull().references(() => crew.id),
  helpers: text('helpers').notNull().default('[]'), // JSON array of crew ids
  duration: integer('duration').notNull(),
  remaining: integer('remaining').notNull(),
  status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE'
  queuedAtTurn: integer('queued_at_turn').notNull().default(0),
})

lib/game/save.ts — bump the version constant and add the v7 step in backfillSave after the v6 topUpMissionBoard step:

export const SCHEMA_VERSION = 7 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table
  if (version < 7) {
    // The tasks table itself is created by the DDL at open (CREATE TABLE IF NOT EXISTS) for
    // every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
    version = 7
  }

lib/db/admin.ts — register in ADMIN_TABLES after events: tasks: schema.tasks,

lib/game/tasks.ts — add the domain shape after TaskType (Task.helpers is the parsed form of the row's JSON-array column; the row↔Task mapping lives at the save.ts boundary, not in this pure module):

export type TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE'

// One queued/active/done action (spec §3). `payload` is a JSON string whose shape depends on
// the TaskType: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} ·
// RESEARCH {techId} · EXPLORE {planetId}.
export interface Task {
  id: number
  saveId: number
  type: TaskType
  payload: string
  leadCrewId: number
  helpers: number[]
  duration: number
  remaining: number
  status: TaskStatus
  queuedAtTurn: number
}
  • Run npx vitest run (full unit suite — the save, admin and client DB tests all touch the schema) → green, 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.
// --- Queueing: validation + row construction (pure, no DB) -------------------------------
//
// queueTask runs over a plain snapshot of the save (QueueState) and decides whether a new
// task can be queued and what row it becomes. Pure like crew.ts / ship-costs.ts — the route
// (a later task) maps schema rows into QueueState, calls queueTask, and persists the result:
// insert the task row (adding `id` + `saveId` at the DB boundary) and the deducted hold.

// Carries an HTTP status so route handlers can map failures to responses without re-parsing
// (same pattern as JumpError / ShipError / ResearchError / ExploreError).
export class TaskError extends Error {
  constructor(
    public readonly status: number,
    message: string,
  ) {
    super(message)
  }
}

// The queueing request — the existing endpoints' bodies plus the crew team. Payload field
// names follow the routes (the spec's names differ):
//   JUMP { systemId, hops }  — the route computes `hops` with jumpToSystem's logic
//   (reachableWithin + NAV AI) and passes it in; the body field is `systemId`, not `toSystemId`
//   EXPLORE { planetId }
//   SHIP_UPGRADE { systemId } · SHIP_EXPAND { systemId } · SHIP_BUILD { type }
//   RESEARCH { techId }      — the tech row's id, not the slug
export interface QueueTaskRequest {
  type: TaskType
  payload: Record<string, unknown>
  leadCrewId: number
  helperCrewIds: number[]
}

// Plain per-save snapshot the decision runs over — the route maps schema rows into these
// (same row↔domain boundary as the tasks table in save.ts).
export interface QueueState {
  turn: number
  crew: CrewDto[]
  tasks: Task[]
  inventory: InventoryItemDto[] // cargo hold, id order (oldest first)
  shipSystems: { id: number; type: string; level: number; capacity: number }[] // UPGRADE/EXPAND costs scale with the target's current level/capacity
  tech: { id: number; slug: string }[] // RESEARCH cost comes from the matching TECHS def, like research.ts
}

// The new row minus the two values only the DB boundary knows: `id` (autoincrement) and
// `saveId` (filled in by save.ts's row↔Task mapping on insert).
export interface QueueResult {
  task: Omit<Task, 'id' | 'saveId'>
  inventory: InventoryItemDto[] // unchanged for JUMP/EXPLORE; deducted for SHIP_* / RESEARCH
}

// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all
// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn.
export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult {
  const lead = state.crew.find((c) => c.id === req.leadCrewId)
  if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`)
  const helpers: CrewDto[] = []
  for (const id of req.helperCrewIds) {
    const helper = state.crew.find((c) => c.id === id)
    if (!helper) throw new TaskError(404, `Unknown crew member ${id}`)
    helpers.push(helper)
  }

  // Busy (409). Spec §3: one ACTIVE task per crew member, and a lead may chain one PENDING
  // task behind it (FIFO — auto-starts when the current one completes). So: any crew member
  // committed to a PENDING task is blocked; a helper on an ACTIVE task is blocked; a lead on
  // an ACTIVE task passes and the new task is queued PENDING behind it.
  const inStatus = (id: number, status: TaskStatus) => state.tasks.some((t) => t.status === status && (t.leadCrewId === id || t.helpers.includes(id)))
  const checkBusy = (c: CrewDto, isLead: boolean) => {
    if (inStatus(c.id, 'PENDING')) throw new TaskError(409, `${c.name} is already committed to a pending task`)
    if (!isLead && inStatus(c.id, 'ACTIVE')) throw new TaskError(409, `${c.name} is already busy`)
  }
  checkBusy(lead, true)
  for (const helper of helpers) checkBusy(helper, false)

  // In transit (409): while any JUMP is ACTIVE, no new tasks of any type (spec §3).
  const jumps = state.tasks.filter((t) => t.type === 'JUMP')
  if (jumps.some((t) => t.status === 'ACTIVE')) throw new TaskError(409, 'A jump is in transit — no new tasks can be queued')
  // Only one JUMP at a time, active or pending (two arrivals could never resolve on one tick).
  // The ACTIVE half is already caught above, so this fires on a pending jump.
  if (req.type === 'JUMP' && jumps.some((t) => t.status !== 'DONE')) throw new TaskError(409, 'A jump is already queued')

  // Costs deduct at queue time (spec §3) — the existing cost logic from ship-costs.ts /
  // tech.ts and spendItems' 422. JUMP/EXPLORE have no resource cost. Target existence and
  // the 409 gates (max level / capacity, not built, already built, build gate, tech prereqs)
  // stay in the routes with their existing codes — the route resolves ids before calling.
  let inventory = state.inventory
  if (req.type === 'SHIP_UPGRADE' || req.type === 'SHIP_EXPAND') {
    const system = state.shipSystems.find((s) => s.id === req.payload.systemId)!
    inventory = deduct(inventory, 'resource', req.type === 'SHIP_UPGRADE' ? upgradeCost(system.level) : expandCost(system.capacity))
  } else if (req.type === 'SHIP_BUILD') {
    inventory = deduct(inventory, 'part', BUILD_PART_COST)
  } else if (req.type === 'RESEARCH') {
    const def = TECHS.find((t) => t.slug === state.tech.find((r) => r.id === req.payload.techId)?.slug)!
    inventory = deduct(inventory, def.costKind, def.costQty)
  }

  // Success: duration from the Task-1 formula, status from what the team is doing right now.
  // A crew member is "free" when on no ACTIVE/PENDING task; after the checks above only a
  // lead chaining behind its own ACTIVE can be non-free.
  const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING')
  const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0
  const duration = computeDuration(req.type, hops, lead, helpers)
  const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING'

  return {
    task: {
      type: req.type,
      payload: JSON.stringify(req.payload),
      leadCrewId: req.leadCrewId,
      helpers: req.helperCrewIds,
      duration,
      remaining: duration,
      status,
      queuedAtTurn: state.turn, // queueing is free — the turn advances only on the tick
    },
    inventory,
  }
}

// Pure version of spendItems' consumption (lib/game/ship.ts): the total is checked up front
// (shortfall → the same 422 spendItems throws), then the oldest rows first (array order = id
// order); rows that hit zero are dropped. The input array is never mutated.
function deduct(items: InventoryItemDto[], kind: 'resource' | 'part', amount: number): InventoryItemDto[] {
  if (totalQty(items, kind) < amount) {
    throw new TaskError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
  }
  let remaining = amount
  const out: InventoryItemDto[] = []
  for (const item of items) {
    if (item.kind !== kind) {
      out.push(item)
      continue
    }
    const take = Math.min(item.qty, remaining)
    remaining -= take
    if (take < item.qty) out.push({ ...item, qty: item.qty - take })
  }
  return out
}
import { describe, expect, it } from 'vitest'
import { computeDuration, queueTask, TaskError, type QueueState, type Task } from './tasks'
import type { CrewDto } from './save'

describe('queueTask', () => {
  // The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills
  // the CrewDto shape (unused by the duration model but part of the type).
  const CREW: CrewDto[] = [
    { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' },
    { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' },
    { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' },
  ]

  const state = (over: Partial<QueueState> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
  const task = (over: Partial<Task> = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over })

  // Sync twin of the rejects.toMatchObject house style — queueTask throws instead of rejecting.
  const expectErr = (fn: () => unknown, status: number, message?: RegExp): void => {
    let err: unknown
    try {
      fn()
    } catch (e) {
      err = e
    }
    expect(err).toBeInstanceOf(TaskError)
    expect((err as TaskError).status).toBe(status)
    if (message) expect((err as TaskError).message).toMatch(message)
  }

  it('unknown lead or helper id → 404', () => {
    expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 42, helperCrewIds: [] }), 404, /Unknown crew member 42/)
    expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [42] }), 404, /Unknown crew member 42/)
  })

  it('busy lead → 409 — already committed to a PENDING task (as lead or helper of one)', () => {
    expectErr(() => queueTask(state({ tasks: [task({ status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/)
    expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [1], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/)
  })

  it('busy helper → 409 — on an ACTIVE task, or committed to a PENDING one', () => {
    expectErr(() => queueTask(state({ tasks: [task({ leadCrewId: 3, helpers: [2] })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already busy/)
    expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [2], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already committed to a pending task/)
  })

  it('in transit (a JUMP is ACTIVE) → 409 for a non-jump action', () => {
    expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3 })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /in transit/)
  })

  it('second jump → 409 while one is pending', () => {
    expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3, status: 'PENDING' })] }), { type: 'JUMP', payload: { systemId: 2, hops: 1 }, leadCrewId: 1, helperCrewIds: [] }), 409, /A jump is already queued/)
  })

  it('SHIP / RESEARCH cost deducts at queue time; shortfall → the existing 422', () => {
    const hold = [{ kind: 'resource' as const, name: 'IRON', qty: 5 }, { kind: 'part' as const, name: 'ALLOY', qty: 4 }]
    const systems = [{ id: 7, type: 'REACTOR', level: 2, capacity: 3 }]
    const techRows = [{ id: 3, slug: 'REACTOR_OVERCLOCK' }] // the def's cost: 3 resources (TECHS)
    const upgraded = queueTask(state({ inventory: hold, shipSystems: systems, tech: techRows }), { type: 'SHIP_UPGRADE', payload: { systemId: 7 }, leadCrewId: 2, helperCrewIds: [] })
    expect(upgraded.inventory).toEqual([{ kind: 'resource', name: 'IRON', qty: 3 }, { kind: 'part', name: 'ALLOY', qty: 4 }]) // upgradeCost(2) = 2, oldest row first
    const shortHold = [{ kind: 'resource' as const, name: 'IRON', qty: 2 }]
    expectErr(() => queueTask(state({ inventory: shortHold, tech: techRows }), { type: 'RESEARCH', payload: { techId: 3 }, leadCrewId: 2, helperCrewIds: [] }), 422, /Insufficient resources in the cargo hold — need 3/)
  })

  it('ACTIVE-vs-PENDING rule: PENDING while the lead is busy (no turn change); all free → ACTIVE', () => {
    const s = state({ tasks: [task({ leadCrewId: 1 })] })
    const pending = queueTask(s, { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] })
    expect(pending.task.status).toBe('PENDING')
    expect(pending.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
    expect(pending.task.queuedAtTurn).toBe(10)
    expect(s.turn).toBe(10) // queueing never advances the turn — only the tick does
    const active = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] })
    expect(active.task.status).toBe('ACTIVE')
    expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
    expect(active.task.queuedAtTurn).toBe(10)
  })
})

Interfaces

  • queueTask(state: QueueState, req: QueueTaskRequest): QueueResult — pure (no DB / Date / rng), 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 <id>
2 any assigned crew (as lead or helper of one) already committed on a PENDING task 409 — <name> is already committed to a pending task
3 a helper on an ACTIVE task (as lead or helper of one) 409 — <name> is already busy · a lead on an ACTIVE task passes: the new task is PENDING behind it (spec §3 — a lead may chain one pending task, auto-started on completion)
4 any JUMP task ACTIVE (in transit) — blocks every type 409 — A jump is in transit — no new tasks can be queued
5 request type JUMP and a JUMP task already ACTIVE or PENDING (the ACTIVE half is caught by 4, so this fires on a pending jump) 409 — A jump is already queued
6 SHIP_* / RESEARCH only — deduct the existing cost: UPGRADE upgradeCost(level) / EXPAND expandCost(capacity) resources · BUILD BUILD_PART_COST parts · RESEARCH the matching TECHS def's costKind × costQty — shortfall 422 — spendItems' exact message Insufficient resources|parts in the cargo hold — need <n>

On success: hops = req.type === 'JUMP' ? req.payload.hops : 0 → duration = computeDuration(type, hops, lead, helpers) (Task 1's pure formula; the roster objects pass through as-is — CrewDto is a structural superset of CrewMember) → status = 'ACTIVE' when no assigned crew is on an ACTIVE/PENDING task, else 'PENDING' (after rules 2–3 only a lead chaining behind its own ACTIVE can be non-free) → remaining = duration, queuedAtTurn = state.turn — queueing never advances the turn (only the tick does) → payload stored as JSON.stringify(req.payload); the route then adds id + saveId at the DB boundary and persists the returned inventory.

Route pre-checks stay in the routes with their existing codes (this task does not move them): unknown target 404; unreachable system / not built / max level / max capacity / already built / build gate / already researched / tech prereqs 409. For JUMP the route computes hops with the same reachableWithin + NAV AI logic as jumpToSystem and passes { systemId, hops } in the payload — the body field is systemId (the spec's targetSystemId / techSlug / kind names never existed in the routes).

    • Write the failing tests — add the describe('queueTask') block to lib/game/tasks.test.ts (create the file with the vitest + ./tasks + type-only ./save imports if Tasks 1–2 left none).
    • Run — expect failure: npx vitest run lib/game/tasks.test.ts → red (the seven new its fail — queueTask is not implemented yet; pre-existing tests in the file stay green).
    • 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.
    • 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.
    • 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, resolver>:

TaskType Function Module Signature
JUMP resolveJumpTask lib/game/save.ts (db: GameDb, task: Task) => Promise<void>
EXPLORE resolveExploreTask lib/game/save.ts (db: GameDb, task: Task) => Promise<{ outcome: FoundItem[]; crewFound: CrewDto | null }>
SHIP_UPGRADE resolveShipUpgradeTask lib/game/ship.ts (db: GameDb, task: Task) => Promise<void>
SHIP_EXPAND resolveShipExpandTask lib/game/ship.ts (db: GameDb, task: Task) => Promise<void>
SHIP_BUILD resolveShipBuildTask lib/game/ship.ts (db: GameDb, task: Task) => Promise<void>
RESEARCH resolveResearchTask lib/game/research.ts (db: GameDb, task: Task) => Promise<void>

State shape they consume: (db: GameDb, task: Task) — the same save-like state the current action functions use (GameDb; the save row is read inside, exactly like today — each resolver starts with ensureSave(db)) plus the domain Task row from lib/game/tasks.ts. Only task.type and task.payload are read; payload is the JSON string queueTask stored (Task 3): JUMP {systemId, hops} · EXPLORE {planetId} · SHIP_UPGRADE/SHIP_EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId}.

Common contract (all six — what Task 4b may rely on):

  • Payload target, not current location. Effects land on the target recorded in the payload. A queued EXPLORE resolves against its planet wherever it is (the ship stays in its current system; a scan never discovers a system) — this is what makes a queued scan resolve correctly while the ship is elsewhere. JUMP moves the ship to the payload system.
  • No turn advance. Resolvers read save.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.
    • Write the failing tests — add to lib/game/save.test.ts (below the explorePlanet crew finds block):
    // --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
    //
    // Each test builds the identical deterministic fixture in two fresh files (seed '42',
    // stamped save → no backfill; ids line up across worlds) and compares their full state:
    // A = the legacy immediate action (validates, costs a turn, applies the effect);
    // B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver
    // applies the effect (ship/research rows deduct the cost up front, as queueTask did at
    // queue time — Task 3). snapshot() normalizes every game table (wall-clock columns
    // excluded), so the two end states must be byte-identical.
    const T0 = '2026-10-01T00:00:00.000Z'
    
    async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> {
      const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
      const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
      const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id })
      await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0, toSystem: s1 })
      await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1, toSystem: s0 })
      const [p0] = await db.db.insert(schema.planets).values({ systemId: s0, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id })
      const [p1] = await db.db.insert(schema.planets).values({ systemId: s1, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id })
      return { sys1: s1, p0, p1 }
    }
    
    // The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed
    // identity fields, the same way the public functions' delegation does.
    const task = (type: 'JUMP' | 'EXPLORE', payload: object): Task =>
      ({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
    
    async function snapshot(db: GameDb): Promise<string> {
      const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([
        db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves),
        db.db.select().from(schema.solarSystems),
        db.db.select().from(schema.jumpRoutes),
        db.db.select().from(schema.planets),
        db.db.select().from(schema.inventoryItems),
        db.db.select().from(schema.crew),
        db.db.select().from(schema.shipSystems),
        db.db.select().from(schema.tech),
        db.db.select().from(schema.missions),
        db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events),
      ])
      return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events })
    }
    
    describe('resolveJumpTask (A/B against jumpToSystem)', () => {
      it('undiscovered target: lands exactly what jumpToSystem lands — move, discover, ring, event', async () => {
        const { db: a } = await freshFile()
        const { db: b } = await freshFile()
        const fa = await buildWorldFixture(a)
        const fb = await buildWorldFixture(b)
    
        await jumpToSystem(a, fa.sys1) // legacy: validates, +1 turn, effect, event at turn 1
        await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) // the tick's job (Task 4b)
        await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
    
        expect(await snapshot(b)).toEqual(await snapshot(a))
      })
    
      it('discovered target: no ring — still byte-identical to jumpToSystem', async () => {
        const { db: a } = await freshFile()
        const { db: b } = await freshFile()
        const fa = await buildWorldFixture(a, true)
        const fb = await buildWorldFixture(b, true)
    
        await jumpToSystem(a, fa.sys1)
        await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
        await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
    
        expect(await snapshot(b)).toEqual(await snapshot(a))
        expect(await b.db.select().from(schema.solarSystems)).toHaveLength(2) // no ring added
      })
    
      it('guards: 404 unknown system, 409 already-current', async () => {
        const { db } = await freshFile()
        const f = await buildWorldFixture(db)
        await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
    
        await expect(resolveJumpTask(db, task('JUMP', { systemId: 999, hops: 1 }))).rejects.toMatchObject({ status: 404, message: 'Unknown system 999' })
        await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, 1))
        await db.db.update(schema.solarSystems).set({ isCurrent: true }).where(eq(schema.solarSystems.id, f.sys1))
        await expect(resolveJumpTask(db, task('JUMP', { systemId: f.sys1, hops: 1 }))).rejects.toMatchObject({ status: 409, message: 'Already at this system' })
      })
    })
    
    describe('resolveExploreTask (A/B against explorePlanet)', () => {
      it('planet in the current system: lands exactly what explorePlanet lands — haul, explored, event', async () => {
        const { db: a } = await freshFile()
        const { db: b } = await freshFile()
        const fa = await buildWorldFixture(a)
        const fb = await buildWorldFixture(b)
    
        await explorePlanet(a, fa.p0)
        await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
        await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p0 }))
    
        expect(await snapshot(b)).toEqual(await snapshot(a))
      })
    
      it('payload planet wins over the current system — the ship stays put (elsewhere semantics)', async () => {
        const { db: a } = await freshFile()
        const { db: b } = await freshFile()
        const fa = await buildWorldFixture(a)
        const fb = await buildWorldFixture(b)
    
        await explorePlanet(a, fa.p1) // the current function already scans any planet, not just the current system's
        await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
        await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p1 }))
    
        expect(await snapshot(b)).toEqual(await snapshot(a))
    
        const sysB = await b.db.select().from(schema.solarSystems)
        expect(sysB.filter((s) => s.isCurrent).map((s) => s.name)).toEqual(['Origin']) // the ship never moved
        expect(sysB.find((s) => s.name === 'Outpost')!.discovered).toBe(false) // a scan never discovers a system
    
        const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'explore')!
        expect(ev.turn).toBe(1)
        // Exact existing format, recomputed the way save.ts computes it: haul first, crew roll on the same stream.
        const rng = explorationRng('42', fb.p1)
        const haul = rollExploration(rng)
        const member = rollCrewMember(rng, new Set<string>())
        const foundText = haul.length > 0 ? haul.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
        expect(ev.message).toBe(`Explored Basalt — found ${foundText}${member ? ` — ${member.name} joined the crew` : ''}`)
      })
    
      it('guards: 404 unknown planet, 409 already explored (double-resolution safe)', async () => {
        const { db } = await freshFile()
        const f = await buildWorldFixture(db)
        await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
    
        await expect(resolveExploreTask(db, task('EXPLORE', { planetId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown planet 999' })
        await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))
        await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' })
      })
    })
    

    Add to lib/game/ship.test.ts:

    // --- A/B: resolvers vs the legacy immediate ship actions (Task 4a) ---------------------
    const T0 = '2026-10-01T00:00:00.000Z'
    
    // Stamped save (no backfill), the full ship per SHIP_TYPES, the full tech tree with
    // WEAPONS_SYSTEMS researched (unlocks the BUILD row). Identical build per world → ids line up.
    async function buildShipFixture(db: GameDb): Promise<void> {
      const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
      for (const def of SHIP_TYPES) {
        await db.db.insert(schema.shipSystems).values({ saveId: save.id, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 })
      }
      for (const def of TECHS) {
        await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: def.slug === 'WEAPONS_SYSTEMS' })
      }
    }
    
    const task = (type: 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD', payload: object): Task =>
      ({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
    
    async function snapshot(db: GameDb): Promise<string> {
      const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([
        db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves),
        db.db.select().from(schema.solarSystems),
        db.db.select().from(schema.jumpRoutes),
        db.db.select().from(schema.planets),
        db.db.select().from(schema.inventoryItems),
        db.db.select().from(schema.crew),
        db.db.select().from(schema.shipSystems),
        db.db.select().from(schema.tech),
        db.db.select().from(schema.missions),
        db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events),
      ])
      return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events })
    }
    
    describe('resolveShip* (A/B against the immediate actions)', () => {
      // A = legacy immediate action (validates, deducts the cost, +1 turn, effect, event).
      // B = the queued shape: the cost is deducted up front (queueTask did it at queue time —
      // Task 3), the tick advanced the turn (Task 4b), then the resolver applies the effect.
      // Same starting world → byte-identical end states.
    
      it('UPGRADE: lands exactly what upgradeSystem lands', async () => {
        const { db: a } = await freshFile()
        const { db: b } = await freshFile()
        await buildShipFixture(a)
        await buildShipFixture(b)
        await addItem(a, 'resource', 'IRON ORE', 1) // level 1 → cost 1
        await addItem(b, 'resource', 'IRON ORE', 1)
    
        const reactorId = systemOf(await getShipState(a), 'REACTOR').id
        await upgradeSystem(a, reactorId)
        await spendItems(b, 1, 'resource', 1) // the queue-time deduction
        await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
        await resolveShipUpgradeTask(b, task('SHIP_UPGRADE', { systemId: reactorId }))
    
        expect(await snapshot(b)).toEqual(await snapshot(a))
        const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'upgrade')!
        expect(ev.turn).toBe(1)
        expect(ev.message).toBe('Upgraded REACTOR to level 2 (cost: 1× resources)')
      })
    
      it('EXPAND: lands exactly what expandSystem lands', async () => {
        const { db: a } = await freshFile()
        const { db: b } = await freshFile()
        await buildShipFixture(a)
        await buildShipFixture(b)
        await addItem(a, 'resource', 'CRYSTAL DUST', 2) // capacity 2 → cost 2
        await addItem(b, 'resource', 'CRYSTAL DUST', 2)
    
        const hullId = systemOf(await getShipState(a), 'HULL').id
        await expandSystem(a, hullId)
        await spendItems(b, 1, 'resource', 2)
        await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
        await resolveShipExpandTask(b, task('SHIP_EXPAND', { systemId: hullId }))
    
        expect(await snapshot(b)).toEqual(await snapshot(a))
        const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'expand')!
        expect(ev.turn).toBe(1)
        expect(ev.message).toBe('Expanded HULL capacity to 3 (cost: 2× resources)')
      })
    
      it('BUILD: lands exactly what buildSystem lands', async () => {
        const { db: a } = await freshFile()
        const { db: b } = await freshFile()
        await buildShipFixture(a)
        await buildShipFixture(b)
        await addItem(a, 'part', 'SERVO ACTUATOR', 3)
        await addItem(b, 'part', 'SERVO ACTUATOR', 3)
    
        await buildSystem(a, 'WEAPONS')
        await spendItems(b, 1, 'part', 3)
        await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
        await resolveShipBuildTask(b, task('SHIP_BUILD', { type: 'WEAPONS' }))
    
        expect(await snapshot(b)).toEqual(await snapshot(a))
        const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'build')!
        expect(ev.turn).toBe(1)
        expect(ev.message).toBe('Built WEAPONS at base values (cost: 3× parts)')
      })
    
      it('guards: 404 unknown, 409 unbuilt / max level / max capacity / gate / already built', async () => {
        const { db } = await freshFile()
        await buildShipFixture(db)
        const state = await getShipState(db)
        const reactorId = systemOf(state, 'REACTOR').id
        const hullId = systemOf(state, 'HULL').id
        const weaponsId = systemOf(state, 'WEAPONS').id
        await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
    
        await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' })
        await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: weaponsId }))).rejects.toMatchObject({ status: 409, message: 'WEAPONS is not built' })
        await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId))
        await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: reactorId }))).rejects.toMatchObject({ status: 409, message: `REACTOR is at max level (${MAX_LEVEL})` })
    
        await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' })
        await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
        await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: hullId }))).rejects.toMatchObject({ status: 409, message: `HULL is at max capacity (${MAX_CAPACITY})` })
    
        await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'SHIELDS' }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system type SHIELDS' })
        await db.db.update(schema.tech).set({ researched: false }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS'))
        await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'WEAPONS' }))).rejects.toMatchObject({ status: 409, message: 'Building WEAPONS requires the WEAPONS SYSTEMS tech' })
        await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'HULL' }))).rejects.toMatchObject({ status: 409, message: 'HULL is already built' })
      })
    })
    

    Add to lib/game/research.test.ts:

    // --- A/B: resolver vs the legacy immediate action (Task 4a) ---------------------------
    const T0 = '2026-10-01T00:00:00.000Z'
    
    // Stamped save (no backfill), the full tech tree, nothing researched yet. Identical build
    // per world → ids line up.
    async function buildResearchFixture(db: GameDb): Promise<void> {
      const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
      for (const def of TECHS) {
        await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
      }
    }
    
    const task = (payload: object): Task =>
      ({ id: 0, saveId: 0, type: 'RESEARCH', payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
    
    async function snapshot(db: GameDb): Promise<string> {
      const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([
        db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves),
        db.db.select().from(schema.solarSystems),
        db.db.select().from(schema.jumpRoutes),
        db.db.select().from(schema.planets),
        db.db.select().from(schema.inventoryItems),
        db.db.select().from(schema.crew),
        db.db.select().from(schema.shipSystems),
        db.db.select().from(schema.tech),
        db.db.select().from(schema.missions),
        db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events),
      ])
      return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events })
    }
    
    describe('resolveResearchTask (A/B against researchTech)', () => {
      // A = legacy immediate action (validates, deducts the cost, +1 turn, marks researched,
      // event). B = the queued shape: the cost was deducted at queue time (Task 3), the tick
      // advanced the turn (Task 4b), then the resolver marks the tech. Same starting world →
      // byte-identical end states.
    
      it('lands exactly what researchTech lands', async () => {
        const { db: a } = await freshFile()
        const { db: b } = await freshFile()
        await buildResearchFixture(a)
        await buildResearchFixture(b)
        await addItem(a, 'resource', 'IRON ORE', 2) // NAVIGATION AI costs 2 resources
        await addItem(b, 'resource', 'IRON ORE', 2)
    
        const [navA] = await a.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
        const [navB] = await b.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
        await researchTech(a, navA.id)
        await spendItems(b, 1, 'resource', 2) // the queue-time deduction
        await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
        await resolveResearchTask(b, task({ techId: navB.id }))
    
        expect(await snapshot(b)).toEqual(await snapshot(a))
        expect((await b.db.select().from(schema.tech)).find((t) => t.id === navB.id)?.researched).toBe(true)
        const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'research')!
        expect(ev.turn).toBe(1)
        expect(ev.message).toBe('Researched NAVIGATION AI (cost: 2× resources)')
      })
    
      it('guards: 404 unknown tech, 409 already researched (prereqs are monotone — no guard needed)', async () => {
        const { db } = await freshFile()
        await buildResearchFixture(db)
        const [nav] = await db.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
        await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
    
        await expect(resolveResearchTask(db, task({ techId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown tech 999' })
        await resolveResearchTask(db, task({ techId: nav.id }))
        await expect(resolveResearchTask(db, task({ techId: nav.id }))).rejects.toMatchObject({ status: 409, message: 'NAVIGATION AI is already researched' })
      })
    })
    
    • 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.
    • 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):
    // --- Per-type resolvers (PR A, spec §3) -------------------------------------------
    //
    // The effect bodies of the instant actions, extracted so the turn tick (Task 4b) can
    // resolve a completed task row against whatever the world looks like at resolution time.
    // Contract (all six resolvers across save.ts / ship.ts / research.ts — see the Interfaces
    // block of this plan): effects land on the PAYLOAD target, never on the current location;
    // no turn advance (the caller owns the turn); no cost deduction (queueTask deducted at
    // queue time — Task 3; the immediate public functions deduct before delegating); a minimal
    // guard against a stale or double-resolved target (existing error class + exact message —
    // a throw is the signal for the 4b tick's re-validation); the existing event text formats
    // at the current turn; persist at the end.
    
    // A stand-in task row for the immediate public functions delegating to a resolver: the
    // resolvers only read `type` + `payload`, so the identity fields are zeroed.
    function syntheticTask(type: TaskType, payload: object): Task {
      return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
    }
    
    // JUMP — move the ship to the payload's system: flip isCurrent, discover the target, and
    // when it was undiscovered grow the chart ring off the same (seed, turn) stream as before.
    export async function resolveJumpTask(db: GameDb, task: Task): Promise<void> {
      await ensureSave(db)
      const { systemId } = JSON.parse(task.payload) as { systemId: number }
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const systems = await db.db.select().from(schema.solarSystems)
    
      const target = systems.find((s) => s.id === systemId)
      if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
      if (target.isCurrent) throw new JumpError(409, 'Already at this system')
      const current = systems.find((s) => s.isCurrent)!
    
      await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
      await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
    
      if (!target.discovered) {
        // Growing the chart: a ring of new (undiscovered) neighbours around the newly reached
        // system, so there is always somewhere to jump next and a route back.
        const rng = expansionRng(save.seed, save.turn)
        const takenNames = new Set(systems.map((s) => s.name))
        const ring = generateRing(rng, target.x, target.y, takenNames, systems)
        const ringIds: number[] = []
        for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
        const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
        await insertRoutes(db, ring.edges, resolve)
      }
    
      await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
      await checkMissionCompletion(db, save.id, save.turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
      await topUpMissionBoard(db)
      await persist(db)
    }
    
    // EXPLORE — scan the payload's planet (any system — the UI only offered the current
    // system's rows; a queued scan may resolve while the ship is elsewhere): deterministic
    // haul into the inventory, planet marked explored, crew-join roll on the same
    // (seed, planet) stream.
    export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outcome: FoundItem[]; crewFound: CrewDto | null }> {
      await ensureSave(db)
      const { planetId } = JSON.parse(task.payload) as { planetId: number }
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const planets = await db.db.select().from(schema.planets)
      const target = planets.find((p) => p.id === planetId)
      if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
      if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
    
      const rng = explorationRng(save.seed, target.id)
      const outcome = rollExploration(rng) // resource/part haul first…
      const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
      const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream
    
      await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
      for (const item of outcome) {
        // Accumulate: the same kind+name on this save bumps qty instead of adding a row.
        const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === item.kind && i.name === item.name)
        if (existing) {
          await db.db.update(schema.inventoryItems).set({ qty: existing.qty + item.qty }).where(eq(schema.inventoryItems.id, existing.id))
        } else {
          await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty })
        }
      }
    
      let crewFound: CrewDto | null = null
      if (foundMember) {
        const systems = await db.db.select().from(schema.solarSystems)
        const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
        const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
        crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
      }
    
      const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
      const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
      await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
      await checkMissionCompletion(db, save.id, save.turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
      await topUpMissionBoard(db)
      await persist(db)
    
      return { outcome, crewFound }
    }
    
    export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldState> {
      await ensureSave(db)
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const systems = await db.db.select().from(schema.solarSystems)
    
      const target = systems.find((s) => s.id === systemId)
      if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
      const current = systems.find((s) => s.isCurrent)!
      if (current.id === target.id) throw new JumpError(409, 'Already at this system')
    
      const routes = await db.db.select().from(schema.jumpRoutes)
      // #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop
      // jump costs one turn per hop, so single-hop jumps keep their existing cost.
      const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
      const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
      if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
    
      // The immediate jump keeps its own turn cost (the tick owns turn advances for queued
      // tasks): advance it before delegating so the resolver's event lands at exactly the
      // turn the old code wrote.
      const turn = save.turn + hopCount
      await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
      await resolveJumpTask(db, syntheticTask('JUMP', { systemId, hops: hopCount }))
    
      return getWorldState(db)
    }
    
    export async function explorePlanet(db: GameDb, planetId: number): Promise<ExploreResult> {
      await ensureSave(db)
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const planets = await db.db.select().from(schema.planets)
      const target = planets.find((p) => p.id === planetId)
      if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
      if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
    
      // The immediate scan keeps its one-turn cost; the resolver applies everything else at that turn.
      const turn = save.turn + 1
      await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
      const { outcome, crewFound } = await resolveExploreTask(db, syntheticTask('EXPLORE', { planetId }))
    
      return { ...(await readSystemState(db)), outcome, crewFound }
    }
    

    (b) lib/game/ship.ts: add the ./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):

    function syntheticTask(type: TaskType, payload: object): Task {
      return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
    }
    
    // SHIP_UPGRADE — level a built system by one, at whatever level it sits at resolution
    // time. No cost here (deducted at queue time — Task 3 — or by upgradeSystem before
    // delegating); no mission hooks (the current action has none).
    export async function resolveShipUpgradeTask(db: GameDb, task: Task): Promise<void> {
      await ensureSave(db)
      const { systemId } = JSON.parse(task.payload) as { systemId: number }
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
      if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
      if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
      const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db))
      if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`)
    
      const cost = upgradeCost(target.level)
      await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id))
      await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'upgrade', message: `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() })
      await persist(db)
    }
    
    // SHIP_EXPAND — same shape for capacity (cost: `capacity` units of any resource, deducted).
    export async function resolveShipExpandTask(db: GameDb, task: Task): Promise<void> {
      await ensureSave(db)
      const { systemId } = JSON.parse(task.payload) as { systemId: number }
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
      if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
      if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
      const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db))
      if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`)
    
      const cost = expandCost(target.capacity)
      await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id))
      await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'expand', message: `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() })
      await persist(db)
    }
    
    // SHIP_BUILD — build an unbuilt type at base values. The flat parts cost is already
    // deducted; some types are gated behind a researched tech (see EFFECTS in lib/game/tech.ts).
    export async function resolveShipBuildTask(db: GameDb, task: Task): Promise<void> {
      await ensureSave(db)
      const { type } = JSON.parse(task.payload) as { type: string }
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
      if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
      if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
      const gate = buildGate(type, await researchedTechSlugs(db))
      if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`)
    
      await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id))
      await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'build', message: `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`, createdAt: new Date().toISOString() })
      await persist(db)
    }
    
    // UPGRADE — level up a built system by one (capped at the effective max level, raised by techs).
    export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
      await ensureSave(db)
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
      if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
      if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
      const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db))
      if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`)
    
      await spendItems(db, save.id, 'resource', upgradeCost(target.level))
      const turn = save.turn + 1
      await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
      await resolveShipUpgradeTask(db, syntheticTask('SHIP_UPGRADE', { systemId }))
      return readShipState(db)
    }
    
    // EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs).
    export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
      await ensureSave(db)
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
      if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
      if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
      const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db))
      if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`)
    
      await spendItems(db, save.id, 'resource', expandCost(target.capacity))
      const turn = save.turn + 1
      await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
      await resolveShipExpandTask(db, syntheticTask('SHIP_EXPAND', { systemId }))
      return readShipState(db)
    }
    
    // BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
    export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
      await ensureSave(db)
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
      if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
      if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
      const gate = buildGate(type, await researchedTechSlugs(db))
      if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`)
    
      await spendItems(db, save.id, 'part', BUILD_PART_COST)
      const turn = save.turn + 1
      await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
      await resolveShipBuildTask(db, syntheticTask('SHIP_BUILD', { type }))
      return readShipState(db)
    }
    

    (c) lib/game/research.ts: add the ./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):

    function syntheticTask(type: TaskType, payload: object): Task {
      return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
    }
    
    // RESEARCH — mark the payload's tech researched at the current turn. No cost here (deducted
    // at queue time — Task 3 — or by researchTech before delegating); no mission hooks (the
    // current action has none).
    export async function resolveResearchTask(db: GameDb, task: Task): Promise<void> {
      await ensureSave(db)
      const { techId } = JSON.parse(task.payload) as { techId: number }
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const rows = await db.db.select().from(schema.tech)
      const target = rows.find((t) => t.id === techId)
      if (!target) throw new ResearchError(404, `Unknown tech ${techId}`)
    
      const def = TECHS.find((t) => t.slug === target.slug)!
      if (target.researched) throw new ResearchError(409, `${def.name} is already researched`)
    
      await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id))
      await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() })
      await persist(db)
    }
    
    // Research one tech: validates prerequisites (409, naming what is missing) and the cost (422),
    // spends it from the cargo hold, then delegates the unlock to the resolver at the new turn.
    export async function researchTech(db: GameDb, techId: number): Promise<TechDto[]> {
      await ensureSave(db)
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const rows = await db.db.select().from(schema.tech)
      const target = rows.find((t) => t.id === techId)
      if (!target) throw new ResearchError(404, `Unknown tech ${techId}`)
    
      const def = TECHS.find((t) => t.slug === target.slug)!
      const researched = await researchedSlugs(db)
      if (researched.has(def.slug)) throw new ResearchError(409, `${def.name} is already researched`)
      const missing = def.prereqs.filter((p) => !researched.has(p)).map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug)
      if (missing.length > 0) throw new ResearchError(409, `${def.name} requires ${missing.join(' and ')} first`)
    
      try {
        await spendItems(db, save.id, def.costKind, def.costQty)
      } catch (err) {
        // spendItems throws ShipError(422) on a short hold — re-throw as this domain's error.
        if (err instanceof ShipError) throw new ResearchError(err.status, err.message)
        throw err
      }
    
      const turn = save.turn + 1
      await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
      await resolveResearchTask(db, syntheticTask('RESEARCH', { techId }))
    
      return getTechState(db)
    }
    
    • 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.
    • 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).

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):

const TASK_RESOLVERS: Record<TaskType, (db: GameDb, task: Task) => Promise<unknown>> = {
  JUMP: resolveJumpTask,
  EXPLORE: resolveExploreTask,
  SHIP_UPGRADE: resolveShipUpgradeTask,
  SHIP_EXPAND: resolveShipExpandTask,
  SHIP_BUILD: resolveShipBuildTask,
  RESEARCH: resolveResearchTask,
}

The resolvers receive the real Task rows from the tick (they only read type + payload — 4a).

Failure semantics (from the 4a contract — "a resolver throw is the signal for the 4b tick's re-validation, which fails the task"): a resolver throw is its minimal guard (unknown target → 404, already-resolved target → 409 — a queue-time check can go stale between queueing and resolution). The orchestrator catches exactly the four domain error classes (JumpError / ExploreError / ShipError / ResearchError) and fails the task: it stays DONE (the tick already freed the team — the turn is spent), the effect simply does not land, no event is written for the failure, and the remaining completed tasks still resolve. Non-domain errors rethrow (a crash is a real bug).

runTurn(db: GameDb): Promise<TickResult> — the orchestrator in save.ts, matching the existing route→save pattern (ensureSave first, unscoped single-player reads, persist last). It returns the pure TickResult unchanged — the full mutated state the snapshot endpoints need; Task 5's route responds with it, composing spec §7's response (the active+pending task array) from it and the existing readers.

  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).

    • 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):
    // --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) -------------------
    //
    // tickTurn is the whole §3 tick over the same plain QueueState queueTask consumes. It owns
    // the turn increment and nothing else: it applies no effect and runs no mission logic —
    // the save.ts orchestrator (runTurn) calls the 4a resolvers for the returned `completed`
    // tasks, in the order returned.
    
    export interface TickResult {
      turn: number // state.turn + 1 — the tick IS the turn increment
      tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE
      completed: Task[] // the tasks that hit 0 this tick, in resolution order
    }
    
    const byQueueOrder = (a: Task, b: Task) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id
    
    export function tickTurn(state: QueueState): TickResult {
      const turn = state.turn + 1
      const tasks = state.tasks.map((t) => ({ ...t })) // never mutate the input (house style — deduct)
    
      // (a) every ACTIVE task ages by one; PENDING/DONE rows do not age.
      for (const t of tasks) if (t.status === 'ACTIVE') t.remaining -= 1
    
      // (b) everything that hit 0 resolves this tick. Marking DONE frees the whole team
      // (busyness is derived from the tasks table — there is no crew status column to update).
      // <= 0, not === 0: a corrupt row at or below zero still completes on the next tick
      // instead of dead-ending the queue (spec focus 4: no dead ends).
      const completed = tasks
        .filter((t) => t.status === 'ACTIVE' && t.remaining <= 0)
        .sort(byQueueOrder)
      for (const t of completed) t.status = 'DONE'
    
      // (c) auto-start: one forward pass over PENDING in (queuedAtTurn ASC, id ASC). Starting a
      // task only makes its team busier — never frees anyone — so a task that cannot start at
      // its position cannot start later in the same pass: one pass is complete. A just-started
      // task keeps its full duration; its first decrement lands on the next tick.
      for (const t of tasks.filter((t) => t.status === 'PENDING').sort(byQueueOrder)) {
        const free = (id: number) =>
          !tasks.some((o) => o.status === 'ACTIVE' && (o.leadCrewId === id || o.helpers.includes(id))) &&
          !tasks.some((o) => o.status === 'PENDING' && o.id !== t.id && (o.leadCrewId === id || o.helpers.includes(id)))
        if ([t.leadCrewId, ...t.helpers].every(free)) t.status = 'ACTIVE'
      }
    
      return { turn, tasks, completed }
    }
    
    describe('tickTurn', () => {
      // The queueTask block's file-scope fixture helpers (above): CREW = [1 AVA PILOT,
      // 2 BEN ENGINEER, 3 CASS SCIENTIST]; state() → { turn: 10, crew: CREW, tasks: [], … };
      // task() → a one-turn row. The tick never parses payloads — the targets below are inert.
      const JUMP = (id: number, over: Partial<Task> = {}): Task => task({ id, type: 'JUMP', payload: '{"systemId":2,"hops":1}', ...over })
      const EXPLORE = (id: number, planetId: number, over: Partial<Task> = {}): Task => task({ id, type: 'EXPLORE', payload: `{"planetId":${planetId}}`, ...over })
    
      it('decrements every ACTIVE task by one; a task that hits 0 is DONE (its team freed); PENDING rows do not age; the input is never mutated', () => {
        const s = state({
          tasks: [
            JUMP(1, { duration: 3, remaining: 3, leadCrewId: 1, queuedAtTurn: 8 }),
            EXPLORE(2, 5, { leadCrewId: 3, helpers: [2], duration: 1, remaining: 1, queuedAtTurn: 9 }),
            EXPLORE(3, 6, { leadCrewId: 1, duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 10 }), // member 1 is on ACTIVE 1
          ],
        })
    
        const r = tickTurn(s)
        expect(r.turn).toBe(11) // the tick is the increment
        expect(r.tasks.find((t) => t.id === 1)!.remaining).toBe(2)
        expect(r.tasks.find((t) => t.id === 1)!.status).toBe('ACTIVE')
        expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE')
        expect(r.completed.map((t) => t.id)).toEqual([2])
        expect(r.tasks.find((t) => t.id === 3)!.status).toBe('PENDING')
        expect(r.tasks.find((t) => t.id === 3)!.remaining).toBe(4) // pending does not age
        expect(s.tasks.find((t) => t.id === 1)!.remaining).toBe(3) // input state untouched
      })
    
      it('a 3-deep PENDING chain on one lead: when the lead task completes, exactly ONE successor starts — earliest queuedAtTurn wins (Review Focus 2)', () => {
        let s = state({
          tasks: [
            JUMP(9, { duration: 3, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }),
            EXPLORE(10, 5, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
            EXPLORE(11, 6, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 9 }),
            EXPLORE(12, 7, { leadCrewId: 1, duration: 1, remaining: 1, status: 'PENDING', queuedAtTurn: 10 }),
          ],
        })
        let r = tickTurn(s)
        expect(r.completed.map((t) => t.id)).toEqual([9])
        expect(r.tasks.find((t) => t.id === 9)!.status).toBe('DONE')
        expect(r.tasks.find((t) => t.id === 10)!.status).toBe('ACTIVE') // earliest queuedAtTurn starts
        expect(r.tasks.find((t) => t.id === 10)!.remaining).toBe(2) // full duration — first decrement next tick
        expect(r.tasks.find((t) => t.id === 11)!.status).toBe('PENDING') // the others wait for their own tick
        expect(r.tasks.find((t) => t.id === 12)!.status).toBe('PENDING')
    
        // Walk the chain to the end: each successor starts on exactly the tick its predecessor completes.
        const EXPECT: [completed: number[], active: number[]][] = [
          [[], [10]],
          [[10], [11]],
          [[], [11]],
          [[11], [12]],
          [[12], []],
        ]
        for (const [done, active] of EXPECT) {
          s = { ...s, turn: r.turn, tasks: r.tasks }
          r = tickTurn(s)
          expect(r.completed.map((t) => t.id)).toEqual(done)
          expect(r.tasks.filter((t) => t.status === 'ACTIVE').map((t) => t.id)).toEqual(active)
        }
        expect(r.turn).toBe(16) // 10 + 1 (the first tick) + 5 (the walk)
        expect(r.tasks.every((t) => t.status === 'DONE')).toBe(true)
      })
    
      it('same queuedAtTurn ties break by id, and a started task blocks a later one sharing a member — at most one start per freed member (Review Focus 2, corrupt-row half)', () => {
        // queueTask forbids a member committed to two PENDINGs (rule 2), so this shape only
        // exists on admin-corrupted rows — the tick must still be deterministic about it.
        const s = state({
          tasks: [
            JUMP(19, { duration: 1, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }),
            EXPLORE(20, 5, { leadCrewId: 2, helpers: [3], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
            EXPLORE(21, 6, { leadCrewId: 3, helpers: [2], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
          ],
        })
        const r = tickTurn(s)
        expect(r.tasks.find((t) => t.id === 20)!.status).toBe('ACTIVE') // (8, 20) < (8, 21) — starts first and takes members 2 and 3
        expect(r.tasks.find((t) => t.id === 21)!.status).toBe('PENDING')
      })
    
      it('a JUMP and an EXPLORE can both resolve on the same tick — completion is location-independent (Review Focus 3, tick half)', () => {
        const s = state({
          tasks: [
            EXPLORE(1, 5, { leadCrewId: 1, duration: 1, remaining: 1, queuedAtTurn: 8 }),
            JUMP(2, { leadCrewId: 2, duration: 1, remaining: 1, queuedAtTurn: 8 }),
          ],
        })
        const r = tickTurn(s)
        expect(r.completed.map((t) => t.id)).toEqual([1, 2]) // both this tick, queue order (explore first)
        expect(r.tasks.find((t) => t.id === 1)!.status).toBe('DONE')
        expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE')
      })
    
      it('free skip: an empty queue advances exactly one turn and changes nothing; a fully-free PENDING still starts (Review Focus 4)', () => {
        const r = tickTurn(state())
        expect(r.turn).toBe(11)
        expect(r.tasks).toEqual([])
        expect(r.completed).toEqual([])
    
        // No ACTIVE tasks and a startable PENDING (a corrupt-row shape — a legal PENDING always
        // has a busy member): the pass still starts it. Skipping means "no resolution", not
        // "no starts".
        const r2 = tickTurn(state({ tasks: [EXPLORE(30, 5, { leadCrewId: 1, helpers: [2, 3], duration: 3, remaining: 3, status: 'PENDING', queuedAtTurn: 9 })] }))
        expect(r2.turn).toBe(11)
        expect(r2.tasks.find((t) => t.id === 30)!.status).toBe('ACTIVE')
      })
    })
    
    // --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) -----------------
    //
    // The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
    // The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the
    // effects and the mission hooks (their contract — invoked here, at the tick's new turn,
    // before the final persist); this function owns the table writes.
    
    // The 4a contract names the resolvers 1:1 exactly for this dispatch; exhaustive by type —
    // a TaskType without a resolver is a compile error.
    const TASK_RESOLVERS: Record<TaskType, (db: GameDb, task: Task) => Promise<unknown>> = {
      JUMP: resolveJumpTask,
      EXPLORE: resolveExploreTask,
      SHIP_UPGRADE: resolveShipUpgradeTask,
      SHIP_EXPAND: resolveShipExpandTask,
      SHIP_BUILD: resolveShipBuildTask,
      RESEARCH: resolveResearchTask,
    }
    
    // Row→domain at the save.ts boundary (the inverse — Task→row at insert — stays with the
    // routes' queueing path).
    function rowToTask(row: { id: number; saveId: number; type: string; payload: string; leadCrewId: number; helpers: string; duration: number; remaining: number; status: string; queuedAtTurn: number }): Task {
      return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn }
    }
    
    export async function runTurn(db: GameDb): Promise<TickResult> {
      await ensureSave(db)
      const [save] = await db.db.select().from(schema.saves).limit(1)
      const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([
        db.db.select().from(schema.crew),
        db.db.select().from(schema.tasks),
        db.db.select().from(schema.inventoryItems),
        db.db.select().from(schema.shipSystems),
        db.db.select().from(schema.tech),
      ])
      const state: QueueState = {
        turn: save.turn,
        crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
        tasks: taskRows.map(rowToTask),
        inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })),
        shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
        tech: tech.map((t) => ({ id: t.id, slug: t.slug })),
      }
    
      const result = tickTurn(state)
    
      // (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
      // and the checkMissionCompletion / topUpMissionBoard hooks (4a contract) land at the new
      // turn, exactly where today's actions place them.
      await db.db.update(schema.saves).set({ turn: result.turn }).where(eq(schema.saves.id, save.id))
    
      // (b) + (d) resolve in the order tickTurn returned. A resolver throw is its 4a guard
      // (stale or double-resolved target): the task fails — it is already DONE (the tick freed
      // the team; the turn is spent), the effect simply does not land, and no event is written
      // for it. Only the four domain error classes fail a task; anything else rethrows.
      for (const task of result.completed) {
        try {
          await TASK_RESOLVERS[task.type](db, task)
        } catch (err) {
          if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
          throw err
        }
      }
    
      // Persist the tick's queue changes (the resolvers already persisted their own effects) —
      // one final whole-DB write makes the turn atomic on disk.
      for (const before of state.tasks) {
        const after = result.tasks.find((t) => t.id === before.id)
        if (after && (after.status !== before.status || after.remaining !== before.remaining)) {
          await db.db.update(schema.tasks).set({ status: after.status, remaining: after.remaining }).where(eq(schema.tasks.id, after.id))
        }
      }
      await persist(db)
    
      return result
    }
    
    describe('runTurn (tick on the DB)', () => {
      // buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no
      // backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) /
      // Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so
      // this block adds its own rows.
      async function addCrew(db: GameDb, count: number): Promise<number[]> {
        const [save] = await db.db.select().from(schema.saves).limit(1)
        const ids: number[] = []
        for (let i = 0; i < count; i++) {
          const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
          ids.push(row.id)
        }
        return ids
      }
    
      async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise<number> {
        const [save] = await db.db.select().from(schema.saves).limit(1)
        const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id })
        return row.id
      }
    
      it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => {
        const { db } = await freshFile()
        await buildWorldFixture(db)
        const count = (t: Parameters<typeof db.db.select>[0]) => db.db.select().from(t).then((rows) => rows.length)
        const before = {
          systems: await count(schema.solarSystems),
          planets: await count(schema.planets),
          inventory: await count(schema.inventoryItems),
          missions: await count(schema.missions),
          events: await count(schema.events),
        }
    
        const r = await runTurn(db)
        expect(r.turn).toBe(1)
        expect(r.tasks).toEqual([])
        expect(r.completed).toEqual([])
    
        const [save] = await db.db.select().from(schema.saves).limit(1)
        expect(save.turn).toBe(1) // the tick is the increment — the only write of the call
        expect(await count(schema.solarSystems)).toBe(before.systems)
        expect(await count(schema.planets)).toBe(before.planets)
        expect(await count(schema.inventoryItems)).toBe(before.inventory)
        expect(await count(schema.missions)).toBe(before.missions) // no top-up without a resolution
        expect(await count(schema.events)).toBe(before.events) // no event for a skip
        expect(await count(schema.tasks)).toBe(0)
      })
    
      it('a resolved JUMP completes the matching DELIVER mission, grants the reward, and tops the board up — the 4a hooks, at the tick\'s new turn', async () => {
        const { db } = await freshFile()
        const f = await buildWorldFixture(db) // s0 current; s1 'Outpost' undiscovered
        const [c1] = await addCrew(db, 1)
        const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 4 })
        const [save] = await db.db.select().from(schema.saves).limit(1)
        await db.db.update(schema.saves).set({ turn: 5 }).where(eq(schema.saves.id, save.id))
        const [missionId] = await db.db.insert(schema.missions).values({ saveId: save.id, type: 'DELIVER', description: 'Jump to Outpost', targetSystemId: f.sys1, progressTarget: 1, rewardKind: 'resource', rewardQty: 2, state: 'ACTIVE' }).returning({ id: schema.missions.id })
    
        const r = await runTurn(db)
        expect(r.turn).toBe(6)
        expect(r.completed.map((t) => t.id)).toEqual([jumpId])
        expect((await db.db.select().from(schema.tasks)).find((t) => t.id === jumpId)!.status).toBe('DONE')
    
        // the mission resolved at the new turn: DONE + reward in the hold + both events at turn 6
        expect((await db.db.select().from(schema.missions)).find((m) => m.id === missionId)).toMatchObject({ state: 'DONE', progress: 1 })
        expect(await db.db.select().from(schema.inventoryItems)).toEqual([{ id: expect.any(Number), saveId: save.id, kind: 'resource', name: 'MISSION REWARD', qty: 2 }])
        const events = (await db.db.select().from(schema.events)).map((e) => [e.turn, e.type] as const)
        expect(events).toHaveLength(2)
        expect(events).toContainEqual([6, 'jump'])
        expect(events).toContainEqual([6, 'mission'])
    
        // the board is topped up after the arrival (the 4a jump-resolver hook) to exactly the floor
        const available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length
        expect(available).toBe(MIN_AVAILABLE)
    
        // the effect half: the ship is at the payload system
        expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true)
      })
    
      it('a JUMP and an EXPLORE both resolving on one tick: the explore effect lands on its payload planet, not the newly arrived system (Review Focus 3)', async () => {
        const { db } = await freshFile()
        const f = await buildWorldFixture(db) // p0 'Alder' in s0 (Origin); p1 'Basalt' in s1 (Outpost)
        const [c1, c2] = await addCrew(db, 2)
        await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
        await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
        const [save] = await db.db.select().from(schema.saves).limit(1)
        await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id))
    
        const r = await runTurn(db)
        expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE', 'JUMP']) // both this tick — the resolvers are payload-targeted (4a), so resolution order cannot change what lands
    
        const planets = await db.db.select().from(schema.planets)
        expect(planets.find((p) => p.id === f.p0)!.explored).toBe(true) // the payload planet — in the OLD system
        expect(planets.find((p) => p.id === f.p1)!.explored).toBe(false) // nothing in the new system
        expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true) // the jump still landed
        // the haul is the payload planet's deterministic stream (seed '42', p0) — never the new system's
        expect((await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind, name: i.name, qty: i.qty }))).toEqual(rollExploration(explorationRng('42', f.p0)))
        // the ~18% crew roll rides the same stream — at most one member joins (PR C pins the stream)
        expect((await db.db.select().from(schema.crew)).length).toBeGreaterThanOrEqual(2)
      })
    
      it('a resolver guard failure fails just that task — the turn still lands, the sibling still resolves, no event for the failure', async () => {
        const { db } = await freshFile()
        const f = await buildWorldFixture(db)
        const [c1, c2] = await addCrew(db, 2)
        const badJumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: 999, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
        const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
        const [save] = await db.db.select().from(schema.saves).limit(1)
        await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id))
    
        const r = await runTurn(db) // must not throw — the 4a guard throw is a failure signal, not an error
        expect(r.turn).toBe(5)
        expect(r.completed.map((t) => t.id)).toEqual([badJumpId, exploreId]) // both reported; only the sibling's effect lands
        expect((await db.db.select().from(schema.tasks)).find((t) => t.id === badJumpId)!.status).toBe('DONE') // failed task: done — the turn is spent, the crew freed
        expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(true) // the sibling resolved
        const types = (await db.db.select().from(schema.events)).map((e) => e.type)
        expect(types.filter((t) => t === 'jump')).toHaveLength(0) // the 404 guard threw before the jump effect/event
        expect(types.filter((t) => t === 'explore')).toHaveLength(1)
        expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move
      })
    })
    
    • 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.
    • 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).
    • 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.
    • 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<Task, 'id' | 'saveId'>): Promise<number> — 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<QueueState> — 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.
    • Write failing tests — app/api/jump/route.test.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:

import { describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { schema } from '@/lib/db/schema'
import { getSystemState } from '@/lib/game/save'
import { POST } from './route'
// same fixture helpers

const post = (body: unknown) => POST(new Request('http://localhost/api/explore', { method: 'POST', body: JSON.stringify(body) }))

describe('POST /api/explore (5a)', () => {
  it('queues an EXPLORE: row with type/payload, turn unchanged, planet not yet scanned, no haul', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [c1, c2] = await addCrew(db, 2)
    const [before] = await db.db.select().from(schema.saves)
    const invBefore = (await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)

    const res = await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })
    expect(res.status).toBe(200)
    const body = await res.json()
    const sys = await getSystemState(db)
    for (const k of Object.keys(sys)) expect(body[k]).toEqual(sys[k]) // today's system snapshot part unchanged; the findings key stays, empty (JC-2)
    const [row] = await db.db.select().from(schema.tasks)
    expect(row).toMatchObject({ type: 'EXPLORE', leadCrewId: c1, helpers: [c2], status: 'ACTIVE', queuedAtTurn: before.turn })
    expect(JSON.parse(row.payload)).toEqual({ planetId: f.p0 })
    expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
    expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(false) // the effect waits for the tick
    expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)).toEqual(invBefore) // no haul yet
  })

  it('unknown crew → 404 (queueTask)', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    expect((await post({ planetId: f.p0, leadCrewId: 999 })).status).toBe(404)
    expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
  })

  it('busy helper → 409, nothing queued', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [c1, c2] = await addCrew(db, 2)
    await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
    expect((await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
    expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
  })

  it('already scanned → 409 (existing pre-check, existing code)', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [c1] = await addCrew(db, 1)
    await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, f.p0))
    expect((await post({ planetId: f.p0, leadCrewId: c1 })).status).toBe(409)
    expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
  })
})
    • Run — expect failure: npx vitest run app/api/jump/route.test.ts app/api/explore/route.test.ts → the new blocks red: the current routes ignore the crew fields and resolve instantly (happy paths fail — the turn advances, no row, snapshot differs) and the 404/409 crew cases return 200 today; the unreachable / already-scanned pre-check cases may already pass — expected, those codes do not move.
    • 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).
    • 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.
    • 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.
    • 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):
// app/api/ship/upgrade/route.test.ts — imports/helpers per Task 5a's route tests
import { upgradeCost } from '@/lib/game/ship-costs'

const post = (body: object) =>
  fetch('/api/ship/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })

const seedHold = (db: Db, rows: { kind: string; name: string; qty: number }[]) =>
  rows.forEach((r) => db.db.insert(schema.inventoryItems).values({ saveId: <fixture save id>, ...r })) // JC-6

describe('POST /api/ship/upgrade', () => {
  it('happy: row with type/payload, turn unchanged, cost deducted, no level change yet', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [c1] = await addCrew(db, 1)
    const sys = /* built, not-maxed system row from f (JC-6) */
    const cost = upgradeCost(sys.level) // exactly the existing cost logic
    await seedHold(db, [{ kind: 'resource', name: 'resource', qty: cost }])
    const [before] = await db.db.select().from(schema.saves)
    const res = await post({ systemId: sys.id, leadCrewId: c1 })
    expect(res.status).toBe(200)
    const [row] = await db.db.select().from(schema.tasks)
    expect(row).toMatchObject({ type: 'SHIP_UPGRADE', leadCrewId: c1, helpers: [], status: 'ACTIVE', queuedAtTurn: before.turn })
    expect(JSON.parse(row.payload)).toEqual({ systemId: sys.id })
    expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
    expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted (if the fixture seeds a hold, assert the resource total drops by exactly cost)
    expect((await db.db.select().from(schema.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(sys.level) // the effect waits for the tick
  })

  it('unaffordable → 422 (existing spendItems message), nothing queued', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [c1] = await addCrew(db, 1)
    const sys = /* the same built system (JC-6) */
    await seedHold(db, [{ kind: 'resource', name: 'resource', qty: upgradeCost(sys.level) - 1 }])
    const res = await post({ systemId: sys.id, leadCrewId: c1 })
    expect(res.status).toBe(422)
    expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
  })

  it('max level → 409 (existing pre-check, existing code), nothing queued', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [c1] = await addCrew(db, 1)
    await db.db.update(schema.shipSystems).set({ level: 99 }) // above any max
    expect((await post({ systemId: /* built system id (JC-6) */, leadCrewId: c1 })).status).toBe(409)
    expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
  })
})
  • expand file: same three blocks — payload { systemId }, 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:
describe('POST /api/missions/[id]/accept', () => {
  it('happy: mission goes active, board returned, turn unchanged', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [before] = await db.db.select().from(schema.saves)
    const res = await fetch(`/api/missions/${f.mission0}/accept`, { method: 'POST' }) // f.mission0 = an AVAILABLE mission (JC-6)
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual(await getMissionBoard(db)) // same board shape as today (JC-6)
    // existing state change: the mission row goes AVAILABLE → active (assert with the schema's status value, as the existing tests do)
    expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // free — no turn cost
  })
  // the existing validation cases (400 non-integer id, each current MissionError case) stay as-is,
  // except expectations asserting a turn increment — those now expect the turn unchanged
})
    • Run — expect failure: npx vitest run app/api/ship/upgrade/route.test.ts app/api/ship/expand/route.test.ts app/api/ship/build/route.test.ts app/api/research/route.test.ts "app/api/missions/[id]/accept/route.test.ts" → red: the action happy paths (today the routes resolve instantly — the turn advances, no task row) and accept's no-turn assertion (it costs a turn today); the 422 / pre-check 409 cases may already pass — expected, those codes do not move (queueTask's own busy / in-transit / second-jump rules are NOT re-tested here — Task 3 owns them).
    • 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).
    • 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.
    • 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):

const db = await getGameDb()
const tick = await runTurn(db) // 4b's orchestrator: decrement → resolve → FIFO auto-start → mission hooks; persists the turn
return Response.json({ ...(await getWorldState(db)), tasks: tick.tasks })
  • runTurn(db: GameDb): Promise<TickResult> (4b) returns the 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.
    • Write the failing tests — new app/api/turn/route.test.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.)

    • Run — expect failure: npx vitest run app/api/turn/route.test.ts → red: POST /api/turn does not exist yet (404 / route not found).
    • 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).
    • Run — expect green: npx vitest run → the whole unit suite passes; npx tsc --noEmit clean.
    • 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<CrewState>.
  • 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 } <system name> — upgrade — e.g. Origin — upgrade
SHIP_EXPAND { systemId } <system name> — expand — e.g. Origin — expand
SHIP_BUILD { type } payload.type as-is — the SHIP_TYPES strings are display names (e.g. WEAPONS)
RESEARCH { techId } the tech.name display column of payload.techId — e.g. NAVIGATION AI

A lookup miss (a target id deleted by the admin) falls back to unknown; SHIP_BUILD never misses, its label lives in the payload.

    • Write the failing tests — add the block below to lib/game/save.test.ts (insertTaskRow = the 4b helper hoisted to file scope in this task; fixtures = the 4a block's buildWorldFixture + the file-scope addCrew):
describe('snapshot task queue (world / system / crew)', () => {
  // buildWorldFixture (4a): a save (seed '42') + Origin (s0, current) / Outpost (s1) +
  // Alder (p0) / Basalt (p1) + the route pair — no crew, no tasks; this block adds its own.

  it('empty queue → every GET snapshot carries tasks: []', async () => {
    const { db } = await freshFile()
    await buildWorldFixture(db)
    expect((await getWorldState(db)).tasks).toEqual([])
    expect((await getSystemState(db)).tasks).toEqual([])
    const crew = await getCrewState(db)
    expect(crew.tasks).toEqual([])
    expect(crew.crew).toEqual([]) // the new wrapper: the roster moves under .crew (JC-7)
  })

  it('one ACTIVE + one PENDING row → exactly those two, ACTIVE first, in all three snapshots', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [c1, c2, c3] = await addCrew(db, 3)
    const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
    const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [c3], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 3 })
    const expected = [
      { id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 },
      { id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4 },
    ]
    expect((await getWorldState(db)).tasks).toEqual(expected)
    expect((await getSystemState(db)).tasks).toEqual(expected)
    expect((await getCrewState(db)).tasks).toEqual(expected)
  })

  it('DONE rows never appear — even when they sort first by (queuedAtTurn, id)', async () => {
    const { db } = await freshFile()
    const f = await buildWorldFixture(db)
    const [c1] = await addCrew(db, 1)
    await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 0, status: 'DONE', queuedAtTurn: 1 }) // oldest, lowest id in the queue
    await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 2 })
    const snap = await getWorldState(db)
    expect(snap.tasks).toHaveLength(1)
    expect(snap.tasks[0].type).toBe('EXPLORE') // the DONE row is filtered out, not reordered to the back
  })
})

(Test 2's two rows share queuedAtTurn 3 — the ACTIVE→PENDING block order, not (queuedAtTurn, id), is what puts the jump first; test 3's DONE row sorts first by (queuedAtTurn, id), so its absence proves the filter.)

    • Run — expect failure: npx vitest run lib/game/save.test.ts → the new block is red: WorldState / 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.
    • 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.
    • 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.
    • The shell's own /api/crew consumer: change its bare-array parse (it casts the response to CrewDto[] and reads .length for the roster count) to the new object shape — parse as the {crew, tasks} CrewState and read crew.length (JC-7's second consumer, flagged in JC-15).
    • Commit (same PR branch as Tasks 4b/5a/5a-ii): feat(api): world/system/crew snapshots expose the active + pending task queue (#44)

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 <n>) 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 <NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} /> 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.
    • 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).
    • Implement — per Files / Interfaces:
'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'

// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from
// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell
// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an
// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an
// in-flight request, which the handler ignores. `disabled` is never set, so the cva
// disabled:opacity-40 styling never applies either.

export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
  const [inFlight, setInFlight] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const tick = () => {
    if (inFlight) return // the only guard — a transient, never a game-state disable
    setInFlight(true)
    setError(null)
    fetch('/api/turn', { method: 'POST' })
      .then(async (res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        onTicked() // the same app-wide snapshot refresh the shell uses after every other action
      })
      .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
      .finally(() => setInFlight(false))
  }

  return (
    <div
      style={{
        position: 'fixed',
        right: '1rem',
        bottom: '1rem',
        zIndex: 60, // above the header/footer's 50
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'flex-end',
        gap: '0.375rem',
      }}
    >
      {error && (
        <span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
      )}
      <span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
        TURN {turn ?? '—'}
      </span>
      <Button variant="EXEC" size="SM" onClick={tick}>
        NEXT TURN
      </Button>
    </div>
  )
}

And the shell: refreshAll = the extracted four-fetch block as a useCallback (the per-fetch cancelled flag moves into it, so unmount still cancels setStates), the mount effect becomes useEffect(() => { refreshAll() }, [refreshAll]), and the NextTurnButton element appended after the footer (position/z-index do the work; the DOM spot is only for reading order).

    • Verify build: npx tsc --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.
    • 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 TaskTypes.

  • 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 // <targetLabel> (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, … }.

    • 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.
    • 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.
    • Implement — per Files / Interfaces:
'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import type { CrewDto, QueueDto } from '@/lib/game/save'
import { computeDuration, type TaskType } from '@/lib/game/tasks'

// Shared crew picker for the four action panels — PR A: an action is queued as a lead +
// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState
// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel
// performs its own fetch with the team fields in the body (Task 5a). The live preview is
// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call
// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask
// so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
  type: TaskType
  hopCount?: number
  roster: CrewDto[]
  tasks: QueueDto[]
  onQueue: (leadCrewId: number, helperCrewIds: number[]) => void
}) {
  const [leadId, setLeadId] = useState<number | null>(null)
  const [helperIds, setHelperIds] = useState<number[]>([])

  // Whole-builder blocks: an ACTIVE jump is in transit (all types); in the jump panel any
  // queued jump is — the second-jump 409 (JC-12).
  const inTransit = tasks.find((t) => t.type === 'JUMP' && t.status === 'ACTIVE')
  const blocked = inTransit ?? (type === 'JUMP' ? tasks.find((t) => t.type === 'JUMP') : null)

  const lead = roster.find((c) => c.id === leadId) ?? null
  const helpers = roster.filter((c) => helperIds.includes(c.id))
  const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember

  const leadReason = (c: CrewDto) => {
    const t = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
    return t ? `${t.type} ALREADY QUEUED` : null
  }
  const helperReason = (c: CrewDto) => {
    const t = tasks.find((x) => (x.leadCrewId === c.id || x.helperCrewIds.includes(c.id)) && (x.status === 'ACTIVE' || x.status === 'PENDING'))
    return t ? `BUSY: ${t.type}` : null
  }

  return (
    <div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
      <span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>PICK TEAM // {type}</span>
      {blocked ? (
        <span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
          {inTransit ? `IN TRANSIT // ${inTransit.targetLabel}` : 'A JUMP IS ALREADY QUEUED'}
        </span>
      ) : (
        roster.map((c) => (
          <label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
            <input
              type="radio"
              name="lead"
              checked={leadId === c.id}
              disabled={leadReason(c) !== null}
              onChange={() => {
                setLeadId(c.id)
                setHelperIds((h) => h.filter((x) => x !== c.id)) // a lead is never also a helper of the same task
              }}
            />
            <input type="checkbox" checked={helperIds.includes(c.id)} disabled={leadId === c.id || helperReason(c) !== null} onChange={() => setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} />
            <span style={{ fontSize: '0.65rem', color: leadId === c.id ? 'var(--text-secondary)' : 'var(--text-muted)', letterSpacing: '0.08em' }}>
              {c.name}
              {leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
            </span>
          </label>
        ))
      )}
      <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
        <span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
          {duration !== null ? `WILL TAKE ${duration} TURN(S)` : 'PICK A LEAD'}
        </span>
        <Button variant="EXEC" size="SM" className="ml-auto" disabled={!lead || blocked !== null || leadReason(lead) !== null} onClick={() => lead && onQueue(lead.id, helperIds)}>
          DISPATCH
        </Button>
      </div>
    </div>
  )
}

And the four panels per the Panels bullet: the armed-target state, the <TeamBuilder> mount (world-map: a floating card over the chart; the others: first child of the column), the /api/crew mount + post-dispatch fetches and the extended bodies — each panel's existing response handling stays as-is.

    • Verify build: npx tsc --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.
    • 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.
    • 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).
    • Implement — per Files / Interfaces:
'use client'

import type { QueueDto } from '@/lib/game/save'

// TASK STRIP — the save's task queue, fixed bottom-left next to the NEXT TURN button (#44).
// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING
// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows,
// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on
// every refreshAll (Task 7) — a pure display of the array it is handed.

export function TaskStrip({ tasks }: { tasks: QueueDto[] }) {
  if (tasks.length === 0) return null

  return (
    <div
      style={{
        position: 'fixed',
        left: '1rem',
        bottom: '1rem',
        zIndex: 60, // above the header/footer's 50
        background: 'var(--surface)',
        border: '1px solid var(--border)',
        padding: '0.5rem 0.75rem',
        maxWidth: '26rem',
      }}
    >
      <span style={{ display: 'block', fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.12em', marginBottom: '0.3rem' }}>
        ACTIVE TASKS
      </span>
      {tasks.map((t) => (
        <div
          key={t.id}
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            gap: '0.75rem',
            padding: '0.2rem 0',
            opacity: t.status === 'PENDING' ? 0.45 : 1, // dim, don't reorder — the array is the order
          }}
        >
          <span style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', letterSpacing: '0.06em' }}>
            {t.type} // {t.targetLabel}
          </span>
          <span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.04em', whiteSpace: 'nowrap' }}>
            {t.leadName}
            {t.helperNames.length > 0 ? ` +${t.helperNames.join(' +')}` : ''}
            {' — '}
            {t.status === 'ACTIVE' ? `${t.remaining} turn(s) left` : 'queued'}
          </span>
        </div>
      ))}
    </div>
  )
}

And the shell: the import next to the NextTurnButton one, and <TaskStrip tasks={system?.tasks ?? []} /> appended right after the button element — position/z-index do the work; the DOM spot only keeps reading order with the button.

    • Verify build: npx tsc --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.
    • 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.
    • 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:
const TASK_TYPE: Record<string, string> = {
  '/api/jump': 'JUMP', '/api/explore': 'EXPLORE',
  '/api/research': 'RESEARCH', '/api/ship/upgrade': 'SHIP',
  '/api/ship/expand': 'SHIP', '/api/ship/build': 'SHIP',
};

// Queue an action POST with a default one-person crew, then tick until that task resolves.
export async function resolveAction(page: Page, path: string, data: Record<string, unknown>): Promise<unknown> {
  const roster = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[] } | CrewDto[];
  const members = Array.isArray(roster) ? roster : roster.crew; // Task 6 / JC-7 CrewState shape
  const res = await page.request.post(path, { data: { ...data, leadCrewId: members[0].id, helperCrewIds: [] } });
  expect(res.status()).toBe(200); // queued — a 409 here is a busy / in-transit rejection (Task 3)
  const snap = (await res.json()) as { tasks?: { type: string; remaining: number }[] };
  const n = snap.tasks?.find((t) => t.type === TASK_TYPE[path])?.remaining ?? 1;
  for (let i = 0; i < n; i++) expect((await page.request.post('/api/turn')).status()).toBe(200);
  return (await page.request.get(path === '/api/jump' ? '/api/world' : path)).json();
}

// jumpToFreshSystem: pick the target as before, then:
//   const after = (await resolveAction(page, '/api/jump', { systemId: target.id })) as WorldState;
//   expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued
//   return after;
    • Rework the existing action tests — apply the recipe to each; the per-test assertion deltas are the whole of the rework:
    • both jump tests — after queueing, the current system is still the origin (no instant landing); only after N ticks does /api/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.
    • 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.
    • 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.
    • 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.
    • 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.

    • 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)".
    • 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).
    • 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.
    • Push + open the PR to master — git push -u origin <branch>, 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.
    • Auto-merge on green — trigger Gitea's auto-merge: POST /repos/OpenCode/SpaceWanderer/pulls/<n>/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.
    • Tag on master — after pulling the merge on master: git tag v0.10.0 && git push origin v0.10.0.
    • 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.
    • 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.