2566 lines
191 KiB
Markdown
2566 lines
191 KiB
Markdown
# 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<version> 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 §3, §4, §5 duration modifiers, §7, §8, §9, §11). Tasks 1–11 follow. -->
|
||
|
||
### PR A — Gitea issue #44 "Crew-driven actions + NEXT TURN engine" → v0.10.0
|
||
|
||
Spec sections: §3 (task model), §4 (duration), §5 (trait duration modifiers), §7 (snapshots), §8 (UI), §9 (tests), §11 (constants).
|
||
|
||
### Task 1: Balance constants + duration model
|
||
|
||
**Files:**
|
||
- Create: `lib/game/crew-costs.ts` (the §11 balance-knob module, alongside the existing `lib/game/ship-costs.ts`), `lib/game/tasks.ts` (pure task model), tests `lib/game/tasks.test.ts` (colocated, matching `lib/game/crew.test.ts`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CrewMember` from `lib/game/crew.ts` (`{ name, role, traitGood, traitBad }` — string fields exactly as generated by `GOOD_TRAITS`/`BAD_TRAITS` there)
|
||
- Produces: from `crew-costs.ts` — `SkillName`, `WORK_POINTS`, `ACTION_SKILL`, `PREFERRED_ROLES`, `POWER_BANDS`, `XP_THRESHOLDS`, `STARTING_LEVELS`, `MISMATCH_CANCEL_TRAIT`, `TRAIT_DURATION_MODIFIERS`, `RECRUIT_CHANCE_BASE`, `RECRUIT_CHANCE_TALKER`; from `tasks.ts` — `TaskType` and `computeDuration(type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf?: (m: CrewMember) => number): number`
|
||
|
||
- [ ] Steps: TDD — write failing tests FIRST in `lib/game/tasks.test.ts` using the spec's EXACT values (table-driven; run `npx vitest run lib/game/tasks.test.ts` → red, the modules do not exist yet):
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import type { CrewMember } from './crew'
|
||
import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, POWER_BANDS, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, TRAIT_DURATION_MODIFIERS, WORK_POINTS, XP_THRESHOLDS } from './crew-costs'
|
||
import type { TaskType } from './tasks'
|
||
import { computeDuration } from './tasks'
|
||
|
||
// Trait strings copied verbatim from lib/game/crew.ts GOOD_TRAITS/BAD_TRAITS — TRAIT_DURATION_MODIFIERS
|
||
// is keyed by exactly these strings, so a rename in crew.ts breaks these tests.
|
||
const NEUTRAL_GOOD = 'FINDS HIDDEN THINGS' // no duration entry → no effect
|
||
const NEUTRAL_BAD = 'JINXED IN A DOGFIGHT' // STANDBY combat trait → no duration effect
|
||
const crew = (role: string, traitGood = NEUTRAL_GOOD, traitBad = NEUTRAL_BAD): CrewMember => ({ name: 'TEST', role, traitGood, traitBad })
|
||
const WORK: Record<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
|
||
})
|
||
})
|
||
```
|
||
|
||
```ts
|
||
describe('role mismatch (lead only, helpers never penalised)', () => {
|
||
it('a lead outside the preferred role adds +1; helpers contribute power only', () => {
|
||
expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [])).toBe(4) // 3 + 1 mismatch
|
||
expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [crew('PILOT')])).toBe(3) // ceil(3/2) + 1 — power 2, mismatch still on the lead
|
||
expect(computeDuration('EXPLORE', 1, crew('SCIENTIST'), [crew('PILOT', 'QUICK HANDS')])).toBe(2) // 4/2 — the helper's trait does not apply
|
||
})
|
||
it('STEADY UNDER PRESSURE cancels the mismatch penalty and adds nothing of its own', () => {
|
||
expect(computeDuration('JUMP', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(3)
|
||
expect(computeDuration('EXPLORE', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(4)
|
||
})
|
||
})
|
||
|
||
describe('floor', () => {
|
||
it('never returns below 1 turn', () => {
|
||
const lead = crew('PILOT', 'QUICK HANDS')
|
||
expect(computeDuration('JUMP', 1, lead, [crew('PILOT'), crew('PILOT')], () => 3)).toBe(1) // ceil(3/9) − 1 = 0 → floor 1
|
||
})
|
||
})
|
||
```
|
||
|
||
```ts
|
||
// All 15 duration traits from spec §5, each tested once in its own action context (plus its
|
||
// non-applicable types). target 'SHIP_UPGRADE' stands for the whole "SHIP work" class.
|
||
const TABLE: [trait: string, isGood: boolean, target: TaskType | 'ANY', delta: number][] = [
|
||
['RECKLESS COURAGE', true, 'JUMP', -1],
|
||
['CALM IN A CRISIS', true, 'EXPLORE', -1],
|
||
['FIXES ANYTHING', true, 'SHIP_UPGRADE', -1],
|
||
['READS STARS BLIND', true, 'JUMP', -1],
|
||
['QUICK HANDS', true, 'ANY', -1],
|
||
['IRON WILL', true, 'RESEARCH', -1],
|
||
['RIGID — SLOW TO ADAPT', false, 'RESEARCH', 1],
|
||
['OVERCAUTIOUS', false, 'ANY', 1],
|
||
['SLOWS THE CREW DOWN', false, 'ANY', 1],
|
||
['CARELESS WITH FUSES', false, 'SHIP_UPGRADE', 1],
|
||
['HOARDS RATIONS', false, 'SHIP_UPGRADE', 1],
|
||
['FORGETS TO EAT', false, 'EXPLORE', 1],
|
||
['ARGUES WITH EVERYONE', false, 'RESEARCH', 1],
|
||
['DOUBTS THE CAPTAIN', false, 'JUMP', 1],
|
||
]
|
||
|
||
describe('the 15 duration traits (spec §5, exact strings from crew.ts)', () => {
|
||
const applies = (type: TaskType, target: TaskType | 'ANY') =>
|
||
target === 'ANY' || target === type || (target !== 'ANY' && SHIP.includes(target) && SHIP.includes(type))
|
||
it('each trait shifts only its own action class, lead only, across all six types', () => {
|
||
for (const [trait, isGood, target, delta] of TABLE) {
|
||
for (const type of TYPES) {
|
||
const lead = isGood ? crew(PREFERRED_ROLES[type][0], trait) : crew(PREFERRED_ROLES[type][0], NEUTRAL_GOOD, trait)
|
||
expect(computeDuration(type, 1, lead, []), `${trait} → ${type}`).toBe(WORK[type] + (applies(type, target) ? delta : 0))
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('crew-costs — the §11 tuning surface', () => {
|
||
it('pins the balance knobs', () => {
|
||
expect(WORK_POINTS).toEqual({ EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 })
|
||
expect(XP_THRESHOLDS).toEqual([5, 10, 15, 20, 25])
|
||
expect(POWER_BANDS).toEqual([1, 1, 1, 2, 2, 3]) // L0–L2 → 1 · L3–L4 → 2 · L5 → 3
|
||
expect(STARTING_LEVELS.PILOT).toEqual({ NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
|
||
expect(STARTING_LEVELS.SCIENTIST).toEqual({ NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 })
|
||
expect(STARTING_LEVELS.COMMANDER).toEqual({ NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
|
||
expect(RECRUIT_CHANCE_BASE).toBe(0.18)
|
||
expect(RECRUIT_CHANCE_TALKER).toBe(0.3)
|
||
expect(Object.keys(TRAIT_DURATION_MODIFIERS)).toHaveLength(15) // all 15 duration traits of spec §5, nothing else
|
||
expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] Implement `lib/game/crew-costs.ts` (pure — no `lib/db` imports; the frontend imports it for the live duration preview, same server+client precedent as `ship-costs.ts`/`tech.ts`):
|
||
|
||
```ts
|
||
// Pure crew-balance module (spec §11): every tuning knob for the duration model and skill
|
||
// progression lives here, so tuning a number means editing one file. No lib/db imports —
|
||
// the frontend imports this for the live duration preview in the crew picker (same precedent
|
||
// as ship-costs.ts).
|
||
import type { TaskType } from './tasks'
|
||
|
||
export type SkillName = 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH'
|
||
|
||
export const WORK_POINTS = { EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 } as const
|
||
|
||
export const ACTION_SKILL: Record<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):
|
||
|
||
```ts
|
||
// Pure task-engine model (spec §3–§4): the task types and the duration formula. No lib/db
|
||
// imports — the frontend imports this for the live duration preview in the crew picker
|
||
// (same precedent as tech.ts / ship-costs.ts).
|
||
import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, TRAIT_DURATION_MODIFIERS, WORK_POINTS } from './crew-costs'
|
||
import type { CrewMember } from './crew'
|
||
|
||
export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH'
|
||
|
||
// Work points per action (spec §4): JUMP scales with hop count, the rest are flat.
|
||
function workPoints(type: TaskType, hopCount: number): number {
|
||
switch (type) {
|
||
case 'JUMP':
|
||
return hopCount * WORK_POINTS.JUMP_PER_HOP
|
||
case 'EXPLORE':
|
||
return WORK_POINTS.EXPLORE
|
||
case 'RESEARCH':
|
||
return WORK_POINTS.RESEARCH
|
||
default:
|
||
return WORK_POINTS.SHIP_WORK // SHIP_UPGRADE | SHIP_EXPAND | SHIP_BUILD
|
||
}
|
||
}
|
||
|
||
// One trait's modifier for a lead on an action type: its per-type entry plus the 'ANY' entry.
|
||
// Traits without a map entry contribute 0.
|
||
function traitModifier(trait: string, type: TaskType): number {
|
||
const mods = TRAIT_DURATION_MODIFIERS[trait]
|
||
if (!mods) return 0
|
||
return (mods[type] ?? 0) + (mods['ANY'] ?? 0)
|
||
}
|
||
|
||
// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4.
|
||
// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the
|
||
// constant 1 until PR B feeds the skill band. The mismatch penalty and the trait modifiers
|
||
// apply to the LEAD only — helpers contribute power, are never penalised, and their traits do
|
||
// not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types).
|
||
export function computeDuration(
|
||
type: TaskType,
|
||
hopCount: number,
|
||
lead: CrewMember,
|
||
helpers: CrewMember[],
|
||
powerOf: (m: CrewMember) => number = () => 1,
|
||
): number {
|
||
const totalPower = Math.max(1, [lead, ...helpers].reduce((sum, m) => sum + powerOf(m), 0))
|
||
const mismatchPenalty = lead.traitGood === MISMATCH_CANCEL_TRAIT || PREFERRED_ROLES[type].includes(lead.role) ? 0 : 1
|
||
const traitModifierSum = traitModifier(lead.traitGood, type) + traitModifier(lead.traitBad, type)
|
||
return Math.max(1, Math.ceil(workPoints(type, hopCount) / totalPower) + mismatchPenalty + traitModifierSum)
|
||
}
|
||
```
|
||
|
||
- [ ] Run `npx vitest run lib/game/tasks.test.ts` → green, then `npx tsc --noEmit` clean
|
||
- [ ] Commit (PR A feature branch off `master`; the PR-level v0.10.0 version bump + CHANGELOG entry land with the PR's final task per the global constraints): `feat(game): crew-costs balance module + computeDuration duration model (#44)`
|
||
|
||
### Task 2: tasks table — schema v7
|
||
|
||
**Files:**
|
||
- Modify: `lib/db/client.ts` (add `tasks` to the `DDL` template), `lib/db/schema.ts` (drizzle table `tasks`), `lib/game/save.ts` (`SCHEMA_VERSION` 6→7 + the idempotent v7 step in `backfillSave`), `lib/db/admin.ts` (`ADMIN_TABLES`), `lib/game/tasks.ts` (add `TaskStatus` + `Task` to the module Task 1 created)
|
||
- Test: extend the `backfill (old saves)` describe in `lib/game/save.test.ts` (existing `freshFile()` + `openGameDb` + `db.raw.exec` pattern); `lib/db/admin.test.ts`'s registry test covers the new table automatically (it derives the expected list from `schema.ts`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ensureSave`/`backfillSave` from `lib/game/save.ts` (currently `SCHEMA_VERSION = 6`); `TaskType` from Task 1
|
||
- Produces: `interface Task { id: number; saveId: number; type: TaskType; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: TaskStatus; queuedAtTurn: number }` (+ `TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE'`) in `lib/game/tasks.ts`; the `tasks` table in `lib/db/schema.ts` and the matching `CREATE TABLE` in `lib/db/client.ts`'s DDL; `tasks: schema.tasks` in `ADMIN_TABLES`
|
||
|
||
- [ ] Steps: TDD — add a failing test to `backfill (old saves)` in `lib/game/save.test.ts`. This pins **Review Focus item 1** (v6→v7 backfill must create the table without touching turn/crew/inventory and be idempotent across restarts). Run → red (`schemaVersion` stays 6, and `tasks` is not in the DDL yet):
|
||
|
||
```ts
|
||
it('v7: a v6-stamped save gets an empty tasks table without touching state; idempotent across restart', async () => {
|
||
const { db, file } = await freshFile()
|
||
const world = await getWorldState(db) // fresh game, stamped at the current version
|
||
const crewBefore = await db.db.select().from(schema.crew)
|
||
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||
|
||
await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12
|
||
const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7
|
||
|
||
expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1)
|
||
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
|
||
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore)
|
||
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
|
||
const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||
expect(tables[0].values.map((v) => v[0])).toContain('tasks')
|
||
expect(Number(db.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) // empty queue after upgrade
|
||
|
||
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||
await getWorldState(reopened)
|
||
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
|
||
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0)
|
||
})
|
||
```
|
||
|
||
Realism note: the production upgrade path is a DB file written by v0.9.12 (no `tasks` table) → `open()` with the new DDL → `CREATE TABLE IF NOT EXISTS` creates it → `ensureSave` stamps v7. The test simulates the save side with the version stamp (the DDL side runs on every open, new or old file).
|
||
|
||
- [ ] Implement, each in the existing style of its file:
|
||
|
||
`lib/db/client.ts` — add to the `DDL` template after the `events` table (must stay in sync with `schema.ts`):
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS tasks (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||
type TEXT NOT NULL, -- 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH'
|
||
payload TEXT NOT NULL, -- JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId}
|
||
lead_crew_id INTEGER NOT NULL REFERENCES crew(id),
|
||
helpers TEXT NOT NULL DEFAULT '[]', -- JSON array of crew ids
|
||
duration INTEGER NOT NULL,
|
||
remaining INTEGER NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE'
|
||
queued_at_turn INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
```
|
||
|
||
`lib/db/schema.ts` — append after `events` (mirrors the DDL above):
|
||
|
||
```ts
|
||
// Queued actions (PR A, spec §3): one row per task assigned to a team. `payload` is a JSON
|
||
// string whose shape depends on `type` (see the Task interface in lib/game/tasks.ts);
|
||
// `helpers` is a JSON array of crew ids. Upgraded saves start with an empty queue — the v7
|
||
// backfill stamps the version, the DDL in client.ts creates the table.
|
||
export const tasks = sqliteTable('tasks', {
|
||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||
type: text('type').notNull(), // TaskType
|
||
payload: text('payload').notNull(),
|
||
leadCrewId: integer('lead_crew_id').notNull().references(() => crew.id),
|
||
helpers: text('helpers').notNull().default('[]'), // JSON array of crew ids
|
||
duration: integer('duration').notNull(),
|
||
remaining: integer('remaining').notNull(),
|
||
status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE'
|
||
queuedAtTurn: integer('queued_at_turn').notNull().default(0),
|
||
})
|
||
```
|
||
|
||
`lib/game/save.ts` — bump the version constant and add the v7 step in `backfillSave` after the v6 `topUpMissionBoard` step:
|
||
|
||
```ts
|
||
export const SCHEMA_VERSION = 7 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table
|
||
```
|
||
|
||
```ts
|
||
if (version < 7) {
|
||
// The tasks table itself is created by the DDL at open (CREATE TABLE IF NOT EXISTS) for
|
||
// every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
|
||
version = 7
|
||
}
|
||
```
|
||
|
||
`lib/db/admin.ts` — register in `ADMIN_TABLES` after `events`: `tasks: schema.tasks,`
|
||
|
||
`lib/game/tasks.ts` — add the domain shape after `TaskType` (`Task.helpers` is the parsed form of the row's JSON-array column; the row↔Task mapping lives at the save.ts boundary, not in this pure module):
|
||
|
||
```ts
|
||
export type TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE'
|
||
|
||
// One queued/active/done action (spec §3). `payload` is a JSON string whose shape depends on
|
||
// the TaskType: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} ·
|
||
// RESEARCH {techId} · EXPLORE {planetId}.
|
||
export interface Task {
|
||
id: number
|
||
saveId: number
|
||
type: TaskType
|
||
payload: string
|
||
leadCrewId: number
|
||
helpers: number[]
|
||
duration: number
|
||
remaining: number
|
||
status: TaskStatus
|
||
queuedAtTurn: number
|
||
}
|
||
```
|
||
|
||
- [ ] Run `npx vitest run` (full unit suite — the save, admin and client DB tests all touch the schema) → green, then `npx tsc --noEmit` clean
|
||
- [ ] Commit (same PR A branch): `feat(game): tasks table + schema v7 backfill, admin registry (#44)`
|
||
|
||
### Task 3: Queueing — validation + insertion
|
||
|
||
**Files**
|
||
|
||
- `lib/game/tasks.ts` — append after the `Task` interface from Task 2 (code below). Add to the top-of-file imports: `import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from './ship-costs'`, `import { TECHS } from './tech'` (both pure modules) and `import type { CrewDto, InventoryItemDto } from './save'` — type-only, so no runtime edge into the DB module (save.ts already imports from tasks.ts for the row↔Task mapping; a runtime import back would be a cycle).
|
||
- `lib/game/tasks.test.ts` — add the `describe('queueTask')` block below (one `it` per locked case, table-driven over the small fixtures); if Tasks 1–2 left no such file, create it first with `import { describe, expect, it } from 'vitest'`, the `./tasks` import and `import type { CrewDto } from './save'`. If Task 1 put `computeDuration` in `lib/game/crew-costs.ts` instead of tasks.ts, point its import at that module — the spec §9 signature is the contract.
|
||
|
||
```ts
|
||
// --- Queueing: validation + row construction (pure, no DB) -------------------------------
|
||
//
|
||
// queueTask runs over a plain snapshot of the save (QueueState) and decides whether a new
|
||
// task can be queued and what row it becomes. Pure like crew.ts / ship-costs.ts — the route
|
||
// (a later task) maps schema rows into QueueState, calls queueTask, and persists the result:
|
||
// insert the task row (adding `id` + `saveId` at the DB boundary) and the deducted hold.
|
||
|
||
// Carries an HTTP status so route handlers can map failures to responses without re-parsing
|
||
// (same pattern as JumpError / ShipError / ResearchError / ExploreError).
|
||
export class TaskError extends Error {
|
||
constructor(
|
||
public readonly status: number,
|
||
message: string,
|
||
) {
|
||
super(message)
|
||
}
|
||
}
|
||
|
||
// The queueing request — the existing endpoints' bodies plus the crew team. Payload field
|
||
// names follow the routes (the spec's names differ):
|
||
// JUMP { systemId, hops } — the route computes `hops` with jumpToSystem's logic
|
||
// (reachableWithin + NAV AI) and passes it in; the body field is `systemId`, not `toSystemId`
|
||
// EXPLORE { planetId }
|
||
// SHIP_UPGRADE { systemId } · SHIP_EXPAND { systemId } · SHIP_BUILD { type }
|
||
// RESEARCH { techId } — the tech row's id, not the slug
|
||
export interface QueueTaskRequest {
|
||
type: TaskType
|
||
payload: Record<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
|
||
}
|
||
```
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { computeDuration, queueTask, TaskError, type QueueState, type Task } from './tasks'
|
||
import type { CrewDto } from './save'
|
||
|
||
describe('queueTask', () => {
|
||
// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills
|
||
// the CrewDto shape (unused by the duration model but part of the type).
|
||
const CREW: CrewDto[] = [
|
||
{ id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' },
|
||
{ id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' },
|
||
{ id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' },
|
||
]
|
||
|
||
const state = (over: Partial<QueueState> = {}): 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).
|
||
|
||
1. - [ ] **Write the failing tests** — add the `describe('queueTask')` block to `lib/game/tasks.test.ts` (create the file with the `vitest` + `./tasks` + type-only `./save` imports if Tasks 1–2 left none).
|
||
2. - [ ] **Run — expect failure**: `npx vitest run lib/game/tasks.test.ts` → red (the seven new `it`s fail — `queueTask` is not implemented yet; pre-existing tests in the file stay green).
|
||
3. - [ ] **Implement** — append `TaskError`, `QueueTaskRequest`, `QueueState`, `QueueResult`, `queueTask` and the private `deduct` to `lib/game/tasks.ts` (code above) plus the `./ship-costs`, `./tech` and type-only `./save` imports.
|
||
4. - [ ] **Run — expect green**: `npx vitest run lib/game/tasks.test.ts` → all seven pass, then the full unit suite `npx vitest run` → green, and `npx tsc --noEmit` clean.
|
||
5. - [ ] **Commit (same PR A branch)**: `feat(game): queueTask — queue-time validation, cost deduction, ACTIVE/PENDING row (#44)`
|
||
|
||
### Task 4a: Resolver refactor (save.ts)
|
||
|
||
**Files**
|
||
|
||
- `lib/game/save.ts` — extract the effect bodies of `jumpToSystem` / `explorePlanet` into `resolveJumpTask` / `resolveExploreTask` (code below); the two public functions keep their signatures, validation, turn advance and return values, and delegate the effect part to the resolvers through a synthetic `Task` row (code below). Add to the imports: `import type { Task, TaskType } from './tasks'` (type-only — `tasks.ts` stays pure, no runtime edge back; merge with Task 2's existing type-only import from `./tasks` if present). `FoundItem` is already imported.
|
||
- `lib/game/ship.ts` — add `resolveShipUpgradeTask` / `resolveShipExpandTask` / `resolveShipBuildTask` (code below); `upgradeSystem` / `expandSystem` / `buildSystem` keep their signatures, validation, `spendItems` cost and turn advance, and delegate (code below). Delete the private `bumpTurnAndEvent` — unused after the refactor (the resolvers inline the event insert without the turn update). Add the same type-only `./tasks` import.
|
||
- `lib/game/research.ts` — add `resolveResearchTask` (code below); `researchTech` keeps its signature, validation, spend + `ResearchError` re-throw and turn advance, and delegates (code below). Add the same type-only `./tasks` import.
|
||
- `lib/game/save.test.ts` — add the `describe('resolveJumpTask (A/B against jumpToSystem)')` and `describe('resolveExploreTask (A/B against explorePlanet)')` blocks below (code). Add `resolveJumpTask, resolveExploreTask` to the existing `./save` import and `import type { Task } from './tasks'`.
|
||
- `lib/game/ship.test.ts` — add the `describe('resolveShip* (A/B against the immediate actions)')` block below (code). Add `spendItems, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask` to the existing `./ship` import; new imports `import { TECHS } from './tech'`, `import { SCHEMA_VERSION } from './save'`, `import type { Task } from './tasks'`.
|
||
- `lib/game/research.test.ts` — add the `describe('resolveResearchTask (A/B against researchTech)')` block below (code). Add `resolveResearchTask` to the existing `./research` import, `spendItems` to the existing `./ship` import; new imports `import { SCHEMA_VERSION } from './save'`, `import type { Task } from './tasks'`.
|
||
- Untouched: `app/api/*` routes, `lib/openapi.ts`, `lib/game/tasks.ts`, `lib/db/*`.
|
||
|
||
**Interfaces**
|
||
|
||
New resolvers — one per `TaskType` value, named 1:1 so Task 4b's tick can dispatch through a `Record<TaskType, 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.
|
||
|
||
1. - [ ] **Write the failing tests** — add to `lib/game/save.test.ts` (below the `explorePlanet crew finds` block):
|
||
|
||
```ts
|
||
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
|
||
//
|
||
// Each test builds the identical deterministic fixture in two fresh files (seed '42',
|
||
// stamped save → no backfill; ids line up across worlds) and compares their full state:
|
||
// A = the legacy immediate action (validates, costs a turn, applies the effect);
|
||
// B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver
|
||
// applies the effect (ship/research rows deduct the cost up front, as queueTask did at
|
||
// queue time — Task 3). snapshot() normalizes every game table (wall-clock columns
|
||
// excluded), so the two end states must be byte-identical.
|
||
const T0 = '2026-10-01T00:00:00.000Z'
|
||
|
||
async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> {
|
||
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
|
||
const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
|
||
const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id })
|
||
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0, toSystem: s1 })
|
||
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1, toSystem: s0 })
|
||
const [p0] = await db.db.insert(schema.planets).values({ systemId: s0, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id })
|
||
const [p1] = await db.db.insert(schema.planets).values({ systemId: s1, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id })
|
||
return { sys1: s1, p0, p1 }
|
||
}
|
||
|
||
// The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed
|
||
// identity fields, the same way the public functions' delegation does.
|
||
const task = (type: 'JUMP' | 'EXPLORE', payload: object): Task =>
|
||
({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
|
||
|
||
async function snapshot(db: GameDb): Promise<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`:
|
||
|
||
```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`:
|
||
|
||
```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' })
|
||
})
|
||
})
|
||
```
|
||
|
||
2. - [ ] **Run — expect failure**: `npx vitest run lib/game/save.test.ts lib/game/ship.test.ts lib/game/research.test.ts` → red: all three files fail at module load because `resolveJumpTask` / `resolveExploreTask` / `resolveShip*Task` / `resolveResearchTask` are not exported yet. No pre-existing test logic is touched — the old describes fail only on the import, and stay green once step 3 lands.
|
||
3. - [ ] **Implement** — (a) `lib/game/save.ts`: add the `./tasks` type-only import, the private `syntheticTask` and the two resolvers, and rewrite `jumpToSystem` / `explorePlanet` as shown below (code):
|
||
|
||
```ts
|
||
// --- Per-type resolvers (PR A, spec §3) -------------------------------------------
|
||
//
|
||
// The effect bodies of the instant actions, extracted so the turn tick (Task 4b) can
|
||
// resolve a completed task row against whatever the world looks like at resolution time.
|
||
// Contract (all six resolvers across save.ts / ship.ts / research.ts — see the Interfaces
|
||
// block of this plan): effects land on the PAYLOAD target, never on the current location;
|
||
// no turn advance (the caller owns the turn); no cost deduction (queueTask deducted at
|
||
// queue time — Task 3; the immediate public functions deduct before delegating); a minimal
|
||
// guard against a stale or double-resolved target (existing error class + exact message —
|
||
// a throw is the signal for the 4b tick's re-validation); the existing event text formats
|
||
// at the current turn; persist at the end.
|
||
|
||
// A stand-in task row for the immediate public functions delegating to a resolver: the
|
||
// resolvers only read `type` + `payload`, so the identity fields are zeroed.
|
||
function syntheticTask(type: TaskType, payload: object): Task {
|
||
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||
}
|
||
|
||
// JUMP — move the ship to the payload's system: flip isCurrent, discover the target, and
|
||
// when it was undiscovered grow the chart ring off the same (seed, turn) stream as before.
|
||
export async function resolveJumpTask(db: GameDb, task: Task): Promise<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):
|
||
|
||
```ts
|
||
function syntheticTask(type: TaskType, payload: object): Task {
|
||
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||
}
|
||
|
||
// SHIP_UPGRADE — level a built system by one, at whatever level it sits at resolution
|
||
// time. No cost here (deducted at queue time — Task 3 — or by upgradeSystem before
|
||
// delegating); no mission hooks (the current action has none).
|
||
export async function resolveShipUpgradeTask(db: GameDb, task: Task): Promise<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):
|
||
|
||
```ts
|
||
function syntheticTask(type: TaskType, payload: object): Task {
|
||
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||
}
|
||
|
||
// RESEARCH — mark the payload's tech researched at the current turn. No cost here (deducted
|
||
// at queue time — Task 3 — or by researchTech before delegating); no mission hooks (the
|
||
// current action has none).
|
||
export async function resolveResearchTask(db: GameDb, task: Task): Promise<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)
|
||
}
|
||
```
|
||
|
||
4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (all six pre-existing action describes in save/ship/research stay green — the delegated public functions are behaviour-identical — plus the new A/B + guard blocks), and `npx tsc --noEmit` clean.
|
||
5. - [ ] **Commit (same PR A branch)**: `feat(game): per-type resolvers — jump/explore/ship/research effect extraction for task resolution (#44)`
|
||
|
||
### Task 4b: tickTurn (tasks.ts)
|
||
|
||
**Files**
|
||
|
||
- `lib/game/tasks.ts` — add `TickResult` and `tickTurn` after the private `deduct` (code in step 3). No new imports — the tick consumes the existing `QueueState` / `Task` and calls nothing outside the module, so it stays pure (no Date, no rng, no lib/db — global constraint).
|
||
- `lib/game/save.ts` — add the orchestrator `runTurn` plus the private `rowToTask` mapping and the `TASK_RESOLVERS` dispatch (code in step 3); replace 4a's type-only `import type { Task, TaskType } from './tasks'` with `import { tickTurn, type QueueState, type Task, type TaskStatus, type TaskType } from './tasks'` and add `import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'` and `import { resolveResearchTask, ResearchError } from './research'`. Import-graph note: ship.ts and research.ts already import save.ts at runtime (`ensureSave`, `InventoryItemDto`), so this adds the reverse edge — cycles save↔ship and save→research→ship. Safe here: every cross-module reference is a function call made at request time, none at module-evaluation time (each file's top level only declares functions/classes) — the standard function-only cycle shape ESM / webpack / Turbopack / Vitest handle.
|
||
- `lib/game/tasks.test.ts` — add the `describe('tickTurn')` block below (code in step 1); reuses the `queueTask` block's file-scope `CREW` / `state` / `task` fixture helpers; add `tickTurn` to the existing `./tasks` import.
|
||
- `lib/game/save.test.ts` — add the `describe('runTurn (tick on the DB)')` block below (code in step 1); reuses the 4a block's file-scope `buildWorldFixture` and the existing `freshFile`, but NOT the 4a `snapshot` helper (it does not select the `tasks` table — this block asserts the queue rows directly); add `runTurn` to the existing `./save` import and a new `import { MIN_AVAILABLE } from './missions'`.
|
||
- Untouched: `app/api/*` (`POST /api/turn` is Task 5), `lib/openapi.ts` (no endpoint changes yet), `lib/db/*` (the `tasks` DDL / registry is Task 2's), the 4a resolvers and public functions.
|
||
|
||
**Interfaces**
|
||
|
||
`tickTurn(state: QueueState): TickResult` — the pure tick over the same plain state `queueTask` consumes (Task 3's `QueueState`, reused as-is — the tick needs no fields beyond it). It reads only `state.turn` and `state.tasks` (it never parses `task.payload` — the target is the resolver's business — and never consults the roster — busyness is derived entirely from the task rows); the other `QueueState` fields pass through untouched, so one row→state mapping at the save.ts boundary serves both queueing and the tick. Never mutates its input (house style — `deduct` sets the pattern).
|
||
|
||
```ts
|
||
export interface TickResult {
|
||
turn: number // state.turn + 1 — the tick IS the turn increment
|
||
tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE
|
||
completed: Task[] // the tasks that hit 0 this tick, in resolution order
|
||
}
|
||
|
||
export function tickTurn(state: QueueState): TickResult
|
||
```
|
||
|
||
The tick does exactly one turn per call, in this order:
|
||
|
||
- **(a) Decrement.** Every `ACTIVE` task's `remaining` −= 1. `PENDING` and `DONE` rows do not age — a task's countdown starts when it starts, and `duration` is never modified after queueing.
|
||
- **(b) Resolve.** Every task that reached 0 is marked `DONE` — that frees the whole team (busyness is derived from the `tasks` table; there is no crew-status column to update) — and returned in `completed` sorted by `(queuedAtTurn ASC, id ASC)` (identical to `id` order, since `queuedAtTurn` is non-decreasing in `id`). The check is `remaining <= 0`, not `=== 0`, so a corrupt row at or below zero still completes on the next tick instead of dead-ending the queue (spec focus 4). `tickTurn` itself applies no effect: it returns WHICH tasks completed, and the orchestrator calls each type's 4a resolver in that order.
|
||
- **(c) Auto-start.** ONE forward pass over `PENDING` in the same `(queuedAtTurn ASC, id ASC)` order. A task starts (PENDING → ACTIVE, `remaining` unchanged — it keeps its full duration; its first decrement lands on the next tick) when its FULL team (lead + all helpers) is free — no member of it is on an `ACTIVE` task and no member is on a `PENDING` task other than this one — evaluated after this tick's completions and any earlier start in this pass. One pass is complete: starting a task only makes its team busier, never frees anyone, so a task that cannot start at its position cannot start later in the same pass. Under `queueTask`'s invariants (one PENDING per member; no one on an ACTIVE holds a PENDING unless they lead both) a freed member has at most one successor, so at most ONE of their PENDINGs starts per tick — the FIFO chain of spec §3, earliest `queuedAtTurn` wins — and the "no other PENDING" clause only bites on admin-corrupted rows, where it keeps the pass deterministic anyway.
|
||
- **(d) Mission hooks.** NOT in `tickTurn` — they stay where `checkMissionCompletion` is called today: inside the save.ts action machinery, at the new turn, before persist. In the task world that machinery IS the 4a resolvers (their locked contract: JUMP → `checkMissionCompletion(db, saveId, turn, { jumpedTo })` + `topUpMissionBoard(db)`; EXPLORE → `checkMissionCompletion(db, saveId, turn, { outcome })` + `topUpMissionBoard(db)`; SHIP_* / RESEARCH → none), so the orchestrator gets the hooks for free by calling the resolvers, at the tick's new turn. A tick with no resolution checks no missions and tops up nothing.
|
||
- **(e) The tick IS the turn.** `tickTurn` returns `state.turn + 1`, and nothing in the pure layer increments it; the orchestrator writes it to `saves.turn` BEFORE the resolvers run (so the resolvers' events and mission hooks land at the new turn — the same placement as today's actions) and persists the outcome. The full state the snapshot endpoints need is exactly the DB after that persist: `getWorldState` / `getSystemState` / `getCrewState` / `getShipState` / `getMissions` / `getEvents` / `getTechState` all re-read their tables and carry `turn`, so they reflect the tick without new plumbing. After Task 5 retires the immediate actions, `runTurn` is the sole writer of `save.turn`; until then the legacy immediate functions keep their existing (unchanged) turn advance.
|
||
|
||
Resolver dispatch — the 4a contract names the six resolvers 1:1 exactly for this; the record is exhaustive by type (a `TaskType` without a resolver is a compile error):
|
||
|
||
```ts
|
||
const TASK_RESOLVERS: Record<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).
|
||
|
||
1. - [ ] **Write the failing tests** — add to `lib/game/tasks.test.ts` (below the `queueTask` block) and to `lib/game/save.test.ts` (below the 4a A/B blocks):
|
||
|
||
```ts
|
||
// --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) -------------------
|
||
//
|
||
// tickTurn is the whole §3 tick over the same plain QueueState queueTask consumes. It owns
|
||
// the turn increment and nothing else: it applies no effect and runs no mission logic —
|
||
// the save.ts orchestrator (runTurn) calls the 4a resolvers for the returned `completed`
|
||
// tasks, in the order returned.
|
||
|
||
export interface TickResult {
|
||
turn: number // state.turn + 1 — the tick IS the turn increment
|
||
tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE
|
||
completed: Task[] // the tasks that hit 0 this tick, in resolution order
|
||
}
|
||
|
||
const byQueueOrder = (a: Task, b: Task) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id
|
||
|
||
export function tickTurn(state: QueueState): TickResult {
|
||
const turn = state.turn + 1
|
||
const tasks = state.tasks.map((t) => ({ ...t })) // never mutate the input (house style — deduct)
|
||
|
||
// (a) every ACTIVE task ages by one; PENDING/DONE rows do not age.
|
||
for (const t of tasks) if (t.status === 'ACTIVE') t.remaining -= 1
|
||
|
||
// (b) everything that hit 0 resolves this tick. Marking DONE frees the whole team
|
||
// (busyness is derived from the tasks table — there is no crew status column to update).
|
||
// <= 0, not === 0: a corrupt row at or below zero still completes on the next tick
|
||
// instead of dead-ending the queue (spec focus 4: no dead ends).
|
||
const completed = tasks
|
||
.filter((t) => t.status === 'ACTIVE' && t.remaining <= 0)
|
||
.sort(byQueueOrder)
|
||
for (const t of completed) t.status = 'DONE'
|
||
|
||
// (c) auto-start: one forward pass over PENDING in (queuedAtTurn ASC, id ASC). Starting a
|
||
// task only makes its team busier — never frees anyone — so a task that cannot start at
|
||
// its position cannot start later in the same pass: one pass is complete. A just-started
|
||
// task keeps its full duration; its first decrement lands on the next tick.
|
||
for (const t of tasks.filter((t) => t.status === 'PENDING').sort(byQueueOrder)) {
|
||
const free = (id: number) =>
|
||
!tasks.some((o) => o.status === 'ACTIVE' && (o.leadCrewId === id || o.helpers.includes(id))) &&
|
||
!tasks.some((o) => o.status === 'PENDING' && o.id !== t.id && (o.leadCrewId === id || o.helpers.includes(id)))
|
||
if ([t.leadCrewId, ...t.helpers].every(free)) t.status = 'ACTIVE'
|
||
}
|
||
|
||
return { turn, tasks, completed }
|
||
}
|
||
```
|
||
|
||
```ts
|
||
describe('tickTurn', () => {
|
||
// The queueTask block's file-scope fixture helpers (above): CREW = [1 AVA PILOT,
|
||
// 2 BEN ENGINEER, 3 CASS SCIENTIST]; state() → { turn: 10, crew: CREW, tasks: [], … };
|
||
// task() → a one-turn row. The tick never parses payloads — the targets below are inert.
|
||
const JUMP = (id: number, over: Partial<Task> = {}): Task => 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')
|
||
})
|
||
})
|
||
```
|
||
|
||
```ts
|
||
// --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) -----------------
|
||
//
|
||
// The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
|
||
// The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the
|
||
// effects and the mission hooks (their contract — invoked here, at the tick's new turn,
|
||
// before the final persist); this function owns the table writes.
|
||
|
||
// The 4a contract names the resolvers 1:1 exactly for this dispatch; exhaustive by type —
|
||
// a TaskType without a resolver is a compile error.
|
||
const TASK_RESOLVERS: Record<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
|
||
}
|
||
```
|
||
|
||
```ts
|
||
describe('runTurn (tick on the DB)', () => {
|
||
// buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no
|
||
// backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) /
|
||
// Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so
|
||
// this block adds its own rows.
|
||
async function addCrew(db: GameDb, count: number): Promise<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
|
||
})
|
||
})
|
||
```
|
||
|
||
2. - [ ] **Run — expect failure**: `npx vitest run lib/game/tasks.test.ts` → the five new `tickTurn` its red (`tickTurn` is not exported), the `queueTask` block stays green; `npx vitest run lib/game/save.test.ts` → the new `runTurn` describe red (`runTurn` is not exported), everything else — including 4a's A/B blocks — stays green.
|
||
3. - [ ] **Implement** — append `TickResult` + `byQueueOrder` + `tickTurn` to `lib/game/tasks.ts` (code above); in `lib/game/save.ts` add the two resolver imports, replace the 4a type-only `./tasks` import with `import { tickTurn, type QueueState, type Task, type TaskStatus, type TaskType } from './tasks'`, and add `TASK_RESOLVERS` / `rowToTask` / `runTurn` (code above).
|
||
4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (queueTask, 4a's A/B + guard blocks, and the new tick blocks), and `npx tsc --noEmit` clean.
|
||
5. - [ ] **Commit (same PR A branch)**: `feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)`
|
||
|
||
### Task 5a: Action endpoints → queueing (jump, explore)
|
||
|
||
The two instant-action endpoints become queueing endpoints: same body fields plus the crew team, same pre-checks with the same codes, then a `queueTask` and a row insert. Effects move to the tick (4a's resolvers), and **these two endpoints no longer advance the turn** — from this task on, `runTurn` is the sole writer of `saves.turn` (tick section).
|
||
|
||
**Files**
|
||
|
||
- `app/api/jump/route.ts` — rewrite `POST`. Body: `{ systemId, leadCrewId, helperCrewIds? }` — `leadCrewId` a required integer, `helperCrewIds` an optional integer array (default `[]`), each validated in the existing 400 style. Keep the target pre-checks in the route with their existing codes (unknown system, unreachable) — extracted from `jumpToSystem` into a small route-callable helper (JC-1). Compute `hops` with the existing reachability logic (`reachableWithin` + NAV AI — what `jumpToSystem` does today). Build the `QueueState` (Task 3) via `queueStateFromSave`, call `queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId, helperCrewIds })`, persist the returned row via the new `saveTaskRow` (inventory is unchanged for JUMP — Task 3 — so no inventory write), return the same snapshot the route returns today (`getWorldState`). Map `TaskError` exactly like today's `JumpError`: `Response.json({ error: err.message }, { status: err.status })`.
|
||
- `app/api/explore/route.ts` — same for EXPLORE: keep the existing pre-check (already scanned) before `queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId, helperCrewIds })`; return the same system snapshot as today (JC-2).
|
||
- `lib/game/save.ts` — add `saveTaskRow(db, save, task: Omit<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**.
|
||
|
||
1. - [ ] **Write failing tests** — `app/api/jump/route.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { schema } from '@/lib/db/schema'
|
||
import { getWorldState } from '@/lib/game/save'
|
||
import { POST } from './route'
|
||
// freshFile / addCrew / buildWorldFixture / insertTaskRow — the 4b tests' fixture helpers
|
||
|
||
const post = (body: unknown) => POST(new Request('http://localhost/api/jump', { method: 'POST', body: JSON.stringify(body) }))
|
||
|
||
describe('POST /api/jump (5a)', () => {
|
||
it('queues a JUMP: row with type/payload/status, turn unchanged, snapshot preserved, ship not moved', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
const [before] = await db.db.select().from(schema.saves)
|
||
|
||
const res = await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual(await getWorldState(db)) // the same fresh snapshot as today — queueing changes nothing else
|
||
const [row] = await db.db.select().from(schema.tasks)
|
||
expect(row).toMatchObject({ type: 'JUMP', leadCrewId: c1, helpers: [c2], status: 'ACTIVE', queuedAtTurn: before.turn })
|
||
expect(JSON.parse(row.payload)).toEqual({ systemId: f.sys1, hops: 1 }) // route-computed via the existing reachability logic
|
||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
|
||
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the effect waits for the tick
|
||
})
|
||
|
||
it('unknown crew → 404 (queueTask)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
expect((await post({ systemId: f.sys1, leadCrewId: 999 })).status).toBe(404)
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||
})
|
||
|
||
it('busy helper → 409, nothing queued', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||
expect((await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||
})
|
||
|
||
it('a jump in transit → 409 (blocks any new task)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 3, remaining: 3, status: 'ACTIVE', queuedAtTurn: 1 })
|
||
expect((await post({ systemId: f.sys1, leadCrewId: c1 })).status).toBe(409)
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||
})
|
||
|
||
it('a jump already pending → second jump 409', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 1 }) // lead chains behind its own ACTIVE
|
||
expect((await post({ systemId: f.sys1, leadCrewId: c2 })).status).toBe(409)
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(2)
|
||
})
|
||
|
||
it('unreachable target → 409 (existing pre-check, existing code)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1] = await addCrew(db, 1)
|
||
expect((await post({ systemId: f.farSystem, leadCrewId: c1 })).status).toBe(409) // JC-4: a beyond-reach system in the fixture; add it to buildWorldFixture if absent
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||
})
|
||
})
|
||
```
|
||
|
||
and `app/api/explore/route.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { eq } from 'drizzle-orm'
|
||
import { schema } from '@/lib/db/schema'
|
||
import { getSystemState } from '@/lib/game/save'
|
||
import { POST } from './route'
|
||
// same fixture helpers
|
||
|
||
const post = (body: unknown) => POST(new Request('http://localhost/api/explore', { method: 'POST', body: JSON.stringify(body) }))
|
||
|
||
describe('POST /api/explore (5a)', () => {
|
||
it('queues an EXPLORE: row with type/payload, turn unchanged, planet not yet scanned, no haul', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
const [before] = await db.db.select().from(schema.saves)
|
||
const invBefore = (await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)
|
||
|
||
const res = await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })
|
||
expect(res.status).toBe(200)
|
||
const body = await res.json()
|
||
const sys = await getSystemState(db)
|
||
for (const k of Object.keys(sys)) expect(body[k]).toEqual(sys[k]) // today's system snapshot part unchanged; the findings key stays, empty (JC-2)
|
||
const [row] = await db.db.select().from(schema.tasks)
|
||
expect(row).toMatchObject({ type: 'EXPLORE', leadCrewId: c1, helpers: [c2], status: 'ACTIVE', queuedAtTurn: before.turn })
|
||
expect(JSON.parse(row.payload)).toEqual({ planetId: f.p0 })
|
||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||
expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(false) // the effect waits for the tick
|
||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)).toEqual(invBefore) // no haul yet
|
||
})
|
||
|
||
it('unknown crew → 404 (queueTask)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
expect((await post({ planetId: f.p0, leadCrewId: 999 })).status).toBe(404)
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||
})
|
||
|
||
it('busy helper → 409, nothing queued', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||
expect((await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||
})
|
||
|
||
it('already scanned → 409 (existing pre-check, existing code)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1] = await addCrew(db, 1)
|
||
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, f.p0))
|
||
expect((await post({ planetId: f.p0, leadCrewId: c1 })).status).toBe(409)
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||
})
|
||
})
|
||
```
|
||
|
||
2. - [ ] **Run — expect failure**: `npx vitest run app/api/jump/route.test.ts app/api/explore/route.test.ts` → the new blocks red: the current routes ignore the crew fields and resolve instantly (happy paths fail — the turn advances, no row, snapshot differs) and the 404/409 crew cases return 200 today; the unreachable / already-scanned pre-check cases may already pass — expected, those codes do not move.
|
||
3. - [ ] **Implement** — rewrite both routes per Files (pre-check extraction into the small save.ts helper, `queueStateFromSave` + `queueTask`, `saveTaskRow`, `TaskError` → `.status` mapping identical to the existing `*Error` mappings, no turn increment in either endpoint); add `saveTaskRow` + `queueStateFromSave` to `lib/game/save.ts`; update the two request schemas in `lib/openapi.ts`; update any e2e spec bodies that POST to these endpoints (JC-3).
|
||
4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (5a's route tests plus 4a/4b's tick and resolver blocks — the resolvers are untouched), and `npx tsc --noEmit` clean.
|
||
5. - [ ] **Commit (same PR branch as Task 4b)**: `feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)`
|
||
|
||
### Task 5a (ii): Action endpoints → queueing (ship, research) + mission accept
|
||
|
||
**Files**
|
||
|
||
- `app/api/ship/upgrade/route.ts` — keep the `systemId` body check (integer, 400); add `leadCrewId` (integer, required, 400) + `helperCrewIds` (array of integers, default `[]`) with the same 400 style as Task 5a's crew parse. Keep the current `upgradeSystem` pre-checks (not built / max level) with their existing `ShipError` codes, then `queueStateFromSave` → `queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId, helperCrewIds })` → persist the row + deducted hold via `saveTaskRow`. Returns the same ship-state shape as today. No turn increment, no immediate effect.
|
||
- `app/api/ship/expand/route.ts` — same treatment: pre-checks not built / max capacity, `ShipError` codes, payload `{ systemId }`.
|
||
- `app/api/ship/build/route.ts` — same treatment: `type` body check (non-empty string, 400), pre-checks already built / build gate, payload `{ type }`.
|
||
- `app/api/research/route.ts` — same treatment: `techId` body check (integer, 400), pre-checks already researched / prereqs / level cap with `ResearchError` codes, payload `{ techId }`.
|
||
- `lib/game/ship.ts` + `lib/game/research.ts` — split the four functions: the pre-checks move into small pre-check-only helpers (e.g. `checkUpgradePreconditions(save, systemId)`) that throw exactly the same `*Error` codes the current functions throw (JC-5); cost deduction, turn increment and immediate effect come out of them (the cost is now `queueTask`'s, at queue time; the effect waits for the tick).
|
||
- `app/api/missions/[id]/accept/route.ts` — becomes instant and free per the approved spec: no body (no crew params); `lib/game/missions.ts` `acceptMission` keeps its validation (the 400 non-integer id stays in the route; the current `MissionError` codes), its AVAILABLE→active state change and its board return, but drops the turn increment.
|
||
- `lib/openapi.ts` — add `leadCrewId` + `helperCrewIds` to the four action request schemas (Task 5a's pattern); accept's schema unchanged (no body).
|
||
- Tests — `app/api/ship/{upgrade,expand,build}/route.test.ts`, `app/api/research/route.test.ts` (append to the existing file, create it if absent — the same note as Task 3), `app/api/missions/[id]/accept/route.test.ts` — the blocks below; update any e2e specs that POST to these endpoints (JC-3).
|
||
|
||
**Interfaces**
|
||
|
||
- Consumes: `queueTask` / `TaskError` / `QueueState` (Task 3); `queueStateFromSave` + `saveTaskRow` (Task 5a); the 4a/5a test fixture suite (`freshFile`, `buildWorldFixture`, `addCrew`, `insertTaskRow`).
|
||
- Produces: SHIP_UPGRADE / SHIP_EXPAND / SHIP_BUILD / RESEARCH queueing — costs deducted at queue time, `TaskError` 422 with the existing `spendItems` message when unaffordable; the four endpoints accept `leadCrewId` + `helperCrewIds` (openapi); mission accept costs no turn. None of the five endpoints advances the turn.
|
||
|
||
1. - [ ] **Write failing tests** — three blocks per action file (upgrade in full below; expand/build/research the same shape, deltas in the note after the code block) + one block for accept. Fixtures from the 4a/5a suite; `seedHold` inserts into `schema.inventoryItems` (column set per the fixture suite — reuse its seeder if it has one):
|
||
|
||
```ts
|
||
// app/api/ship/upgrade/route.test.ts — imports/helpers per Task 5a's route tests
|
||
import { upgradeCost } from '@/lib/game/ship-costs'
|
||
|
||
const post = (body: object) =>
|
||
fetch('/api/ship/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||
|
||
const seedHold = (db: Db, rows: { kind: string; name: string; qty: number }[]) =>
|
||
rows.forEach((r) => db.db.insert(schema.inventoryItems).values({ saveId: <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:
|
||
|
||
```ts
|
||
describe('POST /api/missions/[id]/accept', () => {
|
||
it('happy: mission goes active, board returned, turn unchanged', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [before] = await db.db.select().from(schema.saves)
|
||
const res = await fetch(`/api/missions/${f.mission0}/accept`, { method: 'POST' }) // f.mission0 = an AVAILABLE mission (JC-6)
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual(await getMissionBoard(db)) // same board shape as today (JC-6)
|
||
// existing state change: the mission row goes AVAILABLE → active (assert with the schema's status value, as the existing tests do)
|
||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // free — no turn cost
|
||
})
|
||
// the existing validation cases (400 non-integer id, each current MissionError case) stay as-is,
|
||
// except expectations asserting a turn increment — those now expect the turn unchanged
|
||
})
|
||
```
|
||
|
||
2. - [ ] **Run — expect failure**: `npx vitest run app/api/ship/upgrade/route.test.ts app/api/ship/expand/route.test.ts app/api/ship/build/route.test.ts app/api/research/route.test.ts "app/api/missions/[id]/accept/route.test.ts"` → red: the action happy paths (today the routes resolve instantly — the turn advances, no task row) and accept's no-turn assertion (it costs a turn today); the 422 / pre-check 409 cases may already pass — expected, those codes do not move (queueTask's own busy / in-transit / second-jump rules are NOT re-tested here — Task 3 owns them).
|
||
3. - [ ] **Implement** — rewrite the four action routes per Files (crew parse with the 400s; the pre-check helpers with unchanged codes; `queueStateFromSave` + `queueTask` + `saveTaskRow` + persisting `QueueResult.inventory`; return the same snapshot as today; the catch block maps both `*Error` and `TaskError` to `{ error: err.message }` / `err.status` like today); `acceptMission` drops the turn increment (update its existing unit tests accordingly); the four openapi schemas; the e2e spec bodies (JC-3).
|
||
4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (including the existing ship/research/missions lib tests with their turn-related expectations updated) and `npx tsc --noEmit` clean.
|
||
5. - [ ] **Commit (same PR branch as Tasks 4b/5a)**: `feat(api): ship + research endpoints queue tasks with lead + helpers; mission accept is instant and free (#44)`
|
||
|
||
### Task 5b: POST /api/turn — the manual tick
|
||
|
||
**Files**
|
||
|
||
- `app/api/turn/route.ts` — new: `POST /api/turn` (code in step 3). The route is the world-route one-liner plus a tick: call 4b's `runTurn` (one turn: decrement, resolve, FIFO auto-start, mission hooks — all already 4b) and return the same snapshot `GET /api/world` returns, composed with the tick's queue array.
|
||
- `lib/openapi.ts` — add the `POST /api/turn` schema (no request body; response = world snapshot + `tasks` array) — the spec-in-code rule (AGENTS.md API 3).
|
||
- `app/api/turn/route.test.ts` — new: the `describe('POST /api/turn')` block in step 1; the same helpers as the 5a route tests (`freshFile`, `buildWorldFixture`, `addCrew` — imported from the same source they use).
|
||
- Untouched: `lib/game/save.ts` (`runTurn` is 4b's; this route only calls it), `app/api/world/route.ts`, the 5a routes.
|
||
|
||
**Interfaces**
|
||
|
||
`POST /api/turn` — no body (the handler never reads one):
|
||
|
||
```ts
|
||
const db = await getGameDb()
|
||
const tick = await runTurn(db) // 4b's orchestrator: decrement → resolve → FIFO auto-start → mission hooks; persists the turn
|
||
return Response.json({ ...(await getWorldState(db)), tasks: tick.tasks })
|
||
```
|
||
|
||
- `runTurn(db: GameDb): Promise<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.
|
||
|
||
1. - [ ] **Write the failing tests** — new `app/api/turn/route.test.ts`:
|
||
|
||
```ts
|
||
describe('POST /api/turn', () => {
|
||
it('empty queue → free skip: 200, turn +1, nothing else changes', async () => {
|
||
const { db } = await freshFile()
|
||
await buildWorldFixture(db)
|
||
const [before] = await db.db.select().from(schema.saves)
|
||
const res = await fetch('/api/turn', { method: 'POST' })
|
||
expect(res.status).toBe(200)
|
||
const snap = await res.json()
|
||
expect(snap.turn).toBe(before.turn + 1)
|
||
expect(await db.db.select().from(schema.tasks)).toHaveLength(0) // the tick creates nothing
|
||
expect(snap.tasks).toEqual([]) // the composed queue array is empty
|
||
})
|
||
|
||
it('one ACTIVE task → 200, remaining decrements by exactly 1 in the returned snapshot', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1] = await addCrew(db, 1)
|
||
const [before] = await db.db.select().from(schema.saves)
|
||
// Fixture task: one ACTIVE row, mid-countdown — a direct insert on the queue table
|
||
// (the 4b save.test pattern: it works with the queue rows directly); it never reaches 0
|
||
// this tick, so no resolver and no mission hook runs
|
||
await db.db.insert(schema.tasks).values({
|
||
id: /* fresh id, per the fixture's convention (JC-6) */,
|
||
type: 'JUMP',
|
||
payload: /* a valid JUMP payload at a fixture system (JC-6) */,
|
||
leadCrewId: c1,
|
||
helpers: '[]', // JSON string — the save.ts boundary JSON.parses it
|
||
duration: 5,
|
||
remaining: 3,
|
||
queuedAtTurn: before.turn,
|
||
status: 'ACTIVE',
|
||
})
|
||
const res = await fetch('/api/turn', { method: 'POST' })
|
||
expect(res.status).toBe(200)
|
||
const snap = await res.json()
|
||
expect(snap.turn).toBe(before.turn + 1)
|
||
expect(snap.tasks).toHaveLength(1)
|
||
expect(snap.tasks[0].remaining).toBe(2) // 3 − 1: exactly one decrement
|
||
expect((await db.db.select().from(schema.tasks))[0].remaining).toBe(2) // the persisted row matches the snapshot
|
||
})
|
||
})
|
||
```
|
||
|
||
(Row field names per Task 2's `tasks` DDL / the `Task` row type — only `helpers` is a JSON-encoded column; `id` / `payload` are fixture-dependent, JC-6.)
|
||
|
||
2. - [ ] **Run — expect failure**: `npx vitest run app/api/turn/route.test.ts` → red: `POST /api/turn` does not exist yet (404 / route not found).
|
||
3. - [ ] **Implement** — the route per Interfaces (world-route import pattern + the `runTurn` call + the composed response; a catch mapping any thrown error to `{ error: err.message }` / `(err as { status?: number }).status ?? 500` — only 4b's non-domain rethrow can reach it); the `POST /api/turn` openapi schema in `lib/openapi.ts` (response reuses the world-snapshot shape + the `tasks` array of queue rows).
|
||
4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes; `npx tsc --noEmit` clean.
|
||
5. - [ ] **Commit (same PR branch as Tasks 4b/5a)**: `feat(api): POST /api/turn ticks the queue one turn; an empty queue is a free skip (#44)`
|
||
|
||
### Task 6: Snapshots gain the active + pending tasks array
|
||
|
||
**Files**
|
||
|
||
- `lib/game/save.ts` — add the `QueueDto` / `CrewState` interfaces and the private `taskQueue(db)` reader (code in step 3); add `tasks: QueueDto[]` to `WorldState` and `SystemState`, returned by `getWorldState` / `readSystemState`; `getCrewState` returns the new `CrewState` wrapper instead of the bare `CrewDto[]` (JC-7). Pure read — `taskQueue` selects only the `tasks` / `crew` / `solarSystems` / `planets` / `tech` tables and 4b's tick stays untouched.
|
||
- `lib/game/save.test.ts` — new `describe('snapshot task queue (world / system / crew)')` block (code in step 1); hoist the 4b block's `addCrew` / `insertTaskRow` helpers to file scope so both blocks share them; update the two existing blocks that assert the old crew-array shape (`getCrewState`, `explorePlanet crew finds`) to read `.crew` off the wrapper.
|
||
- `app/api/crew/route.ts` — no code change (it forwards whatever the builder returns); update its header comment to the object shape. `app/api/world/route.ts` / `app/api/system/route.ts` — unchanged (they already forward their builders). `app/api/turn/route.ts` — unchanged (JC-8).
|
||
- `components/crew-panel.tsx` — parse the response as `CrewState` and render `state.crew` (JC-7) — without this the crew tab breaks the moment the builder wraps.
|
||
- `components/mission-control.tsx` — the shell's own `/api/crew` consumer: change its bare-array parse to the `{crew, tasks}` `CrewState` object (JC-7's second consumer, flagged in JC-15).
|
||
- `lib/openapi.ts` — new `TaskDto` + `CrewState` component schemas; `WorldState` / `SystemState` gain the `tasks` property (and required entry) — `ExploreResult` inherits it through its `SystemState` allOf; the `/api/crew` 200 response becomes the `CrewState` object instead of the `CrewMember` array (JC-7).
|
||
|
||
**Interfaces**
|
||
|
||
- Consumes: Task 2's `tasks` table (`schema.tasks`) and Task 1's `TaskType` (`lib/game/tasks.ts`); the existing `crew` / `solarSystems` / `planets` / `tech` tables for name resolution.
|
||
- Produces: `QueueDto { id: number; type: TaskType; targetLabel: string; leadName: string; helperNames: string[]; remaining: number }` and `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` in `lib/game/save.ts`; `WorldState.tasks` / `SystemState.tasks: QueueDto[]`; `getCrewState(db): Promise<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.
|
||
|
||
1. - [ ] **Write the failing tests** — add the block below to `lib/game/save.test.ts` (`insertTaskRow` = the 4b helper hoisted to file scope in this task; fixtures = the 4a block's `buildWorldFixture` + the file-scope `addCrew`):
|
||
|
||
```ts
|
||
describe('snapshot task queue (world / system / crew)', () => {
|
||
// buildWorldFixture (4a): a save (seed '42') + Origin (s0, current) / Outpost (s1) +
|
||
// Alder (p0) / Basalt (p1) + the route pair — no crew, no tasks; this block adds its own.
|
||
|
||
it('empty queue → every GET snapshot carries tasks: []', async () => {
|
||
const { db } = await freshFile()
|
||
await buildWorldFixture(db)
|
||
expect((await getWorldState(db)).tasks).toEqual([])
|
||
expect((await getSystemState(db)).tasks).toEqual([])
|
||
const crew = await getCrewState(db)
|
||
expect(crew.tasks).toEqual([])
|
||
expect(crew.crew).toEqual([]) // the new wrapper: the roster moves under .crew (JC-7)
|
||
})
|
||
|
||
it('one ACTIVE + one PENDING row → exactly those two, ACTIVE first, in all three snapshots', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2, c3] = await addCrew(db, 3)
|
||
const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [c3], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 3 })
|
||
const expected = [
|
||
{ id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 },
|
||
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4 },
|
||
]
|
||
expect((await getWorldState(db)).tasks).toEqual(expected)
|
||
expect((await getSystemState(db)).tasks).toEqual(expected)
|
||
expect((await getCrewState(db)).tasks).toEqual(expected)
|
||
})
|
||
|
||
it('DONE rows never appear — even when they sort first by (queuedAtTurn, id)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1] = await addCrew(db, 1)
|
||
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 0, status: 'DONE', queuedAtTurn: 1 }) // oldest, lowest id in the queue
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 2 })
|
||
const snap = await getWorldState(db)
|
||
expect(snap.tasks).toHaveLength(1)
|
||
expect(snap.tasks[0].type).toBe('EXPLORE') // the DONE row is filtered out, not reordered to the back
|
||
})
|
||
})
|
||
```
|
||
|
||
(Test 2's two rows share queuedAtTurn 3 — the ACTIVE→PENDING block order, not (queuedAtTurn, id), is what puts the jump first; test 3's DONE row sorts first by (queuedAtTurn, id), so its absence proves the filter.)
|
||
|
||
2. - [ ] **Run — expect failure**: `npx vitest run lib/game/save.test.ts` → the new block is red: `WorldState` / `SystemState` carry no `tasks` property (undefined ≠ []) and `getCrewState` still returns the bare array (no `.crew` / `.tasks`). The pre-existing blocks stay green — the shape change lands only in step 3.
|
||
3. - [ ] **Implement** — per Interfaces: the private `taskQueue(db)` (one select of the `tasks` table filtered to status ACTIVE/PENDING + one select each of `crew` / `solarSystems` / `planets` / `tech`; map every row through the table above — crew names by id, `unknown` on a miss; concat the ACTIVE block, then the PENDING block, each sorted by (queuedAtTurn, id)); wire it into `getWorldState` / `readSystemState` / `getCrewState`; hoist the test helpers and point the two existing crew-array assertions at `.crew`; the crew-panel parse; the openapi schemas.
|
||
4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (4a/4b stay green — their A/B blocks normalize the raw tables through `snapshot()`, no test asserts on a full builder object); `npx tsc --noEmit` clean.
|
||
5. - [ ] **The shell's own /api/crew consumer**: change its bare-array parse (it casts the response to CrewDto[] and reads .length for the roster count) to the new object shape — parse as the {crew, tasks} CrewState and read crew.length (JC-7's second consumer, flagged in JC-15).
|
||
6. - [ ] **Commit (same PR branch as Tasks 4b/5a/5a-ii)**: `feat(api): world/system/crew snapshots expose the active + pending task queue (#44)`
|
||
|
||
**Judgment calls (Task 6):**
|
||
- **JC-7:** `getCrewState` returns a bare `CrewDto[]` today — a bare array can't carry a sibling `tasks` field, so `GET /api/crew` becomes `{ crew: CrewDto[]; tasks: QueueDto[] }`; the one UI consumer (`components/crew-panel.tsx`) and the two existing save.test crew-array blocks update in the same commit.
|
||
- **JC-8:** 5b's route composes from `tick.tasks` (all statuses per 4b) vs the GET builders' ACTIVE+PENDING filter; they agree on every suite fixture (5b tests never create a DONE row) and 5b is locked, so it stays as-is — noted as a possible 5b-level follow-up.
|
||
|
||
### Task 7: UI — NEXT TURN button + turn counter
|
||
|
||
**Files**
|
||
|
||
- `components/next-turn-button.tsx` — new: the fixed bottom-right control, the current turn (`TURN <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.
|
||
|
||
1. - [ ] **Write the failing tests — SKIPPED (no component-level test setup)** — the repo has no `components/*.test.tsx` (verified by glob) and no jsdom / `@testing-library/react` in `package.json`; its unit suite is pure-TS only. No test file in this task — the button's behaviour (the click, the counter, the free skip on an empty queue) is covered by the e2e task later in the plan (CI-only per AGENTS.md).
|
||
2. - [ ] **Implement** — per Files / Interfaces:
|
||
|
||
```tsx
|
||
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import { Button } from '@/components/ui/button'
|
||
|
||
// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from
|
||
// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell
|
||
// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an
|
||
// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an
|
||
// in-flight request, which the handler ignores. `disabled` is never set, so the cva
|
||
// disabled:opacity-40 styling never applies either.
|
||
|
||
export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
|
||
const [inFlight, setInFlight] = useState(false)
|
||
const [error, setError] = useState<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).
|
||
|
||
3. - [ ] **Verify build**: `npx tsc --noEmit` clean — the whole unit suite stays untouched (`npx vitest run` still green, `npx playwright test --list` still lists); the button itself is exercised by the e2e task in CI.
|
||
4. - [ ] **Commit (same PR A branch as Tasks 4b/5a/5b/6)**: `feat(ui): NEXT TURN button (bottom-right) + turn counter; empty queue is a free skip (#44)`
|
||
|
||
**Judgment calls**
|
||
|
||
- JC-9 — the locked design says the counter reads from "the world snapshot the shell already holds"; the shell holds no `/api/world` state (WorldMapPanel owns it internally — it fetches on tab mount), and the only shell-held snapshot it already renders a turn from is `ShipState.turn` (the VESSEL METRICS TURN row). The button therefore takes `turn={ship?.turn ?? null}` and shows `TURN —` while the ship fetch is in flight — the shell's own `—` loading convention, and the same value the shell already displays, so the two counters can never disagree.
|
||
- JC-10 — "the same app-wide snapshot refresh the shell uses after every other action": the shell's only explicit post-action refresh today is `refreshSystem` (#30, jump only), while its full snapshot set is the four mount fetches. A tick can change all four (the turn always; inventory on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research completion), so `onTicked` re-runs the four mount fetches (extracted into `refreshAll`) — a strict superset of `refreshSystem`, which stays for the jump callback.
|
||
|
||
### Task 8: UI — team builder (lead + helpers, live duration)
|
||
|
||
**Files**
|
||
|
||
- `components/team-builder.tsx` — new: a self-contained crew picker shared by the four action panels (code in step 3). House style: the panels' inline CSS-variable look (`--surface`, `--border`, `--text-muted`, `--color-red`, mono letter-spaced uppercase) and next-turn-button's `Button variant="EXEC"` for the dispatch control.
|
||
- `components/world-map.tsx` — the JUMP panel: a node click ARMS the target instead of POSTing; the builder is mounted while armed (a floating card over the full-bleed chart, #36); the existing `/api/jump` fetch gains the team fields in its body.
|
||
- `components/local-system.tsx` — the EXPLORE panel: the per-planet EXPLORE button arms the planet; the builder is the first child of the column; the `/api/explore` body gains the team fields.
|
||
- `components/skill-tree.tsx` — the RESEARCH panel: the card RESEARCH button arms the tech; same placement; the `/api/research` body gains the team fields.
|
||
- `components/ship-systems.tsx` — the SHIP panel: the card UPGRADE/EXPAND/BUILD button arms that action; same placement; the local `act(url, body)` fetch gains the team fields.
|
||
- `lib/game/save.ts` — `QueueDto` gains `status` / `leadCrewId` / `helperCrewIds` (JC-11) — Task 6's reader already selects all three (it filters + sorts on status and resolves the names from the ids); its snapshot fixtures and the openapi `TaskDto` schema gain the fields in the same commit.
|
||
|
||
**Interfaces**
|
||
|
||
- Consumes: Task 6's `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` from `GET /api/crew` — roster and queue in ONE response, which is why the builder takes both; Task 1's pure `computeDuration(type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf?)` from `lib/game/tasks.ts` (no DB import — client-safe, the tech.ts / ship-costs.ts precedent); the six `TaskType`s.
|
||
- Produces: `TeamBuilder({ type: TaskType; hopCount?: number; roster: CrewDto[]; tasks: QueueDto[]; onQueue: (leadCrewId: number, helperCrewIds: number[]) => void })` in `components/team-builder.tsx` — no other exports. `hopCount` is JUMP-only: the hops to the armed target from the panel's `reachableWithin` (the same logic the route recomputes, #35); the other types omit it → 0, the value 5a's route passes for non-JUMP, and `computeDuration` ignores it off-JUMP (Task 1's doc comment).
|
||
|
||
- One row per roster member: a LEAD radio + a HELPER checkbox. The checkbox is disabled while that member is the selected lead — a lead is never also a helper of the same task (picking a new lead drops the member from the helper selection). With a lead picked, the LIVE preview reads `will take N turn(s)` = `computeDuration(type, hopCount ?? 0, lead, helpers)` — the roster objects pass through as-is (`CrewDto { id, name, role, traitGood, traitBad, origin }` is a structural superset of `CrewMember { name, role, traitGood, traitBad }`) and the default `powerOf` (constant 1) is what 5a's route calls, so the preview equals the stored duration exactly. A single `DISPATCH` button (EXEC variant) is enabled only when a lead is picked, that lead is not itself blocked, and the builder is not blocked; it fires `onQueue(leadId, helperIds)`.
|
||
- Availability (mirrors Task 3's queueTask — the UI never offers a 409): a member is selectable as LEAD unless they LEAD a PENDING row (an ACTIVE lead may chain one PENDING behind it — stays enabled); a member is selectable as HELPER unless they are lead or helper on any ACTIVE or PENDING row — the disabled state shows the reason, e.g. `busy: EXPLORE` (first matching row in array order, the ACTIVE block first); an ACTIVE JUMP row blocks the whole builder with `in transit // <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, … }`.
|
||
|
||
1. - [ ] **Write the failing tests — SKIPPED (no component-level test setup)** — as in Task 7: the repo has no `components/*.test.tsx` and no jsdom / `@testing-library/react` in `package.json`; its unit suite is pure-TS only. The builder's row states, the live preview, the 409-mirror blocking and the four wired panels are covered by the e2e task later in the plan (the one Task 7's note points at), CI-only per AGENTS.md.
|
||
2. - [ ] **QueueDto gains `status` / `leadCrewId` / `helperCrewIds` (JC-11)** — as specified, Task 6's display-oriented `QueueDto` can't drive the mirror (names only, no status, no crew ids): add the three fields to the interface, the `taskQueue` mapper and the openapi `TaskDto`, and update Task 6's snapshot `toEqual` expected objects (the two queue rows in its second test). `npx vitest run lib/game/save.test.ts` → green, `npx tsc --noEmit` clean. If Task 6 is still unimplemented when this runs, fold the fields into its implementation.
|
||
3. - [ ] **Implement** — per Files / Interfaces:
|
||
|
||
```tsx
|
||
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import { Button } from '@/components/ui/button'
|
||
import type { CrewDto, QueueDto } from '@/lib/game/save'
|
||
import { computeDuration, type TaskType } from '@/lib/game/tasks'
|
||
|
||
// Shared crew picker for the four action panels — PR A: an action is queued as a lead +
|
||
// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState
|
||
// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel
|
||
// performs its own fetch with the team fields in the body (Task 5a). The live preview is
|
||
// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call
|
||
// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask
|
||
// so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
|
||
export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
|
||
type: TaskType
|
||
hopCount?: number
|
||
roster: CrewDto[]
|
||
tasks: QueueDto[]
|
||
onQueue: (leadCrewId: number, helperCrewIds: number[]) => void
|
||
}) {
|
||
const [leadId, setLeadId] = useState<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.
|
||
|
||
4. - [ ] **Verify build**: `npx tsc --noEmit` clean — the unit suite stays green (`npx vitest run`, including the updated Task 6 fixtures) and `npx playwright test --list` still lists; the panels themselves are exercised by the e2e task in CI.
|
||
5. - [ ] **Commit (same PR A branch as Tasks 4b/5a/5b/6/7)**: `feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)`
|
||
|
||
**Judgment calls**
|
||
|
||
- JC-11 — the locked design mirrors Task 3's validation, but Task 6's `QueueDto` as specified is display-oriented (names only, no status, no crew ids): the ACTIVE→PENDING array order doesn't expose the block boundary and name-matching breaks on the `unknown` fallback. The builder therefore needs per-row `status` (lead-PENDING vs lead-ACTIVE chaining, in-transit) and `leadCrewId` / `helperCrewIds` (exact member↔row matching) — all three already selected by Task 6's reader, added in step 2 with the fixture + schema updates.
|
||
- JC-12 — the locked design blocks the whole builder on an ACTIVE (in-transit) JUMP; Task 3's second-jump 409 also rejects a new JUMP while one is merely PENDING, so the JUMP panel's builder disables on ANY queued jump (reason `A JUMP IS ALREADY QUEUED`) while the other three panels are unaffected by a PENDING jump.
|
||
- JC-13 — "pick a team → the existing fetch gains the team fields" needs a target first: the one JUMP hopCount is per-target (the `reachableWithin` map the panel already computes) and the explore / research / ship payloads are per-row. Each existing action control therefore becomes the armer (a click selects the target and shows the builder) and the builder's DISPATCH performs the fetch; the panels' in-flight locks stay as-is.
|
||
- JC-14 — the locked design says the roster + tasks come "from the snapshot"; the only response carrying both is Task 6's `CrewState` from `GET /api/crew`, so each panel fetches it on mount and after each dispatch — self-contained like world-map's self-fetch; the shell's `refreshAll` (Task 7) covers the shell-owned snapshots, and Radix unmounts the panels, which refetch on reopen.
|
||
|
||
### Task 9: UI — active-tasks strip
|
||
|
||
**Files**
|
||
|
||
- `components/task-strip.tsx` — new: the fixed bottom-left strip rendering the save's task queue (code in step 3). House style throughout: the panels' inline CSS-variable look (`--surface`, `--border`, `--text-muted`, `--text-secondary`, mono letter-spaced small type) and next-turn-button's `position: fixed` + `zIndex: 60` convention. No state of its own — a pure display of the array it is handed; an empty array returns `null` (no empty box).
|
||
- `components/mission-control.tsx` — import `TaskStrip` and mount it as the last child of the root div, right after the `NextTurnButton` element (Task 7): `position: fixed` takes it out of layout, and being outside the tab grid it stays visible on every tab. It receives `tasks={system?.tasks ?? []}` — the shell's existing `SystemState` already carries Task 6's `tasks` array, and Task 7's `refreshAll` re-runs the `/api/system` fetch on every run (as does #30's `refreshSystem` after a jump), so the strip updates on every shell refresh with no new state and no fetch of its own.
|
||
|
||
**Interfaces**
|
||
|
||
- Consumes: Task 6's `QueueDto` (with JC-11's `status` — the only JC-11 field this component reads; the array arrives pre-ordered: ACTIVE block, then PENDING block, each by (queuedAtTurn, id)) and the shell's existing `system: SystemState | null` state (the `/api/system` fetch, run on mount and by Task 7's `refreshAll`); Task 7's root-level mount convention.
|
||
- Produces: `TaskStrip({ tasks: QueueDto[] })` in `components/task-strip.tsx` — no other exports. Each row, in array order: type label + `targetLabel` (left); lead name, ` +`-joined helper names (if any), and the status (right: `N turn(s) left` for ACTIVE, `queued` for PENDING). PENDING rows render dimmed (45% opacity) — the order is the array's, never re-sorted.
|
||
|
||
1. - [ ] **Write the failing tests — SKIPPED (no component-level test setup)** — the repo has no `components/*.test.tsx` and no jsdom / `@testing-library/react` in `package.json` (confirmed in Task 7); its unit suite is pure-TS only. No test file in this task — the strip's behaviour (active first, pending dimmed, hidden on an empty queue, updates on every refresh) is covered by the e2e task later in the plan (CI-only per AGENTS.md).
|
||
2. - [ ] **Implement** — per Files / Interfaces:
|
||
|
||
```tsx
|
||
'use client'
|
||
|
||
import type { QueueDto } from '@/lib/game/save'
|
||
|
||
// TASK STRIP — the save's task queue, fixed bottom-left next to the NEXT TURN button (#44).
|
||
// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING
|
||
// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows,
|
||
// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on
|
||
// every refreshAll (Task 7) — a pure display of the array it is handed.
|
||
|
||
export function TaskStrip({ tasks }: { tasks: QueueDto[] }) {
|
||
if (tasks.length === 0) return null
|
||
|
||
return (
|
||
<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.
|
||
|
||
3. - [ ] **Verify build**: `npx tsc --noEmit` clean — the unit suite stays untouched (`npx vitest run` still green, `npx playwright test --list` still lists); the strip itself is exercised by the e2e task in CI.
|
||
4. - [ ] **Commit (same PR A branch as Tasks 4b/5a/5b/6/7/8)**: `feat(ui): active-tasks strip bottom-left — active first, pending dimmed (#44)`
|
||
|
||
**Judgment calls**
|
||
|
||
- JC-15 — "the shell passes the array down" fixes the data source but not which snapshot the shell hands over: after Task 6 the shell holds two that carry a `tasks` array — its `SystemState` (CARGO HOLD) and, via its roster-count fetch, the new `CrewState` object (JC-7). The strip takes `system?.tasks ?? []`: the shell already fetches `/api/system` on mount, and Task 7's `refreshAll` (and #30's `refreshSystem` after a jump) re-run it, so "updates whenever the shell's refresh runs" holds with zero new state and zero new fetches, and the crew fetch stays count-only. Reality note: the shell's own `/api/crew` fetch is a second consumer of JC-7's object response — Task 6's parse-update names only `crew-panel.tsx`; if its bare-array cast is still there when this lands, its `.length` read silently becomes `undefined` (fix belongs to Task 6/8, not this task).
|
||
|
||
### Task 10: E2E rework — queue + tick model
|
||
|
||
**Files**
|
||
|
||
- `e2e/helpers.ts` — the linchpin: `jumpToFreshSystem` is called by every spec that jumps (smoke + state) and today is a single raw `POST /api/jump { systemId }` that landed instantly. Rework it to the queue+tick model (code in step 1) and add a general `resolveAction` it shares with the scan-loop tests: queue an action `POST` carrying a default one-person crew, read the new task's `remaining` (its live duration, Task 6), then `POST /api/turn` that many times and return the final snapshot. Add a minimal `CrewDto` (id + role) next to the existing `WorldState`.
|
||
- `e2e/smoke.spec.ts` — the action tests reworked from instant-resolution to queue+tick. This is where the jump / explore / ship / research flow tests already live, so it is the natural home for the new scenarios too (step 3). Uniform recipe for every affected test: each action `POST` now carries `leadCrewId` + `helperCrewIds` and returns a *queued* state (200, no turn bump, effect not yet visible); the effect is observed only after the NEXT TURN button (Task 7, bottom-right) is clicked N times, N = the team builder's (Task 8) live duration for that team. Affected tests: both jump tests, both explore tests, the SHIP SYSTEMS test, both SKILL TREE research tests, the MISSIONS test, the EVENT LOG test.
|
||
- `e2e/state.spec.ts` — the mid-run `docker compose restart` spec (AGENTS.md). Two instant-resolution assumptions break: the raw `POST /api/explore` no longer flips `explored` in its response, and no turn bumps on queue — so the `sysExplored` baseline it captures (then compares byte-identical after the restart) must be re-derived *after* ticking the explore to completion, not straight off the queue response (step 4). The other baselines (crew / ship / tech / missions / events) don't depend on that explore resolving and stay as-is.
|
||
|
||
**Interfaces**
|
||
|
||
- Consumes: Task 3's queue-time validation (busy / in-transit / second-jump → 409 — the API half of the in-transit-block scenario), Task 4b's `tickTurn` effect resolution, Task 5a/5b's reworked action endpoints (crew params) and Task 5b's `POST /api/turn`, Task 6's `tasks` array in every snapshot, Task 7's NEXT TURN button + `TURN N` counter, Task 8's team builder (source of N), Task 9's task strip (dimmed PENDING rows the FIFO scenario reads).
|
||
- Produces: a reworked `e2e/` suite that compiles and lists cleanly (`npx playwright test --list`); the new scenarios assert the queue+tick model end-to-end. No unit tests are added or changed (the 409 validation and the duration formula are unit-covered in Tasks 1/3) and no app code is touched — test-only, landing in the same PR A batch as Tasks 4b–9.
|
||
|
||
1. - [ ] **Rework the shared jump helper** — `e2e/helpers.ts`. `jumpToFreshSystem` keeps its target choice (prefer an undiscovered neighbour) but queues, then ticks, before returning; `resolveAction` generalizes that for the scan loops. Code:
|
||
|
||
```ts
|
||
const TASK_TYPE: Record<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;
|
||
```
|
||
|
||
2. - [ ] **Rework the existing action tests** — apply the recipe to each; the per-test assertion deltas are the whole of the rework:
|
||
- *both jump tests* — after queueing, the current system is still the origin (no instant landing); only after N ticks does `/api/world` report the target as `current`. The `#30` test's "wait for `TURN ${before.turn + 1}`" becomes "wait for `TURN ${before.turn + N}`" — the tick, not the queue, advances the counter.
|
||
- *both explore tests* — the scan no longer flips `explored` in the POST response and bumps no turn; assert `explored=true` + `SCAN RESULT` only after N ticks. The *yields finds* loop reads the haul from the post-tick `/api/system` snapshot (inventory), not from the queue response's `outcome`.
|
||
- *SHIP SYSTEMS* — UPGRADE/BUILD queue, then tick; the `LV n+1` / `ONLINE` card flips and the turn counter reflect the ticks, not the click.
|
||
- *both SKILL TREE research tests* — RESEARCH queues; the card flips to `RESEARCHED` and the gated build unlocks only after N ticks. The `#35` two-hop test's `turn === turnBefore + 2` (flat hop-scaled cost) becomes `turnBefore + N` where N is that jump's team-builder duration — the hop count now feeds the duration model, not a flat turn delta.
|
||
- *MISSIONS* — accepting a contract is the one action in the suite §7 does not list as a queueing op (JC-19); completion still rides queue+tick through the explore/jump calls in its loop.
|
||
- *EVENT LOG* — the jump + scan it logs now carry the tick's turn (when they resolved), not the queue's turn; the "newer first" ordering assertion still holds.
|
||
|
||
3. - [ ] **Add the five new scenarios** — appended to `e2e/smoke.spec.ts` (its existing jump/explore/ship/research tests make it the natural home):
|
||
1. **Assign → tick → effect** — queue a one-person JUMP; read N from the new task's `remaining` (= the team builder's live duration, Task 8). Assert the current system is unchanged immediately after the queue POST (never instant), unchanged after N-1 ticks, and changed to the target exactly on the Nth tick.
|
||
2. **Parallel tasks** — queue a JUMP (crew A) and an EXPLORE (disjoint crew B); tick max(Na, Nb) times; assert both resolved at their respective durations (jump landed + planet marked) with no interleaving clicks (JC-17: a non-jump action is legal while a jump is in transit).
|
||
3. **FIFO pre-queueing** — queue two tasks for the same crew member (both accepted while it holds no ACTIVE task — JC-16); the strip (Task 9) shows the first and the second dimmed (PENDING); tick until the first resolves and assert the second auto-starts (ACTIVE) on that tick with no new click.
|
||
4. **In-transit block** — queue a JUMP and leave it active (no ticks); attempting to queue a second JUMP is refused `409` at the API (Task 3's "in-transit or second jump" rule). The UI variant (the jump builder/target disabled while in transit) and the full 409 matrix are unit-covered by Task 3, so this single API case stays in e2e.
|
||
5. **Free skip** — with an empty queue, click NEXT TURN; assert the turn counter increments by one and no task appears in the strip.
|
||
|
||
4. - [ ] **Fix `state.spec.ts`** — after its raw `POST /api/explore` baseline, tick the explore to completion via `resolveAction` before capturing `sysExplored`, so the `explored=true` and the turn it pins are the resolved values; the byte-identical post-restart comparisons (crew / ship / tech / missions / events) are otherwise unaffected and stay as-is.
|
||
|
||
5. - [ ] **Verify (CI-only — never run locally)** — `npx tsc --noEmit` clean (the specs are TS) and `npx playwright test --list` shows every reworked + new test compiles and lists. The actual run happens in CI at this PR's Ship task against the dind stack (`E2E_BASE_URL` / `E2E_DEPLOY_CMD` / `PW_REMOTE_BROWSER=0` per AGENTS.md). Do NOT run the suite locally.
|
||
|
||
6. - [ ] **Commit (same PR A branch as Tasks 4b/5a/5b/6/7/8/9)**: `test(e2e): rework the suite to the queue + NEXT TURN model (#44)`
|
||
|
||
**Judgment calls**
|
||
|
||
- JC-16 — Scenario 3 (FIFO) vs §7's "busy/committed crew → 409": read "busy" as "has an *ACTIVE* task." A member holding only a *PENDING* (queued, not yet started) task can still be given a second queued task, so the second shows dimmed in the strip (Task 9's PENDING row) and auto-starts when the predecessor resolves on a tick. If Task 3 instead 409s on any queued task, this scenario must queue the second task *before* the first tick — reconcile with Task 3's validation, don't re-decide here.
|
||
- JC-17 — The in-transit block is scoped to *jump* actions: §7's "in-transit or second jump → 409" blocks a second JUMP while one is in transit, not a non-jump action. That is why scenario 2's parallel JUMP + EXPLORE is legal — the 409 doesn't reach a different-crew, different-type action.
|
||
- JC-18 — `GET /api/crew` shape: the specs today cast it to `CrewDto[]`, but Task 6 / JC-7 make it a `CrewState` object. `resolveAction` reads both (`Array.isArray` guard) so the helper is correct under either; the specs' inline `CrewDto[]` casts (CREW tab, state baseline) should be updated to the object shape in the same edit to keep `tsc` clean.
|
||
- JC-19 — mission-accept is a free, non-queued action per Task 5a (ii) and spec §3 — no crew params, no turn increment; e2e asserts it resolves on the next interaction, not on a NEXT TURN click.
|
||
|
||
### Task 11: Ship v0.10.0 (PR A — Gitea issue #44)
|
||
|
||
**Files touched (this ship step only — no app code, no failing-test step):**
|
||
- `docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md` — line-4 `Status:` → `approved`; §10 issue-table renumbered to reality (#44 = crew-driven actions v0.10.0, #45 = skill progression v0.11.0, #43 = trait outcome effects v0.12.0).
|
||
- `docs/superpowers/plans/2026-10-01-crew-driven-actions.md` — this plan.
|
||
- `package.json` (version bump) and `CHANGELOG.md` (new top entry).
|
||
|
||
This is a process task that ships the PR A already built by Tasks 1–10 (all code + tests already live on the same branch). It has no failing-test step and ends in a verification gate.
|
||
|
||
1. - [ ] **Commit the docs on the PR A branch** (the branch created in Task 1, e.g. `feat/crew-driven-actions`) — in the spec file, first flip line-4 `Status:` to `approved` and correct its §10 issue-table numbers to match reality: `#44` = crew-driven actions engine (v0.10.0), `#45` = skill progression (v0.11.0), `#43` = trait outcome effects (v0.12.0). Then commit the spec and this plan: `git add ... && git commit -m "docs: approved crew-driven-actions spec + implementation plan (#44)"`.
|
||
|
||
2. - [ ] **Version bump** — `package.json` → `"version": "0.10.0"` (from the current `0.9.12`). `CHANGELOG.md`: new entry at the very top in the repo's format `## v0.10.0 — YYYY-MM-DD HH:MM` (fill the date/time at execution) with a bullet summary of the PR: queue-based actions (every action becomes a queued task with a lead + helpers), manual time via `POST /api/turn` + the NEXT TURN button, crew teaming with live duration, trait duration modifiers, and the new `tasks` table (schema v7, idempotent backfill).
|
||
|
||
3. - [ ] **Local verify — ONLY these three (e2e is CI-only, never run locally):** `npx tsc --noEmit` (clean), `npx vitest run` (all pass), `npx playwright test --list` (every spec compiles + lists). All three must pass. Do NOT run the e2e suite (`npx playwright test`) — CI-only per AGENTS.md.
|
||
|
||
4. - [ ] **Push + open the PR to master** — `git push -u origin <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.
|
||
|
||
5. - [ ] **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.
|
||
|
||
6. - [ ] **Tag on master** — after pulling the merge on `master`: `git tag v0.10.0 && git push origin v0.10.0`.
|
||
|
||
7. - [ ] **Manual deploy** (CI never deploys) — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` returns the 0.10.0 payload.
|
||
|
||
8. - [ ] **Close the issue** — Gitea API: close issue #44 with a comment summarizing the shipped state (version 0.10.0, tag v0.10.0, live deploy verified).
|
||
|
||
**Verification gate (whole task):** `/api/version` on the live URL (`https://space-wanderer.dev.kkhost.pl`) reports 0.10.0 **AND** Gitea issue #44 is closed.
|
||
|
||
**Judgment calls**
|
||
|
||
- JC-20 — Gitea credentials: the CI (`.gitea/workflows/ci.yml`) contains **no** Gitea API token — it is only a test runner (checkout → build → unit → e2e), so "the way the CI does it" yields nothing. The repo's documented source is ORCHESTRATOR.md: prefer the pre-configured gitea MCP tools; otherwise raw REST/curl reads `GITEA_TOKEN` from the repo-root `.env` (gitignored, never committed). A fresh checkout has no `.env`, so when absent the path is the gitea MCP tools or your own Gitea API token from the environment. This step states that fallback explicitly rather than assuming CI carries a token.
|