From f52a59c5fca001b3f44186f2aa83840a93b5c1da Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 12:58:57 +0000 Subject: [PATCH 01/20] feat(game): crew-costs balance module + computeDuration duration model (#44) --- lib/game/crew-costs.ts | 71 ++++++++++++++++++++++++++++++++ lib/game/tasks.test.ts | 92 ++++++++++++++++++++++++++++++++++++++++++ lib/game/tasks.ts | 47 +++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 lib/game/crew-costs.ts create mode 100644 lib/game/tasks.test.ts create mode 100644 lib/game/tasks.ts diff --git a/lib/game/crew-costs.ts b/lib/game/crew-costs.ts new file mode 100644 index 0000000..56f7273 --- /dev/null +++ b/lib/game/crew-costs.ts @@ -0,0 +1,71 @@ +// Pure crew-balance module (spec §11): every tuning knob for the duration model and skill +// progression lives here, so tuning a number means editing one file. No lib/db imports — +// the frontend imports this for the live duration preview in the crew picker (same precedent +// as ship-costs.ts). +import type { TaskType } from './tasks' + +export type SkillName = 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' + +export const WORK_POINTS = { EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 } as const + +export const ACTION_SKILL: Record = { + JUMP: 'NAVIGATION', + EXPLORE: 'SCANNING', + SHIP_UPGRADE: 'ENGINEERING', + SHIP_EXPAND: 'ENGINEERING', + SHIP_BUILD: 'ENGINEERING', + RESEARCH: 'RESEARCH', +} + +export const PREFERRED_ROLES: Record = { + JUMP: ['PILOT', 'NAVIGATOR'], + EXPLORE: ['SCIENTIST'], + SHIP_UPGRADE: ['ENGINEER', 'TECHNICIAN'], + SHIP_EXPAND: ['ENGINEER', 'TECHNICIAN'], + SHIP_BUILD: ['ENGINEER', 'TECHNICIAN'], + RESEARCH: ['SCIENTIST'], +} + +export const POWER_BANDS = [1, 1, 1, 2, 2, 3] // skill level 0..5 → power (L0–L2 → 1 · L3–L4 → 2 · L5 → 3) +export const XP_THRESHOLDS = [5, 10, 15, 20, 25] // cumulative task completions for L1..L5 (cap 5) + +// Role → starting skill levels (spec §6): specialists start at L2, everyone else at L0. +export const STARTING_LEVELS: Record> = { + COMMANDER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + PILOT: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + ENGINEER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 }, + MEDIC: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + NAVIGATOR: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + SCIENTIST: { NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 }, + GUNNER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + TECHNICIAN: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 }, +} + +// The lead's good trait that cancels the role-mismatch penalty (spec §5). +export const MISMATCH_CANCEL_TRAIT = 'STEADY UNDER PRESSURE' + +// All 15 duration traits of spec §5, keyed by the EXACT trait strings from lib/game/crew.ts. +// Value = additive turn modifier keyed by action type or 'ANY'; empty object = no additive +// modifier (the trait's effect is the mismatch cancel, see MISMATCH_CANCEL_TRAIT). Traits +// with no entry (the three STANDBY combat traits and #45's outcome traits) contribute 0. +// "SHIP work" covers all three SHIP types. +export const TRAIT_DURATION_MODIFIERS: Record>> = { + 'STEADY UNDER PRESSURE': {}, + 'RECKLESS COURAGE': { JUMP: -1 }, + 'CALM IN A CRISIS': { EXPLORE: -1 }, + 'FIXES ANYTHING': { SHIP_UPGRADE: -1, SHIP_EXPAND: -1, SHIP_BUILD: -1 }, + 'READS STARS BLIND': { JUMP: -1 }, + 'QUICK HANDS': { ANY: -1 }, + 'IRON WILL': { RESEARCH: -1 }, + 'RIGID — SLOW TO ADAPT': { RESEARCH: 1 }, + 'OVERCAUTIOUS': { ANY: 1 }, + 'SLOWS THE CREW DOWN': { ANY: 1 }, + 'CARELESS WITH FUSES': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 }, + 'HOARDS RATIONS': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 }, + 'FORGETS TO EAT': { EXPLORE: 1 }, + 'ARGUES WITH EVERYONE': { RESEARCH: 1 }, + 'DOUBTS THE CAPTAIN': { JUMP: 1 }, +} + +export const RECRUIT_CHANCE_BASE = 0.18 // a scan finds a joining member (matches crew.ts's current CREW_CHANCE) +export const RECRUIT_CHANCE_TALKER = 0.3 // lead with TALKS TO ANYONE (wired in PR C) diff --git a/lib/game/tasks.test.ts b/lib/game/tasks.test.ts new file mode 100644 index 0000000..56cebea --- /dev/null +++ b/lib/game/tasks.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { CrewMember } from './crew' +import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, POWER_BANDS, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, TRAIT_DURATION_MODIFIERS, WORK_POINTS, XP_THRESHOLDS } from './crew-costs' +import type { TaskType } from './tasks' +import { computeDuration } from './tasks' + +// Trait strings copied verbatim from lib/game/crew.ts GOOD_TRAITS/BAD_TRAITS — TRAIT_DURATION_MODIFIERS +// is keyed by exactly these strings, so a rename in crew.ts breaks these tests. +const NEUTRAL_GOOD = 'FINDS HIDDEN THINGS' // no duration entry → no effect +const NEUTRAL_BAD = 'JINXED IN A DOGFIGHT' // STANDBY combat trait → no duration effect +const crew = (role: string, traitGood = NEUTRAL_GOOD, traitBad = NEUTRAL_BAD): CrewMember => ({ name: 'TEST', role, traitGood, traitBad }) +const WORK: Record = { JUMP: 3, EXPLORE: 4, SHIP_UPGRADE: 6, SHIP_EXPAND: 6, SHIP_BUILD: 6, RESEARCH: 9 } +const TYPES = Object.keys(WORK) as TaskType[] +const SHIP: TaskType[] = ['SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD'] + +describe('solo baselines (one crew, power 1, no modifiers)', () => { + it('work points are the duration for the preferred role: EXPLORE 4 · JUMP 3 / 6 · SHIP 6 · RESEARCH 9', () => { + for (const type of TYPES) { + expect(computeDuration(type, 1, crew(PREFERRED_ROLES[type][0]), [])).toBe(WORK[type]) + } + expect(computeDuration('JUMP', 2, crew('PILOT'), [])).toBe(6) // two-hop = 2 × 3 + }) +}) + +describe('role mismatch (lead only, helpers never penalised)', () => { + it('a lead outside the preferred role adds +1; helpers contribute power only', () => { + expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [])).toBe(4) // 3 + 1 mismatch + expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [crew('PILOT')])).toBe(3) // ceil(3/2) + 1 — power 2, mismatch still on the lead + expect(computeDuration('EXPLORE', 1, crew('SCIENTIST'), [crew('PILOT', 'QUICK HANDS')])).toBe(2) // 4/2 — the helper's trait does not apply + }) + it('STEADY UNDER PRESSURE cancels the mismatch penalty and adds nothing of its own', () => { + expect(computeDuration('JUMP', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(3) + expect(computeDuration('EXPLORE', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(4) + }) +}) + +describe('floor', () => { + it('never returns below 1 turn', () => { + const lead = crew('PILOT', 'QUICK HANDS') + expect(computeDuration('JUMP', 1, lead, [crew('PILOT'), crew('PILOT')], () => 3)).toBe(1) // ceil(3/9) − 1 = 0 → floor 1 + }) +}) + +// All 15 duration traits from spec §5, each tested once in its own action context (plus its +// non-applicable types). target 'SHIP_UPGRADE' stands for the whole "SHIP work" class. +const TABLE: [trait: string, isGood: boolean, target: TaskType | 'ANY', delta: number][] = [ + ['RECKLESS COURAGE', true, 'JUMP', -1], + ['CALM IN A CRISIS', true, 'EXPLORE', -1], + ['FIXES ANYTHING', true, 'SHIP_UPGRADE', -1], + ['READS STARS BLIND', true, 'JUMP', -1], + ['QUICK HANDS', true, 'ANY', -1], + ['IRON WILL', true, 'RESEARCH', -1], + ['RIGID — SLOW TO ADAPT', false, 'RESEARCH', 1], + ['OVERCAUTIOUS', false, 'ANY', 1], + ['SLOWS THE CREW DOWN', false, 'ANY', 1], + ['CARELESS WITH FUSES', false, 'SHIP_UPGRADE', 1], + ['HOARDS RATIONS', false, 'SHIP_UPGRADE', 1], + ['FORGETS TO EAT', false, 'EXPLORE', 1], + ['ARGUES WITH EVERYONE', false, 'RESEARCH', 1], + ['DOUBTS THE CAPTAIN', false, 'JUMP', 1], +] + +describe('the 15 duration traits (spec §5, exact strings from crew.ts)', () => { + const applies = (type: TaskType, target: TaskType | 'ANY') => { + if (target === 'ANY') return true + if (target === type) return true + return SHIP.includes(target) && SHIP.includes(type) // "SHIP work" class: any SHIP type → any SHIP 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 + }) +}) diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts new file mode 100644 index 0000000..e36501b --- /dev/null +++ b/lib/game/tasks.ts @@ -0,0 +1,47 @@ +// 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) +} -- 2.54.0 From 6c51c3cec8dac55e855abf2ad40ff53c83c44a10 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 13:08:53 +0000 Subject: [PATCH 02/20] feat(game): tasks table + schema v7 backfill, admin registry (#44) --- lib/db/admin.ts | 1 + lib/db/client.ts | 13 +++++++++++++ lib/db/schema.ts | 17 +++++++++++++++++ lib/game/save.test.ts | 23 +++++++++++++++++++++++ lib/game/save.ts | 7 ++++++- lib/game/tasks.ts | 18 ++++++++++++++++++ 6 files changed, 78 insertions(+), 1 deletion(-) diff --git a/lib/db/admin.ts b/lib/db/admin.ts index 78dfe5b..6f449d2 100644 --- a/lib/db/admin.ts +++ b/lib/db/admin.ts @@ -18,6 +18,7 @@ export const ADMIN_TABLES = { tech: schema.tech, missions: schema.missions, events: schema.events, + tasks: schema.tasks, } as const satisfies Record const TABLE_NAMES: Set = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t))) diff --git a/lib/db/client.ts b/lib/db/client.ts index 5158809..ccdc603 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -116,6 +116,19 @@ CREATE TABLE IF NOT EXISTS events ( message TEXT NOT NULL, created_at TEXT NOT NULL ); + +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 +); `; // SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 9e63ab6..cb204a5 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -136,3 +136,20 @@ export const events = sqliteTable('events', { message: text('message').notNull(), createdAt: text('created_at').notNull(), }) + +// 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), +}) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index fee3b3f..cc1e7f2 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -379,6 +379,29 @@ describe('backfill (old saves)', () => { expect((await db.db.select().from(schema.planets)).length).toBe(planetCount) }) + 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) + }) + it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => { const { db } = await freshFile() await getWorldState(db) diff --git a/lib/game/save.ts b/lib/game/save.ts index 8725970..88115cc 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -97,7 +97,7 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi // What each schema version added to a save. Saves created by older code lack the data newer // versions seed at new-game time, so ensureSave runs these steps in order for anything below // current — each step is idempotent (a no-op when its data already exists). -export const SCHEMA_VERSION = 6 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board +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 // Seeds the starting ship: one row per known type — core types built at base values, the rest // unbuilt (level/capacity 0) until a BUILD action. @@ -173,6 +173,11 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema await topUpMissionBoard(db) // idempotent — only inserts when fewer than MIN_AVAILABLE AVAILABLE contracts exist version = 6 } + 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 + } if (from !== SCHEMA_VERSION) { await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id)) diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts index e36501b..61e6d2d 100644 --- a/lib/game/tasks.ts +++ b/lib/game/tasks.ts @@ -6,6 +6,24 @@ import type { CrewMember } from './crew' export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH' +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 +} + // Work points per action (spec §4): JUMP scales with hop count, the rest are flat. function workPoints(type: TaskType, hopCount: number): number { switch (type) { -- 2.54.0 From d4491e082e95350bdd887764a5c5bdcb51488414 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 13:25:25 +0000 Subject: [PATCH 03/20] =?UTF-8?q?feat(game):=20queueTask=20=E2=80=94=20que?= =?UTF-8?q?ue-time=20validation,=20cost=20deduction,=20ACTIVE/PENDING=20ro?= =?UTF-8?q?w=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/game/tasks.test.ts | 76 +++++++++++++++++++++- lib/game/tasks.ts | 143 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/lib/game/tasks.test.ts b/lib/game/tasks.test.ts index 56cebea..5b1fa42 100644 --- a/lib/game/tasks.test.ts +++ b/lib/game/tasks.test.ts @@ -1,8 +1,8 @@ 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' +import type { CrewDto } from './save' +import { computeDuration, queueTask, TaskError, type QueueState, type Task, type TaskType } 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. @@ -90,3 +90,75 @@ describe('crew-costs — the §11 tuning surface', () => { expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier }) }) + +describe('queueTask', () => { + // The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills + // the CrewDto shape (unused by the duration model but part of the type). + const CREW: CrewDto[] = [ + { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }, + { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' }, + { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' }, + ] + + const state = (over: Partial = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over }) + const task = (over: Partial = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over }) + + // Sync twin of the rejects.toMatchObject house style — queueTask throws instead of rejecting. + const expectErr = (fn: () => unknown, status: number, message?: RegExp): void => { + let err: unknown + try { + fn() + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(TaskError) + expect((err as TaskError).status).toBe(status) + if (message) expect((err as TaskError).message).toMatch(message) + } + + it('unknown lead or helper id → 404', () => { + expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 42, helperCrewIds: [] }), 404, /Unknown crew member 42/) + expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [42] }), 404, /Unknown crew member 42/) + }) + + it('busy lead → 409 — already committed to a PENDING task (as lead or helper of one)', () => { + expectErr(() => queueTask(state({ tasks: [task({ status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/) + expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [1], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/) + }) + + it('busy helper → 409 — on an ACTIVE task, or committed to a PENDING one', () => { + expectErr(() => queueTask(state({ tasks: [task({ leadCrewId: 3, helpers: [2] })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already busy/) + expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [2], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already committed to a pending task/) + }) + + it('in transit (a JUMP is ACTIVE) → 409 for a non-jump action', () => { + expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3 })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /in transit/) + }) + + it('second jump → 409 while one is pending', () => { + expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3, status: 'PENDING' })] }), { type: 'JUMP', payload: { systemId: 2, hops: 1 }, leadCrewId: 1, helperCrewIds: [] }), 409, /A jump is already queued/) + }) + + it('SHIP / RESEARCH cost deducts at queue time; shortfall → the existing 422', () => { + const hold = [{ kind: 'resource' as const, name: 'IRON', qty: 5 }, { kind: 'part' as const, name: 'ALLOY', qty: 4 }] + const systems = [{ id: 7, type: 'REACTOR', level: 2, capacity: 3 }] + const techRows = [{ id: 3, slug: 'REACTOR_OVERCLOCK' }] // the def's cost: 3 resources (TECHS) + const upgraded = queueTask(state({ inventory: hold, shipSystems: systems, tech: techRows }), { type: 'SHIP_UPGRADE', payload: { systemId: 7 }, leadCrewId: 2, helperCrewIds: [] }) + expect(upgraded.inventory).toEqual([{ kind: 'resource', name: 'IRON', qty: 3 }, { kind: 'part', name: 'ALLOY', qty: 4 }]) // upgradeCost(2) = 2, oldest row first + const shortHold = [{ kind: 'resource' as const, name: 'IRON', qty: 2 }] + expectErr(() => queueTask(state({ inventory: shortHold, tech: techRows }), { type: 'RESEARCH', payload: { techId: 3 }, leadCrewId: 2, helperCrewIds: [] }), 422, /Insufficient resources in the cargo hold — need 3/) + }) + + it('ACTIVE-vs-PENDING rule: PENDING while the lead is busy (no turn change); all free → ACTIVE', () => { + const s = state({ tasks: [task({ leadCrewId: 1 })] }) + const pending = queueTask(s, { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }) + expect(pending.task.status).toBe('PENDING') + expect(pending.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]])) + expect(pending.task.queuedAtTurn).toBe(10) + expect(s.turn).toBe(10) // queueing never advances the turn — only the tick does + const active = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }) + expect(active.task.status).toBe('ACTIVE') + expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]])) + expect(active.task.queuedAtTurn).toBe(10) + }) +}) diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts index 61e6d2d..6b0b0bd 100644 --- a/lib/game/tasks.ts +++ b/lib/game/tasks.ts @@ -3,6 +3,9 @@ // (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' +import type { CrewDto, InventoryItemDto } from './save' +import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from './ship-costs' +import { TECHS } from './tech' export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH' @@ -63,3 +66,143 @@ export function computeDuration( const traitModifierSum = traitModifier(lead.traitGood, type) + traitModifier(lead.traitBad, type) return Math.max(1, Math.ceil(workPoints(type, hopCount) / totalPower) + mismatchPenalty + traitModifierSum) } + +// --- Queueing: validation + row construction (pure, no DB) ------------------------------- +// +// queueTask runs over a plain snapshot of the save (QueueState) and decides whether a new +// task can be queued and what row it becomes. Pure like crew.ts / ship-costs.ts — the route +// (a later task) maps schema rows into QueueState, calls queueTask, and persists the result: +// insert the task row (adding `id` + `saveId` at the DB boundary) and the deducted hold. + +// Carries an HTTP status so route handlers can map failures to responses without re-parsing +// (same pattern as JumpError / ShipError / ResearchError / ExploreError). +export class TaskError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + +// The queueing request — the existing endpoints' bodies plus the crew team. Payload field +// names follow the routes (the spec's names differ): +// JUMP { systemId, hops } — the route computes `hops` with jumpToSystem's logic +// (reachableWithin + NAV AI) and passes it in; the body field is `systemId`, not `toSystemId` +// EXPLORE { planetId } +// SHIP_UPGRADE { systemId } · SHIP_EXPAND { systemId } · SHIP_BUILD { type } +// RESEARCH { techId } — the tech row's id, not the slug +export interface QueueTaskRequest { + type: TaskType + payload: Record + leadCrewId: number + helperCrewIds: number[] +} + +// Plain per-save snapshot the decision runs over — the route maps schema rows into these +// (same row↔domain boundary as the tasks table in save.ts). +export interface QueueState { + turn: number + crew: CrewDto[] + tasks: Task[] + inventory: InventoryItemDto[] // cargo hold, id order (oldest first) + shipSystems: { id: number; type: string; level: number; capacity: number }[] // UPGRADE/EXPAND costs scale with the target's current level/capacity + tech: { id: number; slug: string }[] // RESEARCH cost comes from the matching TECHS def, like research.ts +} + +// The new row minus the two values only the DB boundary knows: `id` (autoincrement) and +// `saveId` (filled in by save.ts's row↔Task mapping on insert). +export interface QueueResult { + task: Omit + inventory: InventoryItemDto[] // unchanged for JUMP/EXPLORE; deducted for SHIP_* / RESEARCH +} + +// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all +// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn. +export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult { + const lead = state.crew.find((c) => c.id === req.leadCrewId) + if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`) + const helpers: CrewDto[] = [] + for (const id of req.helperCrewIds) { + const helper = state.crew.find((c) => c.id === id) + if (!helper) throw new TaskError(404, `Unknown crew member ${id}`) + helpers.push(helper) + } + + // Busy (409). Spec §3: one ACTIVE task per crew member, and a lead may chain one PENDING + // task behind it (FIFO — auto-starts when the current one completes). So: any crew member + // committed to a PENDING task is blocked; a helper on an ACTIVE task is blocked; a lead on + // an ACTIVE task passes and the new task is queued PENDING behind it. + const inStatus = (id: number, status: TaskStatus) => state.tasks.some((t) => t.status === status && (t.leadCrewId === id || t.helpers.includes(id))) + const checkBusy = (c: CrewDto, isLead: boolean) => { + if (inStatus(c.id, 'PENDING')) throw new TaskError(409, `${c.name} is already committed to a pending task`) + if (!isLead && inStatus(c.id, 'ACTIVE')) throw new TaskError(409, `${c.name} is already busy`) + } + checkBusy(lead, true) + for (const helper of helpers) checkBusy(helper, false) + + // In transit (409): while any JUMP is ACTIVE, no new tasks of any type (spec §3). + const jumps = state.tasks.filter((t) => t.type === 'JUMP') + if (jumps.some((t) => t.status === 'ACTIVE')) throw new TaskError(409, 'A jump is in transit — no new tasks can be queued') + // Only one JUMP at a time, active or pending (two arrivals could never resolve on one tick). + // The ACTIVE half is already caught above, so this fires on a pending jump. + if (req.type === 'JUMP' && jumps.some((t) => t.status !== 'DONE')) throw new TaskError(409, 'A jump is already queued') + + // Costs deduct at queue time (spec §3) — the existing cost logic from ship-costs.ts / + // tech.ts and spendItems' 422. JUMP/EXPLORE have no resource cost. Target existence and + // the 409 gates (max level / capacity, not built, already built, build gate, tech prereqs) + // stay in the routes with their existing codes — the route resolves ids before calling. + let inventory = state.inventory + if (req.type === 'SHIP_UPGRADE' || req.type === 'SHIP_EXPAND') { + const system = state.shipSystems.find((s) => s.id === req.payload.systemId)! + inventory = deduct(inventory, 'resource', req.type === 'SHIP_UPGRADE' ? upgradeCost(system.level) : expandCost(system.capacity)) + } else if (req.type === 'SHIP_BUILD') { + inventory = deduct(inventory, 'part', BUILD_PART_COST) + } else if (req.type === 'RESEARCH') { + const def = TECHS.find((t) => t.slug === state.tech.find((r) => r.id === req.payload.techId)?.slug)! + inventory = deduct(inventory, def.costKind, def.costQty) + } + + // Success: duration from the Task-1 formula, status from what the team is doing right now. + // A crew member is "free" when on no ACTIVE/PENDING task; after the checks above only a + // lead chaining behind its own ACTIVE can be non-free. + const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING') + const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0 + const duration = computeDuration(req.type, hops, lead, helpers) + const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING' + + return { + task: { + type: req.type, + payload: JSON.stringify(req.payload), + leadCrewId: req.leadCrewId, + helpers: req.helperCrewIds, + duration, + remaining: duration, + status, + queuedAtTurn: state.turn, // queueing is free — the turn advances only on the tick + }, + inventory, + } +} + +// Pure version of spendItems' consumption (lib/game/ship.ts): the total is checked up front +// (shortfall → the same 422 spendItems throws), then the oldest rows first (array order = id +// order); rows that hit zero are dropped. The input array is never mutated. +function deduct(items: InventoryItemDto[], kind: 'resource' | 'part', amount: number): InventoryItemDto[] { + if (totalQty(items, kind) < amount) { + throw new TaskError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`) + } + let remaining = amount + const out: InventoryItemDto[] = [] + for (const item of items) { + if (item.kind !== kind) { + out.push(item) + continue + } + const take = Math.min(item.qty, remaining) + remaining -= take + if (take < item.qty) out.push({ ...item, qty: item.qty - take }) + } + return out +} -- 2.54.0 From bbcd77c41ba18b7463538ca90a45d1fbf5c390b2 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 13:55:32 +0000 Subject: [PATCH 04/20] =?UTF-8?q?feat(game):=20per-type=20resolvers=20?= =?UTF-8?q?=E2=80=94=20jump/explore/ship/research=20effect=20extraction=20?= =?UTF-8?q?for=20task=20resolution=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/game/research.test.ts | 77 ++++++++++++++++++++- lib/game/research.ts | 30 +++++++-- lib/game/save.test.ts | 137 +++++++++++++++++++++++++++++++++++++- lib/game/save.ts | 117 +++++++++++++++++++++++--------- lib/game/ship.test.ts | 129 ++++++++++++++++++++++++++++++++++- lib/game/ship.ts | 91 +++++++++++++++++++------ 6 files changed, 521 insertions(+), 60 deletions(-) diff --git a/lib/game/research.test.ts b/lib/game/research.test.ts index ef91764..a5a5341 100644 --- a/lib/game/research.test.ts +++ b/lib/game/research.test.ts @@ -6,9 +6,11 @@ import { eq } from 'drizzle-orm' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { BUILD_PART_COST, MAX_LEVEL, upgradeCost } from './ship-costs' -import { buildSystem, getShipState, upgradeSystem, type ShipState } from './ship' +import { buildSystem, getShipState, spendItems, upgradeSystem, type ShipState } from './ship' import { TECHS } from './tech' -import { getTechState, researchTech } from './research' +import { SCHEMA_VERSION } from './save' +import { getTechState, researchTech, resolveResearchTask } from './research' +import type { Task } from './tasks' const dirs: string[] = [] @@ -145,3 +147,74 @@ describe('gating of ship actions', () => { expect(systemOf(state, 'REACTOR').level).toBe(MAX_LEVEL + 1) }) }) + +// --- A/B: resolver vs the legacy immediate action (Task 4a) --------------------------- +const T0 = '2026-10-01T00:00:00.000Z' + +// Stamped save (no backfill), the full tech tree, nothing researched yet. Identical build +// per world → ids line up. +async function buildResearchFixture(db: GameDb): Promise { + const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() + for (const def of TECHS) { + await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect }) + } +} + +const task = (payload: object): Task => + ({ id: 0, saveId: 0, type: 'RESEARCH', payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) + +async function snapshot(db: GameDb): Promise { + const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ + db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), + db.db.select().from(schema.solarSystems), + db.db.select().from(schema.jumpRoutes), + db.db.select().from(schema.planets), + db.db.select().from(schema.inventoryItems), + db.db.select().from(schema.crew), + db.db.select().from(schema.shipSystems), + db.db.select().from(schema.tech), + db.db.select().from(schema.missions), + db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), + ]) + return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) +} + +describe('resolveResearchTask (A/B against researchTech)', () => { + // A = legacy immediate action (validates, deducts the cost, +1 turn, marks researched, + // event). B = the queued shape: the cost was deducted at queue time (Task 3), the tick + // advanced the turn (Task 4b), then the resolver marks the tech. Same starting world → + // byte-identical end states. + + it('lands exactly what researchTech lands', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + await buildResearchFixture(a) + await buildResearchFixture(b) + await addItem(a, 'resource', 'IRON ORE', 2) // NAVIGATION AI costs 2 resources + await addItem(b, 'resource', 'IRON ORE', 2) + + const [navA] = await a.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) + const [navB] = await b.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) + await researchTech(a, navA.id) + await spendItems(b, 1, 'resource', 2) // the queue-time deduction + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveResearchTask(b, task({ techId: navB.id })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + expect((await b.db.select().from(schema.tech)).find((t) => t.id === navB.id)?.researched).toBe(true) + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'research')! + expect(ev.turn).toBe(1) + expect(ev.message).toBe('Researched NAVIGATION AI (cost: 2× resources)') + }) + + it('guards: 404 unknown tech, 409 already researched (prereqs are monotone — no guard needed)', async () => { + const { db } = await freshFile() + await buildResearchFixture(db) + const [nav] = await db.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) + await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + + await expect(resolveResearchTask(db, task({ techId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown tech 999' }) + await resolveResearchTask(db, task({ techId: nav.id })) + await expect(resolveResearchTask(db, task({ techId: nav.id }))).rejects.toMatchObject({ status: 409, message: 'NAVIGATION AI is already researched' }) + }) +}) diff --git a/lib/game/research.ts b/lib/game/research.ts index 13cae51..ee9f9be 100644 --- a/lib/game/research.ts +++ b/lib/game/research.ts @@ -4,6 +4,7 @@ import { persist, type GameDb } from '@/lib/db/client' import { EFFECTS, TECHS, techStatus, type TechDto } from './tech' import { ensureSave } from './save' import { ShipError, spendItems } from './ship' +import type { Task, TaskType } from './tasks' // Technology research: the SKILL TREE tab's read + action. Researching a tech costs inventory // items (oldest row first, like ship actions) plus a turn, and its unlock effect then gates or @@ -46,8 +47,31 @@ export async function getTechState(db: GameDb): Promise { }) } +function syntheticTask(type: TaskType, payload: object): Task { + return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } +} + +// RESEARCH — mark the payload's tech researched at the current turn. No cost here (deducted +// at queue time — Task 3 — or by researchTech before delegating); no mission hooks (the +// current action has none). +export async function resolveResearchTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { techId } = JSON.parse(task.payload) as { techId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const rows = await db.db.select().from(schema.tech) + const target = rows.find((t) => t.id === techId) + if (!target) throw new ResearchError(404, `Unknown tech ${techId}`) + + const def = TECHS.find((t) => t.slug === target.slug)! + if (target.researched) throw new ResearchError(409, `${def.name} is already researched`) + + await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() }) + await persist(db) +} + // Research one tech: validates prerequisites (409, naming what is missing) and the cost (422), -// spends it from the cargo hold, marks it researched, costs a turn and writes an event. +// spends it from the cargo hold, then delegates the unlock to the resolver at the new turn. export async function researchTech(db: GameDb, techId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) @@ -70,10 +94,8 @@ export async function researchTech(db: GameDb, techId: number): Promise { }) }) +// --- 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.id, toSystem: s1.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id }) + const [p0] = await db.db.insert(schema.planets).values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id }) + const [p1] = await db.db.insert(schema.planets).values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id }) + return { sys1: s1.id, p0: p0.id, p1: p1.id } +} + +// The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed +// identity fields, the same way the public functions' delegation does. +const task = (type: 'JUMP' | 'EXPLORE', payload: object): Task => + ({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) + +async function snapshot(db: GameDb): Promise { + const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ + db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), + db.db.select().from(schema.solarSystems), + db.db.select().from(schema.jumpRoutes), + db.db.select().from(schema.planets), + db.db.select().from(schema.inventoryItems), + db.db.select().from(schema.crew), + db.db.select().from(schema.shipSystems), + db.db.select().from(schema.tech), + db.db.select().from(schema.missions), + db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), + ]) + return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) +} + +describe('resolveJumpTask (A/B against jumpToSystem)', () => { + it('undiscovered target: lands exactly what jumpToSystem lands — move, discover, ring, event', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + const fa = await buildWorldFixture(a) + const fb = await buildWorldFixture(b) + + await jumpToSystem(a, fa.sys1) // legacy: validates, +1 turn, effect, event at turn 1 + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) // the tick's job (Task 4b) + await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + }) + + it('discovered target: no ring — still byte-identical to jumpToSystem', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + const fa = await buildWorldFixture(a, true) + const fb = await buildWorldFixture(b, true) + + await jumpToSystem(a, fa.sys1) + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + expect(await b.db.select().from(schema.solarSystems)).toHaveLength(2) // no ring added + }) + + it('guards: 404 unknown system, 409 already-current', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + + await expect(resolveJumpTask(db, task('JUMP', { systemId: 999, hops: 1 }))).rejects.toMatchObject({ status: 404, message: 'Unknown system 999' }) + await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, 1)) + await db.db.update(schema.solarSystems).set({ isCurrent: true }).where(eq(schema.solarSystems.id, f.sys1)) + await expect(resolveJumpTask(db, task('JUMP', { systemId: f.sys1, hops: 1 }))).rejects.toMatchObject({ status: 409, message: 'Already at this system' }) + }) +}) + +describe('resolveExploreTask (A/B against explorePlanet)', () => { + it('planet in the current system: lands exactly what explorePlanet lands — haul, explored, event', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + const fa = await buildWorldFixture(a) + const fb = await buildWorldFixture(b) + + await explorePlanet(a, fa.p0) + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p0 })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + }) + + it('payload planet wins over the current system — the ship stays put (elsewhere semantics)', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + const fa = await buildWorldFixture(a) + const fb = await buildWorldFixture(b) + + await explorePlanet(a, fa.p1) // the current function already scans any planet, not just the current system's + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p1 })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + + const sysB = await b.db.select().from(schema.solarSystems) + expect(sysB.filter((s) => s.isCurrent).map((s) => s.name)).toEqual(['Origin']) // the ship never moved + expect(sysB.find((s) => s.name === 'Outpost')!.discovered).toBe(false) // a scan never discovers a system + + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'explore')! + expect(ev.turn).toBe(1) + // Exact existing format, recomputed the way save.ts computes it: haul first, crew roll on the same stream. + const rng = explorationRng('42', fb.p1) + const haul = rollExploration(rng) + const member = rollCrewMember(rng, new Set()) + const foundText = haul.length > 0 ? haul.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' + expect(ev.message).toBe(`Explored Basalt — found ${foundText}${member ? ` — ${member.name} joined the crew` : ''}`) + }) + + it('guards: 404 unknown planet, 409 already explored (double-resolution safe)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + + await expect(resolveExploreTask(db, task('EXPLORE', { planetId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown planet 999' }) + await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 })) + await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' }) + }) +}) + describe('backfill (old saves)', () => { // Planets/moons of one system in insertion order, nested by parent — ids are excluded so a // regenerated set compares equal to the original even though autoincrement moved on. diff --git a/lib/game/save.ts b/lib/game/save.ts index 88115cc..8f1f06c 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -5,6 +5,7 @@ import * as schema from '@/lib/db/schema' import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { checkMissionCompletion, topUpMissionBoard } from './missions' +import type { Task, TaskType } from './tasks' import { TECHS } from './tech' import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world' @@ -211,6 +212,58 @@ export async function getWorldState(db: GameDb): Promise { } } +// --- Per-type resolvers (PR A, spec §3) ------------------------------------------- +// +// The effect bodies of the instant actions, extracted so the turn tick (Task 4b) can +// resolve a completed task row against whatever the world looks like at resolution time. +// Contract (all six resolvers across save.ts / ship.ts / research.ts — see the Interfaces +// block of this plan): effects land on the PAYLOAD target, never on the current location; +// no turn advance (the caller owns the turn); no cost deduction (queueTask deducted at +// queue time — Task 3; the immediate public functions deduct before delegating); a minimal +// guard against a stale or double-resolved target (existing error class + exact message — +// a throw is the signal for the 4b tick's re-validation); the existing event text formats +// at the current turn; persist at the end. + +// A stand-in task row for the immediate public functions delegating to a resolver: the +// resolvers only read `type` + `payload`, so the identity fields are zeroed. +function syntheticTask(type: TaskType, payload: object): Task { + return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } +} + +// JUMP — move the ship to the payload's system: flip isCurrent, discover the target, and +// when it was undiscovered grow the chart ring off the same (seed, turn) stream as before. +export async function resolveJumpTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { systemId } = JSON.parse(task.payload) as { systemId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const systems = await db.db.select().from(schema.solarSystems) + + const target = systems.find((s) => s.id === systemId) + if (!target) throw new JumpError(404, `Unknown system ${systemId}`) + if (target.isCurrent) throw new JumpError(409, 'Already at this system') + const current = systems.find((s) => s.isCurrent)! + + await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id)) + await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id)) + + if (!target.discovered) { + // Growing the chart: a ring of new (undiscovered) neighbours around the newly reached + // system, so there is always somewhere to jump next and a route back. + const rng = expansionRng(save.seed, save.turn) + const takenNames = new Set(systems.map((s) => s.name)) + const ring = generateRing(rng, target.x, target.y, takenNames, systems) + const ringIds: number[] = [] + for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed)) + const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1]) + await insertRoutes(db, ring.edges, resolve) + } + + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() }) + await checkMissionCompletion(db, save.id, save.turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination + await topUpMissionBoard(db) + await persist(db) +} + export async function jumpToSystem(db: GameDb, systemId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) @@ -222,33 +275,18 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise 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.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, 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.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) - await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() }) - await checkMissionCompletion(db, save.id, turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination - await topUpMissionBoard(db) - await persist(db) + await resolveJumpTask(db, syntheticTask('JUMP', { systemId, hops: hopCount })) return getWorldState(db) } @@ -331,12 +369,13 @@ export interface ExploreResult extends SystemState { crewFound: CrewDto | null // a member who joined the ship on this scan (null when none) } -// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored, -// costs a turn, accumulates finds into inventory and writes an event. The same per-planet -// rng stream also rolls for a crew member (~18% of scans) who joins the roster. Any existing -// planet may be scanned (the UI only offers the current system's rows). -export async function explorePlanet(db: GameDb, planetId: number): Promise { +// 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) @@ -347,7 +386,6 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise c.name)) const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream - const turn = save.turn + 1 await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id)) for (const item of outcome) { @@ -370,11 +408,30 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : '' - await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) - await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() }) - await checkMissionCompletion(db, save.id, turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed + 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 } +} + +// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored, +// costs a turn, accumulates finds into inventory and writes an event. The same per-planet +// rng stream also rolls for a crew member (~18% of scans) who joins the roster. Any existing +// planet may be scanned (the UI only offers the current system's rows). +export async function explorePlanet(db: GameDb, planetId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const planets = await db.db.select().from(schema.planets) + const target = planets.find((p) => p.id === planetId) + if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`) + if (target.explored) throw new ExploreError(409, `${target.name} was already explored`) + + // The immediate scan keeps its one-turn cost; the resolver applies everything else at that turn. + const turn = save.turn + 1 + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + const { outcome, crewFound } = await resolveExploreTask(db, syntheticTask('EXPLORE', { planetId })) + return { ...(await readSystemState(db)), outcome, crewFound } } diff --git a/lib/game/ship.test.ts b/lib/game/ship.test.ts index 1c8f374..3f6352f 100644 --- a/lib/game/ship.test.ts +++ b/lib/game/ship.test.ts @@ -6,7 +6,10 @@ import { eq } from 'drizzle-orm' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { BASE_CAPACITY, BASE_LEVEL, BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES } from './ship-costs' -import { buildSystem, expandSystem, getShipState, upgradeSystem, type ShipState } from './ship' +import { buildSystem, expandSystem, getShipState, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, spendItems, upgradeSystem, type ShipState } from './ship' +import { SCHEMA_VERSION } from './save' +import { TECHS } from './tech' +import type { Task } from './tasks' const dirs: string[] = [] @@ -155,3 +158,127 @@ describe('buildSystem', () => { await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type }) }) + +// --- A/B: resolvers vs the legacy immediate ship actions (Task 4a) --------------------- +const T0 = '2026-10-01T00:00:00.000Z' + +// Stamped save (no backfill), the full ship per SHIP_TYPES, the full tech tree with +// WEAPONS_SYSTEMS researched (unlocks the BUILD row). Identical build per world → ids line up. +async function buildShipFixture(db: GameDb): Promise { + const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() + for (const def of SHIP_TYPES) { + await db.db.insert(schema.shipSystems).values({ saveId: save.id, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 }) + } + for (const def of TECHS) { + await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: def.slug === 'WEAPONS_SYSTEMS' }) + } +} + +const task = (type: 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD', payload: object): Task => + ({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) + +async function snapshot(db: GameDb): Promise { + const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ + db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), + db.db.select().from(schema.solarSystems), + db.db.select().from(schema.jumpRoutes), + db.db.select().from(schema.planets), + db.db.select().from(schema.inventoryItems), + db.db.select().from(schema.crew), + db.db.select().from(schema.shipSystems), + db.db.select().from(schema.tech), + db.db.select().from(schema.missions), + db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), + ]) + return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) +} + +describe('resolveShip* (A/B against the immediate actions)', () => { + // A = legacy immediate action (validates, deducts the cost, +1 turn, effect, event). + // B = the queued shape: the cost is deducted up front (queueTask did it at queue time — + // Task 3), the tick advanced the turn (Task 4b), then the resolver applies the effect. + // Same starting world → byte-identical end states. + + it('UPGRADE: lands exactly what upgradeSystem lands', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + await buildShipFixture(a) + await buildShipFixture(b) + await addItem(a, 'resource', 'IRON ORE', 1) // level 1 → cost 1 + await addItem(b, 'resource', 'IRON ORE', 1) + + const reactorId = systemOf(await getShipState(a), 'REACTOR').id + await upgradeSystem(a, reactorId) + await spendItems(b, 1, 'resource', 1) // the queue-time deduction + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveShipUpgradeTask(b, task('SHIP_UPGRADE', { systemId: reactorId })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'upgrade')! + expect(ev.turn).toBe(1) + expect(ev.message).toBe('Upgraded REACTOR to level 2 (cost: 1× resources)') + }) + + it('EXPAND: lands exactly what expandSystem lands', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + await buildShipFixture(a) + await buildShipFixture(b) + await addItem(a, 'resource', 'CRYSTAL DUST', 2) // capacity 2 → cost 2 + await addItem(b, 'resource', 'CRYSTAL DUST', 2) + + const hullId = systemOf(await getShipState(a), 'HULL').id + await expandSystem(a, hullId) + await spendItems(b, 1, 'resource', 2) + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveShipExpandTask(b, task('SHIP_EXPAND', { systemId: hullId })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'expand')! + expect(ev.turn).toBe(1) + expect(ev.message).toBe('Expanded HULL capacity to 3 (cost: 2× resources)') + }) + + it('BUILD: lands exactly what buildSystem lands', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + await buildShipFixture(a) + await buildShipFixture(b) + await addItem(a, 'part', 'SERVO ACTUATOR', 3) + await addItem(b, 'part', 'SERVO ACTUATOR', 3) + + await buildSystem(a, 'WEAPONS') + await spendItems(b, 1, 'part', 3) + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveShipBuildTask(b, task('SHIP_BUILD', { type: 'WEAPONS' })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'build')! + expect(ev.turn).toBe(1) + expect(ev.message).toBe('Built WEAPONS at base values (cost: 3× parts)') + }) + + it('guards: 404 unknown, 409 unbuilt / max level / max capacity / gate / already built', async () => { + const { db } = await freshFile() + await buildShipFixture(db) + const state = await getShipState(db) + const reactorId = systemOf(state, 'REACTOR').id + const hullId = systemOf(state, 'HULL').id + const weaponsId = systemOf(state, 'WEAPONS').id + await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + + await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' }) + await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: weaponsId }))).rejects.toMatchObject({ status: 409, message: 'WEAPONS is not built' }) + await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) + await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: reactorId }))).rejects.toMatchObject({ status: 409, message: `REACTOR is at max level (${MAX_LEVEL})` }) + + await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' }) + await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId)) + await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: hullId }))).rejects.toMatchObject({ status: 409, message: `HULL is at max capacity (${MAX_CAPACITY})` }) + + await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'SHIELDS' }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system type SHIELDS' }) + await db.db.update(schema.tech).set({ researched: false }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS')) + await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'WEAPONS' }))).rejects.toMatchObject({ status: 409, message: 'Building WEAPONS requires the WEAPONS SYSTEMS tech' }) + await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'HULL' }))).rejects.toMatchObject({ status: 409, message: 'HULL is already built' }) + }) +}) diff --git a/lib/game/ship.ts b/lib/game/ship.ts index 969bdb3..0a39baa 100644 --- a/lib/game/ship.ts +++ b/lib/game/ship.ts @@ -4,6 +4,7 @@ import { persist, type GameDb } from '@/lib/db/client' import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs' import { buildGate, effectiveCapacityCap, effectiveLevelCap } from './tech' import { ensureSave, type InventoryItemDto } from './save' +import type { Task, TaskType } from './tasks' export interface ShipSystemDto { id: number @@ -68,17 +69,69 @@ export async function spendItems(db: GameDb, saveId: number, kind: 'resource' | } } -async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type: string, message: string): Promise { - await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, saveId)) - await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() }) -} - // Slugs of the researched techs on this save — drives the effective caps and build gates below. async function researchedTechSlugs(db: GameDb): Promise> { return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug)) } -// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). Cost: `level` units of any resource. +function syntheticTask(type: TaskType, payload: object): Task { + return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } +} + +// SHIP_UPGRADE — level a built system by one, at whatever level it sits at resolution +// time. No cost here (deducted at queue time — Task 3 — or by upgradeSystem before +// delegating); no mission hooks (the current action has none). +export async function resolveShipUpgradeTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { systemId } = JSON.parse(task.payload) as { systemId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) + if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) + if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) + const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) + if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) + + const cost = upgradeCost(target.level) + await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'upgrade', message: `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() }) + await persist(db) +} + +// SHIP_EXPAND — same shape for capacity (cost: `capacity` units of any resource, deducted). +export async function resolveShipExpandTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { systemId } = JSON.parse(task.payload) as { systemId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) + if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) + if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) + const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) + if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) + + const cost = expandCost(target.capacity) + await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'expand', message: `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() }) + await persist(db) +} + +// SHIP_BUILD — build an unbuilt type at base values. The flat parts cost is already +// deducted; some types are gated behind a researched tech (see EFFECTS in lib/game/tech.ts). +export async function resolveShipBuildTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { type } = JSON.parse(task.payload) as { type: string } + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type) + if (!target) throw new ShipError(404, `Unknown ship system type ${type}`) + if (target.level > 0) throw new ShipError(409, `${target.type} is already built`) + const gate = buildGate(type, await researchedTechSlugs(db)) + if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) + + await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'build', message: `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`, createdAt: new Date().toISOString() }) + await persist(db) +} + +// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). export async function upgradeSystem(db: GameDb, systemId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) @@ -88,16 +141,14 @@ export async function upgradeSystem(db: GameDb, systemId: number): Promise= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) - const cost = upgradeCost(target.level) - await spendItems(db, save.id, 'resource', cost) + await spendItems(db, save.id, 'resource', upgradeCost(target.level)) const turn = save.turn + 1 - await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id)) - await bumpTurnAndEvent(db, save.id, turn, 'upgrade', `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`) - await persist(db) + 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). Cost: `capacity` units of any resource. +// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). export async function expandSystem(db: GameDb, systemId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) @@ -107,17 +158,14 @@ export async function expandSystem(db: GameDb, systemId: number): Promise= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) - const cost = expandCost(target.capacity) - await spendItems(db, save.id, 'resource', cost) + await spendItems(db, save.id, 'resource', expandCost(target.capacity)) const turn = save.turn + 1 - await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id)) - await bumpTurnAndEvent(db, save.id, turn, 'expand', `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`) - await persist(db) + 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. Some types are -// gated behind a researched tech (see EFFECTS in lib/game/tech.ts). +// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. export async function buildSystem(db: GameDb, type: string): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) @@ -129,8 +177,7 @@ export async function buildSystem(db: GameDb, type: string): Promise await spendItems(db, save.id, 'part', BUILD_PART_COST) const turn = save.turn + 1 - await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id)) - await bumpTurnAndEvent(db, save.id, turn, 'build', `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`) - await persist(db) + 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) } -- 2.54.0 From d7faf739a961a936a2dbe8fd3b8f9111554235de Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 14:30:49 +0000 Subject: [PATCH 05/20] =?UTF-8?q?feat(game):=20tickTurn=20+=20runTurn=20?= =?UTF-8?q?=E2=80=94=20the=20turn=20tick:=20decrement,=20resolve,=20FIFO?= =?UTF-8?q?=20auto-start,=20one=20turn=20per=20call=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/game/save.test.ts | 130 +++++++++++++++++++++++++++++++++++++- lib/game/save.ts | 80 +++++++++++++++++++++++- lib/game/tasks.test.ts | 137 +++++++++++++++++++++++++++++++++++++---- lib/game/tasks.ts | 52 ++++++++++++++++ 4 files changed, 385 insertions(+), 14 deletions(-) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index e681879..f7c0148 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -6,9 +6,10 @@ import { eq } from 'drizzle-orm' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew' +import { MIN_AVAILABLE } from './missions' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { TECHS } from './tech' -import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, type WorldState } from './save' +import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save' import type { Task } from './tasks' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' @@ -439,6 +440,133 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => { }) }) +// --- 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. +describe('runTurn (tick on the DB)', () => { + // buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no + // backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) / + // Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so + // this block adds its own rows. + async function addCrew(db: GameDb, count: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const ids: number[] = [] + for (let i = 0; i < count; i++) { + const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id }) + ids.push(row.id) + } + return ids + } + + async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id }) + return row.id + } + + it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const count = (t: typeof schema.solarSystems | typeof schema.planets | typeof schema.inventoryItems | typeof schema.missions | typeof schema.events | typeof schema.tasks) => 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 [mission] = 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 === mission.id)).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 + }) +}) + describe('backfill (old saves)', () => { // Planets/moons of one system in insertion order, nested by parent — ids are excluded so a // regenerated set compares equal to the original even though autoincrement moved on. diff --git a/lib/game/save.ts b/lib/game/save.ts index 8f1f06c..2ae6cf2 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -5,7 +5,9 @@ import * as schema from '@/lib/db/schema' import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { checkMissionCompletion, topUpMissionBoard } from './missions' -import type { Task, TaskType } from './tasks' +import { resolveResearchTask, ResearchError } from './research' +import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship' +import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks' import { TECHS } from './tech' import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world' @@ -435,3 +437,79 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise Promise> = { + JUMP: resolveJumpTask, + EXPLORE: resolveExploreTask, + SHIP_UPGRADE: resolveShipUpgradeTask, + SHIP_EXPAND: resolveShipExpandTask, + SHIP_BUILD: resolveShipBuildTask, + RESEARCH: resolveResearchTask, +} + +// Row→domain at the save.ts boundary (the inverse — Task→row at insert — stays with the +// routes' queueing path). +function rowToTask(row: { id: number; saveId: number; type: string; payload: string; leadCrewId: number; helpers: string; duration: number; remaining: number; status: string; queuedAtTurn: number }): Task { + return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn } +} + +export async function runTurn(db: GameDb): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([ + db.db.select().from(schema.crew), + db.db.select().from(schema.tasks), + db.db.select().from(schema.inventoryItems), + db.db.select().from(schema.shipSystems), + db.db.select().from(schema.tech), + ]) + const state: QueueState = { + turn: save.turn, + crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })), + tasks: taskRows.map(rowToTask), + inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })), + shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })), + tech: tech.map((t) => ({ id: t.id, slug: t.slug })), + } + + const result = tickTurn(state) + + // (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events + // and the checkMissionCompletion / topUpMissionBoard hooks (4a contract) land at the new + // turn, exactly where today's actions place them. + await db.db.update(schema.saves).set({ turn: result.turn }).where(eq(schema.saves.id, save.id)) + + // (b) + (d) resolve in the order tickTurn returned. A resolver throw is its 4a guard + // (stale or double-resolved target): the task fails — it is already DONE (the tick freed + // the team; the turn is spent), the effect simply does not land, and no event is written + // for it. Only the four domain error classes fail a task; anything else rethrows. + for (const task of result.completed) { + try { + await TASK_RESOLVERS[task.type](db, task) + } catch (err) { + if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue + throw err + } + } + + // Persist the tick's queue changes (the resolvers already persisted their own effects) — + // one final whole-DB write makes the turn atomic on disk. + for (const before of state.tasks) { + const after = result.tasks.find((t) => t.id === before.id) + if (after && (after.status !== before.status || after.remaining !== before.remaining)) { + await db.db.update(schema.tasks).set({ status: after.status, remaining: after.remaining }).where(eq(schema.tasks.id, after.id)) + } + } + await persist(db) + + return result +} diff --git a/lib/game/tasks.test.ts b/lib/game/tasks.test.ts index 5b1fa42..cbd5b77 100644 --- a/lib/game/tasks.test.ts +++ b/lib/game/tasks.test.ts @@ -2,7 +2,7 @@ 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 { CrewDto } from './save' -import { computeDuration, queueTask, TaskError, type QueueState, type Task, type TaskType } from './tasks' +import { computeDuration, queueTask, tickTurn, TaskError, type QueueState, type Task, type TaskType } 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. @@ -91,18 +91,20 @@ describe('crew-costs — the §11 tuning surface', () => { }) }) +// Shared queue fixtures (file scope — reused by the queueTask and tickTurn blocks): the roster, +// a state factory (turn 10, empty queue) and a one-turn row factory. +// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills +// the CrewDto shape (unused by the duration model but part of the type). +const CREW: CrewDto[] = [ + { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }, + { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' }, + { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' }, +] + +const state = (over: Partial = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over }) +const task = (over: Partial = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over }) + describe('queueTask', () => { - // The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills - // the CrewDto shape (unused by the duration model but part of the type). - const CREW: CrewDto[] = [ - { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }, - { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' }, - { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' }, - ] - - const state = (over: Partial = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over }) - const task = (over: Partial = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over }) - // Sync twin of the rejects.toMatchObject house style — queueTask throws instead of rejecting. const expectErr = (fn: () => unknown, status: number, message?: RegExp): void => { let err: unknown @@ -162,3 +164,114 @@ describe('queueTask', () => { expect(active.task.queuedAtTurn).toBe(10) }) }) + +// --- 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. +describe('tickTurn', () => { + // The queueTask block's file-scope fixture helpers (above): CREW = [1 AVA PILOT, + // 2 BEN ENGINEER, 3 CASS SCIENTIST]; state() → { turn: 10, crew: CREW, tasks: [], … }; + // task() → a one-turn row. The tick never parses payloads — the targets below are inert. + const JUMP = (id: number, over: Partial = {}): Task => task({ id, type: 'JUMP', payload: '{"systemId":2,"hops":1}', ...over }) + const EXPLORE = (id: number, planetId: number, over: Partial = {}): Task => task({ id, type: 'EXPLORE', payload: `{"planetId":${planetId}}`, ...over }) + + it('decrements every ACTIVE task by one; a task that hits 0 is DONE (its team freed); PENDING rows do not age; the input is never mutated', () => { + const s = state({ + tasks: [ + JUMP(1, { duration: 3, remaining: 3, leadCrewId: 1, queuedAtTurn: 8 }), + EXPLORE(2, 5, { leadCrewId: 3, helpers: [2], duration: 1, remaining: 1, queuedAtTurn: 9 }), + EXPLORE(3, 6, { leadCrewId: 1, duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 10 }), // member 1 is on ACTIVE 1 + ], + }) + + const r = tickTurn(s) + expect(r.turn).toBe(11) // the tick is the increment + expect(r.tasks.find((t) => t.id === 1)!.remaining).toBe(2) + expect(r.tasks.find((t) => t.id === 1)!.status).toBe('ACTIVE') + expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE') + expect(r.completed.map((t) => t.id)).toEqual([2]) + expect(r.tasks.find((t) => t.id === 3)!.status).toBe('PENDING') + expect(r.tasks.find((t) => t.id === 3)!.remaining).toBe(4) // pending does not age + expect(s.tasks.find((t) => t.id === 1)!.remaining).toBe(3) // input state untouched + }) + + it('a 3-deep PENDING chain on one lead: when the lead task completes, exactly ONE successor starts — earliest queuedAtTurn wins (Review Focus 2)', () => { + let s = state({ + tasks: [ + JUMP(9, { duration: 3, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }), + EXPLORE(10, 5, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), + EXPLORE(11, 6, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 9 }), + EXPLORE(12, 7, { leadCrewId: 1, duration: 1, remaining: 1, status: 'PENDING', queuedAtTurn: 10 }), + ], + }) + let r = tickTurn(s) + expect(r.completed.map((t) => t.id)).toEqual([9]) + expect(r.tasks.find((t) => t.id === 9)!.status).toBe('DONE') + expect(r.tasks.find((t) => t.id === 10)!.status).toBe('ACTIVE') // earliest queuedAtTurn starts + expect(r.tasks.find((t) => t.id === 10)!.remaining).toBe(2) // full duration — first decrement next tick + expect(r.tasks.find((t) => t.id === 11)!.status).toBe('PENDING') // the others wait for their own tick + expect(r.tasks.find((t) => t.id === 12)!.status).toBe('PENDING') + + // Walk the chain to the end: each successor starts on exactly the tick its predecessor completes. + const EXPECT: [completed: number[], active: number[]][] = [ + [[], [10]], + [[10], [11]], + [[], [11]], + [[11], [12]], + [[12], []], + ] + for (const [done, active] of EXPECT) { + s = { ...s, turn: r.turn, tasks: r.tasks } + r = tickTurn(s) + expect(r.completed.map((t) => t.id)).toEqual(done) + expect(r.tasks.filter((t) => t.status === 'ACTIVE').map((t) => t.id)).toEqual(active) + } + expect(r.turn).toBe(16) // 10 + 1 (the first tick) + 5 (the walk) + expect(r.tasks.every((t) => t.status === 'DONE')).toBe(true) + }) + + it('same queuedAtTurn ties break by id, and a started task blocks a later one sharing a member — at most one start per freed member (Review Focus 2, corrupt-row half)', () => { + // queueTask forbids a member committed to two PENDINGs (rule 2), so this shape only + // exists on admin-corrupted rows — the tick must still be deterministic about it. + const s = state({ + tasks: [ + JUMP(19, { duration: 1, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }), + EXPLORE(20, 5, { leadCrewId: 2, helpers: [3], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), + EXPLORE(21, 6, { leadCrewId: 3, helpers: [2], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), + ], + }) + const r = tickTurn(s) + expect(r.tasks.find((t) => t.id === 20)!.status).toBe('ACTIVE') // (8, 20) < (8, 21) — starts first and takes members 2 and 3 + expect(r.tasks.find((t) => t.id === 21)!.status).toBe('PENDING') + }) + + it('a JUMP and an EXPLORE can both resolve on the same tick — completion is location-independent (Review Focus 3, tick half)', () => { + const s = state({ + tasks: [ + EXPLORE(1, 5, { leadCrewId: 1, duration: 1, remaining: 1, queuedAtTurn: 8 }), + JUMP(2, { leadCrewId: 2, duration: 1, remaining: 1, queuedAtTurn: 8 }), + ], + }) + const r = tickTurn(s) + expect(r.completed.map((t) => t.id)).toEqual([1, 2]) // both this tick, queue order (explore first) + expect(r.tasks.find((t) => t.id === 1)!.status).toBe('DONE') + expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE') + }) + + it('free skip: an empty queue advances exactly one turn and changes nothing; a fully-free PENDING still starts (Review Focus 4)', () => { + const r = tickTurn(state()) + expect(r.turn).toBe(11) + expect(r.tasks).toEqual([]) + expect(r.completed).toEqual([]) + + // No ACTIVE tasks and a startable PENDING (a corrupt-row shape — a legal PENDING always + // has a busy member): the pass still starts it. Skipping means "no resolution", not + // "no starts". + const r2 = tickTurn(state({ tasks: [EXPLORE(30, 5, { leadCrewId: 1, helpers: [2, 3], duration: 3, remaining: 3, status: 'PENDING', queuedAtTurn: 9 })] })) + expect(r2.turn).toBe(11) + expect(r2.tasks.find((t) => t.id === 30)!.status).toBe('ACTIVE') + }) +}) diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts index 6b0b0bd..0c2b2c8 100644 --- a/lib/game/tasks.ts +++ b/lib/game/tasks.ts @@ -206,3 +206,55 @@ function deduct(items: InventoryItemDto[], kind: 'resource' | 'part', amount: nu } return out } + +// --- 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). 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 on an + // ACTIVE task, and no member committed to an EARLIER (queuedAtTurn, id) PENDING that is still + // pending. A member on a LATER PENDING does not block — this task has first claim, and once + // started it holds that member via the ACTIVE clause, blocking the later PENDING in the same + // pass. Under queueTask's invariants (one PENDING per member) the PENDING clause only bites + // on admin-corrupted rows, where it keeps the pass deterministic: of a contested member's + // PENDINGs the earliest (queuedAtTurn, id) always gets first claim, so at most ONE of their + // PENDINGs starts per tick (the FIFO chain of spec §3), and no PENDING starves behind a peer. + // A start 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. + 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 && byQueueOrder(o, t) < 0 && (o.leadCrewId === id || o.helpers.includes(id))) + if ([t.leadCrewId, ...t.helpers].every(free)) t.status = 'ACTIVE' + } + + return { turn, tasks, completed } +} -- 2.54.0 From 4e312d054fffcff18f9e8c75bd00908b07946b22 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 15:47:31 +0000 Subject: [PATCH 06/20] feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44) --- app/api/explore/route.test.ts | 84 ++++++++++++++++++++++++++ app/api/explore/route.ts | 39 ++++++++++-- app/api/jump/route.test.ts | 109 ++++++++++++++++++++++++++++++++++ app/api/jump/route.ts | 37 ++++++++++-- lib/game/crew.ts | 5 ++ lib/game/save.test.ts | 43 +++----------- lib/game/save.ts | 59 +++++++++++++++++- lib/game/test-fixtures.ts | 80 +++++++++++++++++++++++++ lib/openapi.test.ts | 12 ++++ lib/openapi.ts | 53 ++++++++++------- 10 files changed, 455 insertions(+), 66 deletions(-) create mode 100644 app/api/explore/route.test.ts create mode 100644 app/api/jump/route.test.ts create mode 100644 lib/game/test-fixtures.ts diff --git a/app/api/explore/route.test.ts b/app/api/explore/route.test.ts new file mode 100644 index 0000000..881bc0b --- /dev/null +++ b/app/api/explore/route.test.ts @@ -0,0 +1,84 @@ +// Handler-level tests for the queueing explore route (Task 5a): POST /api/explore with a crew +// team queues an EXPLORE row instead of resolving instantly — same system snapshot (findings key +// kept, empty), same pre-check codes, no turn spent at queue time. +import { eq } from 'drizzle-orm' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getSystemState, type SystemState } from '@/lib/game/save' +import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-explore-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +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) as (keyof SystemState)[]) 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, status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column + 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) + }) +}) diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts index a8e644c..0fe51df 100644 --- a/app/api/explore/route.ts +++ b/app/api/explore/route.ts @@ -1,8 +1,13 @@ import { getGameDb } from '@/lib/db/client' -import { ExploreError, explorePlanet } from '@/lib/game/save' +import { MAX_TEAM_SIZE } from '@/lib/game/crew' +import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save' +import { queueTask, TaskError } from '@/lib/game/tasks' -// Scan one body of the current system. Body: { "planetId": number }. Returns the fresh -// system state plus what this scan found. Costs a turn; each body can only be scanned once. +// Scan one body of the current system: assign the crew team (lead + helpers) and queue the +// EXPLORE task — the haul, the scanned flag, the crew roll and the event land when the task +// completes on the turn tick (runTurn). Body: { planetId, leadCrewId, helperCrewIds? }. +// Queueing spends no turn; returns the same system snapshot as the instant scan, with the +// findings key empty until the scan resolves (JC-2). export async function POST(req: Request) { let body: unknown try { @@ -15,12 +20,36 @@ export async function POST(req: Request) { if (typeof planetId !== 'number' || !Number.isInteger(planetId)) { return Response.json({ error: 'planetId must be an integer' }, { status: 400 }) } + const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId + if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) { + return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 }) + } + let helperCrewIds: number[] = [] + const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds + if (rawHelpers !== undefined) { + if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) { + return Response.json({ error: 'helperCrewIds must be an array of integers' }, { status: 400 }) + } + helperCrewIds = rawHelpers + } + // Team rules (route layer, not queueTask's): one task team, distinct members, size cap. + const team = [leadCrewId, ...helperCrewIds] + if (new Set(team).size !== team.length) { + return Response.json({ error: 'crew members must be unique' }, { status: 400 }) + } + if (team.length > MAX_TEAM_SIZE) { + return Response.json({ error: `a task team holds at most ${MAX_TEAM_SIZE} crew members` }, { status: 400 }) + } try { const db = await getGameDb() - return Response.json(await explorePlanet(db, planetId)) + await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned + const state = await queueStateFromSave(db) + const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId, helperCrewIds }) + await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write + return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null }) } catch (err) { - if (err instanceof ExploreError) { + if (err instanceof ExploreError || err instanceof TaskError) { return Response.json({ error: err.message }, { status: err.status }) } throw err diff --git a/app/api/jump/route.test.ts b/app/api/jump/route.test.ts new file mode 100644 index 0000000..f0129dd --- /dev/null +++ b/app/api/jump/route.test.ts @@ -0,0 +1,109 @@ +// Handler-level tests for the queueing jump route (Task 5a): POST /api/jump with a crew +// team queues a JUMP row instead of resolving instantly — same snapshots, same pre-check +// codes, no turn spent at queue time. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getWorldState } from '@/lib/game/save' +import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-jump-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +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, status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column + 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) + }) + + it('malformed crew fields → 400, nothing queued', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + expect((await post({ systemId: f.sys1 })).status).toBe(400) // leadCrewId is required + expect((await post({ systemId: f.sys1, leadCrewId: 'x' })).status).toBe(400) + expect((await post({ systemId: f.sys1, leadCrewId: 1.5 })).status).toBe(400) + expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: 'nope' })).status).toBe(400) + expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: [1, 'x'] })).status).toBe(400) + expect((await post('not json at all' as unknown)).status).toBe(400) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) diff --git a/app/api/jump/route.ts b/app/api/jump/route.ts index 68bfd59..e6226d7 100644 --- a/app/api/jump/route.ts +++ b/app/api/jump/route.ts @@ -1,7 +1,12 @@ import { getGameDb } from '@/lib/db/client' -import { JumpError, jumpToSystem } from '@/lib/game/save' +import { MAX_TEAM_SIZE } from '@/lib/game/crew' +import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save' +import { queueTask, TaskError } from '@/lib/game/tasks' -// Jump to an adjacent solar system. Body: { "systemId": number }. Returns the fresh world state. +// Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP +// task — the move lands when the task completes on the turn tick (runTurn). Body: +// { systemId, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh +// world state as the instant jump did. export async function POST(req: Request) { let body: unknown try { @@ -14,12 +19,36 @@ export async function POST(req: Request) { if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) } + const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId + if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) { + return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 }) + } + let helperCrewIds: number[] = [] + const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds + if (rawHelpers !== undefined) { + if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) { + return Response.json({ error: 'helperCrewIds must be an array of integers' }, { status: 400 }) + } + helperCrewIds = rawHelpers + } + // Team rules (route layer, not queueTask's): one task team, distinct members, size cap. + const team = [leadCrewId, ...helperCrewIds] + if (new Set(team).size !== team.length) { + return Response.json({ error: 'crew members must be unique' }, { status: 400 }) + } + if (team.length > MAX_TEAM_SIZE) { + return Response.json({ error: `a task team holds at most ${MAX_TEAM_SIZE} crew members` }, { status: 400 }) + } try { const db = await getGameDb() - return Response.json(await jumpToSystem(db, systemId)) + const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range + const state = await queueStateFromSave(db) + const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId, helperCrewIds }) + await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write + return Response.json(await getWorldState(db)) } catch (err) { - if (err instanceof JumpError) { + if (err instanceof JumpError || err instanceof TaskError) { return Response.json({ error: err.message }, { status: err.status }) } throw err diff --git a/lib/game/crew.ts b/lib/game/crew.ts index 20da79a..a39f9c6 100644 --- a/lib/game/crew.ts +++ b/lib/game/crew.ts @@ -40,6 +40,11 @@ const STARTING_CREW_MIN = 2 const STARTING_CREW_SPREAD = 2 // 2..3 starting members export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans) +// A task team is one lead plus up to this many members total — the queueing routes reject +// a bigger team with a 400 (spec §3 leaves the cap to balance; 4 lets a full crew bring +// RESEARCH's 9 work points down to 3 turns, matching a solo L5 specialist). +export const MAX_TEAM_SIZE = 4 + function title(word: string): string { return word.charAt(0) + word.slice(1).toLowerCase() } diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index f7c0148..31091ac 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -10,6 +10,7 @@ import { MIN_AVAILABLE } from './missions' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { TECHS } from './tech' import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save' +import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures' import type { Task } from './tasks' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' @@ -308,25 +309,14 @@ describe('explorePlanet crew finds', () => { // --- 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: +// Each test builds the identical deterministic fixture (buildWorldFixture — the shared +// fixture suite in ./test-fixtures: seed '42', stamped save → no backfill; ids line up +// across worlds) in two fresh files 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.id, toSystem: s1.id }) - await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id }) - const [p0] = await db.db.insert(schema.planets).values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id }) - const [p1] = await db.db.insert(schema.planets).values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id }) - return { sys1: s1.id, p0: p0.id, p1: p1.id } -} // The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed // identity fields, the same way the public functions' delegation does. @@ -374,7 +364,7 @@ describe('resolveJumpTask (A/B against jumpToSystem)', () => { 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 + expect(await b.db.select().from(schema.solarSystems)).toHaveLength(3) // the fixture's three systems, no ring added }) it('guards: 404 unknown system, 409 already-current', async () => { @@ -447,26 +437,9 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => { // 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. describe('runTurn (tick on the DB)', () => { - // buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no - // backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) / - // Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so - // this block adds its own rows. - async function addCrew(db: GameDb, count: number): Promise { - const [save] = await db.db.select().from(schema.saves).limit(1) - const ids: number[] = [] - for (let i = 0; i < count; i++) { - const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id }) - ids.push(row.id) - } - return ids - } - - async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise { - const [save] = await db.db.select().from(schema.saves).limit(1) - const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id }) - return row.id - } - + // buildWorldFixture (the shared suite in ./test-fixtures) seeds the save + Origin / + // Outpost / Farpoint + Alder / Basalt + the route chain — but no crew or tasks; the tick + // needs both, so each test here mints its own rows via addCrew / insertTaskRow. 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) diff --git a/lib/game/save.ts b/lib/game/save.ts index 2ae6cf2..de89d56 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -293,6 +293,27 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + 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. + 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}`) + return hopCount +} + // --- Local system + exploration --------------------------------------------- export interface PlanetDto { @@ -438,6 +459,16 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise { + await ensureSave(db) + 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 turn tick on the DB (POST /api/turn — Task 5 wires the route) --------------------- // // The route→save pattern: ensureSave first, unscoped single-player reads, persist last. @@ -462,7 +493,10 @@ function rowToTask(row: { id: number; saveId: number; type: string; payload: str 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 { +// The plain queue snapshot Task 3's engine runs over, mapped from the schema's rows: crew, +// tasks, inventory, shipSystems and tech, all in id order (single save, unscoped). The route +// never touches lib/db/schema, so the single save row is resolved here, not passed in. +export async function queueStateFromSave(db: GameDb): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([ @@ -472,7 +506,7 @@ export async function runTurn(db: GameDb): Promise { db.db.select().from(schema.shipSystems), db.db.select().from(schema.tech), ]) - const state: QueueState = { + return { 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), @@ -480,6 +514,27 @@ export async function runTurn(db: GameDb): Promise { 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 })), } +} + +// The route's DB-boundary insert (the route→save pattern): queueTask's row minus the two +// values only the DB boundary knows (id autoincrement, saveId = the single save's id). +// payload is already a JSON string from queueTask (passed through); the crew ids serialize +// to the JSON column. Persists so the queue survives a crash; returns the new row id. +export async function saveTaskRow(db: GameDb, task: Omit): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db + .insert(schema.tasks) + .values({ saveId: save.id, type: task.type, payload: task.payload, leadCrewId: task.leadCrewId, helpers: JSON.stringify(task.helpers), duration: task.duration, remaining: task.remaining, status: task.status, queuedAtTurn: task.queuedAtTurn }) + .returning({ id: schema.tasks.id }) + await persist(db) + return row.id +} + +export async function runTurn(db: GameDb): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const state = await queueStateFromSave(db) const result = tickTurn(state) diff --git a/lib/game/test-fixtures.ts b/lib/game/test-fixtures.ts new file mode 100644 index 0000000..20f8f89 --- /dev/null +++ b/lib/game/test-fixtures.ts @@ -0,0 +1,80 @@ +// Shared fixture suite for the game tests (save.test.ts, the 5a route tests, and the later +// ship/research route tests): one deterministic world — seed '42', save stamped at the +// current schema version so no backfill runs — plus the crew and task-row minters the +// queueing tests use. Ids line up across worlds built in fresh files (same insert order), +// so two fixture worlds are comparable row for row. +import type { GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { SCHEMA_VERSION } from './save' + +// Fixed so a fixture's event rows carry a stable created_at (tests never assert on it, but +// a wall-clock Date would make two fixture worlds differ byte-for-byte). +export const T0 = '2026-10-01T00:00:00.000Z' + +// Origin (s0, id 1, current) — Outpost (s1, one hop: the jump target) — Farpoint (s2, two +// hops: beyond one-hop range, so the unreachable pre-check has a target to 409 on; with +// NAVIGATION AI researched it would be reachable). p0 'Alder' in s0, p1 'Basalt' in s1. +// No crew, tasks, ship, or tech rows — the caller mints exactly what a test needs. +export async function buildWorldFixture( + db: GameDb, + outpostDiscovered = false, +): Promise<{ sys1: number; p0: number; p1: number; farSystem: 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 }) + const [s2] = await db.db + .insert(schema.solarSystems) + .values({ saveId: save.id, name: 'Farpoint', x: 60, y: 50, discovered: false }) + .returning({ id: schema.solarSystems.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s2.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s2.id, toSystem: s1.id }) + const [p0] = await db.db + .insert(schema.planets) + .values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }) + .returning({ id: schema.planets.id }) + const [p1] = await db.db + .insert(schema.planets) + .values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }) + .returning({ id: schema.planets.id }) + return { sys1: s1.id, p0: p0.id, p1: p1.id, farSystem: s2.id } +} + +// `count` identical crew members, in insertion order — the queueing tests need real crew +// ids to lead and help with. +export async function addCrew(db: GameDb, count: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const ids: number[] = [] + for (let i = 0; i < count; i++) { + const [row] = await db.db + .insert(schema.crew) + .values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }) + .returning({ id: schema.crew.id }) + ids.push(row.id) + } + return ids +} + +// Mint one tasks-table row with a chosen state — the queueing/tick tests seed the queue +// directly instead of going through the routes (which would run the same validation). +export async function insertTaskRow( + db: GameDb, + over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }, +): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db + .insert(schema.tasks) + .values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }) + .returning({ id: schema.tasks.id }) + return row.id +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 9cb3ff1..e781c79 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -122,6 +122,12 @@ describe('OpenAPI spec', () => { for (const status of ['400', '404', '409'] as const) { expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } + + // The queued-scan request: the scan target plus the crew team (lead required, helpers optional). + const exploreReq = openApiSpec.components.schemas.ExploreRequest as { properties: Record; required: string[] }; + expect(Object.keys(exploreReq.properties)).toEqual(['planetId', 'leadCrewId', 'helperCrewIds']); + expect(exploreReq.required).toEqual(['planetId', 'leadCrewId']); + expect((exploreReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('describes the world endpoints as WorldState in and out', () => { @@ -134,6 +140,12 @@ describe('OpenAPI spec', () => { for (const status of ['400', '404', '409'] as const) { expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } + + // The queued-jump request: the jump target plus the crew team (lead required, helpers optional). + const jumpReq = openApiSpec.components.schemas.JumpRequest as { properties: Record; required: string[] }; + expect(Object.keys(jumpReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']); + expect(jumpReq.required).toEqual(['systemId', 'leadCrewId']); + expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('keeps info.version in sync with package.json', () => { diff --git a/lib/openapi.ts b/lib/openapi.ts index b22746e..92cb366 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -1,4 +1,5 @@ import pkg from '@/package.json'; +import { MAX_TEAM_SIZE } from './game/crew'; // OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be // served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated @@ -8,7 +9,7 @@ export const openApiSpec = { info: { title: 'SpaceWanderer API', description: - 'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between solar systems within range (adjacent by default; two hops once NAVIGATION AI is researched).', + 'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state. Jump and exploration queue a crew team (lead + helpers) and their effects land when the task completes on the turn tick — queueing spends no turn (jump range is adjacent by default, two hops once NAVIGATION AI is researched); ship work, research and mission accept still resolve instantly, each costing a turn.', // Sourced from package.json so it stays in sync with every version bump. version: pkg.version, }, @@ -52,9 +53,9 @@ export const openApiSpec = { }, '/api/jump': { post: { - summary: 'Jump to a solar system within range', + summary: 'Queue a jump to a solar system within range', description: - 'Validates that the target is within jump range of the current system — one hop away, or two hops once NAVIGATION AI is researched (#35) — sets it as current (discovering it if new and generating its neighbours), advances the turn counter by the hop count and writes a jump event. Returns the fresh world state.', + 'Queues a JUMP task with the given crew team (lead + helpers): validates that the target is within jump range of the current system (one hop away, or two hops once NAVIGATION AI is researched — #35) and that the team is free, then the move — target becomes current, discovered if new with its neighbours generated, jump event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh world state.', operationId: 'jumpToSystem', requestBody: { required: true, @@ -66,7 +67,7 @@ export const openApiSpec = { }, responses: { 200: { - description: 'Fresh world state after the jump', + description: 'Fresh world state — the jump itself lands when the task completes on the turn tick', content: { 'application/json': { schema: { $ref: '#/components/schemas/WorldState' }, @@ -74,7 +75,7 @@ export const openApiSpec = { }, }, 400: { - description: 'Malformed body or non-integer systemId', + description: 'Malformed body, a non-integer systemId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -82,7 +83,7 @@ export const openApiSpec = { }, }, 404: { - description: 'Unknown system id', + description: 'Unknown system id or unknown crew member', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -90,7 +91,7 @@ export const openApiSpec = { }, }, 409: { - description: 'Target is outside jump range of the current system — more than one hop away, or more than two hops without NAVIGATION AI researched (or is the current system itself)', + description: 'Target is outside jump range of the current system (more than one hop away, or more than two without NAVIGATION AI; or is the current system itself) — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -120,9 +121,9 @@ export const openApiSpec = { }, '/api/explore': { post: { - summary: 'Explore a planet or moon', + summary: 'Queue a scan of a planet or moon', description: - 'Scans one body of the save with a deterministic haul (a resource and/or a part, or nothing), marks it explored, increments the turn counter, accumulates finds into inventory and writes an explore event. Returns the fresh system state plus this scan outcome.', + 'Queues an EXPLORE task with the given crew team (lead + helpers): validates that the body exists and has not been scanned yet, and that the team is free, then the scan — a deterministic haul into the cargo hold, the explored flag, the ~18% crew roll and the explore event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh system state, with the outcome key empty and crewFound null until the scan resolves.', operationId: 'explorePlanet', requestBody: { required: true, @@ -134,7 +135,7 @@ export const openApiSpec = { }, responses: { 200: { - description: 'Fresh system state plus the scan outcome', + description: 'Fresh system state — the scan itself lands when the task completes on the turn tick (outcome empty, crewFound null)', content: { 'application/json': { schema: { $ref: '#/components/schemas/ExploreResult' }, @@ -142,7 +143,7 @@ export const openApiSpec = { }, }, 400: { - description: 'Malformed body or non-integer planetId', + description: 'Malformed body, a non-integer planetId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -150,7 +151,7 @@ export const openApiSpec = { }, }, 404: { - description: 'Unknown planet id', + description: 'Unknown planet id or unknown crew member', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -158,7 +159,7 @@ export const openApiSpec = { }, }, 409: { - description: 'Body was already explored (each can only be scanned once)', + description: 'Body was already explored (each can only be scanned once) — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -827,7 +828,7 @@ export const openApiSpec = { maxItems: 2, }, }, - turn: { type: 'integer', description: 'Turn counter — one per jump' }, + turn: { type: 'integer', description: 'Turn counter — advances on the turn tick and on the still-instant actions (ship work, research, mission accept); a queued jump or exploration costs no turn' }, }, required: ['systems', 'edges', 'turn'], }, @@ -835,8 +836,14 @@ export const openApiSpec = { type: 'object', properties: { systemId: { type: 'integer', description: 'solar_systems.id of the jump target' }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' }, + helperCrewIds: { + type: 'array', + description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`, + items: { type: 'integer' }, + }, }, - required: ['systemId'], + required: ['systemId', 'leadCrewId'], }, Planet: { type: 'object', @@ -900,7 +907,7 @@ export const openApiSpec = { items: { $ref: '#/components/schemas/ShipSystem' }, }, inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, - turn: { type: 'integer', description: 'Turn counter — one per jump, exploration or ship action' }, + turn: { type: 'integer', description: 'Turn counter — one per ship action (research and mission accept cost one too; a queued jump or exploration costs no turn)' }, }, required: ['systems', 'inventory', 'turn'], }, @@ -993,7 +1000,7 @@ export const openApiSpec = { items: { $ref: '#/components/schemas/Planet' }, }, inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, - turn: { type: 'integer', description: 'Turn counter — one per jump or exploration' }, + turn: { type: 'integer', description: 'Turn counter — advances on the turn tick and on the still-instant actions (ship work, research, mission accept); a queued jump or exploration costs no turn' }, }, required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'], }, @@ -1001,8 +1008,14 @@ export const openApiSpec = { type: 'object', properties: { planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' }, + helperCrewIds: { + type: 'array', + description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`, + items: { type: 'integer' }, + }, }, - required: ['planetId'], + required: ['planetId', 'leadCrewId'], }, ExploreResult: { allOf: [ @@ -1012,11 +1025,11 @@ export const openApiSpec = { properties: { outcome: { type: 'array', - description: 'What this scan found — may be empty (some scans find nothing)', + description: 'What this scan found — always empty in the queueing response; populated only once the scan resolves on the turn tick (a resolved scan may still find nothing)', items: { $ref: '#/components/schemas/FoundItem' }, }, crewFound: { - description: 'A crew member who joined the ship on this scan (~18% of scans), null when none', + description: 'A crew member who joined the ship on this scan (~18% of scans), null when none — always null in the queueing response', anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }], }, }, -- 2.54.0 From 03b8b661f6addc32c55ff5fbc238879c418b493b Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 18:36:14 +0000 Subject: [PATCH 07/20] feat(api): ship + research endpoints queue tasks with lead + helpers; mission accept is instant and free (#44) --- app/api/crew-team.ts | 35 +++++++ app/api/explore/route.ts | 26 +----- app/api/jump/route.ts | 26 +----- app/api/missions/[id]/accept/route.test.ts | 100 ++++++++++++++++++++ app/api/missions/[id]/accept/route.ts | 3 +- app/api/research/route.test.ts | 103 +++++++++++++++++++++ app/api/research/route.ts | 24 ++++- app/api/ship/build/route.test.ts | 96 +++++++++++++++++++ app/api/ship/build/route.ts | 25 ++++- app/api/ship/expand/route.test.ts | 94 +++++++++++++++++++ app/api/ship/expand/route.ts | 25 ++++- app/api/ship/upgrade/route.test.ts | 94 +++++++++++++++++++ app/api/ship/upgrade/route.ts | 25 ++++- lib/game/missions.test.ts | 8 +- lib/game/missions.ts | 17 ++-- lib/game/research.ts | 17 ++++ lib/game/save.ts | 14 +++ lib/game/ship.ts | 36 +++++++ lib/openapi.test.ts | 23 +++++ lib/openapi.ts | 84 +++++++++++------ 20 files changed, 772 insertions(+), 103 deletions(-) create mode 100644 app/api/crew-team.ts create mode 100644 app/api/missions/[id]/accept/route.test.ts create mode 100644 app/api/research/route.test.ts create mode 100644 app/api/ship/build/route.test.ts create mode 100644 app/api/ship/expand/route.test.ts create mode 100644 app/api/ship/upgrade/route.test.ts diff --git a/app/api/crew-team.ts b/app/api/crew-team.ts new file mode 100644 index 0000000..d641d0c --- /dev/null +++ b/app/api/crew-team.ts @@ -0,0 +1,35 @@ +import { MAX_TEAM_SIZE } from '@/lib/game/crew' + +// The crew-team half of a queueing-action body (leadCrewId + helperCrewIds) plus the route-layer +// team rules: one task team, distinct members, the size cap. queueTask (lib) deliberately does +// NOT validate teams, so this shared parse is the single place the team 400s live — extracted +// verbatim from the 5a jump/explore crew-parse blocks so the six queueing routes can't drift. +// Returns a 400-shaped error object instead of throwing, so the caller maps it to a response. + +export type CrewTeam = + | { leadCrewId: number; helperCrewIds: number[] } + | { error: string; status: number } + +export function parseCrewTeam(body: unknown): CrewTeam { + const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId + if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) { + return { error: 'leadCrewId must be an integer', status: 400 } + } + let helperCrewIds: number[] = [] + const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds + if (rawHelpers !== undefined) { + if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) { + return { error: 'helperCrewIds must be an array of integers', status: 400 } + } + helperCrewIds = rawHelpers + } + // Team rules (route layer, not queueTask's): one task team, distinct members, size cap. + const team = [leadCrewId, ...helperCrewIds] + if (new Set(team).size !== team.length) { + return { error: 'crew members must be unique', status: 400 } + } + if (team.length > MAX_TEAM_SIZE) { + return { error: `a task team holds at most ${MAX_TEAM_SIZE} crew members`, status: 400 } + } + return { leadCrewId, helperCrewIds } +} diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts index 0fe51df..68e65c4 100644 --- a/app/api/explore/route.ts +++ b/app/api/explore/route.ts @@ -1,5 +1,5 @@ import { getGameDb } from '@/lib/db/client' -import { MAX_TEAM_SIZE } from '@/lib/game/crew' +import { parseCrewTeam } from '@/app/api/crew-team' import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' @@ -20,32 +20,16 @@ export async function POST(req: Request) { if (typeof planetId !== 'number' || !Number.isInteger(planetId)) { return Response.json({ error: 'planetId must be an integer' }, { status: 400 }) } - const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId - if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) { - return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 }) - } - let helperCrewIds: number[] = [] - const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds - if (rawHelpers !== undefined) { - if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) { - return Response.json({ error: 'helperCrewIds must be an array of integers' }, { status: 400 }) - } - helperCrewIds = rawHelpers - } - // Team rules (route layer, not queueTask's): one task team, distinct members, size cap. - const team = [leadCrewId, ...helperCrewIds] - if (new Set(team).size !== team.length) { - return Response.json({ error: 'crew members must be unique' }, { status: 400 }) - } - if (team.length > MAX_TEAM_SIZE) { - return Response.json({ error: `a task team holds at most ${MAX_TEAM_SIZE} crew members` }, { status: 400 }) + const team = parseCrewTeam(body) + if ('error' in team) { + return Response.json({ error: team.error }, { status: team.status }) } try { const db = await getGameDb() await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned const state = await queueStateFromSave(db) - const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId, helperCrewIds }) + const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null }) } catch (err) { diff --git a/app/api/jump/route.ts b/app/api/jump/route.ts index e6226d7..f86a812 100644 --- a/app/api/jump/route.ts +++ b/app/api/jump/route.ts @@ -1,5 +1,5 @@ import { getGameDb } from '@/lib/db/client' -import { MAX_TEAM_SIZE } from '@/lib/game/crew' +import { parseCrewTeam } from '@/app/api/crew-team' import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' @@ -19,32 +19,16 @@ export async function POST(req: Request) { if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) } - const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId - if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) { - return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 }) - } - let helperCrewIds: number[] = [] - const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds - if (rawHelpers !== undefined) { - if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) { - return Response.json({ error: 'helperCrewIds must be an array of integers' }, { status: 400 }) - } - helperCrewIds = rawHelpers - } - // Team rules (route layer, not queueTask's): one task team, distinct members, size cap. - const team = [leadCrewId, ...helperCrewIds] - if (new Set(team).size !== team.length) { - return Response.json({ error: 'crew members must be unique' }, { status: 400 }) - } - if (team.length > MAX_TEAM_SIZE) { - return Response.json({ error: `a task team holds at most ${MAX_TEAM_SIZE} crew members` }, { status: 400 }) + const team = parseCrewTeam(body) + if ('error' in team) { + return Response.json({ error: team.error }, { status: team.status }) } try { const db = await getGameDb() const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range const state = await queueStateFromSave(db) - const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId, helperCrewIds }) + const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write return Response.json(await getWorldState(db)) } catch (err) { diff --git a/app/api/missions/[id]/accept/route.test.ts b/app/api/missions/[id]/accept/route.test.ts new file mode 100644 index 0000000..b296f43 --- /dev/null +++ b/app/api/missions/[id]/accept/route.test.ts @@ -0,0 +1,100 @@ +// Handler-level tests for mission accept (Task 5a(ii)): instant and free per the approved +// spec (JC-19) — the AVAILABLE → ACTIVE flip and the event land at the current turn, no +// turn is spent, no crew params; the board response shape is unchanged. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getMissions } from '@/lib/game/missions' +import { buildWorldFixture } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-mission-accept-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +const post = (id: string) => POST(new Request(`http://localhost/api/missions/${id}/accept`, { method: 'POST' }), { params: Promise.resolve({ id }) }) + +// The fixture suite mints no mission rows (JC-6) — insert exactly what the test needs (same +// shape as the insertMission helper in lib/game/missions.test.ts, plus a state override). +async function insertMission( + db: GameDb, + fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number; state?: string }, +): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db + .insert(schema.missions) + .values({ saveId: save.id, type: fields.type, description: fields.description, targetSystemId: fields.targetSystemId ?? null, conditionKind: fields.conditionKind ?? null, progressTarget: fields.progressTarget ?? 1, rewardKind: fields.rewardKind ?? 'resource', rewardQty: fields.rewardQty ?? 1, state: fields.state ?? 'AVAILABLE' }) + .returning() + return row.id +} + +describe('POST /api/missions/[id]/accept (5a(ii))', () => { + it('happy: mission goes active, board returned, turn unchanged (free)', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const id = await insertMission(db, { type: 'DELIVER', description: 'Jump to Outpost', targetSystemId: 2 }) + const [before] = await db.db.select().from(schema.saves) + + const res = await post(String(id)) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(await getMissions(db)) // same board shape as today + expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE') // AVAILABLE → active + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // free — no turn cost + }) + + it('non-integer id → 400, nothing changes', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [before] = await db.db.select().from(schema.saves) + expect((await post('abc')).status).toBe(400) + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) + }) + + it('unknown id → 404, nothing changes', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [before] = await db.db.select().from(schema.saves) + expect((await post('999')).status).toBe(404) + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) + }) + + it('not AVAILABLE → 409 (existing code), nothing changes', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, state: 'ACTIVE' }) + const [before] = await db.db.select().from(schema.saves) + expect((await post(String(id))).status).toBe(409) + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) + }) + + it('a mission already active → 409 (existing code), the target stays AVAILABLE', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + await insertMission(db, { type: 'FIND', description: 'Find a part while scanning', conditionKind: 'part', state: 'ACTIVE' }) + const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2 }) + const [before] = await db.db.select().from(schema.saves) + expect((await post(String(id))).status).toBe(409) + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) + expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('AVAILABLE') + }) +}) diff --git a/app/api/missions/[id]/accept/route.ts b/app/api/missions/[id]/accept/route.ts index 6e9e2b0..539dc7d 100644 --- a/app/api/missions/[id]/accept/route.ts +++ b/app/api/missions/[id]/accept/route.ts @@ -2,7 +2,8 @@ import { getGameDb } from '@/lib/db/client' import { ensureSave } from '@/lib/game/save' import { acceptMission, MissionError } from '@/lib/game/missions' -// Accept an AVAILABLE mission — it becomes the single active one. Costs a turn; returns the fresh board. +// Accept an AVAILABLE mission — it becomes the single active one. Instant and free (no turn, +// no body); returns the fresh board. export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { const db = await getGameDb() await ensureSave(db) // acceptMission assumes a save exists diff --git a/app/api/research/route.test.ts b/app/api/research/route.test.ts new file mode 100644 index 0000000..6d2862d --- /dev/null +++ b/app/api/research/route.test.ts @@ -0,0 +1,103 @@ +// Handler-level tests for the queueing research route (Task 5a(ii)): POST /api/research with +// a crew team queues a RESEARCH row instead of resolving instantly — the tech stays +// unresearched until the task completes on the turn tick, the cost comes out of the cargo +// hold at queue time, same snapshot shape, same pre-check codes, no turn spent at queue time. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getTechState } from '@/lib/game/research' +import { TECHS, type TechDef } from '@/lib/game/tech' +import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-research-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +const post = (body: unknown) => POST(new Request('http://localhost/api/research', { method: 'POST', body: JSON.stringify(body) })) + +// The fixture suite mints no tech rows (JC-6) — insert exactly what the test needs from the +// pure TECHS definitions (WEAPONS_SYSTEMS: part cost, no prerequisites). The happy path seeds +// every row, like the new-game backfill: the snapshot read (getTechState) maps every TECHS def. +async function insertTech(db: GameDb, def: TechDef, over: { researched?: boolean } = {}) { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = 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: over.researched ?? false }) + .returning() + return row +} + +// The fixture suite mints no cargo hold (JC-6) — seed rows directly. +async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) { + const [save] = await db.db.select().from(schema.saves).limit(1) + for (const r of rows) { + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r }) + } +} + +describe('POST /api/research (5a(ii))', () => { + it('happy: row with type/payload, turn unchanged, cost deducted, tech not researched yet', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const def = TECHS[0] // WEAPONS_SYSTEMS — the tech row's cost comes from its TECHS def + for (const d of TECHS) await insertTech(db, d) // every row, like the new-game backfill + const tech = (await db.db.select().from(schema.tech)).find((t) => t.slug === def.slug)! + await seedHold(db, [{ kind: def.costKind, name: def.costKind === 'resource' ? 'resource' : 'part', qty: def.costQty }]) + const [before] = await db.db.select().from(schema.saves) + + const res = await post({ techId: tech.id, leadCrewId: c1 }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(await getTechState(db)) // the same snapshot shape the instant action returned + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'RESEARCH', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column + expect(JSON.parse(row.payload)).toEqual({ techId: tech.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 at queue time + expect((await db.db.select().from(schema.tech)).find((t) => t.id === tech.id)!.researched).toBe(false) // the effect waits for the tick + }) + + it('unaffordable → 422 (existing spendItems message), nothing queued', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const def = TECHS[0] + const tech = await insertTech(db, def) + await seedHold(db, [{ kind: def.costKind, name: def.costKind === 'resource' ? 'resource' : 'part', qty: def.costQty - 1 }]) + const res = await post({ techId: tech.id, leadCrewId: c1 }) + expect(res.status).toBe(422) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) + + it('already researched → 409 (existing pre-check, existing code), nothing queued', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const def = TECHS[0] + const tech = await insertTech(db, def, { researched: true }) + expect((await post({ techId: tech.id, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) diff --git a/app/api/research/route.ts b/app/api/research/route.ts index 239ef84..b808940 100644 --- a/app/api/research/route.ts +++ b/app/api/research/route.ts @@ -1,7 +1,14 @@ import { getGameDb } from '@/lib/db/client' -import { ResearchError, researchTech } from '@/lib/game/research' +import { parseCrewTeam } from '@/app/api/crew-team' +import { getTechState, checkResearchPreconditions, ResearchError } from '@/lib/game/research' +import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { queueTask, TaskError } from '@/lib/game/tasks' -// Research one tech. Body: { "techId": number }. Costs inventory items + a turn; returns the fresh tech tree state. +// Research one tech: assign the crew team (lead + helpers) and queue the RESEARCH task — the +// tech cost is deducted at queue time and the tech is marked researched (unlock effect +// included) when the task completes on the turn tick (runTurn). Body: +// { techId, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh +// tech tree state the instant action returned. export async function POST(req: Request) { let body: unknown try { @@ -14,12 +21,21 @@ export async function POST(req: Request) { if (typeof techId !== 'number' || !Number.isInteger(techId)) { return Response.json({ error: 'techId must be an integer' }, { status: 400 }) } + const team = parseCrewTeam(body) + if ('error' in team) { + return Response.json({ error: team.error }, { status: team.status }) + } try { const db = await getGameDb() - return Response.json(await researchTech(db, techId)) + await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq + const state = await queueStateFromSave(db) + const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + await saveTaskRow(db, task) + await saveInventory(db, inventory) // the cost was deducted at queue time + return Response.json(await getTechState(db)) } catch (err) { - if (err instanceof ResearchError) { + if (err instanceof ResearchError || err instanceof TaskError) { return Response.json({ error: err.message }, { status: err.status }) } throw err diff --git a/app/api/ship/build/route.test.ts b/app/api/ship/build/route.test.ts new file mode 100644 index 0000000..b6b8588 --- /dev/null +++ b/app/api/ship/build/route.test.ts @@ -0,0 +1,96 @@ +// Handler-level tests for the queueing ship-build route (Task 5a(ii)): POST /api/ship/build +// with a crew team queues a SHIP_BUILD row instead of resolving instantly — the build at +// base values waits for the turn tick, the flat parts cost comes out of the cargo hold at +// queue time, same snapshot shape, same pre-check codes, no turn spent at queue time. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getShipState } from '@/lib/game/ship' +import { BUILD_PART_COST } from '@/lib/game/ship-costs' +import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-build-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +const post = (body: unknown) => POST(new Request('http://localhost/api/ship/build', { method: 'POST', body: JSON.stringify(body) })) + +// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs. HULL is +// the only type that is unbuilt by default AND has no build gate (WEAPONS is gated behind +// the WEAPONS SYSTEMS tech, which the fixture leaves unresearched). +async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).returning() + return row +} + +// The fixture suite mints no cargo hold (JC-6) — seed rows directly. +async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) { + const [save] = await db.db.select().from(schema.saves).limit(1) + for (const r of rows) { + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r }) + } +} + +describe('POST /api/ship/build (5a(ii))', () => { + it('happy: row with type/payload, turn unchanged, parts deducted, not built yet', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = await insertSystem(db, { type: 'HULL', level: 0, capacity: 0 }) // unbuilt, no gate + await seedHold(db, [{ kind: 'part', name: 'part', qty: BUILD_PART_COST }]) // exactly the existing flat cost + const [before] = await db.db.select().from(schema.saves) + + const res = await post({ type: sys.type, leadCrewId: c1 }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'SHIP_BUILD', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column + expect(JSON.parse(row.payload)).toEqual({ type: sys.type }) + 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 at queue time + expect((await db.db.select().from(schema.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(0) // the effect waits for the tick + }) + + it('unaffordable → 422 (existing spendItems message), nothing queued', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + await insertSystem(db, { type: 'HULL', level: 0, capacity: 0 }) + await seedHold(db, [{ kind: 'part', name: 'part', qty: BUILD_PART_COST - 1 }]) + const res = await post({ type: 'HULL', leadCrewId: c1 }) + expect(res.status).toBe(422) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) + + it('already built → 409 (existing pre-check, existing code), nothing queued', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = await insertSystem(db, { type: 'HULL', level: 1, capacity: 2 }) // built at base values + expect((await post({ type: sys.type, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) diff --git a/app/api/ship/build/route.ts b/app/api/ship/build/route.ts index 56775ae..a25d204 100644 --- a/app/api/ship/build/route.ts +++ b/app/api/ship/build/route.ts @@ -1,8 +1,14 @@ import { getGameDb } from '@/lib/db/client' -import { ShipError, buildSystem } from '@/lib/game/ship' +import { parseCrewTeam } from '@/app/api/crew-team' +import { checkBuildPreconditions, getShipState, ShipError } from '@/lib/game/ship' +import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { queueTask, TaskError } from '@/lib/game/tasks' -// Build an unbuilt ship system type at base values. Body: { "type": string }. -// Costs inventory items + a turn; returns the fresh ship state. +// Build an unbuilt ship system type at base values: assign the crew team (lead + helpers) +// and queue the SHIP_BUILD task — the flat parts cost is deducted at queue time and the +// build lands when the task completes on the turn tick (runTurn). Body: +// { type, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh +// ship state the instant action returned. export async function POST(req: Request) { let body: unknown try { @@ -15,12 +21,21 @@ export async function POST(req: Request) { if (typeof type !== 'string' || type.length === 0) { return Response.json({ error: 'type must be a non-empty string' }, { status: 400 }) } + const team = parseCrewTeam(body) + if ('error' in team) { + return Response.json({ error: team.error }, { status: team.status }) + } try { const db = await getGameDb() - return Response.json(await buildSystem(db, type)) + await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate + const state = await queueStateFromSave(db) + const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + await saveTaskRow(db, task) + await saveInventory(db, inventory) // the cost was deducted at queue time + return Response.json(await getShipState(db)) } catch (err) { - if (err instanceof ShipError) { + if (err instanceof ShipError || err instanceof TaskError) { return Response.json({ error: err.message }, { status: err.status }) } throw err diff --git a/app/api/ship/expand/route.test.ts b/app/api/ship/expand/route.test.ts new file mode 100644 index 0000000..d7c627e --- /dev/null +++ b/app/api/ship/expand/route.test.ts @@ -0,0 +1,94 @@ +// Handler-level tests for the queueing ship-expand route (Task 5a(ii)): POST /api/ship/expand +// with a crew team queues a SHIP_EXPAND row instead of resolving instantly — the capacity +// bump waits for the turn tick, the cost comes out of the cargo hold at queue time, same +// snapshot shape, same pre-check codes, no turn spent at queue time. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getShipState } from '@/lib/game/ship' +import { expandCost } from '@/lib/game/ship-costs' +import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-expand-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +const post = (body: unknown) => POST(new Request('http://localhost/api/ship/expand', { method: 'POST', body: JSON.stringify(body) })) + +// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs. +async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).returning() + return row +} + +// The fixture suite mints no cargo hold (JC-6) — seed rows directly. +async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) { + const [save] = await db.db.select().from(schema.saves).limit(1) + for (const r of rows) { + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r }) + } +} + +describe('POST /api/ship/expand (5a(ii))', () => { + it('happy: row with type/payload, turn unchanged, cost deducted, no capacity change yet', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 2 }) // built, not maxed (base cap 10) + await seedHold(db, [{ kind: 'resource', name: 'resource', qty: expandCost(sys.capacity) }]) // exactly the existing cost logic + const [before] = await db.db.select().from(schema.saves) + + const res = await post({ systemId: sys.id, leadCrewId: c1 }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'SHIP_EXPAND', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column + 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 at queue time + expect((await db.db.select().from(schema.shipSystems)).find((s) => s.id === sys.id)!.capacity).toBe(sys.capacity) // the effect waits for the tick + }) + + it('unaffordable → 422 (existing spendItems message), nothing queued', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 2 }) + await seedHold(db, [{ kind: 'resource', name: 'resource', qty: expandCost(sys.capacity) - 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 capacity → 409 (existing pre-check, existing code), nothing queued', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 99 }) // above any max + expect((await post({ systemId: sys.id, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) diff --git a/app/api/ship/expand/route.ts b/app/api/ship/expand/route.ts index 34a0e82..bc29815 100644 --- a/app/api/ship/expand/route.ts +++ b/app/api/ship/expand/route.ts @@ -1,8 +1,14 @@ import { getGameDb } from '@/lib/db/client' -import { ShipError, expandSystem } from '@/lib/game/ship' +import { parseCrewTeam } from '@/app/api/crew-team' +import { checkExpandPreconditions, getShipState, ShipError } from '@/lib/game/ship' +import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { queueTask, TaskError } from '@/lib/game/tasks' -// Raise a built ship system's capacity by one (capped at max capacity). Body: { "systemId": number }. -// Costs inventory items + a turn; returns the fresh ship state. +// Raise a built ship system's capacity by one (capped at max capacity): assign the crew team +// (lead + helpers) and queue the SHIP_EXPAND task — the cost (`capacity` units of any +// resource) is deducted at queue time and the capacity bump lands when the task completes on +// the turn tick (runTurn). Body: { systemId, leadCrewId, helperCrewIds? }. Queueing spends no +// turn; returns the same fresh ship state the instant action returned. export async function POST(req: Request) { let body: unknown try { @@ -15,12 +21,21 @@ export async function POST(req: Request) { if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) } + const team = parseCrewTeam(body) + if ('error' in team) { + return Response.json({ error: team.error }, { status: team.status }) + } try { const db = await getGameDb() - return Response.json(await expandSystem(db, systemId)) + await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity + const state = await queueStateFromSave(db) + const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + await saveTaskRow(db, task) + await saveInventory(db, inventory) // the cost was deducted at queue time + return Response.json(await getShipState(db)) } catch (err) { - if (err instanceof ShipError) { + if (err instanceof ShipError || err instanceof TaskError) { return Response.json({ error: err.message }, { status: err.status }) } throw err diff --git a/app/api/ship/upgrade/route.test.ts b/app/api/ship/upgrade/route.test.ts new file mode 100644 index 0000000..44db4b0 --- /dev/null +++ b/app/api/ship/upgrade/route.test.ts @@ -0,0 +1,94 @@ +// Handler-level tests for the queueing ship-upgrade route (Task 5a(ii)): POST /api/ship/upgrade +// with a crew team queues a SHIP_UPGRADE row instead of resolving instantly — the level-up +// waits for the turn tick, the cost comes out of the cargo hold at queue time, same snapshot +// shape, same pre-check codes, no turn spent at queue time. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { getShipState } from '@/lib/game/ship' +import { upgradeCost } from '@/lib/game/ship-costs' +import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-upgrade-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +const post = (body: unknown) => POST(new Request('http://localhost/api/ship/upgrade', { method: 'POST', body: JSON.stringify(body) })) + +// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs. +async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).returning() + return row +} + +// The fixture suite mints no cargo hold (JC-6) — seed rows directly. +async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) { + const [save] = await db.db.select().from(schema.saves).limit(1) + for (const r of rows) { + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r }) + } +} + +describe('POST /api/ship/upgrade (5a(ii))', () => { + it('happy: row with type/payload, turn unchanged, cost deducted, no level change yet', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = await insertSystem(db, { type: 'REACTOR', level: 2, capacity: 2 }) // built, not maxed (base cap 5) + await seedHold(db, [{ kind: 'resource', name: 'resource', qty: upgradeCost(sys.level) }]) // exactly the existing cost logic + const [before] = await db.db.select().from(schema.saves) + + const res = await post({ systemId: sys.id, leadCrewId: c1 }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'SHIP_UPGRADE', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column + 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 at queue time + 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() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = await insertSystem(db, { type: 'REACTOR', level: 2, capacity: 2 }) + 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() + await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = await insertSystem(db, { type: 'REACTOR', level: 99, capacity: 2 }) // above any max + expect((await post({ systemId: sys.id, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) diff --git a/app/api/ship/upgrade/route.ts b/app/api/ship/upgrade/route.ts index 3e44660..f404b07 100644 --- a/app/api/ship/upgrade/route.ts +++ b/app/api/ship/upgrade/route.ts @@ -1,8 +1,14 @@ import { getGameDb } from '@/lib/db/client' -import { ShipError, upgradeSystem } from '@/lib/game/ship' +import { parseCrewTeam } from '@/app/api/crew-team' +import { checkUpgradePreconditions, getShipState, ShipError } from '@/lib/game/ship' +import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { queueTask, TaskError } from '@/lib/game/tasks' -// Level up a built ship system by one (capped at max level). Body: { "systemId": number }. -// Costs inventory items + a turn; returns the fresh ship state. +// Level up a built ship system by one (capped at max level): assign the crew team (lead + +// helpers) and queue the SHIP_UPGRADE task — the cost (`level` units of any resource) is +// deducted at queue time and the level-up lands when the task completes on the turn tick +// (runTurn). Body: { systemId, leadCrewId, helperCrewIds? }. Queueing spends no turn; +// returns the same fresh ship state the instant action returned. export async function POST(req: Request) { let body: unknown try { @@ -15,12 +21,21 @@ export async function POST(req: Request) { if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) } + const team = parseCrewTeam(body) + if ('error' in team) { + return Response.json({ error: team.error }, { status: team.status }) + } try { const db = await getGameDb() - return Response.json(await upgradeSystem(db, systemId)) + await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level + const state = await queueStateFromSave(db) + const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + await saveTaskRow(db, task) + await saveInventory(db, inventory) // the cost was deducted at queue time + return Response.json(await getShipState(db)) } catch (err) { - if (err instanceof ShipError) { + if (err instanceof ShipError || err instanceof TaskError) { return Response.json({ error: err.message }, { status: err.status }) } throw err diff --git a/lib/game/missions.test.ts b/lib/game/missions.test.ts index 91a90cc..c9031b1 100644 --- a/lib/game/missions.test.ts +++ b/lib/game/missions.test.ts @@ -138,7 +138,7 @@ describe('acceptMission', () => { await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ }) }) - it('activates an AVAILABLE contract, costs a turn, writes an event and keeps the board topped up', async () => { + it('activates an AVAILABLE contract, spends no turn, writes an event and keeps the board topped up', async () => { const { db } = await freshFile() await getWorldState(db) const [save] = await db.db.select().from(schema.saves).limit(1) @@ -149,11 +149,11 @@ describe('acceptMission', () => { expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE') expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up const [after] = await db.db.select().from(schema.saves).limit(1) - expect(after.turn).toBe(save.turn + 1) + expect(after.turn).toBe(save.turn) // free — no turn cost const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')! expect(event.message).toContain('Accepted mission') - expect(event.turn).toBe(save.turn + 1) + expect(event.turn).toBe(save.turn) // stamped at the current turn — accept spends none }) }) @@ -185,7 +185,7 @@ describe('completion', () => { expect(reward.qty).toBe(2) const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))! - expect(event.turn).toBe(4) // accept (1) + three scans (2–4); completion is written by the third scan + expect(event.turn).toBe(3) // free accept (turn 0) + three scans (1–3); completion is written by the third scan expect(event.message).toContain('Scan 3 bodies') }) diff --git a/lib/game/missions.ts b/lib/game/missions.ts index 5fbdca0..53c2a70 100644 --- a/lib/game/missions.ts +++ b/lib/game/missions.ts @@ -34,8 +34,12 @@ export interface MissionSystem { isCurrent: boolean } -// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts. Every -// action costs exactly one turn, so the stream never repeats for two different top-ups. +// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts. +// Turn-advancing actions re-anchor the stream, so top-ups on different turns generate +// different contracts. Mission accept is free and tops up at the current turn, so it may +// share an anchor with a top-up that already ran at that turn (e.g. the new-game seed at +// turn 0) — the generated contract can then mirror an existing one: cosmetic, never a +// double reward (rewards are per row). export function missionRng(seed: string, turn: number): () => number { return mulberry32((Number(seed) + Math.imul(turn, 0x9e3779b1)) >>> 0) } @@ -152,8 +156,9 @@ export const REWARD_ITEM_NAME = 'MISSION REWARD' const kindLabel = (kind: ItemKind): string => (kind === 'resource' ? 'RESOURCES' : 'PARTS') -// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time), costs a turn like -// every other action and writes an event. Returns the fresh board. +// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time). Instant and +// free (JC-19) — no turn, no cost; the event is stamped at the current turn. Returns the fresh +// board. export async function acceptMission(db: GameDb, missionId: number): Promise { const [save] = await db.db.select().from(schema.saves).limit(1) if (!save) throw new MissionError(404, 'No save yet') @@ -164,10 +169,8 @@ export async function acceptMission(db: GameDb, missionId: number): Promise m.state === 'ACTIVE') if (active) throw new MissionError(409, `One mission at a time — finish "${active.description}" first`) - const turn = save.turn + 1 await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id)) - await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) - await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() }) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() }) await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up await persist(db) diff --git a/lib/game/research.ts b/lib/game/research.ts index ee9f9be..9fb3ae5 100644 --- a/lib/game/research.ts +++ b/lib/game/research.ts @@ -70,6 +70,23 @@ export async function resolveResearchTask(db: GameDb, task: Task): Promise await persist(db) } +// The research pre-checks, extracted for the queueing route (5a(ii)): the same codes and +// messages as researchTech — unknown tech (404), already researched (409), missing prereqs +// (409, naming what is missing). The cost check stays out: the queueing path deducts at +// queue time (queueTask) and the research lands when the task completes on the tick. +export async function checkResearchPreconditions(db: GameDb, techId: number): Promise { + await ensureSave(db) + 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`) +} + // 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 { diff --git a/lib/game/save.ts b/lib/game/save.ts index de89d56..add17e1 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -531,6 +531,20 @@ export async function saveTaskRow(db: GameDb, task: Omit) return row.id } +// The route's DB-boundary hold write (the route→save pattern): queueTask's deducted inventory +// replaces the save's rows — array order = oldest first = id order, so spendItems' oldest-row- +// first consumption and the id-ordered reads stay consistent after the rewrite. Persists so +// the deduction survives a crash; the queueing routes call it with queueTask's result. +export async function saveInventory(db: GameDb, items: InventoryItemDto[]): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.saveId, save.id)) + for (const item of items) { + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty }) + } + await persist(db) +} + export async function runTurn(db: GameDb): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) diff --git a/lib/game/ship.ts b/lib/game/ship.ts index 0a39baa..88e3341 100644 --- a/lib/game/ship.ts +++ b/lib/game/ship.ts @@ -131,6 +131,19 @@ export async function resolveShipBuildTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + 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})`) +} + // UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). export async function upgradeSystem(db: GameDb, systemId: number): Promise { await ensureSave(db) @@ -148,6 +161,17 @@ export async function upgradeSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + 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})`) +} + // EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). export async function expandSystem(db: GameDb, systemId: number): Promise { await ensureSave(db) @@ -165,6 +189,18 @@ export async function expandSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + 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`) +} + // BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. export async function buildSystem(db: GameDb, type: string): Promise { await ensureSave(db) diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index e781c79..d58128e 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -44,6 +44,23 @@ describe('OpenAPI spec', () => { expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } } + + // The queued-ship-work requests: the action target plus the crew team (lead required, + // helpers optional) — the same team fields as JumpRequest / ExploreRequest. + const upgradeReq = openApiSpec.components.schemas.UpgradeRequest as { properties: Record; required: string[] }; + expect(Object.keys(upgradeReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']); + expect(upgradeReq.required).toEqual(['systemId', 'leadCrewId']); + expect((upgradeReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); + + const expandReq = openApiSpec.components.schemas.ExpandRequest as { properties: Record; required: string[] }; + expect(Object.keys(expandReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']); + expect(expandReq.required).toEqual(['systemId', 'leadCrewId']); + expect((expandReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); + + const buildReq = openApiSpec.components.schemas.BuildRequest as { properties: Record; required: string[] }; + expect(Object.keys(buildReq.properties)).toEqual(['type', 'leadCrewId', 'helperCrewIds']); + expect(buildReq.required).toEqual(['type', 'leadCrewId']); + expect((buildReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('describes the tech endpoints: GET /api/tech → Tech[], POST /api/research with error responses', () => { @@ -61,6 +78,12 @@ describe('OpenAPI spec', () => { for (const status of ['400', '404', '409', '422'] as const) { expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } + + // The queued-research request: the tech target plus the crew team (lead required, helpers optional). + const researchReq = openApiSpec.components.schemas.ResearchRequest as { properties: Record; required: string[] }; + expect(Object.keys(researchReq.properties)).toEqual(['techId', 'leadCrewId', 'helperCrewIds']); + expect(researchReq.required).toEqual(['techId', 'leadCrewId']); + expect((researchReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('describes the mission endpoints: GET /api/missions → Mission[], POST accept with error responses', () => { diff --git a/lib/openapi.ts b/lib/openapi.ts index 92cb366..8f1376d 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -9,7 +9,7 @@ export const openApiSpec = { info: { title: 'SpaceWanderer API', description: - 'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state. Jump and exploration queue a crew team (lead + helpers) and their effects land when the task completes on the turn tick — queueing spends no turn (jump range is adjacent by default, two hops once NAVIGATION AI is researched); ship work, research and mission accept still resolve instantly, each costing a turn.', + 'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state. Jump, exploration, ship work and research queue a crew team (lead + helpers) and their effects land when the task completes on the turn tick — queueing spends no turn (jump range is adjacent by default, two hops once NAVIGATION AI is researched); mission accept is instant and free (no turn).', // Sourced from package.json so it stays in sync with every version bump. version: pkg.version, }, @@ -209,7 +209,7 @@ export const openApiSpec = { post: { summary: 'Upgrade a ship system', description: - "Level up a built ship system by one (capped at max level). Costs `level` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.", + "Level up a built ship system by one (capped at max level). Assign the crew team and queue the SHIP_UPGRADE task — the cost (`level` units of any resource) is deducted at queue time and the level-up lands when the task completes on the turn tick; queueing spends no turn. Returns the fresh ship state.", operationId: 'upgradeShipSystem', requestBody: { required: true, @@ -221,7 +221,7 @@ export const openApiSpec = { }, responses: { 200: { - description: 'Fresh ship state after the upgrade', + description: 'Fresh ship state — the upgrade lands when the task completes on the turn tick', content: { 'application/json': { schema: { $ref: '#/components/schemas/ShipState' }, @@ -229,7 +229,7 @@ export const openApiSpec = { }, }, 400: { - description: 'Malformed body or non-integer systemId', + description: 'Malformed body, a non-integer systemId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -237,7 +237,7 @@ export const openApiSpec = { }, }, 404: { - description: 'Unknown ship system id', + description: 'Unknown ship system id or unknown crew member', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -245,7 +245,7 @@ export const openApiSpec = { }, }, 409: { - description: 'System is not built or already at max level', + description: 'System is not built or already at max level — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -267,7 +267,7 @@ export const openApiSpec = { post: { summary: 'Expand a ship system', description: - "Raise a built ship system's capacity by one (capped at max capacity). Costs `capacity` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.", + "Raise a built ship system's capacity by one (capped at max capacity). Assign the crew team and queue the SHIP_EXPAND task — the cost (`capacity` units of any resource) is deducted at queue time and the capacity bump lands when the task completes on the turn tick; queueing spends no turn. Returns the fresh ship state.", operationId: 'expandShipSystem', requestBody: { required: true, @@ -279,7 +279,7 @@ export const openApiSpec = { }, responses: { 200: { - description: 'Fresh ship state after the expansion', + description: 'Fresh ship state — the expansion lands when the task completes on the turn tick', content: { 'application/json': { schema: { $ref: '#/components/schemas/ShipState' }, @@ -287,7 +287,7 @@ export const openApiSpec = { }, }, 400: { - description: 'Malformed body or non-integer systemId', + description: 'Malformed body, a non-integer systemId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -295,7 +295,7 @@ export const openApiSpec = { }, }, 404: { - description: 'Unknown ship system id', + description: 'Unknown ship system id or unknown crew member', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -303,7 +303,7 @@ export const openApiSpec = { }, }, 409: { - description: 'System is not built or already at max capacity', + description: 'System is not built or already at max capacity — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -325,7 +325,7 @@ export const openApiSpec = { post: { summary: 'Build a ship system', description: - 'Build an unbuilt ship system type at base values. Costs a flat amount of parts, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.', + 'Build an unbuilt ship system type at base values. Assign the crew team and queue the SHIP_BUILD task — the flat parts cost is deducted at queue time and the build lands when the task completes on the turn tick; queueing spends no turn. Returns the fresh ship state.', operationId: 'buildShipSystem', requestBody: { required: true, @@ -337,7 +337,7 @@ export const openApiSpec = { }, responses: { 200: { - description: 'Fresh ship state after the build', + description: 'Fresh ship state — the build lands when the task completes on the turn tick', content: { 'application/json': { schema: { $ref: '#/components/schemas/ShipState' }, @@ -345,7 +345,7 @@ export const openApiSpec = { }, }, 400: { - description: 'Malformed body or empty type', + description: 'Malformed body, a non-string or empty type, a non-integer leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -353,7 +353,7 @@ export const openApiSpec = { }, }, 404: { - description: 'Unknown ship system type', + description: 'Unknown ship system type or unknown crew member', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -361,7 +361,7 @@ export const openApiSpec = { }, }, 409: { - description: 'System is already built', + description: 'System is already built, or the build-gate tech is not researched yet — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -401,7 +401,7 @@ export const openApiSpec = { post: { summary: 'Research a tech', description: - "Validates the prerequisites (409, naming what is missing) and the cost against the cargo hold (422), spends the cost from the oldest row first, marks the tech researched, increments the turn counter and writes a research event. Returns the fresh tech tree state.", + "Validates the prerequisites (409, naming what is missing) and assigns the crew team, queueing the RESEARCH task — the tech cost is deducted at queue time and the tech is marked researched (unlock effect included) when the task completes on the turn tick; queueing spends no turn. Returns the fresh tech tree state.", operationId: 'researchTech', requestBody: { required: true, @@ -413,7 +413,7 @@ export const openApiSpec = { }, responses: { 200: { - description: 'Fresh tech tree state after the research', + description: 'Fresh tech tree state — the research lands when the task completes on the turn tick', content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } }, @@ -421,7 +421,7 @@ export const openApiSpec = { }, }, 400: { - description: 'Malformed body or non-integer techId', + description: 'Malformed body, a non-integer techId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -429,7 +429,7 @@ export const openApiSpec = { }, }, 404: { - description: 'Unknown tech id', + description: 'Unknown tech id or unknown crew member', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -437,7 +437,7 @@ export const openApiSpec = { }, }, 409: { - description: 'Tech is already researched or a prerequisite is not researched yet (message names the missing prereq)', + description: 'Tech is already researched or a prerequisite is not researched yet (message names the missing prereq) — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, @@ -477,7 +477,7 @@ export const openApiSpec = { post: { summary: 'Accept a mission', description: - "Makes an AVAILABLE contract the single ACTIVE one (409 while another is active, or when it isn't AVAILABLE). Costs a turn and writes a mission event. Returns the fresh board.", + "Makes an AVAILABLE contract the single ACTIVE one (409 while another is active, or when it isn't AVAILABLE). Instant and free — no turn, no body — and writes a mission event at the current turn. Returns the fresh board.", operationId: 'acceptMission', parameters: [ { @@ -828,7 +828,7 @@ export const openApiSpec = { maxItems: 2, }, }, - turn: { type: 'integer', description: 'Turn counter — advances on the turn tick and on the still-instant actions (ship work, research, mission accept); a queued jump or exploration costs no turn' }, + turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' }, }, required: ['systems', 'edges', 'turn'], }, @@ -907,7 +907,7 @@ export const openApiSpec = { items: { $ref: '#/components/schemas/ShipSystem' }, }, inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, - turn: { type: 'integer', description: 'Turn counter — one per ship action (research and mission accept cost one too; a queued jump or exploration costs no turn)' }, + turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' }, }, required: ['systems', 'inventory', 'turn'], }, @@ -938,8 +938,14 @@ export const openApiSpec = { type: 'object', properties: { techId: { type: 'integer', description: 'tech.id of the tech to research' }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' }, + helperCrewIds: { + type: 'array', + description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`, + items: { type: 'integer' }, + }, }, - required: ['techId'], + required: ['techId', 'leadCrewId'], }, Mission: { type: 'object', @@ -961,7 +967,7 @@ export const openApiSpec = { type: 'object', properties: { id: { type: 'integer' }, - turn: { type: 'integer', description: 'The save turn the action cost (new_game is 0)' }, + turn: { type: 'integer', description: 'The save turn the event was written (new_game is 0)' }, type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] }, message: { type: 'string' }, createdAt: { type: 'string', description: 'ISO 8601 timestamp' }, @@ -972,22 +978,40 @@ export const openApiSpec = { type: 'object', properties: { systemId: { type: 'integer', description: 'ship_systems.id of the built system to level up' }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' }, + helperCrewIds: { + type: 'array', + description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`, + items: { type: 'integer' }, + }, }, - required: ['systemId'], + required: ['systemId', 'leadCrewId'], }, ExpandRequest: { type: 'object', properties: { systemId: { type: 'integer', description: 'ship_systems.id of the built system to expand' }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' }, + helperCrewIds: { + type: 'array', + description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`, + items: { type: 'integer' }, + }, }, - required: ['systemId'], + required: ['systemId', 'leadCrewId'], }, BuildRequest: { type: 'object', properties: { type: { type: 'string', description: "Ship system type to build, e.g. WEAPONS (see ShipSystem.type)" }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' }, + helperCrewIds: { + type: 'array', + description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`, + items: { type: 'integer' }, + }, }, - required: ['type'], + required: ['type', 'leadCrewId'], }, SystemState: { type: 'object', @@ -1000,7 +1024,7 @@ export const openApiSpec = { items: { $ref: '#/components/schemas/Planet' }, }, inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, - turn: { type: 'integer', description: 'Turn counter — advances on the turn tick and on the still-instant actions (ship work, research, mission accept); a queued jump or exploration costs no turn' }, + turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' }, }, required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'], }, -- 2.54.0 From 178de6e4fb4028b299a2ea2a8892038b6832071f Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 19:12:51 +0000 Subject: [PATCH 08/20] feat(api): POST /api/turn ticks the queue one turn; an empty queue is a free skip (#44) --- app/api/turn/route.test.ts | 70 ++++++++++++++++++++++++++++++++++++++ app/api/turn/route.ts | 21 ++++++++++++ lib/openapi.test.ts | 20 +++++++++++ lib/openapi.ts | 62 +++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 app/api/turn/route.test.ts create mode 100644 app/api/turn/route.ts diff --git a/app/api/turn/route.test.ts b/app/api/turn/route.test.ts new file mode 100644 index 0000000..755ab44 --- /dev/null +++ b/app/api/turn/route.test.ts @@ -0,0 +1,70 @@ +// Handler-level tests for the manual tick route (Task 5b): POST /api/turn (no body) advances +// exactly one turn — an empty queue is a FREE SKIP (200, turn +1, nothing else changes, the +// route never rejects), and a mid-countdown ACTIVE task decrements by exactly one in both the +// returned snapshot and the persisted row (no resolution, no mission hook — it never hits 0). +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { getGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures' +import { POST } from './route' + +const dirs: string[] = [] +// The route talks to the app-process singleton (getGameDb), so each test points it at a +// fresh temp file and uses that very instance for fixtures and assertions — two in-memory +// sql.js copies of one file would never see each other's writes. +const g = globalThis as unknown as { __spaceWandererGameDb?: Promise } + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-turn-route-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + process.env.SPACE_WANDERER_DB_PATH = file + delete g.__spaceWandererGameDb + return { db: await getGameDb(), file } +} + +afterAll(async () => { + delete process.env.SPACE_WANDERER_DB_PATH + delete g.__spaceWandererGameDb + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +// No request body — the handler never reads one. +const post = () => POST() + +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 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 insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 5, remaining: 3, status: 'ACTIVE', queuedAtTurn: before.turn }) + + const res = await 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 + }) +}) diff --git a/app/api/turn/route.ts b/app/api/turn/route.ts new file mode 100644 index 0000000..02b4fef --- /dev/null +++ b/app/api/turn/route.ts @@ -0,0 +1,21 @@ +import { getGameDb } from '@/lib/db/client' +import { getWorldState, runTurn } from '@/lib/game/save' + +// The manual tick (spec §3): advance exactly one turn — every ACTIVE task ages by one, tasks +// that reach 0 resolve (the 4a resolvers + their mission hooks, 4b), and PENDING successors +// auto-start in FIFO order. An empty queue is a FREE SKIP: turn +1 with nothing else changed — +// the route never rejects a tick, so the UI's NEXT TURN button is never disabled. No request +// body. Returns the same world snapshot GET /api/world returns, composed with the tick's +// task array (the snapshot readers do not select the tasks table, so it comes from the TickResult). +export async function POST() { + try { + const db = await getGameDb() + const tick = await runTurn(db) + return Response.json({ ...(await getWorldState(db)), tasks: tick.tasks }) + } catch (err) { + // No domain errors escape runTurn (4b fails the task instead) — anything reaching here is + // a non-domain rethrow, i.e. a real bug: the error-mapping pattern, 500 when no status. + const e = err as { status?: number; message: string } + return Response.json({ error: e.message }, { status: e.status ?? 500 }) + } +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index d58128e..2fb4f2b 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -20,6 +20,7 @@ describe('OpenAPI spec', () => { '/api/ship/upgrade', '/api/system', '/api/tech', + '/api/turn', '/api/version', '/api/world', ]); @@ -171,6 +172,25 @@ describe('OpenAPI spec', () => { expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); + it('describes the turn endpoint: POST /api/turn with no body → WorldState + tasks, 500 on internal error', () => { + const post = openApiSpec.paths['/api/turn'].post; + expect(post).not.toHaveProperty('requestBody'); + expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/TurnResult'); + expect(post.responses[500].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); + + // TurnResult extends WorldState with the task queue after the tick. + const turnResult = openApiSpec.components.schemas.TurnResult as { allOf: unknown[] }; + expect((turnResult.allOf[0] as { $ref: string }).$ref).toBe('#/components/schemas/WorldState'); + const extra = (turnResult.allOf[1] as { properties: Record }).properties; + expect(Object.keys(extra)).toEqual(['tasks']); + + const props = openApiSpec.components.schemas.Task.properties; + expect(Object.keys(props)).toEqual(['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn']); + expect((props.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']); + expect((props.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING', 'DONE']); + expect((props.helpers as { items: { type: string } }).items.type).toBe('integer'); + }); + it('keeps info.version in sync with package.json', () => { expect(openApiSpec.info.title).toBe('SpaceWanderer API'); expect(openApiSpec.info.version).toBe(pkg.version); diff --git a/lib/openapi.ts b/lib/openapi.ts index 8f1376d..64eaa8d 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -51,6 +51,32 @@ export const openApiSpec = { }, }, }, + '/api/turn': { + post: { + summary: 'Advance exactly one turn (manual tick)', + description: + "Ticks the queue one turn — no request body: every ACTIVE task ages by one, tasks that reach 0 resolve (jump, scan, ship-work or research effects land, mission checks run for what resolved) and PENDING successors auto-start in FIFO order. An empty queue is a FREE SKIP — turn +1 with nothing else changed, always 200 (the UI's NEXT TURN button is never disabled; a task that fails to resolve simply does not land its effect). Returns the same world snapshot as GET /api/world, plus the task queue after the tick.", + operationId: 'nextTurn', + responses: { + 200: { + description: 'World state after the tick, plus the task queue (every task row: ACTIVE, PENDING, DONE)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/TurnResult' }, + }, + }, + }, + 500: { + description: 'Internal error — no domain failures escape the tick (a failed task is silently dropped, not an error)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, '/api/jump': { post: { summary: 'Queue a jump to a solar system within range', @@ -832,6 +858,42 @@ export const openApiSpec = { }, required: ['systems', 'edges', 'turn'], }, + TurnResult: { + allOf: [ + { $ref: '#/components/schemas/WorldState' }, + { + type: 'object', + properties: { + tasks: { + type: 'array', + description: + 'Every task row after the tick — ACTIVE (decremented this turn, or auto-started this turn), PENDING (waiting for its team to free) and DONE (resolved this tick or earlier)', + items: { $ref: '#/components/schemas/Task' }, + }, + }, + required: ['tasks'], + }, + ], + }, + Task: { + type: 'object', + properties: { + id: { type: 'integer' }, + saveId: { type: 'integer' }, + type: { type: 'string', enum: ['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH'] }, + payload: { + type: 'string', + description: 'JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId}', + }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead' }, + helpers: { type: 'array', description: 'crew.id of the supporting crew (empty when none)', items: { type: 'integer' } }, + duration: { type: 'integer' }, + remaining: { type: 'integer' }, + status: { type: 'string', enum: ['ACTIVE', 'PENDING', 'DONE'] }, + queuedAtTurn: { type: 'integer' }, + }, + required: ['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn'], + }, JumpRequest: { type: 'object', properties: { -- 2.54.0 From 84d1aa8122e2cc01e140088cb5b12e4e928120d9 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 19:42:19 +0000 Subject: [PATCH 09/20] feat(api): world/system/crew snapshots expose the active + pending task queue (#44) --- app/api/crew/route.ts | 3 +- components/crew-panel.tsx | 6 +-- components/mission-control.tsx | 6 +-- lib/game/save.test.ts | 55 ++++++++++++++++++++-- lib/game/save.ts | 86 ++++++++++++++++++++++++++++++++-- lib/openapi.test.ts | 26 ++++++++-- lib/openapi.ts | 54 +++++++++++++++++++-- 7 files changed, 212 insertions(+), 24 deletions(-) diff --git a/app/api/crew/route.ts b/app/api/crew/route.ts index 6ca4457..acd12f7 100644 --- a/app/api/crew/route.ts +++ b/app/api/crew/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' import { getCrewState } from '@/lib/game/save' -// The ship's crew roster: starting crew plus members found while exploring. Auto-initializes on first hit. +// The ship's crew roster wrapped with the snapshot task queue: { crew, tasks } — the roster +// (starting crew plus members found while exploring) and the ACTIVE + PENDING queue. Auto-initializes on first hit. export async function GET() { const db = await getGameDb() return Response.json(await getCrewState(db)) diff --git a/components/crew-panel.tsx b/components/crew-panel.tsx index 19580a0..f848c43 100644 --- a/components/crew-panel.tsx +++ b/components/crew-panel.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import type { CrewDto } from '@/lib/game/save' +import type { CrewDto, CrewState } from '@/lib/game/save' // CREW tab — the ship's roster: starting crew plus members found while exploring. Fetches // /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches @@ -22,10 +22,10 @@ export function CrewPanel({ narrow }: CrewPanelProps) { fetch('/api/crew') .then(async (res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`) - return (await res.json()) as CrewDto[] + return (await res.json()) as CrewState // { crew, tasks } — the endpoint wraps the roster (JC-7) }) .then((r) => { - if (!cancelled) setRoster(r) + if (!cancelled) setRoster(r.crew) }) .catch((err: unknown) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 9d57581..18e2944 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -17,7 +17,7 @@ import { ShipSystemsPanel } from '@/components/ship-systems' import { SkillTreePanel } from '@/components/skill-tree' import { WorldMapPanel } from '@/components/world-map' import { CREW_CAPACITY } from '@/lib/game/ship-costs' -import type { CrewDto, SystemState } from '@/lib/game/save' +import type { CrewState, SystemState } from '@/lib/game/save' import type { ShipState } from '@/lib/game/ship' import type { TechDto } from '@/lib/game/tech' import pkg from '@/package.json' @@ -94,10 +94,10 @@ export function MissionControl() { fetch('/api/crew') .then(async (res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`) - return (await res.json()) as CrewDto[] + return (await res.json()) as CrewState // { crew, tasks } — the endpoint wraps the roster (JC-7) }) .then((c) => { - if (!cancelled) setCrewCount(c.length) + if (!cancelled) setCrewCount(c.crew.length) }) .catch(() => {}) return () => { diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 31091ac..35d702c 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -239,7 +239,7 @@ describe('explorePlanet', () => { describe('getCrewState', () => { it('seeds a starting crew of 2–3 members derived from the save seed', async () => { const { db } = await freshFile() - const crew = await getCrewState(db) // ensureSave runs inside, so this also seeds the crew + const crew = (await getCrewState(db)).crew // ensureSave runs inside, so this also seeds the crew (the roster moved under .crew — JC-7) expect(crew.length).toBeGreaterThanOrEqual(2) expect(crew.length).toBeLessThanOrEqual(3) @@ -285,7 +285,7 @@ describe('explorePlanet crew finds', () => { await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false }) } - const before = await getCrewState(db) // empty — this save was hand-built, no starting crew seeded + const before = (await getCrewState(db)).crew // empty — this save was hand-built, no starting crew seeded const result = await explorePlanet(db, crewPlanetId) expect(result.crewFound).not.toBeNull() @@ -297,13 +297,13 @@ describe('explorePlanet crew finds', () => { expect([result.crewFound!.traitGood, result.crewFound!.traitBad]).toEqual([expectedMember.traitGood, expectedMember.traitBad]) expect(result.crewFound!.origin).toBe(`${planetName}, Test System`) - const after = await getCrewState(db) + const after = (await getCrewState(db)).crew expect(after.length).toBe(before.length + 1) // exactly one join expect(after.find((c) => c.id === result.crewFound!.id)).toMatchObject({ name: expectedMember.name, origin: `${planetName}, Test System` }) // The body is already explored → a second scan is rejected and no duplicate joins. await expect(explorePlanet(db, crewPlanetId)).rejects.toMatchObject({ status: 409 }) - expect((await getCrewState(db)).length).toBe(before.length + 1) + expect((await getCrewState(db)).crew.length).toBe(before.length + 1) }) }) @@ -540,6 +540,53 @@ describe('runTurn (tick on the DB)', () => { }) }) +// --- The GET snapshots' task queue (Task 6) --------------------------------------------- +// +// Every GET snapshot (world / system / crew) carries the active + pending queue: ACTIVE rows +// first, then PENDING, each block ordered by (queuedAtTurn, id); DONE rows never appear. A +// pure read — `remaining` is the stored value, never recomputed; crew names resolve from the +// roster by id. +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 + }) +}) + describe('backfill (old saves)', () => { // Planets/moons of one system in insertion order, nested by parent — ids are excluded so a // regenerated set compares equal to the original even though autoincrement moved on. diff --git a/lib/game/save.ts b/lib/game/save.ts index add17e1..2170838 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import type { GameDb } from '@/lib/db/client' import { persist } from '@/lib/db/client' import * as schema from '@/lib/db/schema' @@ -24,10 +24,23 @@ export interface WorldSystemDto { current: boolean } +// One row of the snapshot task queue — what the GET builders expose of the tasks table +// (distinct from the tick's TickResult, which reports every row including DONE): the display +// fields the UI needs, names resolved, no raw payload. +export interface QueueDto { + id: number + type: TaskType + targetLabel: string + leadName: string + helperNames: string[] + remaining: number +} + export interface WorldState { systems: WorldSystemDto[] edges: [number, number][] // undirected system-id pairs, deduped + sorted for stable output turn: number + tasks: QueueDto[] // the active + pending queue — DONE rows never appear } // Carries an HTTP status so route handlers can map failures to responses without re-parsing. @@ -188,6 +201,58 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema } } +// The snapshot task queue — the GET builders' read of the tasks table (distinct from the +// tick's TickResult, which reports every row): one select of the tasks table filtered to +// ACTIVE + PENDING (DONE rows never appear) plus one select each of the name-resolution +// tables. The ACTIVE block comes first, then the PENDING block, each ordered by +// (queuedAtTurn, id). Pure read: `remaining` is the stored row value, never recomputed; +// crew names resolve from the roster by id, `unknown` tolerating a deleted member (the same +// spirit as 4b's <= 0 check). Private — every caller is a public read that already ran +// ensureSave. +async function taskQueue(db: GameDb): Promise { + const [taskRows, crew, systems, planets, tech] = await Promise.all([ + db.db.select().from(schema.tasks).where(inArray(schema.tasks.status, ['ACTIVE', 'PENDING'])), + db.db.select().from(schema.crew), + db.db.select().from(schema.solarSystems), + db.db.select().from(schema.planets), + db.db.select().from(schema.tech), + ]) + + const systemName = new Map(systems.map((s): [number, string] => [s.id, s.name])) + const planetName = new Map(planets.map((p): [number, string] => [p.id, p.name])) + const techName = new Map(tech.map((t): [number, string] => [t.id, t.name])) + const crewName = (id: number) => crew.find((c) => c.id === id)?.name ?? 'unknown' + + // targetLabel from the 4a payload shapes, per type (the Task interface in tasks.ts): the + // target's display name (the jump destination, the scanned body, the researched tech), the + // ship system's name with the verb suffixed for upgrade/expand, and the SHIP_TYPES string + // as-is for a build (its label lives in the payload, so it never misses). A deleted lookup + // target falls back to `unknown`. + const labelFor: Record string> = { + JUMP: (payload) => systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown', + EXPLORE: (payload) => planetName.get((JSON.parse(payload) as { planetId: number }).planetId) ?? 'unknown', + SHIP_UPGRADE: (payload) => `${systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown'} — upgrade`, + SHIP_EXPAND: (payload) => `${systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown'} — expand`, + SHIP_BUILD: (payload) => (JSON.parse(payload) as { type: string }).type, + RESEARCH: (payload) => techName.get((JSON.parse(payload) as { techId: number }).techId) ?? 'unknown', + } + + const queue = taskRows.map((t) => ({ + id: t.id, + type: t.type as TaskType, + targetLabel: labelFor[t.type as TaskType](t.payload), + leadName: crewName(t.leadCrewId), + helperNames: (JSON.parse(t.helpers) as number[]).map(crewName), + remaining: t.remaining, + status: t.status as TaskStatus, + queuedAtTurn: t.queuedAtTurn, + })) + const byQueueOrder = (rows: typeof queue) => rows.sort((a, b) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id) + const ordered = [...byQueueOrder(queue.filter((t) => t.status === 'ACTIVE')), ...byQueueOrder(queue.filter((t) => t.status === 'PENDING'))] + // The sort keys are only for ordering — the exposed shape is exactly QueueDto. + return ordered.map(({ status, queuedAtTurn, ...dto }) => dto) +} + export async function getWorldState(db: GameDb): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) @@ -211,6 +276,7 @@ export async function getWorldState(db: GameDb): Promise { systems: systems.map((s) => ({ id: s.id, name: s.name, x: s.x, y: s.y, discovered: s.discovered, current: s.isCurrent })), edges, turn: save.turn, + tasks: await taskQueue(db), } } @@ -337,6 +403,7 @@ export interface SystemState { planets: PlanetDto[] // insertion order = display order (a moon follows its parent row) inventory: InventoryItemDto[] turn: number + tasks: QueueDto[] // the active + pending queue — DONE rows never appear } // Carries an HTTP status so route handlers can map failures to responses without re-parsing. @@ -362,7 +429,7 @@ async function readSystemState(db: GameDb): Promise { parent: p.parentId, })) const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })) - return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn } + return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn, tasks: await taskQueue(db) } } export async function getSystemState(db: GameDb): Promise { @@ -381,10 +448,19 @@ export interface CrewDto { origin: string // 'STARTING CREW' or ', ' for members found while exploring } -// The full roster in join order (starting crew first, then finds). Auto-initializes like the other reads. -export async function getCrewState(db: GameDb): Promise { +// GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot +// carry a sibling tasks field, so the endpoint returns this object. +export interface CrewState { + crew: CrewDto[] + tasks: QueueDto[] +} + +// The full roster in join order (starting crew first, then finds), wrapped with the queue. +// Auto-initializes like the other reads. +export async function getCrewState(db: GameDb): Promise { await ensureSave(db) - return (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) + const crew = (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) + return { crew, tasks: await taskQueue(db) } } export interface ExploreResult extends SystemState { diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 2fb4f2b..6dc1616 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -120,10 +120,20 @@ describe('OpenAPI spec', () => { expect(get.responses['400'].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); }); - it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => { + it('describes /api/crew as the CrewState { crew, tasks } object and crewFound on ExploreResult', () => { const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema; - expect(crew.type).toBe('array'); - expect((crew.items as { $ref: string }).$ref).toBe('#/components/schemas/CrewMember'); + expect(crew.$ref).toBe('#/components/schemas/CrewState'); + + const crewState = openApiSpec.components.schemas.CrewState as { properties: Record; required: string[] }; + expect(Object.keys(crewState.properties)).toEqual(['crew', 'tasks']); + expect(crewState.required).toEqual(['crew', 'tasks']); + expect((crewState.properties.crew as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/CrewMember'); + expect((crewState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto'); + + const taskDto = openApiSpec.components.schemas.TaskDto as { properties: Record; required: string[] }; + expect(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining']); + expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining']); + expect((taskDto.properties.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']); const member = openApiSpec.components.schemas.CrewMember.properties; expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']); @@ -137,6 +147,11 @@ describe('OpenAPI spec', () => { const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema; expect(system.$ref).toBe('#/components/schemas/SystemState'); + // The snapshot carries the active + pending task queue. + const systemState = openApiSpec.components.schemas.SystemState as { properties: Record; required: string[] }; + expect(systemState.required).toEqual(['systemId', 'systemName', 'planets', 'inventory', 'turn', 'tasks']); + expect((systemState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto'); + const explore = openApiSpec.paths['/api/explore'].post; expect(explore.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreRequest'); expect(explore.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreResult'); @@ -158,6 +173,11 @@ describe('OpenAPI spec', () => { const world = openApiSpec.paths['/api/world'].get.responses[200].content['application/json'].schema; expect(world.$ref).toBe('#/components/schemas/WorldState'); + // The snapshot carries the active + pending task queue. + const worldState = openApiSpec.components.schemas.WorldState as { properties: Record; required: string[] }; + expect(worldState.required).toEqual(['systems', 'edges', 'turn', 'tasks']); + expect((worldState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto'); + const jump = openApiSpec.paths['/api/jump'].post; expect(jump.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/JumpRequest'); expect(jump.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/WorldState'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 64eaa8d..38c58d5 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -199,14 +199,14 @@ export const openApiSpec = { get: { summary: 'Crew roster', description: - 'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring planets/moons. Each member has one good and one bad trait and an origin. Auto-initializes a new game when no save exists yet.', + 'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring planets/moons — returned as the { crew, tasks } object, wrapped with the active + pending task queue. Each member has one good and one bad trait and an origin. Auto-initializes a new game when no save exists yet.', operationId: 'getCrew', responses: { 200: { - description: 'Crew roster', + description: 'Crew roster plus the active + pending task queue', content: { 'application/json': { - schema: { type: 'array', items: { $ref: '#/components/schemas/CrewMember' } }, + schema: { $ref: '#/components/schemas/CrewState' }, }, }, }, @@ -855,8 +855,13 @@ export const openApiSpec = { }, }, turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' }, + tasks: { + type: 'array', + description: 'The active + pending task queue — the ACTIVE block first, then the PENDING block, each ordered by (queuedAtTurn, id); DONE rows never appear', + items: { $ref: '#/components/schemas/TaskDto' }, + }, }, - required: ['systems', 'edges', 'turn'], + required: ['systems', 'edges', 'turn', 'tasks'], }, TurnResult: { allOf: [ @@ -894,6 +899,23 @@ export const openApiSpec = { }, required: ['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn'], }, + TaskDto: { + type: 'object', + description: 'One row of the snapshot task queue — the ACTIVE + PENDING rows of the tasks table as the GET snapshots expose them (DONE rows never appear)', + properties: { + id: { type: 'integer' }, + type: { type: 'string', enum: ['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH'] }, + targetLabel: { + type: 'string', + description: + 'The task target by display name — the jump destination, the scanned body, the ship system with the verb suffixed (e.g. Origin — upgrade), the built system type, or the researched tech; `unknown` when the target row no longer exists', + }, + leadName: { type: 'string', description: 'The team lead by name; `unknown` when the crew row no longer exists' }, + helperNames: { type: 'array', description: 'The supporting crew by name (empty when none)', items: { type: 'string' } }, + remaining: { type: 'integer', description: 'Turns left — the stored row value (a pure read, not recomputed)' }, + }, + required: ['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining'], + }, JumpRequest: { type: 'object', properties: { @@ -949,6 +971,23 @@ export const openApiSpec = { }, required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'], }, + CrewState: { + type: 'object', + description: 'GET /api/crew response — the roster wrapped with the snapshot task queue (a bare array cannot carry a sibling tasks field)', + properties: { + crew: { + type: 'array', + description: 'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring', + items: { $ref: '#/components/schemas/CrewMember' }, + }, + tasks: { + type: 'array', + description: 'The active + pending task queue — the ACTIVE block first, then the PENDING block, each ordered by (queuedAtTurn, id); DONE rows never appear', + items: { $ref: '#/components/schemas/TaskDto' }, + }, + }, + required: ['crew', 'tasks'], + }, ShipSystem: { type: 'object', properties: { @@ -1087,8 +1126,13 @@ export const openApiSpec = { }, inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, turn: { type: 'integer', description: 'Turn counter — advances on the turn tick; queued actions (jump, exploration, ship work, research) cost no turn, and mission accept is free' }, + tasks: { + type: 'array', + description: 'The active + pending task queue — the ACTIVE block first, then the PENDING block, each ordered by (queuedAtTurn, id); DONE rows never appear', + items: { $ref: '#/components/schemas/TaskDto' }, + }, }, - required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'], + required: ['systemId', 'systemName', 'planets', 'inventory', 'turn', 'tasks'], }, ExploreRequest: { type: 'object', -- 2.54.0 From 1084f4e099a6a0f6b95c56cff0f3ed03c4b918af Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 19:58:40 +0000 Subject: [PATCH 10/20] fix(api): guard task-queue name resolution against corrupted rows (#44) --- lib/game/save.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ lib/game/save.ts | 26 ++++++++++++++--------- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 35d702c..1f16627 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -550,6 +550,20 @@ 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. + // insertTaskRow JSON-encodes its arguments, so the corruption tests seed raw strings straight + // into the tasks table (same row shape, no encoding) to reach states the encoder can't. + async function insertRawTaskRow( + db: GameDb, + over: { type: string; payload: string; helpers: string; leadCrewId: number; duration: number; remaining: number; status: string; queuedAtTurn: number }, + ): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db + .insert(schema.tasks) + .values({ saveId: save.id, ...over }) + .returning({ id: schema.tasks.id }) + return row.id + } + it('empty queue → every GET snapshot carries tasks: []', async () => { const { db } = await freshFile() await buildWorldFixture(db) @@ -585,6 +599,41 @@ describe('snapshot task queue (world / system / crew)', () => { expect(snap.tasks).toHaveLength(1) expect(snap.tasks[0].type).toBe('EXPLORE') // the DONE row is filtered out, not reordered to the back }) + + it('a corrupted payload degrades to targetLabel `unknown` — the snapshot still builds', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + const badId = await insertRawTaskRow(db, { type: 'EXPLORE', payload: 'not-json', helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 2 }) + const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) + const snap = await getWorldState(db) // must not throw out of the read + expect(snap.tasks).toEqual([ + { id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 }, + { id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1 }, // the well-formed sibling still resolves + ]) + }) + + it('a corrupted helpers blob degrades to helperNames [] — no throw', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const badId = await insertRawTaskRow(db, { type: 'JUMP', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[not-json', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) + const snap = await getWorldState(db) + expect(snap.tasks).toEqual([ + { id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 }, + ]) + }) + + it('a corrupted type string degrades to targetLabel `unknown` — no throw', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const badId = await insertRawTaskRow(db, { type: 'TELEPORT', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) + const snap = await getWorldState(db) + expect(snap.tasks).toEqual([ + { id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 }, + ]) + }) }) describe('backfill (old saves)', () => { diff --git a/lib/game/save.ts b/lib/game/save.ts index 2170838..9d645a4 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -223,26 +223,32 @@ async function taskQueue(db: GameDb): Promise { const techName = new Map(tech.map((t): [number, string] => [t.id, t.name])) const crewName = (id: number) => crew.find((c) => c.id === id)?.name ?? 'unknown' + // Corrupted-row tolerance: every JSON read goes through a guarded parse — a malformed + // payload / helpers / type degrades to `unknown` / [] instead of throwing out of this + // pure read (one bad row must not 500 the /api/world, /api/system and /api/crew routes). + const parse = (raw: string): T | null => { try { return JSON.parse(raw) as T } catch { return null } } + const parseHelpers = (raw: string): number[] => { const v = parse(raw); return Array.isArray(v) ? (v as number[]) : [] } + // targetLabel from the 4a payload shapes, per type (the Task interface in tasks.ts): the // target's display name (the jump destination, the scanned body, the researched tech), the // ship system's name with the verb suffixed for upgrade/expand, and the SHIP_TYPES string - // as-is for a build (its label lives in the payload, so it never misses). A deleted lookup - // target falls back to `unknown`. + // as-is for a build. A deleted lookup target, a malformed payload, or a missing field + // falls back to `unknown`. const labelFor: Record string> = { - JUMP: (payload) => systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown', - EXPLORE: (payload) => planetName.get((JSON.parse(payload) as { planetId: number }).planetId) ?? 'unknown', - SHIP_UPGRADE: (payload) => `${systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown'} — upgrade`, - SHIP_EXPAND: (payload) => `${systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown'} — expand`, - SHIP_BUILD: (payload) => (JSON.parse(payload) as { type: string }).type, - RESEARCH: (payload) => techName.get((JSON.parse(payload) as { techId: number }).techId) ?? 'unknown', + JUMP: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? (systemName.get(p.systemId) ?? 'unknown') : 'unknown' }, + EXPLORE: (payload) => { const p = parse<{ planetId: number }>(payload); return p ? (planetName.get(p.planetId) ?? 'unknown') : 'unknown' }, + SHIP_UPGRADE: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? `${systemName.get(p.systemId) ?? 'unknown'} — upgrade` : 'unknown' }, + SHIP_EXPAND: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? `${systemName.get(p.systemId) ?? 'unknown'} — expand` : 'unknown' }, + SHIP_BUILD: (payload) => parse<{ type: string }>(payload)?.type ?? 'unknown', + RESEARCH: (payload) => { const p = parse<{ techId: number }>(payload); return p ? (techName.get(p.techId) ?? 'unknown') : 'unknown' }, } const queue = taskRows.map((t) => ({ id: t.id, type: t.type as TaskType, - targetLabel: labelFor[t.type as TaskType](t.payload), + targetLabel: labelFor[t.type as TaskType]?.(t.payload) ?? 'unknown', leadName: crewName(t.leadCrewId), - helperNames: (JSON.parse(t.helpers) as number[]).map(crewName), + helperNames: parseHelpers(t.helpers).map(crewName), remaining: t.remaining, status: t.status as TaskStatus, queuedAtTurn: t.queuedAtTurn, -- 2.54.0 From 07c15089999ed44df9eae1559a6821119e8f72d7 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 20:04:38 +0000 Subject: [PATCH 11/20] feat(ui): NEXT TURN button (bottom-right) + turn counter; empty queue is a free skip (#44) --- components/mission-control.tsx | 16 +++++++++- components/next-turn-button.tsx | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 components/next-turn-button.tsx diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 18e2944..a579712 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -13,6 +13,7 @@ import { CrewPanel } from '@/components/crew-panel' import { EventLogPanel } from '@/components/event-log' import { LocalSystemPanel } from '@/components/local-system' import { MissionPanel } from '@/components/missions' +import { NextTurnButton } from '@/components/next-turn-button' import { ShipSystemsPanel } from '@/components/ship-systems' import { SkillTreePanel } from '@/components/skill-tree' import { WorldMapPanel } from '@/components/world-map' @@ -62,7 +63,12 @@ export function MissionControl() { // #38 — admin DB CRUD overlay, opened from the header. const [adminOpen, setAdminOpen] = useState(false) - useEffect(() => { + // #44 — the four mount fetches, extracted into a callback so the NEXT TURN button (5b) can + // re-run them after /api/turn: a tick can change all four snapshots (the turn always; inventory + // on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research + // completion) — a strict superset of refreshSystem, which stays for the jump callback. Same + // failure policy as the mount fetch — keep the old state on error. + const refreshAll = useCallback(() => { let cancelled = false fetch('/api/ship') .then(async (res) => { @@ -105,6 +111,9 @@ export function MissionControl() { } }, []) + // The effect returns refreshAll's cleanup, so unmount still cancels the in-flight setStates. + useEffect(() => refreshAll(), [refreshAll]) + useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 1000) return () => clearInterval(id) @@ -548,6 +557,11 @@ export function MissionControl() { SYS ONLINE + + {/* #44 — the manual tick control (5b): fixed bottom-right, outside the tab grid so it + stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes + the snapshots this shell owns) */} + ) } diff --git a/components/next-turn-button.tsx b/components/next-turn-button.tsx new file mode 100644 index 0000000..9d5d10e --- /dev/null +++ b/components/next-turn-button.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' + +// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from +// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell +// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an +// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an +// in-flight request, which the handler ignores. `disabled` is never set, so the cva +// disabled:opacity-40 styling never applies either. + +export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) { + const [inFlight, setInFlight] = useState(false) + const [error, setError] = useState(null) + + const tick = () => { + if (inFlight) return // the only guard — a transient, never a game-state disable + setInFlight(true) + setError(null) + fetch('/api/turn', { method: 'POST' }) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + onTicked() // the same app-wide snapshot refresh the shell uses after every other action + }) + .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setInFlight(false)) + } + + return ( +
+ {error && ( + {error.toUpperCase()} + )} + + TURN {turn ?? '—'} + + +
+ ) +} -- 2.54.0 From a199b643485fda00033e360e54f2f4c1da634432 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 20:34:14 +0000 Subject: [PATCH 12/20] =?UTF-8?q?feat(ui):=20team=20builder=20=E2=80=94=20?= =?UTF-8?q?lead=20+=20helpers=20with=20live=20duration=20preview,=20wired?= =?UTF-8?q?=20into=20the=20action=20panels=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/local-system.tsx | 58 +++++++++++++++++----- components/ship-systems.tsx | 91 +++++++++++++++++++++++++++++----- components/skill-tree.tsx | 62 +++++++++++++++++++---- components/team-builder.tsx | 81 ++++++++++++++++++++++++++++++ components/world-map.tsx | 99 ++++++++++++++++++++++++++++++------- lib/game/save.test.ts | 12 ++--- lib/game/save.ts | 14 ++++-- lib/openapi.test.ts | 6 ++- lib/openapi.ts | 13 ++++- 9 files changed, 374 insertions(+), 62 deletions(-) create mode 100644 components/team-builder.tsx diff --git a/components/local-system.tsx b/components/local-system.tsx index e8745cb..a006c5a 100644 --- a/components/local-system.tsx +++ b/components/local-system.tsx @@ -1,16 +1,19 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import type { ExploreResult, SystemState } from '@/lib/game/save' +import { TeamBuilder } from '@/components/team-builder' +import type { CrewState, ExploreResult, SystemState } from '@/lib/game/save' import type { FoundItem } from '@/lib/game/world' // LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an -// EXPLORE button per unexplored row. The parent (MissionControl) owns the /api/system state so -// the right column's CARGO HOLD mirrors scans immediately; this panel reports each scan's -// response up via onChanged and keeps only its own UI bits locally (no polling). Turn-based: -// one scan costs a turn, each body once. +// EXPLORE button per unexplored row. A button click ARMS the planet (#44): the crew team +// builder mounts at the top of the column and its DISPATCH POSTs /api/explore with the team. +// The parent (MissionControl) owns the /api/system state so the right column's CARGO HOLD +// mirrors scans immediately; this panel reports each scan's response up via onChanged and +// keeps only its own UI bits locally (no polling). Turn-based: one scan costs a turn, each +// body once. interface LocalSystemPanelProps { system: SystemState | null @@ -21,27 +24,50 @@ export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) { const [lastOutcome, setLastOutcome] = useState(null) const [error, setError] = useState(null) const [exploringId, setExploringId] = useState(null) + // #44 — the armed planet (the last-clicked unexplored row); the builder above the list picks + // the team and its DISPATCH performs the scan. + const [armedPlanetId, setArmedPlanetId] = useState(null) + const [crew, setCrew] = useState(null) + + // JC-14 — the roster + queue the builder mirrors: fetched on mount and after every + // successful dispatch (the scan response updates the system state, not the roster). + // Keep-old-on-failure, the house .catch(() => {}) pattern. + const fetchCrew = useCallback(() => { + fetch('/api/crew') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as CrewState + }) + .then((c) => setCrew(c)) + .catch(() => {}) + }, []) + + useEffect(() => { + fetchCrew() + }, [fetchCrew]) const explore = useCallback( - async (planetId: number) => { + async (planetId: number, leadCrewId: number, helperCrewIds: number[]) => { setExploringId(planetId) + setArmedPlanetId(null) try { const res = await fetch('/api/explore', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ planetId }), + body: JSON.stringify({ planetId, leadCrewId, helperCrewIds }), }) const body = (await res.json()) as ExploreResult & { error?: string } if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`) onChanged(body) // fresh state straight from the response — parent mirrors it to CARGO HOLD setLastOutcome(body.outcome) + fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)) } finally { setExploringId(null) } }, - [onChanged] + [onChanged, fetchCrew] ) if (!system) { @@ -57,6 +83,16 @@ export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) { return (
+ {/* #44 — the armed planet's crew picker (JC-13: mounted only while a planet is armed); + its DISPATCH performs the scan with the picked team. */} + {armedPlanetId !== null && crew !== null && ( + void explore(armedPlanetId, leadCrewId, helperCrewIds)} + /> + )}
void explore(row.id)} + onClick={() => setArmedPlanetId(armedPlanetId === row.id ? null : row.id)} // #44 — arms/disarms; the builder dispatches > - {exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // EXPLORE`} + {exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // ${armedPlanetId === row.id ? 'PICK TEAM' : 'EXPLORE'}`} )}
diff --git a/components/ship-systems.tsx b/components/ship-systems.tsx index 8588381..9a35d6f 100644 --- a/components/ship-systems.tsx +++ b/components/ship-systems.tsx @@ -1,16 +1,21 @@ 'use client' -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { TeamBuilder } from '@/components/team-builder' import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs' import type { ShipState, ShipSystemDto } from '@/lib/game/ship' +import type { CrewState } from '@/lib/game/save' import { buildGate, effectiveCapacityCap, effectiveLevelCap } from '@/lib/game/tech' +import type { TaskType } from '@/lib/game/tasks' // SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left // column mirrors actions immediately; this panel applies each action's response directly via -// onChanged (no polling). Buttons are disabled when unaffordable, capped or gated by an +// onChanged (no polling). A card's UPGRADE / EXPAND / BUILD button ARMS the action (#44): the +// crew team builder mounts at the top of the column and its DISPATCH POSTs the ship route with +// the team. Buttons are disabled when unaffordable, capped or gated by an // unresearched tech, using the same pure helpers the API enforces (lib/game/ship-costs.ts + // lib/game/tech.ts). @@ -24,6 +29,32 @@ interface ShipSystemsPanelProps { export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: ShipSystemsPanelProps) { const [error, setError] = useState(null) const [busy, setBusy] = useState(false) // one action at a time — turn-based game + // #44 — the armed ship action (which card + which of UPGRADE/EXPAND/BUILD); the builder above + // the grid picks the team and its DISPATCH performs it. + const [armed, setArmed] = useState<{ type: TaskType; url: string; body: Record; systemId: number } | null>(null) + const [crew, setCrew] = useState(null) + + // JC-14 — the roster + queue the builder mirrors: fetched on mount and after every + // successful dispatch (the action response updates the ship state, not the roster). + // Keep-old-on-failure, the house .catch(() => {}) pattern. + const fetchCrew = useCallback(() => { + fetch('/api/crew') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as CrewState + }) + .then((c) => setCrew(c)) + .catch(() => {}) + }, []) + + useEffect(() => { + fetchCrew() + }, [fetchCrew]) + + // #44 — arm an action (toggle: clicking the armed action's button again disarms it). + const arm = useCallback((type: TaskType, url: string, body: Record, systemId: number) => { + setArmed((a) => (a && a.systemId === systemId && a.type === type ? null : { type, url, body, systemId })) + }, []) if (!ship) { return ( @@ -39,11 +70,13 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S async function act(url: string, body: unknown) { setBusy(true) setError(null) + setArmed(null) try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) const data = (await res.json()) as ShipState & { error?: string } if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`) onChanged(data) // response is the fresh ship state — apply directly + fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)) } finally { @@ -53,6 +86,16 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S return (
+ {/* #44 — the armed ship action's crew picker (JC-13: mounted only while an action is + armed); its DISPATCH performs it with the picked team. */} + {armed !== null && crew !== null && ( + void act(armed.url, { ...armed.body, leadCrewId, helperCrewIds })} + /> + )} {error && (
{error.toUpperCase()} @@ -60,7 +103,16 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S )}
{ship.systems.map((system) => ( - + ))}
@@ -73,14 +125,16 @@ function SystemCard({ parts, busy, researchedSlugs, - onAct, + armedType, + onArm, }: { system: ShipSystemDto resources: number parts: number busy: boolean researchedSlugs: Set - onAct: (url: string, body: unknown) => void + armedType: TaskType | null // #44 — which of this card's actions is the armed target + onArm: (type: TaskType, url: string, body: Record, systemId: number) => void }) { const maxLevel = effectiveLevelCap(system.type, researchedSlugs) const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs) @@ -116,16 +170,31 @@ function SystemCard({ {system.built ? ( <> - - ) : ( - )} diff --git a/components/skill-tree.tsx b/components/skill-tree.tsx index 8a3827a..f70eed9 100644 --- a/components/skill-tree.tsx +++ b/components/skill-tree.tsx @@ -1,16 +1,20 @@ 'use client' -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { TeamBuilder } from '@/components/team-builder' import { totalQty } from '@/lib/game/ship-costs' import type { ShipState } from '@/lib/game/ship' +import type { CrewState } from '@/lib/game/save' import type { TechDto } from '@/lib/game/tech' // SKILL TREE tab — live data. The parent (MissionControl) owns the tech state + ship state; this panel -// applies each research response directly via onResearched (no polling). RESEARCH is enabled only when -// the tech is AVAILABLE and its cost is payable from the cargo hold, mirroring what POST /api/research enforces. +// applies each research response directly via onResearched (no polling). A card's RESEARCH button ARMS +// the tech (#44): the crew team builder mounts at the top of the column and its DISPATCH POSTs +// /api/research with the team. The button is enabled only when the tech is AVAILABLE and its cost is +// payable from the cargo hold, mirroring what POST /api/research enforces. interface SkillTreePanelProps { techs: TechDto[] | null @@ -22,6 +26,27 @@ interface SkillTreePanelProps { export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreePanelProps) { const [error, setError] = useState(null) const [busy, setBusy] = useState(false) // one action at a time — turn-based game + // #44 — the armed tech (last-clicked AVAILABLE card); the builder above the grid picks the + // team and its DISPATCH performs the research. + const [armedTechId, setArmedTechId] = useState(null) + const [crew, setCrew] = useState(null) + + // JC-14 — the roster + queue the builder mirrors: fetched on mount and after every + // successful dispatch (the research response updates the tech state, not the roster). + // Keep-old-on-failure, the house .catch(() => {}) pattern. + const fetchCrew = useCallback(() => { + fetch('/api/crew') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as CrewState + }) + .then((c) => setCrew(c)) + .catch(() => {}) + }, []) + + useEffect(() => { + fetchCrew() + }, [fetchCrew]) if (!techs || !ship) { return ( @@ -34,14 +59,16 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP const resources = totalQty(ship.inventory, 'resource') const parts = totalQty(ship.inventory, 'part') - async function research(tech: TechDto) { + async function research(tech: TechDto, leadCrewId: number, helperCrewIds: number[]) { setBusy(true) setError(null) + setArmedTechId(null) try { - const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id }) }) + const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id, leadCrewId, helperCrewIds }) }) const data = (await res.json()) as TechDto[] & { error?: string } if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`) onResearched(data) // response is the fresh tech tree state — apply directly + fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)) } finally { @@ -51,6 +78,19 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP return (
+ {/* #44 — the armed tech's crew picker (JC-13: mounted only while a tech is armed); + its DISPATCH performs the research with the picked team. */} + {armedTechId !== null && crew !== null && ( + { + const tech = techs.find((t) => t.id === armedTechId) + if (tech) void research(tech, leadCrewId, helperCrewIds) + }} + /> + )} {error && (
{error.toUpperCase()} @@ -58,7 +98,7 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP )}
{techs.map((tech) => ( - + setArmedTechId(armedTechId === t.id ? null : t.id)} /> ))}
@@ -70,13 +110,15 @@ function TechCard({ resources, parts, busy, - onResearch, + armed, + onArm, }: { tech: TechDto resources: number parts: number busy: boolean - onResearch: (tech: TechDto) => void + armed: boolean // #44 — this card's tech is the armed target (the builder is open for it) + onArm: (tech: TechDto) => void }) { const affordable = tech.costKind === 'resource' ? resources >= tech.costQty : parts >= tech.costQty const researchDisabled = busy || tech.state !== 'AVAILABLE' || !affordable @@ -101,8 +143,8 @@ function TechCard({ )} - diff --git a/components/team-builder.tsx b/components/team-builder.tsx new file mode 100644 index 0000000..9746fac --- /dev/null +++ b/components/team-builder.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import type { CrewDto, QueueDto } from '@/lib/game/save' +import { computeDuration, type TaskType } from '@/lib/game/tasks' + +// Shared crew picker for the four action panels — PR A: an action is queued as a lead + +// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState +// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel +// performs its own fetch with the team fields in the body (Task 5a). The live preview is +// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call +// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask +// so the UI never offers a 409 (JC-11's fields make the member↔row check exact). +export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { + type: TaskType + hopCount?: number + roster: CrewDto[] + tasks: QueueDto[] + onQueue: (leadCrewId: number, helperCrewIds: number[]) => void +}) { + const [leadId, setLeadId] = useState(null) + const [helperIds, setHelperIds] = useState([]) + + // Whole-builder blocks: an ACTIVE jump is in transit (all types); in the jump panel any + // queued jump is — the second-jump 409 (JC-12). + const inTransit = tasks.find((t) => t.type === 'JUMP' && t.status === 'ACTIVE') + const blocked = inTransit ?? (type === 'JUMP' ? tasks.find((t) => t.type === 'JUMP') : null) + + const lead = roster.find((c) => c.id === leadId) ?? null + const helpers = roster.filter((c) => helperIds.includes(c.id)) + const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember + + const leadReason = (c: CrewDto) => { + const t = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING') + return t ? `${t.type} ALREADY QUEUED` : null + } + const helperReason = (c: CrewDto) => { + const t = tasks.find((x) => (x.leadCrewId === c.id || x.helperCrewIds.includes(c.id)) && (x.status === 'ACTIVE' || x.status === 'PENDING')) + return t ? `BUSY: ${t.type}` : null + } + + return ( +
+ PICK TEAM // {type} + {blocked ? ( + + {inTransit ? `IN TRANSIT // ${inTransit.targetLabel}` : 'A JUMP IS ALREADY QUEUED'} + + ) : ( + roster.map((c) => ( + + )) + )} +
+ + {duration !== null ? `WILL TAKE ${duration} TURN(S)` : 'PICK A LEAD'} + + +
+
+ ) +} diff --git a/components/world-map.tsx b/components/world-map.tsx index 1b772fd..217f1cc 100644 --- a/components/world-map.tsx +++ b/components/world-map.tsx @@ -1,16 +1,18 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from 'd3-force' import type { SimulationNodeDatum } from 'd3-force' import { select } from 'd3-selection' import { zoom, zoomIdentity } from 'd3-zoom' -import type { WorldState } from '@/lib/game/save' +import { TeamBuilder } from '@/components/team-builder' +import type { CrewState, WorldState } from '@/lib/game/save' import { reachableWithin } from '@/lib/game/world' // WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with -// pan + zoom (d3-zoom), per #28. Clicking a system within jump range of the current one jumps -// there (same POST /api/jump flow as before; two hops away once NAVIGATION AI is researched, +// pan + zoom (d3-zoom), per #28. Clicking a system within jump range of the current one ARMS +// it (#44): the crew team builder mounts as a floating card over the chart, and its DISPATCH +// POSTs /api/jump with the team in the body (two hops away once NAVIGATION AI is researched, // #35); the current and unreachable systems are dimmed and not // clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount, // refetch via the jump response — no polling. In wide mode (#36) the chart fills the whole tab @@ -55,6 +57,9 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ const [world, setWorld] = useState(null) const [error, setError] = useState(null) const [jumpingId, setJumpingId] = useState(null) + // #44 — the team builder: the clicked node is the armed target; its DISPATCH performs the jump. + const [crew, setCrew] = useState(null) + const [armedId, setArmedId] = useState(null) const containerRef = useRef(null) useEffect(() => { @@ -75,53 +80,85 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ } }, []) + // JC-14 — the roster + queue the builder mirrors: fetched on mount and after every + // successful dispatch (the jump response updates the world state, not the roster). + // Keep-old-on-failure, the house .catch(() => {}) pattern. + const fetchCrew = useCallback(() => { + fetch('/api/crew') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as CrewState + }) + .then((c) => setCrew(c)) + .catch(() => {}) + }, []) + + useEffect(() => { + fetchCrew() + }, [fetchCrew]) + + // #35 — the jump-reach map: one hop away, or two once NAVIGATION AI is researched. The chart + // marks its fields clickable from it, and the team builder previews the armed target's + // duration from its hop count (the same logic /api/jump recomputes). + const hopMap = useMemo(() => { + if (!world) return null + const current = world.systems.find((s) => s.current) + return current ? reachableWithin(world.edges, current.id, navAi ? 2 : 1) : null + }, [world, navAi]) + // Ref guard so a double-click can't start two jumps before the re-render locks the nodes. const jumpingRef = useRef(false) - const jump = useCallback(async (systemId: number) => { + // #44 — a node click ARMS the target instead of jumping: the builder over the chart picks the + // team, and its DISPATCH performs the fetch (below). Clicking another jumpable node retargets. + const arm = useCallback((systemId: number) => { + if (jumpingRef.current) return + setArmedId(systemId) + }, []) + + const jump = useCallback(async (systemId: number, leadCrewId: number, helperCrewIds: number[]) => { if (jumpingRef.current) return jumpingRef.current = true setJumpingId(systemId) + setArmedId(null) try { const res = await fetch('/api/jump', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ systemId }), + body: JSON.stringify({ systemId, leadCrewId, helperCrewIds }), }) const body = (await res.json()) as WorldState & { error?: string } if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`) setWorld(body) // fresh state straight from the response — no refetch needed onJumped?.() // #30 — parent re-fetches /api/system (LOCAL SYSTEM + CARGO HOLD mirror it) + fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)) } finally { jumpingRef.current = false setJumpingId(null) } - }, [onJumped]) + }, [onJumped, fetchCrew]) // Rebuild the chart on every world/lock change — the force layout re-settles from the API's // coordinates, so nodes visibly rearrange after each jump (#28). useEffect(() => { const container = containerRef.current - if (!container || !world) return + if (!container || !world || !hopMap) return // Measured pane size (#36): the flex chain gives a definite box in wide mode; `height` is the // fallback when the container has no resolved size yet (narrow flow, or tab hidden on load). const W = container.clientWidth || 800 const H = container.clientHeight || height - const current = world.systems.find((s) => s.current)! - // Clickable chart fields: one hop away, or two hops once NAVIGATION AI is researched (#35). - const hops = reachableWithin(world.edges, current.id, navAi ? 2 : 1) const nodes: ChartNode[] = world.systems.map((s) => ({ id: s.id, name: s.name.toUpperCase(), - status: s.current ? 'current' : hops.has(s.id) ? 'jumpable' : 'offline', + status: s.current ? 'current' : hopMap.has(s.id) ? 'jumpable' : 'offline', sublabel: s.current ? 'CURRENT' - : hops.has(s.id) - ? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hops.get(s.id) ?? 1) > 1 ? ' ×2' : ''}` + : hopMap.has(s.id) + ? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hopMap.get(s.id) ?? 1) > 1 ? ' ×2' : ''}` : s.discovered ? 'DISCOVERED' : 'UNKNOWN', x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart y: (s.y / 100) * H, @@ -187,18 +224,19 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ // Same accessible contract as the old chart: only jumpable nodes are role=button with their // name as the accessible label (e2e clicks them by role/name); all locked while a jump is in flight. + // A click ARMS the node (#44) — the team builder over the chart performs the jump. nodeSel.filter((d) => d.status === 'jumpable' && jumpingId === null) .attr('role', 'button') .attr('tabindex', 0) .attr('aria-label', (d) => d.name) .style('cursor', 'pointer') .on('click', (event: MouseEvent, d) => { - if (!panned) void jump(d.id) // a real pan ends with a click too — don't jump on that one + if (!panned) arm(d.id) // a real pan ends with a click too — don't arm on that one }) .on('keydown', (event: KeyboardEvent, d) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - void jump(d.id) + arm(d.id) } }) @@ -246,7 +284,7 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ simulation.stop() svg.remove() } - }, [world, jumpingId, jump, height, navAi]) + }, [world, jumpingId, arm, height, navAi, hopMap]) if (error && !world) { return ( @@ -269,13 +307,15 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ // flex (not height:'100%'): the pane chain is sized by min-height, where percentages don't resolve return (
- {/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns everything inside it. */} + {/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns the + chart container inside it; the armed target's team builder floats over the chart (#44). */}
@@ -293,6 +333,29 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
+ + {/* #44 — the armed target's crew picker, floating over the chart (JC-13: mounted only + while a target is armed); its DISPATCH performs the jump with the picked team. */} + {armedId !== null && crew !== null && ( +
+ void jump(armedId, leadCrewId, helperCrewIds)} + /> +
+ )}
{error && ( diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 1f16627..56fad68 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -581,8 +581,8 @@ describe('snapshot task queue (world / system / crew)', () => { const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [c3], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 3 }) const expected = [ - { id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 }, - { id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4 }, + { id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] }, + { id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4, status: 'PENDING', leadCrewId: c2, helperCrewIds: [c3] }, ] expect((await getWorldState(db)).tasks).toEqual(expected) expect((await getSystemState(db)).tasks).toEqual(expected) @@ -608,8 +608,8 @@ describe('snapshot task queue (world / system / crew)', () => { const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) const snap = await getWorldState(db) // must not throw out of the read expect(snap.tasks).toEqual([ - { id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 }, - { id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1 }, // the well-formed sibling still resolves + { id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] }, + { id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c2, helperCrewIds: [] }, // the well-formed sibling still resolves ]) }) @@ -620,7 +620,7 @@ describe('snapshot task queue (world / system / crew)', () => { const badId = await insertRawTaskRow(db, { type: 'JUMP', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[not-json', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) const snap = await getWorldState(db) expect(snap.tasks).toEqual([ - { id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 }, + { id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] }, ]) }) @@ -631,7 +631,7 @@ describe('snapshot task queue (world / system / crew)', () => { const badId = await insertRawTaskRow(db, { type: 'TELEPORT', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) const snap = await getWorldState(db) expect(snap.tasks).toEqual([ - { id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 }, + { id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] }, ]) }) }) diff --git a/lib/game/save.ts b/lib/game/save.ts index 9d645a4..0d058f6 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -26,7 +26,10 @@ export interface WorldSystemDto { // One row of the snapshot task queue — what the GET builders expose of the tasks table // (distinct from the tick's TickResult, which reports every row including DONE): the display -// fields the UI needs, names resolved, no raw payload. +// fields the UI needs, names resolved, no raw payload — plus the raw row state (JC-11): +// `status` (the array's ACTIVE-block-first order isn't readable back from it) and the crew +// ids, which the team builder needs for exact member↔row matching (name matching breaks on +// the `unknown` fallback). export interface QueueDto { id: number type: TaskType @@ -34,6 +37,9 @@ export interface QueueDto { leadName: string helperNames: string[] remaining: number + status: TaskStatus // ACTIVE block first in the array, then PENDING + leadCrewId: number // crew.id of the team lead + helperCrewIds: number[] // crew.id of the supporting crew (degrades to [] on a corrupted blob, like helperNames) } export interface WorldState { @@ -251,12 +257,14 @@ async function taskQueue(db: GameDb): Promise { helperNames: parseHelpers(t.helpers).map(crewName), remaining: t.remaining, status: t.status as TaskStatus, + leadCrewId: t.leadCrewId, + helperCrewIds: parseHelpers(t.helpers), queuedAtTurn: t.queuedAtTurn, })) const byQueueOrder = (rows: typeof queue) => rows.sort((a, b) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id) const ordered = [...byQueueOrder(queue.filter((t) => t.status === 'ACTIVE')), ...byQueueOrder(queue.filter((t) => t.status === 'PENDING'))] - // The sort keys are only for ordering — the exposed shape is exactly QueueDto. - return ordered.map(({ status, queuedAtTurn, ...dto }) => dto) + // queuedAtTurn is only for ordering — the exposed shape is exactly QueueDto (status stays: JC-11). + return ordered.map(({ queuedAtTurn, ...dto }) => dto) } export async function getWorldState(db: GameDb): Promise { diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 6dc1616..175bc38 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -131,9 +131,11 @@ describe('OpenAPI spec', () => { expect((crewState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto'); const taskDto = openApiSpec.components.schemas.TaskDto as { properties: Record; required: string[] }; - expect(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining']); - expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining']); + expect(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']); + expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']); expect((taskDto.properties.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']); + expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']); + expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); const member = openApiSpec.components.schemas.CrewMember.properties; expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']); diff --git a/lib/openapi.ts b/lib/openapi.ts index 38c58d5..9619ade 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -913,8 +913,19 @@ export const openApiSpec = { leadName: { type: 'string', description: 'The team lead by name; `unknown` when the crew row no longer exists' }, helperNames: { type: 'array', description: 'The supporting crew by name (empty when none)', items: { type: 'string' } }, remaining: { type: 'integer', description: 'Turns left — the stored row value (a pure read, not recomputed)' }, + status: { + type: 'string', + enum: ['ACTIVE', 'PENDING'], + description: 'The row state — the ACTIVE block precedes the PENDING block in the array', + }, + leadCrewId: { type: 'integer', description: 'crew.id of the team lead' }, + helperCrewIds: { + type: 'array', + description: 'crew.id of the supporting crew (empty when none)', + items: { type: 'integer' }, + }, }, - required: ['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining'], + required: ['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds'], }, JumpRequest: { type: 'object', -- 2.54.0 From 1cabea797207fd18d2032cb8479cbc0bd7bcae57 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 20:55:26 +0000 Subject: [PATCH 13/20] fix(ui): team builder lead rule mirrors the route busy check (helper-on-PENDING is not a valid lead) (#44) --- components/team-builder.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/team-builder.tsx b/components/team-builder.tsx index 9746fac..db66b72 100644 --- a/components/team-builder.tsx +++ b/components/team-builder.tsx @@ -32,8 +32,10 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { 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 leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING') + if (leads) return `${leads.type} ALREADY QUEUED` + const helps = tasks.find((x) => x.helperCrewIds.includes(c.id) && x.status === 'PENDING') + return helps ? `COMMITTED: ${helps.type}` : 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')) -- 2.54.0 From f1b5645e9d0a286a9055b8869aeb65374eabf0c9 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 21:02:37 +0000 Subject: [PATCH 14/20] =?UTF-8?q?feat(ui):=20active-tasks=20strip=20bottom?= =?UTF-8?q?-left=20=E2=80=94=20active=20first,=20pending=20dimmed=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/mission-control.tsx | 7 +++++ components/task-strip.tsx | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 components/task-strip.tsx diff --git a/components/mission-control.tsx b/components/mission-control.tsx index a579712..d45cfba 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -16,6 +16,7 @@ import { MissionPanel } from '@/components/missions' import { NextTurnButton } from '@/components/next-turn-button' import { ShipSystemsPanel } from '@/components/ship-systems' import { SkillTreePanel } from '@/components/skill-tree' +import { TaskStrip } from '@/components/task-strip' import { WorldMapPanel } from '@/components/world-map' import { CREW_CAPACITY } from '@/lib/game/ship-costs' import type { CrewState, SystemState } from '@/lib/game/save' @@ -562,6 +563,12 @@ export function MissionControl() { stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes the snapshots this shell owns) */} + + {/* #44 — the active-tasks strip: fixed bottom-left (same mount convention as the button — + outside the tab grid so it stays visible on every tab). Data: the shell's system + snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on + every shell refresh with no fetch of its own. */} +
) } diff --git a/components/task-strip.tsx b/components/task-strip.tsx new file mode 100644 index 0000000..92294c2 --- /dev/null +++ b/components/task-strip.tsx @@ -0,0 +1,54 @@ +'use client' + +import type { QueueDto } from '@/lib/game/save' + +// TASK STRIP — the save's task queue, fixed bottom-left next to the NEXT TURN button (#44). +// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING +// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows, +// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on +// every refreshAll (Task 7) — a pure display of the array it is handed. + +export function TaskStrip({ tasks }: { tasks: QueueDto[] }) { + if (tasks.length === 0) return null + + return ( +
+ + ACTIVE TASKS + + {tasks.map((t) => ( +
+ + {t.type} // {t.targetLabel} + + + {t.leadName} + {t.helperNames.length > 0 ? ` +${t.helperNames.join(' +')}` : ''} + {' — '} + {t.status === 'ACTIVE' ? `${t.remaining} turn(s) left` : 'queued'} + +
+ ))} +
+ ) +} -- 2.54.0 From 53cf1bdaccce2095df01f3f5c7bd58c7ce157be1 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 23:11:37 +0000 Subject: [PATCH 15/20] test(e2e): rework shared helpers + state spec to the queue + NEXT TURN model (1/3) (#44) --- e2e/helpers.ts | 53 +++++++++++++++++++++++++++++++++++++++++++---- e2e/state.spec.ts | 23 +++++++++++++------- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 6afb625..31c4e19 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -5,11 +5,56 @@ import { expect, type Page } from '@playwright/test'; interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean } interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number } +// Minimal crew member — GET /api/crew is the {crew, tasks} object (Task 6 / JC-7); the full +// member shape lives in e2e/state.spec.ts. +interface CrewDto { id: number; role: string } + +// The real TaskType strings (lib/game/tasks.ts) keyed by the action POST path — there is no +// bare SHIP type, ship actions split into UPGRADE / EXPAND / BUILD. +const TASK_TYPE: Record = { + '/api/jump': 'JUMP', + '/api/explore': 'EXPLORE', + '/api/research': 'RESEARCH', + '/api/ship/upgrade': 'SHIP_UPGRADE', + '/api/ship/expand': 'SHIP_EXPAND', + '/api/ship/build': 'SHIP_BUILD', +}; + +// The GET endpoint that serves each action's final snapshot (the action paths are POST-only). +const FINAL_SNAPSHOT: Record = { + '/api/jump': '/api/world', + '/api/explore': '/api/system', + '/api/research': '/api/tech', + '/api/ship/upgrade': '/api/ship', + '/api/ship/expand': '/api/ship', + '/api/ship/build': '/api/ship', +}; + +// Queue an action POST with a default one-person crew, then tick until that task resolves. +// The POST only queues (200, no turn bump, effect not yet visible); the effect appears once the +// task hits 0 on a tick — a task that starts ACTIVE with remaining N resolves on the Nth tick +// (lib/game/tasks.ts tickTurn) — so N (the full duration) is read from the queue response's +// tasks array and POST /api/turn is sent exactly N times. Returns the final snapshot for the +// path. A 409 on the queue POST is a busy / in-transit rejection (Task 3), not a tick-count bug. +export async function resolveAction(page: Page, path: string, data: Record): Promise { + const roster = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[] } | CrewDto[]; + const members: CrewDto[] = Array.isArray(roster) ? roster : (roster.crew ?? []); // {crew, tasks} object (JC-7); the array guard keeps the helper shape-tolerant + expect(members.length).toBeGreaterThan(0); // starting crew is always seeded + const res = await page.request.post(path, { data: { ...data, leadCrewId: members[0].id, helperCrewIds: [] } }); + expect(res.status()).toBe(200); + 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(FINAL_SNAPSHOT[path])).json(); +} + // Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an // undiscovered system generates 4 fresh neighbours (lib/game/world.ts), so fresh territory is // always available — picking the FIRST edge instead ping-pongs between exhausted systems, because // edges come back in insertion order and the oldest route out of a system is usually the one that -// led here. Falls back to any non-current adjacent system if none is undiscovered. +// led here. Falls back to any non-current adjacent system if none is undiscovered. The jump is +// queued with a default one-person crew and ticked to completion (resolveAction) before this +// returns, so the caller is already standing in the target system, not merely en route to it. export async function jumpToFreshSystem(page: Page): Promise { const world = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = world.systems.find((s) => s.current)!.id; @@ -23,7 +68,7 @@ export async function jumpToFreshSystem(page: Page): Promise { const target = candidates.find((s) => !s.discovered) ?? candidates[0]; expect(target).toBeDefined(); // every system has at least one route out (ring generation) - const res = await page.request.post('/api/jump', { data: { systemId: target.id } }); - expect(res.status()).toBe(200); - return (await res.json()) as WorldState; + 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; } diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index bec588b..2cae7ca 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -1,7 +1,7 @@ import { execSync } from 'node:child_process'; import path from 'node:path'; import { expect, test } from '@playwright/test'; -import { jumpToFreshSystem } from './helpers'; +import { jumpToFreshSystem, resolveAction } from './helpers'; // Repo root — docker-compose.yml lives here; `docker compose restart` runs on // this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the @@ -13,6 +13,10 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } +// Mirrors the task row of lib/game/tasks.ts minus saveId — GET /api/crew is the {crew, tasks} +// object (Task 6 / JC-7): ACTIVE block first, then PENDING, DONE filtered. +interface TaskDto { id: number; type: string; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number } +interface CrewState { crew: CrewDto[]; tasks: TaskDto[] } interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] } @@ -39,13 +43,15 @@ test('app survives a container restart (stack + volume persistence)', async ({ p sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState; } const targetPlanet = sysBefore.planets.find((p) => !p.explored)!; - const exploreRes = await page.request.post('/api/explore', { data: { planetId: targetPlanet.id } }); - expect(exploreRes.status()).toBe(200); - const sysExplored = (await exploreRes.json()) as SystemState; + // The explore POST only queues — no turn bump, explored not yet flipped — so tick it to + // completion and re-derive the baseline from the post-tick /api/system snapshot: the turn it + // pins (compared after the restart) must be the resolved one, not the queue's. + const sysExplored = (await resolveAction(page, '/api/explore', { planetId: targetPlanet.id })) as SystemState; + expect(sysExplored.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true); // the scan resolved before the baseline is pinned // Crew baseline: the roster (starting crew + any finds so far) must survive too. - const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; - expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded + const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewState; + expect(crewBefore.crew.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded // Ship baseline: the systems (levels/capacities) + cargo hold must survive too. const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState; @@ -95,8 +101,9 @@ test('app survives a container restart (stack + volume persistence)', async ({ p expect(sysAfter.turn).toBe(sysExplored.turn); expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true); - // Crew roster survived too: same members, traits and origins (no re-seed on restart). - const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; + // Crew roster survived too: same members, traits and origins (no re-seed on restart). The + // {crew, tasks} object shape (JC-7) means the still-queued task rows survive with it too. + const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewState; expect(crewAfter).toEqual(crewBefore); // Ship state survived too: same systems, levels/capacities and cargo hold. -- 2.54.0 From b51bde3c254374ad67cdeede9bee814364c25b0e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 23:45:11 +0000 Subject: [PATCH 16/20] test(e2e): rework the existing action tests to the queue + NEXT TURN model (2/3) (#44) --- e2e/smoke.spec.ts | 282 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 230 insertions(+), 52 deletions(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 840250e..4cd0750 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,12 +1,15 @@ -import { expect, test } from '@playwright/test'; -import { jumpToFreshSystem } from './helpers'; +import { expect, test, type Page } from '@playwright/test'; +import { jumpToFreshSystem, resolveAction } from './helpers'; // Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained. +// Since Task 6 both snapshots also carry the save's task queue (ACTIVE block first, then PENDING). interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean } -interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number } +// The subset of QueueDto the spec reads: the task type and its live duration (`remaining`). +interface QueueTaskDto { type: string; remaining: number } +interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number; tasks: QueueTaskDto[] } interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } interface InventoryItemDto { kind: string; name: string; qty: number } -interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number } +interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number; tasks: QueueTaskDto[] } interface FoundItem { kind: string; name: string; qty: number } interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } @@ -47,6 +50,40 @@ const shortestJumpPath = (fromId: number, toId: number, edges: [number, number][ return path; }; +// --- PR A's UI flow (Task 8's team builder + Task 7's NEXT TURN button) ----------------- + +// The team-builder half of every action panel: pick the default one-person crew in the mounted +// builder — the first lead radio, the same default member (the roster is in join order) that +// resolveAction queues with, no helpers — and click DISPATCH. Returns the queue POST's response: +// 200, no turn bump, the effect not yet applied. Its `tasks` array (where the snapshot carries +// one) holds the new task's `remaining` — the team's live duration N (Task 6). +async function dispatchDefaultCrew(page: Page, path: string) { + await page.getByRole('tabpanel').locator('input[type="radio"][name="lead"]').first().check(); + const [res] = await Promise.all([ + page.waitForResponse((r) => r.request().method() === 'POST' && r.url().endsWith(path)), + page.getByRole('button', { name: 'DISPATCH' }).click(), + ]); + return res; +} + +// One NEXT TURN click (Task 7) = one POST /api/turn. The button drops a click while a tick is +// in flight, so each click waits for the button's own TURN span (the shell's ship snapshot — +// refreshed on every tick) to show the next turn before the following click. +async function tickTo(page: Page, toTurn: number) { + await page.getByRole('button', { name: 'NEXT TURN' }).click(); + await expect(page.getByText(`TURN ${toTurn}`, { exact: true })).toBeVisible(); +} + +// The N for a ship or research action, read from the crew snapshot: the queueing routes for +// /api/ship/* and /api/research return a bare ShipState / TechDto[] with no `tasks` field of +// their own (unlike the world/system snapshots), but the queue itself is the same one GET +// /api/crew exposes (JC-7). Every test ticks its actions to completion, so at most one task of +// a given type is ever in the queue when this reads it. +async function crewTasks(page: Page): Promise { + const { tasks } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; + return tasks; +} + test('landing page loads with the SpaceWanderer title', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); @@ -142,11 +179,22 @@ test('jumping to an adjacent system updates the world state and turn counter', a const targetName = before.systems.find((s) => s.id === targetId)!.name; await page.goto('/'); - // Click the target's chart field — clickable nodes are role=button with their name as accessible label. + // Click the target's chart field — it ARMS the node: the crew team builder floats over the chart. await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click(); + // Dispatch with the default one-person crew — the POST only QUEUES the JUMP: 200, no turn bump, + // and the ship has not moved yet (the origin is still `current` in the queue response). + const res = await dispatchDefaultCrew(page, '/api/jump'); + expect(res.status()).toBe(200); + const queued = (await res.json()) as WorldState; + expect(queued.turn).toBe(before.turn); + expect(queued.systems.find((s) => s.current)?.id).toBe(currentId); // no instant landing + const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; // N = the team's live duration (Task 6/8) + + // The tick, not the queue, advances the counter: N NEXT TURN clicks, one turn each. + for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k); const after = (await (await page.request.get('/api/world')).json()) as WorldState; - expect(after.turn).toBe(before.turn + 1); + expect(after.turn).toBe(before.turn + n); expect(after.systems.find((s) => s.current)?.id).toBe(targetId); }); @@ -158,18 +206,28 @@ test('local system shows the new current system after a jump, without a page rel const targetName = before.systems.find((s) => s.id === targetId)!.name; await page.goto('/'); - // Jump through the UI: click the target's chart field on WORLD MAP (the default tab). + // Jump through the UI: click the target's chart field on WORLD MAP (the default tab) — it arms + // the node and floats the crew team builder over the chart. await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click(); + const res = await dispatchDefaultCrew(page, '/api/jump'); + expect(res.status()).toBe(200); + // The queue response is applied to the chart — but the turn is still the origin's: no landing, + // no counter bump until the ticks. + const queued = (await res.json()) as WorldState; + expect(queued.turn).toBe(before.turn); + expect(queued.systems.find((s) => s.current)?.id).toBe(currentId); + const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; - // Wait until the jump response is applied to the chart — server state is committed by then, so - // the /api/world read below can't race the in-flight POST. - await expect(page.getByText(`GALACTIC CHART // JUMP ROUTES — TURN ${before.turn + 1}`)).toBeVisible(); + // Wait until the ticks are done: the tick, not the queue, advances the counter (the button's + // TURN span is the shell's live counter; the last click lands the jump and shows TURN before+N). + for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k); const after = (await (await page.request.get('/api/world')).json()) as WorldState; const newCurrent = after.systems.find((s) => s.current)!; - expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed + expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed, on the Nth tick - // Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30). + // Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30): + // the shell re-fetches /api/system on every tick, so its header carries the final turn. await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click(); await expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible(); }); @@ -194,13 +252,29 @@ test('exploring a body flips its row state, bumps the turn and shows the scan re await page.goto('/'); await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click(); + // The row's button ARMS the planet (it flips to PICK TEAM); the crew team builder mounts above the list. await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click(); + // Dispatch with the default one-person crew — the POST only QUEUES the scan: 200, no turn bump, + // the body still unexplored in the response and its `outcome` empty until the scan resolves (JC-2). + const res = await dispatchDefaultCrew(page, '/api/explore'); + expect(res.status()).toBe(200); + const queued = (await res.json()) as ExploreResult; + expect(queued.turn).toBe(before.turn); + expect(queued.planets.find((p) => p.id === target.id)?.explored).toBe(false); + expect(queued.outcome).toEqual([]); + expect(queued.crewFound).toBeNull(); + const n = queued.tasks.find((t) => t.type === 'EXPLORE')!.remaining; + + // The ticks: the scan resolves (explored flips, the haul lands) on the Nth one. + for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k); const after = (await (await page.request.get('/api/system')).json()) as SystemState; - expect(after.turn).toBe(before.turn + 1); + expect(after.turn).toBe(before.turn + n); expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true); - // UI reflects it: the row badge flipped to SCANNED and its EXPLORE button is gone. + // UI reflects it after the ticks: the row badge flipped to SCANNED and its EXPLORE button is gone. + // (The SCAN RESULT block already showed the queue response's empty outcome — NOTHING FOUND — as + // soon as the scan was dispatched; the haul itself only ever lands on the tick, into the hold.) await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0); await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible(); }); @@ -215,18 +289,22 @@ test('exploration yields finds that accumulate in the inventory', async ({ page const sys = (await (await page.request.get('/api/system')).json()) as SystemState; const unexplored = sys.planets.filter((p) => !p.explored); if (unexplored.length > 0) { - const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } }); - expect(res.status()).toBe(200); - for (const item of ((await res.json()) as ExploreResult).outcome) { - found.set(`${item.kind}|${item.name}`, item.qty); + // Queue + tick the scan (resolveAction). The queue response's `outcome` is empty until the + // tick (JC-2), so the haul is read from the post-tick /api/system snapshot it returns: + // the inventory delta of this scan (the queue is clean at the start of every iteration). + const after = (await resolveAction(page, '/api/explore', { planetId: unexplored[0].id })) as SystemState; + const before = new Map(sys.inventory.map((i) => [`${i.kind}|${i.name}`, i.qty])); + for (const item of after.inventory) { + const qty = item.qty - (before.get(`${item.kind}|${item.name}`) ?? 0); + if (qty > 0) found.set(`${item.kind}|${item.name}`, qty); } } else { - // Current system fully scanned — jump somewhere and keep scanning. + // Current system fully scanned — jump somewhere (queued, then ticked) and keep scanning. const world = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = world.systems.find((s) => s.current)!.id; const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!; const targetId = edge[0] === currentId ? edge[1] : edge[0]; - expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200); + await resolveAction(page, '/api/jump', { systemId: targetId }); } } expect(found.size).toBeGreaterThan(0); @@ -240,13 +318,14 @@ test('exploration yields finds that accumulate in the inventory', async ({ page }); test('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => { - const roster = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; - expect(roster.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded + // GET /api/crew is the {crew, tasks} object since Task 6 (JC-18) — the roster is its crew field. + const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; + expect(crew.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded await page.goto('/'); await page.getByRole('tab', { name: 'CREW' }).click(); - for (const member of roster) { + for (const member of crew) { const heading = page.getByRole('heading', { name: member.name }); await expect(heading).toBeVisible(); // The card is the third div ancestor of the title (flex row → CardHeader → Card). @@ -260,7 +339,9 @@ test('CREW tab renders real roster cards, each showing both traits and an origin test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => { // Scan bodies until something is affordable: an upgrade costs `level` resources (at least one), - // a build costs three parts. Jumping keeps it going if a whole system is exhausted. + // a build costs three parts. Every scan/jump is queued with a default crew and ticked to + // completion (resolveAction) — the hold only changes once the task resolves on a tick. Jumping + // keeps it going if a whole system is exhausted. let ship = (await (await page.request.get('/api/ship')).json()) as ShipState; const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level); // WEAPONS is gated behind the unresearched WEAPONS SYSTEMS tech — its build path is covered by @@ -273,13 +354,13 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card const sys = (await (await page.request.get('/api/system')).json()) as SystemState; const unexplored = sys.planets.filter((p) => !p.explored); if (unexplored.length > 0) { - expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200); + await resolveAction(page, '/api/explore', { planetId: unexplored[0].id }); } else { const world = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = world.systems.find((s) => s.current)!.id; const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!; const targetId = edge[0] === currentId ? edge[1] : edge[0]; - expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200); + await resolveAction(page, '/api/jump', { systemId: targetId }); } ship = (await (await page.request.get('/api/ship')).json()) as ShipState; } @@ -292,28 +373,58 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click(); if (upgrade) { + // The UPGRADE button ARMS the action — the crew team builder mounts above the grid. const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]'); await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled(); await card.getByRole('button', { name: 'UPGRADE' }).click(); + // Dispatch with the default one-person crew — the POST only QUEUES the upgrade: 200, no turn + // bump, the cost (`level` resources) already deducted from the hold, the level NOT yet up. + const res = await dispatchDefaultCrew(page, '/api/ship/upgrade'); + expect(res.status()).toBe(200); + const queued = (await res.json()) as ShipState; + expect(queued.turn).toBe(ship.turn); + expect(queued.systems.find((s) => s.id === upgrade.id)!.level).toBe(upgrade.level); // no instant upgrade + expect(totalQty(queued.inventory, 'resource')).toBe(totalQty(ship.inventory, 'resource') - upgrade.level); // cost at queue time + const n = (await crewTasks(page)).find((t) => t.type === 'SHIP_UPGRADE')!.remaining; // N = the team's live duration (Task 6/8) + + // The ticks: the level-up lands on the Nth one. + for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k); + const after = (await (await page.request.get('/api/ship')).json()) as ShipState; const row = after.systems.find((s) => s.id === upgrade.id)!; expect(row.level).toBe(upgrade.level + 1); - expect(after.turn).toBe(ship.turn + 1); + expect(after.turn).toBe(ship.turn + n); // the tick, not the click, advances the counter - // The card badge flipped to the new level and the left column mirrors it. + // The card badge flipped to the new level and the left column mirrors it — both re-fetched by + // the tick, not by the click. await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible(); await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible(); } else { const target = build!; + // The BUILD NEW button ARMS the action — the crew team builder mounts above the grid. const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]'); await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled(); await card.getByRole('button', { name: 'BUILD NEW' }).click(); + // Dispatch — the POST only QUEUES the build: 200, no turn bump, the flat 3-parts cost already + // deducted, the system NOT yet built. + const res = await dispatchDefaultCrew(page, '/api/ship/build'); + expect(res.status()).toBe(200); + const queued = (await res.json()) as ShipState; + expect(queued.turn).toBe(ship.turn); + expect(queued.systems.find((s) => s.id === target.id)!.built).toBe(false); // no instant build + expect(totalQty(queued.inventory, 'part')).toBe(totalQty(ship.inventory, 'part') - 3); // cost at queue time + const n = (await crewTasks(page)).find((t) => t.type === 'SHIP_BUILD')!.remaining; + + // The ticks: the build lands (OFFLINE → LV 1) on the Nth one. + for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k); + const after = (await (await page.request.get('/api/ship')).json()) as ShipState; const row = after.systems.find((s) => s.id === target.id)!; expect(row.built).toBe(true); expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 }); + expect(after.turn).toBe(ship.turn + n); // the tick, not the click, advances the counter // The card badge flipped OFFLINE → LV 1 and the left column's online count went up. await expect(card.getByText('LV 1')).toBeVisible(); @@ -322,7 +433,8 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card }); test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => { - // Research costs two parts, building WEAPONS three — scan until the hold has five. Parts drop on + // Research costs two parts, building WEAPONS three — scan until the hold has five. Every + // scan/jump is queued with a default crew and ticked to completion (resolveAction). Parts drop on // ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts. let ship = (await (await page.request.get('/api/ship')).json()) as ShipState; let iterations = 0; @@ -331,7 +443,7 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu const sys = (await (await page.request.get('/api/system')).json()) as SystemState; const unexplored = sys.planets.filter((p) => !p.explored); if (unexplored.length > 0) { - expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200); + await resolveAction(page, '/api/explore', { planetId: unexplored[0].id }); } else { await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems } @@ -347,25 +459,51 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu await expect(weaponsCard.getByText('REQUIRES // WEAPONS SYSTEMS')).toBeVisible(); await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeDisabled(); - // SKILL TREE tab: research WEAPONS SYSTEMS (two parts) — the card flips to RESEARCHED. + // SKILL TREE tab: research WEAPONS SYSTEMS (two parts). The RESEARCH button ARMS the tech — the + // crew team builder mounts — and its DISPATCH queues the RESEARCH task: 200, no turn bump, the + // cost already deducted, and the card NOT yet RESEARCHED (it flips on the resolving tick). await page.getByRole('tab', { name: 'SKILL TREE' }).click(); const techCard = page.getByRole('heading', { name: 'WEAPONS SYSTEMS' }).locator('xpath=ancestor::div[3]'); await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // parts ≥ 2 const turnBefore = ship.turn; await techCard.getByRole('button', { name: 'RESEARCH' }).click(); - await expect(techCard.getByText('RESEARCHED')).toBeVisible(); + const res = await dispatchDefaultCrew(page, '/api/research'); + expect(res.status()).toBe(200); + const queuedTechs = (await res.json()) as { id: number; slug: string; state: string }[]; + expect(queuedTechs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.state).toBe('AVAILABLE'); // no instant flip + // The research response is a bare TechDto[] — the cost deduction is checked on the ship snapshot. + const queuedShip = (await (await page.request.get('/api/ship')).json()) as ShipState; + expect(queuedShip.turn).toBe(turnBefore); + expect(totalQty(queuedShip.inventory, 'part')).toBe(totalQty(ship.inventory, 'part') - 2); // cost at queue time + const n = (await crewTasks(page)).find((t) => t.type === 'RESEARCH')!.remaining; + // The ticks: the tech flips RESEARCHED (and unlocks the gated build) on the Nth one. + for (let k = 1; k <= n; k++) await tickTo(page, turnBefore + k); + await expect(techCard.getByText('RESEARCHED')).toBeVisible(); // the card flips on the resolving tick const afterResearch = (await (await page.request.get('/api/ship')).json()) as ShipState; - expect(afterResearch.turn).toBe(turnBefore + 1); // research costs a turn + expect(afterResearch.turn).toBe(turnBefore + n); // the tick, not the click, advances the counter - // The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and builds. + // The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and ARMS the build. await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click(); await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled(); await weaponsCard.getByRole('button', { name: 'BUILD NEW' }).click(); + // Dispatch the build — again queue, not instant: 200, no turn bump, still OFFLINE, cost deducted. + const buildRes = await dispatchDefaultCrew(page, '/api/ship/build'); + expect(buildRes.status()).toBe(200); + const queuedBuild = (await buildRes.json()) as ShipState; + expect(queuedBuild.turn).toBe(afterResearch.turn); + expect(queuedBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(false); // no instant build + expect(totalQty(queuedBuild.inventory, 'part')).toBe(totalQty(afterResearch.inventory, 'part') - 3); // cost at queue time + const n2 = (await crewTasks(page)).find((t) => t.type === 'SHIP_BUILD')!.remaining; + + // The ticks: WEAPONS comes online (OFFLINE → LV 1) on the Nth one. + for (let k = 1; k <= n2; k++) await tickTo(page, afterResearch.turn + k); + const afterBuild = (await (await page.request.get('/api/ship')).json()) as ShipState; expect(afterBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(true); + expect(afterBuild.turn).toBe(afterResearch.turn + n2); await expect(weaponsCard.getByText('LV 1')).toBeVisible(); }); @@ -375,6 +513,7 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[]; const navAiState = () => techs.find((t) => t.slug === 'NAV_AI')!.state; if (navAiState() !== 'RESEARCHED') { + // Every scan/jump is queued with a default crew and ticked to completion (resolveAction). let ship = (await (await page.request.get('/api/ship')).json()) as ShipState; let iterations = 0; while (totalQty(ship.inventory, 'resource') < 2 && iterations < 40) { @@ -382,7 +521,7 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn const sys = (await (await page.request.get('/api/system')).json()) as SystemState; const unexplored = sys.planets.filter((p) => !p.explored); if (unexplored.length > 0) { - expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200); + await resolveAction(page, '/api/explore', { planetId: unexplored[0].id }); } else { await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems } @@ -391,12 +530,25 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn expect(totalQty(ship.inventory, 'resource')).toBeGreaterThanOrEqual(2); await page.goto('/'); - // SKILL TREE tab: research NAVIGATION AI (two resources) — the card flips to RESEARCHED. + // SKILL TREE tab: research NAVIGATION AI (two resources). The RESEARCH button ARMS the tech — + // the crew team builder mounts — and its DISPATCH queues the RESEARCH task: 200, no turn bump, + // the cost already deducted, and the card NOT yet RESEARCHED. await page.getByRole('tab', { name: 'SKILL TREE' }).click(); const techCard = page.getByRole('heading', { name: 'NAVIGATION AI' }).locator('xpath=ancestor::div[3]'); await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // resources ≥ 2 await techCard.getByRole('button', { name: 'RESEARCH' }).click(); + const res = await dispatchDefaultCrew(page, '/api/research'); + expect(res.status()).toBe(200); + const queuedTechs = (await res.json()) as { id: number; slug: string; state: string }[]; + expect(queuedTechs.find((t) => t.slug === 'NAV_AI')!.state).toBe('AVAILABLE'); // no instant flip + const queuedShip = (await (await page.request.get('/api/ship')).json()) as ShipState; + expect(queuedShip.turn).toBe(ship.turn); + expect(totalQty(queuedShip.inventory, 'resource')).toBe(totalQty(ship.inventory, 'resource') - 2); // cost at queue time + const n = (await crewTasks(page)).find((t) => t.type === 'RESEARCH')!.remaining; + + // The ticks: the card flips RESEARCHED on the Nth one (the unlock lands with it). + for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k); await expect(techCard.getByText('RESEARCHED')).toBeVisible(); } @@ -426,11 +578,26 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn // The two-hop systems render as distinct chart fields (// JUMP ×2 sublabel) — several at once, so .first(). await expect(panel.getByText(/(DISCOVERED|UNKNOWN) \/\/ JUMP ×2/).first()).toBeVisible(); + // Click the target's chart field — it ARMS the node: the crew team builder floats over the chart. await panel.getByRole('button', { name: target.name.toUpperCase() }).click(); + // Dispatch with the default one-person crew — the POST only QUEUES the two-hop jump: 200, no + // turn bump, no landing yet (the origin is still `current` in the queue response). + const res = await dispatchDefaultCrew(page, '/api/jump'); + expect(res.status()).toBe(200); + const queued = (await res.json()) as WorldState; + expect(queued.turn).toBe(turnBefore); + expect(queued.systems.find((s) => s.current)?.id).toBe(world.systems.find((s) => s.current)!.id); // no instant landing + // The hop count now feeds the duration model, not a flat turn delta: N is the team's live + // duration for a two-hop jump (the route puts the hop count in the queued payload). + const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; + + // The ticks: the counter advances one per tick, and the jump lands on the Nth one. + for (let k = 1; k <= n; k++) await tickTo(page, turnBefore + k); + const after = (await (await page.request.get('/api/world')).json()) as WorldState; - expect(after.systems.find((s) => s.id === target.id)!.current).toBe(true); // world state updated in one action - expect(after.turn).toBe(turnBefore + 2); // the turn cost scales with hop count (#35) + expect(after.systems.find((s) => s.id === target.id)!.current).toBe(true); // the jump landed on the Nth tick + expect(after.turn).toBe(turnBefore + n); // the hop count scaled the duration, not a flat +2 (#35) }); test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => { @@ -461,14 +628,19 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn // One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active). await expect(acceptButtons.nth(idx)).toBeEnabled(); + // Accepting is the one action in the suite that does NOT queue (JC-19): no crew params, and no + // turn bump — the turn counter only ever moves on a NEXT TURN tick. + const worldBefore = (await (await page.request.get('/api/world')).json()) as WorldState; await acceptButtons.nth(idx).click(); + const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState; + expect(worldAfter.turn).toBe(worldBefore.turn); // JC-19 — accept is free, it does not tick // UI flipped: exactly one ACTIVE badge on the board and the accepted card's button is now disabled. // No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE". await expect(panel.getByText('ACTIVE')).toBeVisible(); await expect(acceptButtons.nth(idx)).toBeDisabled(); - // API confirms it: exactly one ACTIVE mission, the picked one (accepting cost a turn). + // API confirms it: exactly one ACTIVE mission, the picked one. const boardAfterAccept = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; expect(boardAfterAccept.filter((m) => m.state === 'ACTIVE')).toEqual([boardAfterAccept.find((m) => m.id === picked.id)!]); } @@ -477,9 +649,11 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState; const baseline = totalQty(shipBefore.inventory, picked.rewardKind); - // Complete it. EXPLORE/FIND: scan until done, jumping when a system exhausts (parts drop on ~30% of - // scans, so P(a FIND-part misses in 80 scans) ≈ 0.7^80 — negligible). DELIVER: follow the shortest - // jump path to its target (edges only ever get added, so the precomputed path stays valid). + // Complete it. Every scan/jump is queued with a default crew and ticked to completion + // (resolveAction) — the mission hooks run on the resolving ticks. EXPLORE/FIND: scan until done, + // jumping when a system exhausts (parts drop on ~30% of scans, so P(a FIND-part misses in 80 + // scans) ≈ 0.7^80 — negligible). DELIVER: follow the shortest jump path to its target (edges + // only ever get added, so the precomputed path stays valid). let deliverPath: number[] = []; if (picked.type === 'DELIVER') { const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState; @@ -491,12 +665,12 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn while (state !== 'DONE' && iterations < 80) { iterations++; if (deliverPath.length > 0) { - expect((await page.request.post('/api/jump', { data: { systemId: deliverPath.shift()! } })).status()).toBe(200); + await resolveAction(page, '/api/jump', { systemId: deliverPath.shift()! }); } else { const sys = (await (await page.request.get('/api/system')).json()) as SystemState; const unexplored = sys.planets.filter((p) => !p.explored); if (unexplored.length > 0) { - expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200); + await resolveAction(page, '/api/explore', { planetId: unexplored[0].id }); } else { await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems } @@ -512,9 +686,11 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn test('EVENT LOG tab shows real game events, newest first', async ({ page }) => { // Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and // message), never on absolute counts — earlier attempts leave their events behind. + // The jump is queued + ticked to completion: the event it writes carries the tick it RESOLVED + // on, not the turn of the queueing POST. const afterJump = await jumpToFreshSystem(page); const jumpedName = afterJump.systems.find((s) => s.current)!.name; - const jumpTurn = afterJump.turn; + const jumpTurn = afterJump.turn; // the resolution tick's turn // Scan one body — if the current system is exhausted (leftover from a previous attempt), hop again first. let sys = (await (await page.request.get('/api/system')).json()) as SystemState; @@ -523,23 +699,25 @@ test('EVENT LOG tab shows real game events, newest first', async ({ page }) => { sys = (await (await page.request.get('/api/system')).json()) as SystemState; } const planet = sys.planets.find((p) => !p.explored)!; - const exploreRes = await page.request.post('/api/explore', { data: { planetId: planet.id } }); - expect(exploreRes.status()).toBe(200); - const explored = (await exploreRes.json()) as ExploreResult; + // The explore POST only queues — no turn bump, no event yet — so tick it to completion: the scan + // event lands on the resolving tick, and the post-tick /api/system snapshot carries that turn. + const explored = (await resolveAction(page, '/api/explore', { planetId: planet.id })) as SystemState; + const exploreTurn = explored.turn; - // API: this run's jump + scan are in the log, and the newer one (the scan) comes first. + // API: this run's jump + scan are in the log, each stamped with the tick it resolved on, and the + // newer one (the scan) comes first. const events = (await (await page.request.get('/api/events')).json()) as EventDto[]; - const exploreEvent = events.find((e) => e.turn === explored.turn && e.type === 'explore'); + const exploreEvent = events.find((e) => e.turn === exploreTurn && e.type === 'explore'); expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`)); const jumpEvent = events.find((e) => e.type === 'jump' && e.message === `Jumped to ${jumpedName}`); expect(jumpEvent).toBeDefined(); // system names are unique, so the message pins this run's jump expect(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!)); - // UI: the terminal renders both lines (T // , uppercased). + // UI: the terminal renders both lines (T // , uppercased) with the resolution turns. await page.goto('/'); await page.getByRole('tab', { name: 'EVENT LOG' }).click(); const panel = page.getByRole('tabpanel'); - await expect(panel.getByText(new RegExp(`T${explored.turn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible(); + await expect(panel.getByText(new RegExp(`T${exploreTurn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible(); await expect(panel.getByText(new RegExp(`T${jumpTurn} // JUMPED TO ${jumpedName.toUpperCase()}`))).toBeVisible(); }); -- 2.54.0 From 1ddb8ad4694bbae6feebcdc647e3b90fa38bdff6 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 3 Oct 2026 00:05:18 +0000 Subject: [PATCH 17/20] test(e2e): rework the suite to the queue + NEXT TURN model (#44) --- e2e/smoke.spec.ts | 273 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 4cd0750..890afe5 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -728,3 +728,276 @@ test('GET /api/version reports the app version as JSON', async ({ page }) => { const body = (await res.json()) as { version: string }; expect(body.version).toMatch(/^\d+\.\d+\.\d+$/); }); + +// --- The new queue + tick model scenarios (Task 10 / #44) ------------------------------- +// All five start from a clean queue (drainQueue): the CI DB persists across retries, and a +// failed earlier attempt can leave an ACTIVE / PENDING row — or an in-transit JUMP — behind. +// Every duration below is read live from the queue responses (the team builder's value, Task 8), +// never hard-coded, and every tick goes through the raw POST /api/turn — the only turn bump. + +// The full queue row keyed by id — the same array the Task 9 strip renders (ACTIVE block first, +// then PENDING; DONE rows are filtered out of the snapshots, so only ACTIVE/PENDING appear). +interface QueueTaskRow { id: number; type: string; status: string; remaining: number } +// World / system snapshot views carrying that full row shape (the top-level DTOs keep the minimal +// {type, remaining} subset the reworked tests read). +interface WorldStateQ { systems: WorldSystemDto[]; edges: [number, number][]; turn: number; tasks: QueueTaskRow[] } +interface SystemStateQ { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number; tasks: QueueTaskRow[] } + +// One POST /api/turn (no body) — the only thing that advances the turn counter. +const tickOnce = async (page: Page) => { + expect((await page.request.post('/api/turn')).status()).toBe(200); +}; + +// Tick the save's task queue until it is empty. Free skips are legal 200s and every tick +// decrements every ACTIVE row (PENDING rows auto-start), so a non-empty queue always shrinks — +// the bound only fails a pathological state, not a slow one. +async function drainQueue(page: Page): Promise { + for (let ticks = 0; ; ticks++) { + const { tasks } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; + if (tasks.length === 0) return; + expect(ticks).toBeLessThan(60); + await tickOnce(page); + } +} + +test('queueing a one-person JUMP leaves the ship in the origin until its Nth tick (#44)', async ({ page }) => { + await drainQueue(page); + const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + const currentId = world.systems.find((s) => s.current)!.id; + const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!; + const targetId = edge[0] === currentId ? edge[1] : edge[0]; + const T = world.turn; + const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; + expect(crew.length).toBeGreaterThanOrEqual(2); + + // The queue POST: 200, no turn bump, no landing — the free member starts the JUMP ACTIVE at once. + const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } }); + expect(res.status()).toBe(200); + const queued = (await res.json()) as WorldStateQ; + expect(queued.turn).toBe(T); + expect(queued.systems.find((s) => s.current)?.id).toBe(currentId); // unchanged immediately after the queue POST + const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; // N = the team's live duration (Task 6/8) + expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE'); + + // N-1 ticks: still in transit — remaining 1, the origin still current. + for (let k = 0; k < n - 1; k++) await tickOnce(page); + const mid = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(mid.turn).toBe(T + n - 1); + expect(mid.systems.find((s) => s.current)?.id).toBe(currentId); // unchanged after N-1 ticks + expect(mid.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(1); + + // The Nth tick lands the jump. + await tickOnce(page); + const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(after.turn).toBe(T + n); + expect(after.systems.find((s) => s.current)?.id).toBe(targetId); // changed exactly on the Nth tick + expect(after.tasks.some((t) => t.type === 'JUMP')).toBe(false); // resolved rows are filtered out +}); + +test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their own durations (#44)', async ({ page }) => { + await drainQueue(page); + const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; + expect(crew.length).toBeGreaterThanOrEqual(2); // disjoint one-person teams + + // The explore's body: an unexplored planet in the current system (jump first if it is exhausted). + let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + if (!sys.planets.some((p) => !p.explored)) { + await jumpToFreshSystem(page); + sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + } + const planet = sys.planets.find((p) => !p.explored)!; + + const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + const currentId = world.systems.find((s) => s.current)!.id; + const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!; + const targetId = edge[0] === currentId ? edge[1] : edge[0]; + const targetName = world.systems.find((s) => s.id === targetId)!.name; + const T = world.turn; + + // Mandated order (JC-17): the EXPLORE is queued FIRST — while a JUMP is ACTIVE no new task of any + // type may be queued, so a jump-first order would 409 the explore. Both crews are free, so both + // tasks start ACTIVE immediately and run in parallel, with no interleaving clicks. + const expRes = await page.request.post('/api/explore', { data: { planetId: planet.id, leadCrewId: crew[1].id, helperCrewIds: [] } }); + expect(expRes.status()).toBe(200); + const expQ = (await expRes.json()) as SystemStateQ; + expect(expQ.turn).toBe(T); + const nExp = expQ.tasks.find((t) => t.type === 'EXPLORE')!.remaining; + expect(expQ.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE'); + + const jmpRes = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } }); + expect(jmpRes.status()).toBe(200); // legal even while the non-jump explore is ACTIVE + const jmpQ = (await jmpRes.json()) as WorldStateQ; + expect(jmpQ.turn).toBe(T); + const nJmp = jmpQ.tasks.find((t) => t.type === 'JUMP')!.remaining; + expect(jmpQ.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE'); // the explore kept running + expect(jmpQ.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE'); + + const getEvents = async () => (await (await page.request.get('/api/events')).json()) as EventDto[]; + // A planet flip and a jump are both recorded by the event log (the same records the EVENT LOG test + // reads), each stamped with the tick it resolved on — turn numbers are unique within a run, so the + // (turn, message) pair pins this run's own tasks. + const exploreEventAt = async (turn: number) => + (await getEvents()).find((e) => e.type === 'explore' && e.turn === turn && e.message.startsWith(`Explored ${planet.name} — found`)); + const jumpEventAt = async (turn: number) => + (await getEvents()).find((e) => e.type === 'jump' && e.turn === turn && e.message === `Jumped to ${targetName}`); + + if (nExp < nJmp) { + // The scan resolves first (tick nExp), while the ship is still in the origin — so the flip is + // readable directly off the current system. + for (let k = 0; k < nExp; k++) await tickOnce(page); + const mid = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + expect(mid.turn).toBe(T + nExp); + expect(mid.planets.find((p) => p.id === planet.id)?.explored).toBe(true); // the scan's effect, on its own tick + expect(mid.tasks.some((t) => t.type === 'EXPLORE')).toBe(false); + expect(await exploreEventAt(T + nExp)).toBeDefined(); + const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(midWorld.systems.find((s) => s.current)?.id).toBe(currentId); // not landed yet + expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE'); + expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nJmp - nExp); // nJmp - nExp ticks left + for (let k = 0; k < nJmp - nExp; k++) await tickOnce(page); + } else if (nJmp < nExp) { + // The jump lands first (tick nJmp); the origin is no longer `current` (GET /api/system is + // current-only), so the scan's flip is proven by the event it writes on its own resolution tick. + for (let k = 0; k < nJmp; k++) await tickOnce(page); + const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(midWorld.turn).toBe(T + nJmp); + expect(midWorld.systems.find((s) => s.current)?.id).toBe(targetId); + expect(await jumpEventAt(T + nJmp)).toBeDefined(); + expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE'); + expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - nJmp); // nExp - nJmp ticks left + for (let k = 0; k < nExp - nJmp; k++) await tickOnce(page); + } else { + // Equal durations: both decrement in lockstep and stay ACTIVE through every tick before the last. + for (let k = 1; k < nExp; k++) { + await tickOnce(page); + const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - k); + expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nExp - k); + } + } + + // Both resolved at their own durations: the jump landed, each event is stamped on its task's + // resolution tick, and the queue is clean again. + const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(after.turn).toBe(T + Math.max(nExp, nJmp)); + expect(after.systems.find((s) => s.current)?.id).toBe(targetId); + expect(after.tasks.some((t) => t.type === 'JUMP' || t.type === 'EXPLORE')).toBe(false); + expect(await exploreEventAt(T + nExp)).toBeDefined(); + expect(await jumpEventAt(T + nJmp)).toBeDefined(); +}); + +test('a second task for a member on an ACTIVE one is pre-queued PENDING and auto-starts on its tick (#44)', async ({ page }) => { + await drainQueue(page); + const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; + expect(crew.length).toBeGreaterThanOrEqual(2); + + // Two unexplored bodies in the current system; a fresh system always has them, so jump until the + // current one does (the bounded guard fails the test if the world ever stops producing them). + let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + for (let guard = 0; sys.planets.filter((p) => !p.explored).length < 2 && guard < 10; guard++) { + await jumpToFreshSystem(page); + sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + } + const unexplored = sys.planets.filter((p) => !p.explored); + expect(unexplored.length).toBeGreaterThanOrEqual(2); + const planetA = unexplored[0]!; + const planetB = unexplored[1]!; + const T = sys.turn; + + // Task 1: crew[0] is free, so the scan starts ACTIVE at once. + const res1 = await page.request.post('/api/explore', { data: { planetId: planetA.id, leadCrewId: crew[0].id, helperCrewIds: [] } }); + expect(res1.status()).toBe(200); + const q1 = (await res1.json()) as SystemStateQ; + expect(q1.turn).toBe(T); // no turn bump on queue + const t1 = q1.tasks.find((t) => t.type === 'EXPLORE')!; // the only row — the queue was clean + expect(t1.status).toBe('ACTIVE'); + const na = t1.remaining; + + // Task 2, same member, a different target: a lead on an ACTIVE may chain (JC-16) — it is accepted + // (the member is on ACTIVE, not PENDING, and no jump is in transit) and starts PENDING. + const res2 = await page.request.post('/api/explore', { data: { planetId: planetB.id, leadCrewId: crew[0].id, helperCrewIds: [] } }); + expect(res2.status()).toBe(200); + const q2 = (await res2.json()) as SystemStateQ; + expect(q2.turn).toBe(T); + expect(q2.tasks.find((t) => t.id === t1.id)!.status).toBe('ACTIVE'); + const t2 = q2.tasks.find((t) => t.id !== t1.id && t.type === 'EXPLORE')!; + expect(t2.status).toBe('PENDING'); // the second is dimmed in the strip (Task 9) until it starts + const nb = t2.remaining; // its full duration — untouched until it starts + // The snapshot orders the ACTIVE block before the PENDING block (Task 6). + expect(q2.tasks.findIndex((t) => t.id === t1.id)).toBeLessThan(q2.tasks.findIndex((t) => t.id === t2.id)); + + // Tick until task 1 resolves — a loop keyed on its specific id (resolveAction's find-by-type would + // grab the earliest same-type row, which is the ambiguity two same-type rows create). + for (let k = 1; k < na; k++) { + await tickOnce(page); + const snap = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + expect(snap.tasks.find((t) => t.id === t1.id)!.remaining).toBe(na - k); // the ACTIVE row decrements + expect(snap.tasks.find((t) => t.id === t2.id)!.status).toBe('PENDING'); // the second keeps waiting + } + // The na-th tick: task 1 resolves (filtered out of the snapshot), and task 2 auto-starts on that + // same tick — ACTIVE with its FULL remaining (the first decrement lands on the next tick). + await tickOnce(page); + const done = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + expect(done.tasks.some((t) => t.id === t1.id)).toBe(false); + expect(done.tasks.find((t) => t.id === t2.id)!.status).toBe('ACTIVE'); + expect(done.tasks.find((t) => t.id === t2.id)!.remaining).toBe(nb); + + // Leave the queue clean for the next scenario: tick task 2 to completion. + for (let k = 0; k < nb; k++) await tickOnce(page); + const clean = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + expect(clean.tasks.length).toBe(0); +}); + +test('queueing a second JUMP while one is in transit is refused 409 (#44)', async ({ page }) => { + await drainQueue(page); + const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + const currentId = world.systems.find((s) => s.current)!.id; + const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!; + const targetId = edge[0] === currentId ? edge[1] : edge[0]; + const T = world.turn; + const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; + expect(crew.length).toBeGreaterThanOrEqual(2); + + const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } }); + expect(res.status()).toBe(200); + const queued = (await res.json()) as WorldStateQ; + expect(queued.turn).toBe(T); + expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE'); // in transit — and deliberately not ticked + + // A second JUMP while the first is in transit — a different, free member, so the 409 is the + // global in-transit rule ("a jump is in transit — no new tasks can be queued" / "a jump is + // already queued"), not crew availability. (The UI variant and the full 409 matrix are + // unit-covered by Task 3 — this single API case is what e2e carries.) + const second = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[1].id, helperCrewIds: [] } }); + expect(second.status()).toBe(409); + expect(await second.text()).toMatch(/jump/i); + + // The first is still in transit, untouched by the refused queue. + const still = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(still.turn).toBe(T); + expect(still.systems.find((s) => s.current)?.id).toBe(currentId); + expect(still.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE'); + // The jump is left in transit on purpose (the scenario is about the blocked queue); the next + // scenario's drainQueue ticks it out before its empty-queue precondition. +}); + +test('NEXT TURN with an empty queue is a free skip — +1 turn, the strip stays hidden (#44)', async ({ page }) => { + await drainQueue(page); + const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(world.tasks.length).toBe(0); // the precondition: nothing queued + const T = world.turn; + + await page.goto('/'); + // The button's live counter shows the current turn; with an empty queue the strip renders nothing. + await expect(page.getByText(`TURN ${T}`, { exact: true })).toBeVisible(); + await expect(page.getByText('ACTIVE TASKS', { exact: true })).toHaveCount(0); + + await page.getByRole('button', { name: 'NEXT TURN' }).click(); + await expect(page.getByText(`TURN ${T + 1}`, { exact: true })).toBeVisible(); // the click is a real tick + await expect(page.getByText('ACTIVE TASKS', { exact: true })).toHaveCount(0); // a free skip queues no task + + // API confirms the model: the turn advanced by exactly one and the queue is still empty. + const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ; + expect(after.turn).toBe(T + 1); + expect(after.tasks.length).toBe(0); +}); -- 2.54.0 From a0307e4ccd4a0cb6c5a5e505e96de64cf5b94408 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 3 Oct 2026 00:35:10 +0000 Subject: [PATCH 18/20] =?UTF-8?q?fix(e2e):=20task-10=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20Nth=20tick,=20retry-safe=20preambles,=20queue-DTO?= =?UTF-8?q?=20shape=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/helpers.ts | 15 +++++++++++++++ e2e/smoke.spec.ts | 33 ++++++++++++++++++--------------- e2e/state.spec.ts | 13 +++++++++---- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 31c4e19..331f3db 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -72,3 +72,18 @@ export async function jumpToFreshSystem(page: Page): Promise { expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued return after; } + +// Tick the save's task queue until it is empty (free skips are legal 200s; every tick decrements +// every ACTIVE row and PENDING rows auto-start, so a non-empty queue always shrinks — the bound +// only fails a pathological state). Every spec that queues actions starts from this on retry: +// the CI DB persists across attempts, and a failed one may have left a row behind — an in-transit +// JUMP would 409 every new queue (lib/game/tasks.ts's in-transit rule). +export async function drainQueue(page: Page): Promise { + for (let ticks = 0; ; ticks++) { + const body = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[]; tasks?: { id: number }[] } | CrewDto[]; + const tasks = Array.isArray(body) ? [] : (body.tasks ?? []); + if (tasks.length === 0) return; + expect(ticks).toBeLessThan(60); + expect((await page.request.post('/api/turn')).status()).toBe(200); + } +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 890afe5..3fc8068 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { jumpToFreshSystem, resolveAction } from './helpers'; +import { drainQueue, jumpToFreshSystem, resolveAction } from './helpers'; // Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained. // Since Task 6 both snapshots also carry the save's task queue (ACTIVE block first, then PENDING). @@ -172,6 +172,7 @@ test('world map shows the generated starting system and its neighbours', async ( }); test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) const before = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = before.systems.find((s) => s.current)!.id; const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!; @@ -199,6 +200,7 @@ test('jumping to an adjacent system updates the world state and turn counter', a }); test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) const before = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = before.systems.find((s) => s.current)!.id; const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!; @@ -247,6 +249,7 @@ test('local system shows the current system bodies with staggered moons and expl }); test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) const before = (await (await page.request.get('/api/system')).json()) as SystemState; const target = before.planets.find((p) => !p.explored)!; @@ -280,6 +283,7 @@ test('exploring a body flips its row state, bumps the turn and shows the scan re }); test('exploration yields finds that accumulate in the inventory', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) // Scan bodies until one yields something (most scans do; jumping keeps it going if a whole // system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible. const found = new Map(); // kind|name -> qty @@ -338,6 +342,7 @@ test('CREW tab renders real roster cards, each showing both traits and an origin }); test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) // Scan bodies until something is affordable: an upgrade costs `level` resources (at least one), // a build costs three parts. Every scan/jump is queued with a default crew and ticked to // completion (resolveAction) — the hold only changes once the task resolves on a tick. Jumping @@ -433,6 +438,7 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card }); test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) // Research costs two parts, building WEAPONS three — scan until the hold has five. Every // scan/jump is queued with a default crew and ticked to completion (resolveAction). Parts drop on // ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts. @@ -508,6 +514,7 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu }); test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) // Research costs two resources (70% of scans drop one), so a short scan loop makes it affordable; // jumping keeps it going if a system exhausts. Idempotent: a retry finds it already RESEARCHED. let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[]; @@ -601,6 +608,7 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn }); test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up @@ -684,6 +692,7 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn }); test('EVENT LOG tab shows real game events, newest first', async ({ page }) => { + await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) // Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and // message), never on absolute counts — earlier attempts leave their events behind. // The jump is queued + ticked to completion: the event it writes carries the tick it RESOLVED @@ -748,18 +757,6 @@ const tickOnce = async (page: Page) => { expect((await page.request.post('/api/turn')).status()).toBe(200); }; -// Tick the save's task queue until it is empty. Free skips are legal 200s and every tick -// decrements every ACTIVE row (PENDING rows auto-start), so a non-empty queue always shrinks — -// the bound only fails a pathological state, not a slow one. -async function drainQueue(page: Page): Promise { - for (let ticks = 0; ; ticks++) { - const { tasks } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; - if (tasks.length === 0) return; - expect(ticks).toBeLessThan(60); - await tickOnce(page); - } -} - test('queueing a one-person JUMP leaves the ship in the origin until its Nth tick (#44)', async ({ page }) => { await drainQueue(page); const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ; @@ -799,13 +796,16 @@ test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their ow const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; expect(crew.length).toBeGreaterThanOrEqual(2); // disjoint one-person teams - // The explore's body: an unexplored planet in the current system (jump first if it is exhausted). + // The explore's body: an unexplored planet in the current system. A jump's fallback target can + // be a fully-explored system (jumpToFreshSystem prefers undiscovered, not unexplored), so loop + // like the other preambles instead of a single if. let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ; - if (!sys.planets.some((p) => !p.explored)) { + for (let guard = 0; !sys.planets.some((p) => !p.explored) && guard < 10; guard++) { await jumpToFreshSystem(page); sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ; } const planet = sys.planets.find((p) => !p.explored)!; + expect(planet).toBeDefined(); // the world keeps producing fresh unscanned bodies const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ; const currentId = world.systems.find((s) => s.current)!.id; @@ -874,6 +874,9 @@ test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their ow expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - k); expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nExp - k); } + // The Nth tick resolves both — the loop above covered ticks 1..N-1 only (mirrors how the other + // scenarios end on their resolving tick). + await tickOnce(page); } // Both resolved at their own durations: the jump landed, each event is stamped on its task's diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index 2cae7ca..29aa700 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -1,7 +1,7 @@ import { execSync } from 'node:child_process'; import path from 'node:path'; import { expect, test } from '@playwright/test'; -import { jumpToFreshSystem, resolveAction } from './helpers'; +import { drainQueue, jumpToFreshSystem, resolveAction } from './helpers'; // Repo root — docker-compose.yml lives here; `docker compose restart` runs on // this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the @@ -13,9 +13,10 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } -// Mirrors the task row of lib/game/tasks.ts minus saveId — GET /api/crew is the {crew, tasks} -// object (Task 6 / JC-7): ACTIVE block first, then PENDING, DONE filtered. -interface TaskDto { id: number; type: string; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number } +// Mirrors the QueueDto of lib/game/save.ts — GET /api/crew is the {crew, tasks} object (Task 6 / +// JC-7): the queue's display shape (resolved names, no raw payload), ACTIVE block first, then +// PENDING, DONE filtered. +interface TaskDto { id: number; type: string; targetLabel: string; leadName: string; helperNames: string[]; remaining: number; status: string; leadCrewId: number; helperCrewIds: number[] } interface CrewState { crew: CrewDto[]; tasks: TaskDto[] } interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } @@ -26,6 +27,10 @@ interface EventDto { id: number; turn: number; type: string; message: string; cr test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); + // Retry-safety: a failed earlier attempt may have left a row queued — an in-transit JUMP would + // 409 every new queue (lib/game/tasks.ts's in-transit rule), so drain before the baselines. + await drainQueue(page); + // Baseline: the app serves and reports its version. const resBefore = await page.request.get('/api/version'); expect(resBefore.status()).toBe(200); -- 2.54.0 From babae7c39285808eb8f3c3aab920dacca6299fb0 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 3 Oct 2026 00:57:11 +0000 Subject: [PATCH 19/20] docs: approved crew-driven-actions spec + implementation plan (#44) --- .../plans/2026-10-01-crew-driven-actions.md | 2565 +++++++++++++++++ .../2026-10-01-crew-driven-actions-design.md | 121 + 2 files changed, 2686 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-crew-driven-actions.md create mode 100644 docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md diff --git a/docs/superpowers/plans/2026-10-01-crew-driven-actions.md b/docs/superpowers/plans/2026-10-01-crew-driven-actions.md new file mode 100644 index 0000000..56375dd --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-crew-driven-actions.md @@ -0,0 +1,2565 @@ +# Crew-Driven Actions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make crew the central resource of SpaceWanderer: every action becomes a queued task with an assigned team, variable duration from roles/skills/traits, and manual time via a NEXT TURN button; crews then specialise through skill progression (PR B) and traits gain outcome effects (PR C). + +**Architecture:** A pure task engine (lib/game/tasks.ts + lib/game/crew-costs.ts, no DB imports — shared server+client like the tech.ts precedent) computes durations and validates queues; save.ts keeps DB orchestration and gains a turn tick that resolves completed tasks via refactored per-type resolvers. PR B adds a crew_skills table feeding power bands into the same formula; PR C hooks two trait outcome effects into the explore resolver. + +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript strict, bun, SQLite via Drizzle + sql.js (WASM), Vitest unit tests, Playwright e2e (CI-only) + +**Spec:** docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md + +## Global Constraints + +- AGENTS.md change process per PR: feature branch off master, no direct pushes to master; version bump in package.json + CHANGELOG entry at top (`## v — YYYY-MM-DD HH:MM`); local verify ONLY `npx tsc --noEmit`, `npx vitest run`, `npx playwright test --list` (e2e is CI-only, never run locally); push → CI auto-merge on green (verify merged_at) → tag v on master → manual deploy `docker compose up -d --build` + verify /api/version at https://space-wanderer.dev.kkhost.pl. +- DDL in lib/db/client.ts must stay in sync with lib/db/schema.ts; schema changes only via idempotent backfill steps in save.ts ensureSave (SCHEMA_VERSION 6→7→8); no DB wipes ever. +- OpenAPI spec in lib/openapi.ts updated whenever an endpoint changes (and lib/openapi.test.ts pins, which must be updated with it). +- Single-player by design: NO auth, accounts, or multiplayer, ever. +- Determinism: every game outcome reproducible from seed + player actions; no wall-clock randomness. +- New pure modules (crew-costs.ts, tasks.ts, skills.ts) must not import lib/db/* — shared server+client like the tech.ts precedent (required for live duration preview in the UI). + +## Review Focus + +1. Existing save loaded after deploy has no tasks/crew_skills tables → backfill must create them without touching turn, crew, or inventory state, and be idempotent across restarts. +2. Two ACTIVE tasks completing on the same tick that share a freed crew member's successor chain → FIFO auto-start must start at most one task per freed crew member (earliest queuedAtTurn wins); later successors stay PENDING until their own tick. +3. Non-jump task active while a JUMP completes on the same tick → both resolve; the non-jump effect applies to its payload target, not the new current system. +4. All crew busy → every queueing action is disabled/409 with a reason, but NEXT TURN always works (free skip); no dead-end because every ACTIVE task completes in finite turns. +5. Seeded-stream draw order must be fixed regardless of traits: an extra haul roll for FINDS HIDDEN THINGS shifts later draws on that per-planet stream — pin the exploration outcome as a pure function of (seed, planetId, assigned crew) with determinism tests both with and without the trait. + +(Pinning tests for each item live in the owning task's steps.) + +--- + + + +### PR A — Gitea issue #44 "Crew-driven actions + NEXT TURN engine" → v0.10.0 + +Spec sections: §3 (task model), §4 (duration), §5 (trait duration modifiers), §7 (snapshots), §8 (UI), §9 (tests), §11 (constants). + +### Task 1: Balance constants + duration model + +**Files:** +- Create: `lib/game/crew-costs.ts` (the §11 balance-knob module, alongside the existing `lib/game/ship-costs.ts`), `lib/game/tasks.ts` (pure task model), tests `lib/game/tasks.test.ts` (colocated, matching `lib/game/crew.test.ts`) + +**Interfaces:** +- Consumes: `CrewMember` from `lib/game/crew.ts` (`{ name, role, traitGood, traitBad }` — string fields exactly as generated by `GOOD_TRAITS`/`BAD_TRAITS` there) +- Produces: from `crew-costs.ts` — `SkillName`, `WORK_POINTS`, `ACTION_SKILL`, `PREFERRED_ROLES`, `POWER_BANDS`, `XP_THRESHOLDS`, `STARTING_LEVELS`, `MISMATCH_CANCEL_TRAIT`, `TRAIT_DURATION_MODIFIERS`, `RECRUIT_CHANCE_BASE`, `RECRUIT_CHANCE_TALKER`; from `tasks.ts` — `TaskType` and `computeDuration(type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf?: (m: CrewMember) => number): number` + +- [ ] Steps: TDD — write failing tests FIRST in `lib/game/tasks.test.ts` using the spec's EXACT values (table-driven; run `npx vitest run lib/game/tasks.test.ts` → red, the modules do not exist yet): + +```ts +import { describe, expect, it } from 'vitest' +import type { CrewMember } from './crew' +import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, POWER_BANDS, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, TRAIT_DURATION_MODIFIERS, WORK_POINTS, XP_THRESHOLDS } from './crew-costs' +import type { TaskType } from './tasks' +import { computeDuration } from './tasks' + +// Trait strings copied verbatim from lib/game/crew.ts GOOD_TRAITS/BAD_TRAITS — TRAIT_DURATION_MODIFIERS +// is keyed by exactly these strings, so a rename in crew.ts breaks these tests. +const NEUTRAL_GOOD = 'FINDS HIDDEN THINGS' // no duration entry → no effect +const NEUTRAL_BAD = 'JINXED IN A DOGFIGHT' // STANDBY combat trait → no duration effect +const crew = (role: string, traitGood = NEUTRAL_GOOD, traitBad = NEUTRAL_BAD): CrewMember => ({ name: 'TEST', role, traitGood, traitBad }) +const WORK: Record = { JUMP: 3, EXPLORE: 4, SHIP_UPGRADE: 6, SHIP_EXPAND: 6, SHIP_BUILD: 6, RESEARCH: 9 } +const TYPES = Object.keys(WORK) as TaskType[] +const SHIP: TaskType[] = ['SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD'] + +describe('solo baselines (one crew, power 1, no modifiers)', () => { + it('work points are the duration for the preferred role: EXPLORE 4 · JUMP 3 / 6 · SHIP 6 · RESEARCH 9', () => { + for (const type of TYPES) { + expect(computeDuration(type, 1, crew(PREFERRED_ROLES[type][0]), [])).toBe(WORK[type]) + } + expect(computeDuration('JUMP', 2, crew('PILOT'), [])).toBe(6) // two-hop = 2 × 3 + }) +}) +``` + +```ts +describe('role mismatch (lead only, helpers never penalised)', () => { + it('a lead outside the preferred role adds +1; helpers contribute power only', () => { + expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [])).toBe(4) // 3 + 1 mismatch + expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [crew('PILOT')])).toBe(3) // ceil(3/2) + 1 — power 2, mismatch still on the lead + expect(computeDuration('EXPLORE', 1, crew('SCIENTIST'), [crew('PILOT', 'QUICK HANDS')])).toBe(2) // 4/2 — the helper's trait does not apply + }) + it('STEADY UNDER PRESSURE cancels the mismatch penalty and adds nothing of its own', () => { + expect(computeDuration('JUMP', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(3) + expect(computeDuration('EXPLORE', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(4) + }) +}) + +describe('floor', () => { + it('never returns below 1 turn', () => { + const lead = crew('PILOT', 'QUICK HANDS') + expect(computeDuration('JUMP', 1, lead, [crew('PILOT'), crew('PILOT')], () => 3)).toBe(1) // ceil(3/9) − 1 = 0 → floor 1 + }) +}) +``` + +```ts +// All 15 duration traits from spec §5, each tested once in its own action context (plus its +// non-applicable types). target 'SHIP_UPGRADE' stands for the whole "SHIP work" class. +const TABLE: [trait: string, isGood: boolean, target: TaskType | 'ANY', delta: number][] = [ + ['RECKLESS COURAGE', true, 'JUMP', -1], + ['CALM IN A CRISIS', true, 'EXPLORE', -1], + ['FIXES ANYTHING', true, 'SHIP_UPGRADE', -1], + ['READS STARS BLIND', true, 'JUMP', -1], + ['QUICK HANDS', true, 'ANY', -1], + ['IRON WILL', true, 'RESEARCH', -1], + ['RIGID — SLOW TO ADAPT', false, 'RESEARCH', 1], + ['OVERCAUTIOUS', false, 'ANY', 1], + ['SLOWS THE CREW DOWN', false, 'ANY', 1], + ['CARELESS WITH FUSES', false, 'SHIP_UPGRADE', 1], + ['HOARDS RATIONS', false, 'SHIP_UPGRADE', 1], + ['FORGETS TO EAT', false, 'EXPLORE', 1], + ['ARGUES WITH EVERYONE', false, 'RESEARCH', 1], + ['DOUBTS THE CAPTAIN', false, 'JUMP', 1], +] + +describe('the 15 duration traits (spec §5, exact strings from crew.ts)', () => { + const applies = (type: TaskType, target: TaskType | 'ANY') => + target === 'ANY' || target === type || (target !== 'ANY' && SHIP.includes(target) && SHIP.includes(type)) + it('each trait shifts only its own action class, lead only, across all six types', () => { + for (const [trait, isGood, target, delta] of TABLE) { + for (const type of TYPES) { + const lead = isGood ? crew(PREFERRED_ROLES[type][0], trait) : crew(PREFERRED_ROLES[type][0], NEUTRAL_GOOD, trait) + expect(computeDuration(type, 1, lead, []), `${trait} → ${type}`).toBe(WORK[type] + (applies(type, target) ? delta : 0)) + } + } + }) +}) + +describe('crew-costs — the §11 tuning surface', () => { + it('pins the balance knobs', () => { + expect(WORK_POINTS).toEqual({ EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 }) + expect(XP_THRESHOLDS).toEqual([5, 10, 15, 20, 25]) + expect(POWER_BANDS).toEqual([1, 1, 1, 2, 2, 3]) // L0–L2 → 1 · L3–L4 → 2 · L5 → 3 + expect(STARTING_LEVELS.PILOT).toEqual({ NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }) + expect(STARTING_LEVELS.SCIENTIST).toEqual({ NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 }) + expect(STARTING_LEVELS.COMMANDER).toEqual({ NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }) + expect(RECRUIT_CHANCE_BASE).toBe(0.18) + expect(RECRUIT_CHANCE_TALKER).toBe(0.3) + expect(Object.keys(TRAIT_DURATION_MODIFIERS)).toHaveLength(15) // all 15 duration traits of spec §5, nothing else + expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier + }) +}) +``` + +- [ ] Implement `lib/game/crew-costs.ts` (pure — no `lib/db` imports; the frontend imports it for the live duration preview, same server+client precedent as `ship-costs.ts`/`tech.ts`): + +```ts +// Pure crew-balance module (spec §11): every tuning knob for the duration model and skill +// progression lives here, so tuning a number means editing one file. No lib/db imports — +// the frontend imports this for the live duration preview in the crew picker (same precedent +// as ship-costs.ts). +import type { TaskType } from './tasks' + +export type SkillName = 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' + +export const WORK_POINTS = { EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 } as const + +export const ACTION_SKILL: Record = { + JUMP: 'NAVIGATION', + EXPLORE: 'SCANNING', + SHIP_UPGRADE: 'ENGINEERING', + SHIP_EXPAND: 'ENGINEERING', + SHIP_BUILD: 'ENGINEERING', + RESEARCH: 'RESEARCH', +} + +export const PREFERRED_ROLES: Record = { + JUMP: ['PILOT', 'NAVIGATOR'], + EXPLORE: ['SCIENTIST'], + SHIP_UPGRADE: ['ENGINEER', 'TECHNICIAN'], + SHIP_EXPAND: ['ENGINEER', 'TECHNICIAN'], + SHIP_BUILD: ['ENGINEER', 'TECHNICIAN'], + RESEARCH: ['SCIENTIST'], +} + +export const POWER_BANDS = [1, 1, 1, 2, 2, 3] // skill level 0..5 → power (L0–L2 → 1 · L3–L4 → 2 · L5 → 3) +export const XP_THRESHOLDS = [5, 10, 15, 20, 25] // cumulative task completions for L1..L5 (cap 5) + +// Role → starting skill levels (spec §6): specialists start at L2, everyone else at L0. +export const STARTING_LEVELS: Record> = { + COMMANDER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + PILOT: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + ENGINEER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 }, + MEDIC: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + NAVIGATOR: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + SCIENTIST: { NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 }, + GUNNER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + TECHNICIAN: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 }, +} + +// The lead's good trait that cancels the role-mismatch penalty (spec §5). +export const MISMATCH_CANCEL_TRAIT = 'STEADY UNDER PRESSURE' + +// All 15 duration traits of spec §5, keyed by the EXACT trait strings from lib/game/crew.ts. +// Value = additive turn modifier keyed by action type or 'ANY'; empty object = no additive +// modifier (the trait's effect is the mismatch cancel, see MISMATCH_CANCEL_TRAIT). Traits +// with no entry (the three STANDBY combat traits and #45's outcome traits) contribute 0. +// "SHIP work" covers all three SHIP types. +export const TRAIT_DURATION_MODIFIERS: Record>> = { + 'STEADY UNDER PRESSURE': {}, + 'RECKLESS COURAGE': { JUMP: -1 }, + 'CALM IN A CRISIS': { EXPLORE: -1 }, + 'FIXES ANYTHING': { SHIP_UPGRADE: -1, SHIP_EXPAND: -1, SHIP_BUILD: -1 }, + 'READS STARS BLIND': { JUMP: -1 }, + 'QUICK HANDS': { ANY: -1 }, + 'IRON WILL': { RESEARCH: -1 }, + 'RIGID — SLOW TO ADAPT': { RESEARCH: 1 }, + 'OVERCAUTIOUS': { ANY: 1 }, + 'SLOWS THE CREW DOWN': { ANY: 1 }, + 'CARELESS WITH FUSES': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 }, + 'HOARDS RATIONS': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 }, + 'FORGETS TO EAT': { EXPLORE: 1 }, + 'ARGUES WITH EVERYONE': { RESEARCH: 1 }, + 'DOUBTS THE CAPTAIN': { JUMP: 1 }, +} + +export const RECRUIT_CHANCE_BASE = 0.18 // a scan finds a joining member (matches crew.ts's current CREW_CHANCE) +export const RECRUIT_CHANCE_TALKER = 0.3 // lead with TALKS TO ANYONE (wired in PR C) +``` + +- [ ] Implement `lib/game/tasks.ts` (pure — no `lib/db` imports): + +```ts +// Pure task-engine model (spec §3–§4): the task types and the duration formula. No lib/db +// imports — the frontend imports this for the live duration preview in the crew picker +// (same precedent as tech.ts / ship-costs.ts). +import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, TRAIT_DURATION_MODIFIERS, WORK_POINTS } from './crew-costs' +import type { CrewMember } from './crew' + +export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH' + +// Work points per action (spec §4): JUMP scales with hop count, the rest are flat. +function workPoints(type: TaskType, hopCount: number): number { + switch (type) { + case 'JUMP': + return hopCount * WORK_POINTS.JUMP_PER_HOP + case 'EXPLORE': + return WORK_POINTS.EXPLORE + case 'RESEARCH': + return WORK_POINTS.RESEARCH + default: + return WORK_POINTS.SHIP_WORK // SHIP_UPGRADE | SHIP_EXPAND | SHIP_BUILD + } +} + +// One trait's modifier for a lead on an action type: its per-type entry plus the 'ANY' entry. +// Traits without a map entry contribute 0. +function traitModifier(trait: string, type: TaskType): number { + const mods = TRAIT_DURATION_MODIFIERS[trait] + if (!mods) return 0 + return (mods[type] ?? 0) + (mods['ANY'] ?? 0) +} + +// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4. +// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the +// constant 1 until PR B feeds the skill band. The mismatch penalty and the trait modifiers +// apply to the LEAD only — helpers contribute power, are never penalised, and their traits do +// not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types). +export function computeDuration( + type: TaskType, + hopCount: number, + lead: CrewMember, + helpers: CrewMember[], + powerOf: (m: CrewMember) => number = () => 1, +): number { + const totalPower = Math.max(1, [lead, ...helpers].reduce((sum, m) => sum + powerOf(m), 0)) + const mismatchPenalty = lead.traitGood === MISMATCH_CANCEL_TRAIT || PREFERRED_ROLES[type].includes(lead.role) ? 0 : 1 + const traitModifierSum = traitModifier(lead.traitGood, type) + traitModifier(lead.traitBad, type) + return Math.max(1, Math.ceil(workPoints(type, hopCount) / totalPower) + mismatchPenalty + traitModifierSum) +} +``` + +- [ ] Run `npx vitest run lib/game/tasks.test.ts` → green, then `npx tsc --noEmit` clean +- [ ] Commit (PR A feature branch off `master`; the PR-level v0.10.0 version bump + CHANGELOG entry land with the PR's final task per the global constraints): `feat(game): crew-costs balance module + computeDuration duration model (#44)` + +### Task 2: tasks table — schema v7 + +**Files:** +- Modify: `lib/db/client.ts` (add `tasks` to the `DDL` template), `lib/db/schema.ts` (drizzle table `tasks`), `lib/game/save.ts` (`SCHEMA_VERSION` 6→7 + the idempotent v7 step in `backfillSave`), `lib/db/admin.ts` (`ADMIN_TABLES`), `lib/game/tasks.ts` (add `TaskStatus` + `Task` to the module Task 1 created) +- Test: extend the `backfill (old saves)` describe in `lib/game/save.test.ts` (existing `freshFile()` + `openGameDb` + `db.raw.exec` pattern); `lib/db/admin.test.ts`'s registry test covers the new table automatically (it derives the expected list from `schema.ts`) + +**Interfaces:** +- Consumes: `ensureSave`/`backfillSave` from `lib/game/save.ts` (currently `SCHEMA_VERSION = 6`); `TaskType` from Task 1 +- Produces: `interface Task { id: number; saveId: number; type: TaskType; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: TaskStatus; queuedAtTurn: number }` (+ `TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE'`) in `lib/game/tasks.ts`; the `tasks` table in `lib/db/schema.ts` and the matching `CREATE TABLE` in `lib/db/client.ts`'s DDL; `tasks: schema.tasks` in `ADMIN_TABLES` + +- [ ] Steps: TDD — add a failing test to `backfill (old saves)` in `lib/game/save.test.ts`. This pins **Review Focus item 1** (v6→v7 backfill must create the table without touching turn/crew/inventory and be idempotent across restarts). Run → red (`schemaVersion` stays 6, and `tasks` is not in the DDL yet): + +```ts +it('v7: a v6-stamped save gets an empty tasks table without touching state; idempotent across restart', async () => { + const { db, file } = await freshFile() + const world = await getWorldState(db) // fresh game, stamped at the current version + const crewBefore = await db.db.select().from(schema.crew) + const invBefore = await db.db.select().from(schema.inventoryItems) + + await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12 + const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7 + + expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1) + expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(7) + expect(await db.db.select().from(schema.crew)).toEqual(crewBefore) + expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) + const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'") + expect(tables[0].values.map((v) => v[0])).toContain('tasks') + expect(Number(db.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) // empty queue after upgrade + + const reopened = await openGameDb(file) // restart: the backfill must be a no-op + await getWorldState(reopened) + expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(7) + expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) +}) +``` + +Realism note: the production upgrade path is a DB file written by v0.9.12 (no `tasks` table) → `open()` with the new DDL → `CREATE TABLE IF NOT EXISTS` creates it → `ensureSave` stamps v7. The test simulates the save side with the version stamp (the DDL side runs on every open, new or old file). + +- [ ] Implement, each in the existing style of its file: + +`lib/db/client.ts` — add to the `DDL` template after the `events` table (must stay in sync with `schema.ts`): + +```sql +CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + save_id INTEGER NOT NULL REFERENCES saves(id), + type TEXT NOT NULL, -- 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH' + payload TEXT NOT NULL, -- JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId} + lead_crew_id INTEGER NOT NULL REFERENCES crew(id), + helpers TEXT NOT NULL DEFAULT '[]', -- JSON array of crew ids + duration INTEGER NOT NULL, + remaining INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE' + queued_at_turn INTEGER NOT NULL DEFAULT 0 +); +``` + +`lib/db/schema.ts` — append after `events` (mirrors the DDL above): + +```ts +// Queued actions (PR A, spec §3): one row per task assigned to a team. `payload` is a JSON +// string whose shape depends on `type` (see the Task interface in lib/game/tasks.ts); +// `helpers` is a JSON array of crew ids. Upgraded saves start with an empty queue — the v7 +// backfill stamps the version, the DDL in client.ts creates the table. +export const tasks = sqliteTable('tasks', { + id: integer('id').primaryKey({ autoIncrement: true }), + saveId: integer('save_id').notNull().references(() => saves.id), + type: text('type').notNull(), // TaskType + payload: text('payload').notNull(), + leadCrewId: integer('lead_crew_id').notNull().references(() => crew.id), + helpers: text('helpers').notNull().default('[]'), // JSON array of crew ids + duration: integer('duration').notNull(), + remaining: integer('remaining').notNull(), + status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE' + queuedAtTurn: integer('queued_at_turn').notNull().default(0), +}) +``` + +`lib/game/save.ts` — bump the version constant and add the v7 step in `backfillSave` after the v6 `topUpMissionBoard` step: + +```ts +export const SCHEMA_VERSION = 7 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table +``` + +```ts + if (version < 7) { + // The tasks table itself is created by the DDL at open (CREATE TABLE IF NOT EXISTS) for + // every file — new or old — so an upgraded save simply starts with an empty queue; stamp. + version = 7 + } +``` + +`lib/db/admin.ts` — register in `ADMIN_TABLES` after `events`: `tasks: schema.tasks,` + +`lib/game/tasks.ts` — add the domain shape after `TaskType` (`Task.helpers` is the parsed form of the row's JSON-array column; the row↔Task mapping lives at the save.ts boundary, not in this pure module): + +```ts +export type TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE' + +// One queued/active/done action (spec §3). `payload` is a JSON string whose shape depends on +// the TaskType: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · +// RESEARCH {techId} · EXPLORE {planetId}. +export interface Task { + id: number + saveId: number + type: TaskType + payload: string + leadCrewId: number + helpers: number[] + duration: number + remaining: number + status: TaskStatus + queuedAtTurn: number +} +``` + +- [ ] Run `npx vitest run` (full unit suite — the save, admin and client DB tests all touch the schema) → green, then `npx tsc --noEmit` clean +- [ ] Commit (same PR A branch): `feat(game): tasks table + schema v7 backfill, admin registry (#44)` + +### Task 3: Queueing — validation + insertion + +**Files** + +- `lib/game/tasks.ts` — append after the `Task` interface from Task 2 (code below). Add to the top-of-file imports: `import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from './ship-costs'`, `import { TECHS } from './tech'` (both pure modules) and `import type { CrewDto, InventoryItemDto } from './save'` — type-only, so no runtime edge into the DB module (save.ts already imports from tasks.ts for the row↔Task mapping; a runtime import back would be a cycle). +- `lib/game/tasks.test.ts` — add the `describe('queueTask')` block below (one `it` per locked case, table-driven over the small fixtures); if Tasks 1–2 left no such file, create it first with `import { describe, expect, it } from 'vitest'`, the `./tasks` import and `import type { CrewDto } from './save'`. If Task 1 put `computeDuration` in `lib/game/crew-costs.ts` instead of tasks.ts, point its import at that module — the spec §9 signature is the contract. + +```ts +// --- Queueing: validation + row construction (pure, no DB) ------------------------------- +// +// queueTask runs over a plain snapshot of the save (QueueState) and decides whether a new +// task can be queued and what row it becomes. Pure like crew.ts / ship-costs.ts — the route +// (a later task) maps schema rows into QueueState, calls queueTask, and persists the result: +// insert the task row (adding `id` + `saveId` at the DB boundary) and the deducted hold. + +// Carries an HTTP status so route handlers can map failures to responses without re-parsing +// (same pattern as JumpError / ShipError / ResearchError / ExploreError). +export class TaskError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + +// The queueing request — the existing endpoints' bodies plus the crew team. Payload field +// names follow the routes (the spec's names differ): +// JUMP { systemId, hops } — the route computes `hops` with jumpToSystem's logic +// (reachableWithin + NAV AI) and passes it in; the body field is `systemId`, not `toSystemId` +// EXPLORE { planetId } +// SHIP_UPGRADE { systemId } · SHIP_EXPAND { systemId } · SHIP_BUILD { type } +// RESEARCH { techId } — the tech row's id, not the slug +export interface QueueTaskRequest { + type: TaskType + payload: Record + leadCrewId: number + helperCrewIds: number[] +} + +// Plain per-save snapshot the decision runs over — the route maps schema rows into these +// (same row↔domain boundary as the tasks table in save.ts). +export interface QueueState { + turn: number + crew: CrewDto[] + tasks: Task[] + inventory: InventoryItemDto[] // cargo hold, id order (oldest first) + shipSystems: { id: number; type: string; level: number; capacity: number }[] // UPGRADE/EXPAND costs scale with the target's current level/capacity + tech: { id: number; slug: string }[] // RESEARCH cost comes from the matching TECHS def, like research.ts +} + +// The new row minus the two values only the DB boundary knows: `id` (autoincrement) and +// `saveId` (filled in by save.ts's row↔Task mapping on insert). +export interface QueueResult { + task: Omit + inventory: InventoryItemDto[] // unchanged for JUMP/EXPLORE; deducted for SHIP_* / RESEARCH +} + +// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all +// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn. +export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult { + const lead = state.crew.find((c) => c.id === req.leadCrewId) + if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`) + const helpers: CrewDto[] = [] + for (const id of req.helperCrewIds) { + const helper = state.crew.find((c) => c.id === id) + if (!helper) throw new TaskError(404, `Unknown crew member ${id}`) + helpers.push(helper) + } + + // Busy (409). Spec §3: one ACTIVE task per crew member, and a lead may chain one PENDING + // task behind it (FIFO — auto-starts when the current one completes). So: any crew member + // committed to a PENDING task is blocked; a helper on an ACTIVE task is blocked; a lead on + // an ACTIVE task passes and the new task is queued PENDING behind it. + const inStatus = (id: number, status: TaskStatus) => state.tasks.some((t) => t.status === status && (t.leadCrewId === id || t.helpers.includes(id))) + const checkBusy = (c: CrewDto, isLead: boolean) => { + if (inStatus(c.id, 'PENDING')) throw new TaskError(409, `${c.name} is already committed to a pending task`) + if (!isLead && inStatus(c.id, 'ACTIVE')) throw new TaskError(409, `${c.name} is already busy`) + } + checkBusy(lead, true) + for (const helper of helpers) checkBusy(helper, false) + + // In transit (409): while any JUMP is ACTIVE, no new tasks of any type (spec §3). + const jumps = state.tasks.filter((t) => t.type === 'JUMP') + if (jumps.some((t) => t.status === 'ACTIVE')) throw new TaskError(409, 'A jump is in transit — no new tasks can be queued') + // Only one JUMP at a time, active or pending (two arrivals could never resolve on one tick). + // The ACTIVE half is already caught above, so this fires on a pending jump. + if (req.type === 'JUMP' && jumps.some((t) => t.status !== 'DONE')) throw new TaskError(409, 'A jump is already queued') + + // Costs deduct at queue time (spec §3) — the existing cost logic from ship-costs.ts / + // tech.ts and spendItems' 422. JUMP/EXPLORE have no resource cost. Target existence and + // the 409 gates (max level / capacity, not built, already built, build gate, tech prereqs) + // stay in the routes with their existing codes — the route resolves ids before calling. + let inventory = state.inventory + if (req.type === 'SHIP_UPGRADE' || req.type === 'SHIP_EXPAND') { + const system = state.shipSystems.find((s) => s.id === req.payload.systemId)! + inventory = deduct(inventory, 'resource', req.type === 'SHIP_UPGRADE' ? upgradeCost(system.level) : expandCost(system.capacity)) + } else if (req.type === 'SHIP_BUILD') { + inventory = deduct(inventory, 'part', BUILD_PART_COST) + } else if (req.type === 'RESEARCH') { + const def = TECHS.find((t) => t.slug === state.tech.find((r) => r.id === req.payload.techId)?.slug)! + inventory = deduct(inventory, def.costKind, def.costQty) + } + + // Success: duration from the Task-1 formula, status from what the team is doing right now. + // A crew member is "free" when on no ACTIVE/PENDING task; after the checks above only a + // lead chaining behind its own ACTIVE can be non-free. + const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING') + const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0 + const duration = computeDuration(req.type, hops, lead, helpers) + const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING' + + return { + task: { + type: req.type, + payload: JSON.stringify(req.payload), + leadCrewId: req.leadCrewId, + helpers: req.helperCrewIds, + duration, + remaining: duration, + status, + queuedAtTurn: state.turn, // queueing is free — the turn advances only on the tick + }, + inventory, + } +} + +// Pure version of spendItems' consumption (lib/game/ship.ts): the total is checked up front +// (shortfall → the same 422 spendItems throws), then the oldest rows first (array order = id +// order); rows that hit zero are dropped. The input array is never mutated. +function deduct(items: InventoryItemDto[], kind: 'resource' | 'part', amount: number): InventoryItemDto[] { + if (totalQty(items, kind) < amount) { + throw new TaskError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`) + } + let remaining = amount + const out: InventoryItemDto[] = [] + for (const item of items) { + if (item.kind !== kind) { + out.push(item) + continue + } + const take = Math.min(item.qty, remaining) + remaining -= take + if (take < item.qty) out.push({ ...item, qty: item.qty - take }) + } + return out +} +``` + +```ts +import { describe, expect, it } from 'vitest' +import { computeDuration, queueTask, TaskError, type QueueState, type Task } from './tasks' +import type { CrewDto } from './save' + +describe('queueTask', () => { + // The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills + // the CrewDto shape (unused by the duration model but part of the type). + const CREW: CrewDto[] = [ + { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }, + { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' }, + { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' }, + ] + + const state = (over: Partial = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over }) + const task = (over: Partial = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over }) + + // Sync twin of the rejects.toMatchObject house style — queueTask throws instead of rejecting. + const expectErr = (fn: () => unknown, status: number, message?: RegExp): void => { + let err: unknown + try { + fn() + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(TaskError) + expect((err as TaskError).status).toBe(status) + if (message) expect((err as TaskError).message).toMatch(message) + } + + it('unknown lead or helper id → 404', () => { + expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 42, helperCrewIds: [] }), 404, /Unknown crew member 42/) + expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [42] }), 404, /Unknown crew member 42/) + }) + + it('busy lead → 409 — already committed to a PENDING task (as lead or helper of one)', () => { + expectErr(() => queueTask(state({ tasks: [task({ status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/) + expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [1], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/) + }) + + it('busy helper → 409 — on an ACTIVE task, or committed to a PENDING one', () => { + expectErr(() => queueTask(state({ tasks: [task({ leadCrewId: 3, helpers: [2] })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already busy/) + expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [2], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already committed to a pending task/) + }) + + it('in transit (a JUMP is ACTIVE) → 409 for a non-jump action', () => { + expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3 })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /in transit/) + }) + + it('second jump → 409 while one is pending', () => { + expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3, status: 'PENDING' })] }), { type: 'JUMP', payload: { systemId: 2, hops: 1 }, leadCrewId: 1, helperCrewIds: [] }), 409, /A jump is already queued/) + }) + + it('SHIP / RESEARCH cost deducts at queue time; shortfall → the existing 422', () => { + const hold = [{ kind: 'resource' as const, name: 'IRON', qty: 5 }, { kind: 'part' as const, name: 'ALLOY', qty: 4 }] + const systems = [{ id: 7, type: 'REACTOR', level: 2, capacity: 3 }] + const techRows = [{ id: 3, slug: 'REACTOR_OVERCLOCK' }] // the def's cost: 3 resources (TECHS) + const upgraded = queueTask(state({ inventory: hold, shipSystems: systems, tech: techRows }), { type: 'SHIP_UPGRADE', payload: { systemId: 7 }, leadCrewId: 2, helperCrewIds: [] }) + expect(upgraded.inventory).toEqual([{ kind: 'resource', name: 'IRON', qty: 3 }, { kind: 'part', name: 'ALLOY', qty: 4 }]) // upgradeCost(2) = 2, oldest row first + const shortHold = [{ kind: 'resource' as const, name: 'IRON', qty: 2 }] + expectErr(() => queueTask(state({ inventory: shortHold, tech: techRows }), { type: 'RESEARCH', payload: { techId: 3 }, leadCrewId: 2, helperCrewIds: [] }), 422, /Insufficient resources in the cargo hold — need 3/) + }) + + it('ACTIVE-vs-PENDING rule: PENDING while the lead is busy (no turn change); all free → ACTIVE', () => { + const s = state({ tasks: [task({ leadCrewId: 1 })] }) + const pending = queueTask(s, { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }) + expect(pending.task.status).toBe('PENDING') + expect(pending.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]])) + expect(pending.task.queuedAtTurn).toBe(10) + expect(s.turn).toBe(10) // queueing never advances the turn — only the tick does + const active = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }) + expect(active.task.status).toBe('ACTIVE') + expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]])) + expect(active.task.queuedAtTurn).toBe(10) + }) +}) +``` + +**Interfaces** + +- `queueTask(state: QueueState, req: QueueTaskRequest): QueueResult` — pure (no DB / Date / rng), in `lib/game/tasks.ts`. On failure it throws `TaskError(status, message)` — the same shape as `JumpError` / `ShipError` / `ResearchError` / `ExploreError`, so the route maps it exactly like the existing routes (`Response.json({ error: err.message }, { status: err.status })`). On success it returns the row to insert (everything except the DB-boundary `id` / `saveId`) plus the (possibly deducted) hold. + +Validation, in this order (all before any cost deduction): + +| # | Rule | Outcome | +|---|---|---| +| 1 | `leadCrewId` or any `helperCrewIds` not in the roster (lead first, then helpers in order) | `404` — `Unknown crew member ` | +| 2 | any assigned crew (as lead or helper of one) already committed on a PENDING task | `409` — ` is already committed to a pending task` | +| 3 | a helper on an ACTIVE task (as lead or helper of one) | `409` — ` is already busy` · a lead on an ACTIVE task passes: the new task is PENDING behind it (spec §3 — a lead may chain one pending task, auto-started on completion) | +| 4 | any JUMP task ACTIVE (in transit) — blocks every type | `409` — `A jump is in transit — no new tasks can be queued` | +| 5 | request type JUMP and a JUMP task already ACTIVE or PENDING (the ACTIVE half is caught by 4, so this fires on a pending jump) | `409` — `A jump is already queued` | +| 6 | SHIP_* / RESEARCH only — deduct the existing cost: UPGRADE `upgradeCost(level)` / EXPAND `expandCost(capacity)` resources · BUILD `BUILD_PART_COST` parts · RESEARCH the matching `TECHS` def's `costKind` × `costQty` — shortfall | `422` — spendItems' exact message `Insufficient resources\|parts in the cargo hold — need ` | + +On success: `hops = req.type === 'JUMP' ? req.payload.hops : 0` → `duration = computeDuration(type, hops, lead, helpers)` (Task 1's pure formula; the roster objects pass through as-is — `CrewDto` is a structural superset of `CrewMember`) → `status = 'ACTIVE' when no assigned crew is on an ACTIVE/PENDING task, else 'PENDING'` (after rules 2–3 only a lead chaining behind its own ACTIVE can be non-free) → `remaining = duration`, `queuedAtTurn = state.turn` — queueing never advances the turn (only the tick does) → `payload` stored as `JSON.stringify(req.payload)`; the route then adds `id` + `saveId` at the DB boundary and persists the returned `inventory`. + +Route pre-checks stay in the routes with their existing codes (this task does not move them): unknown target `404`; unreachable system / not built / max level / max capacity / already built / build gate / already researched / tech prereqs `409`. For JUMP the route computes `hops` with the same `reachableWithin` + NAV AI logic as `jumpToSystem` and passes `{ systemId, hops }` in the payload — the body field is `systemId` (the spec's `targetSystemId` / `techSlug` / `kind` names never existed in the routes). + +1. - [ ] **Write the failing tests** — add the `describe('queueTask')` block to `lib/game/tasks.test.ts` (create the file with the `vitest` + `./tasks` + type-only `./save` imports if Tasks 1–2 left none). +2. - [ ] **Run — expect failure**: `npx vitest run lib/game/tasks.test.ts` → red (the seven new `it`s fail — `queueTask` is not implemented yet; pre-existing tests in the file stay green). +3. - [ ] **Implement** — append `TaskError`, `QueueTaskRequest`, `QueueState`, `QueueResult`, `queueTask` and the private `deduct` to `lib/game/tasks.ts` (code above) plus the `./ship-costs`, `./tech` and type-only `./save` imports. +4. - [ ] **Run — expect green**: `npx vitest run lib/game/tasks.test.ts` → all seven pass, then the full unit suite `npx vitest run` → green, and `npx tsc --noEmit` clean. +5. - [ ] **Commit (same PR A branch)**: `feat(game): queueTask — queue-time validation, cost deduction, ACTIVE/PENDING row (#44)` + +### Task 4a: Resolver refactor (save.ts) + +**Files** + +- `lib/game/save.ts` — extract the effect bodies of `jumpToSystem` / `explorePlanet` into `resolveJumpTask` / `resolveExploreTask` (code below); the two public functions keep their signatures, validation, turn advance and return values, and delegate the effect part to the resolvers through a synthetic `Task` row (code below). Add to the imports: `import type { Task, TaskType } from './tasks'` (type-only — `tasks.ts` stays pure, no runtime edge back; merge with Task 2's existing type-only import from `./tasks` if present). `FoundItem` is already imported. +- `lib/game/ship.ts` — add `resolveShipUpgradeTask` / `resolveShipExpandTask` / `resolveShipBuildTask` (code below); `upgradeSystem` / `expandSystem` / `buildSystem` keep their signatures, validation, `spendItems` cost and turn advance, and delegate (code below). Delete the private `bumpTurnAndEvent` — unused after the refactor (the resolvers inline the event insert without the turn update). Add the same type-only `./tasks` import. +- `lib/game/research.ts` — add `resolveResearchTask` (code below); `researchTech` keeps its signature, validation, spend + `ResearchError` re-throw and turn advance, and delegates (code below). Add the same type-only `./tasks` import. +- `lib/game/save.test.ts` — add the `describe('resolveJumpTask (A/B against jumpToSystem)')` and `describe('resolveExploreTask (A/B against explorePlanet)')` blocks below (code). Add `resolveJumpTask, resolveExploreTask` to the existing `./save` import and `import type { Task } from './tasks'`. +- `lib/game/ship.test.ts` — add the `describe('resolveShip* (A/B against the immediate actions)')` block below (code). Add `spendItems, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask` to the existing `./ship` import; new imports `import { TECHS } from './tech'`, `import { SCHEMA_VERSION } from './save'`, `import type { Task } from './tasks'`. +- `lib/game/research.test.ts` — add the `describe('resolveResearchTask (A/B against researchTech)')` block below (code). Add `resolveResearchTask` to the existing `./research` import, `spendItems` to the existing `./ship` import; new imports `import { SCHEMA_VERSION } from './save'`, `import type { Task } from './tasks'`. +- Untouched: `app/api/*` routes, `lib/openapi.ts`, `lib/game/tasks.ts`, `lib/db/*`. + +**Interfaces** + +New resolvers — one per `TaskType` value, named 1:1 so Task 4b's tick can dispatch through a `Record`: + +| `TaskType` | Function | Module | Signature | +|---|---|---|---| +| `JUMP` | `resolveJumpTask` | `lib/game/save.ts` | `(db: GameDb, task: Task) => Promise` | +| `EXPLORE` | `resolveExploreTask` | `lib/game/save.ts` | `(db: GameDb, task: Task) => Promise<{ outcome: FoundItem[]; crewFound: CrewDto \| null }>` | +| `SHIP_UPGRADE` | `resolveShipUpgradeTask` | `lib/game/ship.ts` | `(db: GameDb, task: Task) => Promise` | +| `SHIP_EXPAND` | `resolveShipExpandTask` | `lib/game/ship.ts` | `(db: GameDb, task: Task) => Promise` | +| `SHIP_BUILD` | `resolveShipBuildTask` | `lib/game/ship.ts` | `(db: GameDb, task: Task) => Promise` | +| `RESEARCH` | `resolveResearchTask` | `lib/game/research.ts` | `(db: GameDb, task: Task) => Promise` | + +State shape they consume: `(db: GameDb, task: Task)` — the same save-like state the current action functions use (`GameDb`; the save row is read inside, exactly like today — each resolver starts with `ensureSave(db)`) plus the domain `Task` row from `lib/game/tasks.ts`. Only `task.type` and `task.payload` are read; `payload` is the JSON string `queueTask` stored (Task 3): JUMP `{systemId, hops}` · EXPLORE `{planetId}` · SHIP_UPGRADE/SHIP_EXPAND `{systemId}` · SHIP_BUILD `{type}` · RESEARCH `{techId}`. + +Common contract (all six — what Task 4b may rely on): + +- **Payload target, not current location.** Effects land on the target recorded in the payload. A queued EXPLORE resolves against its planet wherever it is (the ship stays in its current system; a scan never discovers a system) — this is what makes a queued scan resolve correctly while the ship is elsewhere. JUMP moves the ship to the payload system. +- **No turn advance.** Resolvers read `save.turn` and never write it — the caller owns the turn (the 4b tick for queued tasks; the immediate public function advances it before delegating). Event rows are written at the current `save.turn`. +- **No cost deduction.** SHIP_*/RESEARCH costs are deducted at queue time (`queueTask`, Task 3); the immediate public functions deduct inside themselves before delegating. Resolvers never call `spendItems`. +- **Guards, not re-validation.** Each resolver keeps a minimal guard against a stale or double-resolved target: unknown target → 404, already-done target → 409, using the existing error class and exact message (`JumpError` / `ExploreError` / `ShipError` / `ResearchError`). Reachability/cost/prerequisite checks are NOT repeated (they belong to queue time; a resolver throw is the signal for the 4b tick's re-validation, which fails the task). +- **Mission hooks unchanged.** JUMP → `checkMissionCompletion(db, saveId, turn, { jumpedTo: target.id })` + `topUpMissionBoard(db)`; EXPLORE → `checkMissionCompletion(db, saveId, turn, { outcome })` + `topUpMissionBoard(db)`; SHIP_* / RESEARCH → none (the current actions have none). +- **Event strings.** The exact existing message formats, values computed from the resolution-time state. +- **Persist.** Each resolver ends with `persist(db)` — the existing route→module pattern (jump/explore in save.ts, ship in ship.ts, research in research.ts). +- **Delegation keeps the legacy path intact.** `jumpToSystem`, `explorePlanet`, `upgradeSystem`, `expandSystem`, `buildSystem`, `researchTech` keep their signatures, validation, (immediate-path) cost, turn advance and return values; they call their resolver with a private `syntheticTask(...)` stand-in row (zeroed identity fields — resolvers only read `type`/`payload`). Routes and the OpenAPI spec are unchanged. The 4b row↔Task mapping for the `tasks` table is NOT part of this task. + +1. - [ ] **Write the failing tests** — add to `lib/game/save.test.ts` (below the `explorePlanet crew finds` block): + + ```ts + // --- A/B: resolvers vs the legacy immediate actions (Task 4a) ------------------------- + // + // Each test builds the identical deterministic fixture in two fresh files (seed '42', + // stamped save → no backfill; ids line up across worlds) and compares their full state: + // A = the legacy immediate action (validates, costs a turn, applies the effect); + // B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver + // applies the effect (ship/research rows deduct the cost up front, as queueTask did at + // queue time — Task 3). snapshot() normalizes every game table (wall-clock columns + // excluded), so the two end states must be byte-identical. + const T0 = '2026-10-01T00:00:00.000Z' + + async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> { + const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() + const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id }) + const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0, toSystem: s1 }) + await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1, toSystem: s0 }) + const [p0] = await db.db.insert(schema.planets).values({ systemId: s0, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id }) + const [p1] = await db.db.insert(schema.planets).values({ systemId: s1, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id }) + return { sys1: s1, p0, p1 } + } + + // The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed + // identity fields, the same way the public functions' delegation does. + const task = (type: 'JUMP' | 'EXPLORE', payload: object): Task => + ({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) + + async function snapshot(db: GameDb): Promise { + const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ + db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), + db.db.select().from(schema.solarSystems), + db.db.select().from(schema.jumpRoutes), + db.db.select().from(schema.planets), + db.db.select().from(schema.inventoryItems), + db.db.select().from(schema.crew), + db.db.select().from(schema.shipSystems), + db.db.select().from(schema.tech), + db.db.select().from(schema.missions), + db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), + ]) + return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) + } + + describe('resolveJumpTask (A/B against jumpToSystem)', () => { + it('undiscovered target: lands exactly what jumpToSystem lands — move, discover, ring, event', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + const fa = await buildWorldFixture(a) + const fb = await buildWorldFixture(b) + + await jumpToSystem(a, fa.sys1) // legacy: validates, +1 turn, effect, event at turn 1 + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) // the tick's job (Task 4b) + await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + }) + + it('discovered target: no ring — still byte-identical to jumpToSystem', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + const fa = await buildWorldFixture(a, true) + const fb = await buildWorldFixture(b, true) + + await jumpToSystem(a, fa.sys1) + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + expect(await b.db.select().from(schema.solarSystems)).toHaveLength(2) // no ring added + }) + + it('guards: 404 unknown system, 409 already-current', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + + await expect(resolveJumpTask(db, task('JUMP', { systemId: 999, hops: 1 }))).rejects.toMatchObject({ status: 404, message: 'Unknown system 999' }) + await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, 1)) + await db.db.update(schema.solarSystems).set({ isCurrent: true }).where(eq(schema.solarSystems.id, f.sys1)) + await expect(resolveJumpTask(db, task('JUMP', { systemId: f.sys1, hops: 1 }))).rejects.toMatchObject({ status: 409, message: 'Already at this system' }) + }) + }) + + describe('resolveExploreTask (A/B against explorePlanet)', () => { + it('planet in the current system: lands exactly what explorePlanet lands — haul, explored, event', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + const fa = await buildWorldFixture(a) + const fb = await buildWorldFixture(b) + + await explorePlanet(a, fa.p0) + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p0 })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + }) + + it('payload planet wins over the current system — the ship stays put (elsewhere semantics)', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + const fa = await buildWorldFixture(a) + const fb = await buildWorldFixture(b) + + await explorePlanet(a, fa.p1) // the current function already scans any planet, not just the current system's + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p1 })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + + const sysB = await b.db.select().from(schema.solarSystems) + expect(sysB.filter((s) => s.isCurrent).map((s) => s.name)).toEqual(['Origin']) // the ship never moved + expect(sysB.find((s) => s.name === 'Outpost')!.discovered).toBe(false) // a scan never discovers a system + + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'explore')! + expect(ev.turn).toBe(1) + // Exact existing format, recomputed the way save.ts computes it: haul first, crew roll on the same stream. + const rng = explorationRng('42', fb.p1) + const haul = rollExploration(rng) + const member = rollCrewMember(rng, new Set()) + const foundText = haul.length > 0 ? haul.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' + expect(ev.message).toBe(`Explored Basalt — found ${foundText}${member ? ` — ${member.name} joined the crew` : ''}`) + }) + + it('guards: 404 unknown planet, 409 already explored (double-resolution safe)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + + await expect(resolveExploreTask(db, task('EXPLORE', { planetId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown planet 999' }) + await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 })) + await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' }) + }) + }) + ``` + + Add to `lib/game/ship.test.ts`: + + ```ts + // --- A/B: resolvers vs the legacy immediate ship actions (Task 4a) --------------------- + const T0 = '2026-10-01T00:00:00.000Z' + + // Stamped save (no backfill), the full ship per SHIP_TYPES, the full tech tree with + // WEAPONS_SYSTEMS researched (unlocks the BUILD row). Identical build per world → ids line up. + async function buildShipFixture(db: GameDb): Promise { + const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() + for (const def of SHIP_TYPES) { + await db.db.insert(schema.shipSystems).values({ saveId: save.id, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 }) + } + for (const def of TECHS) { + await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: def.slug === 'WEAPONS_SYSTEMS' }) + } + } + + const task = (type: 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD', payload: object): Task => + ({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) + + async function snapshot(db: GameDb): Promise { + const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ + db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), + db.db.select().from(schema.solarSystems), + db.db.select().from(schema.jumpRoutes), + db.db.select().from(schema.planets), + db.db.select().from(schema.inventoryItems), + db.db.select().from(schema.crew), + db.db.select().from(schema.shipSystems), + db.db.select().from(schema.tech), + db.db.select().from(schema.missions), + db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), + ]) + return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) + } + + describe('resolveShip* (A/B against the immediate actions)', () => { + // A = legacy immediate action (validates, deducts the cost, +1 turn, effect, event). + // B = the queued shape: the cost is deducted up front (queueTask did it at queue time — + // Task 3), the tick advanced the turn (Task 4b), then the resolver applies the effect. + // Same starting world → byte-identical end states. + + it('UPGRADE: lands exactly what upgradeSystem lands', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + await buildShipFixture(a) + await buildShipFixture(b) + await addItem(a, 'resource', 'IRON ORE', 1) // level 1 → cost 1 + await addItem(b, 'resource', 'IRON ORE', 1) + + const reactorId = systemOf(await getShipState(a), 'REACTOR').id + await upgradeSystem(a, reactorId) + await spendItems(b, 1, 'resource', 1) // the queue-time deduction + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveShipUpgradeTask(b, task('SHIP_UPGRADE', { systemId: reactorId })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'upgrade')! + expect(ev.turn).toBe(1) + expect(ev.message).toBe('Upgraded REACTOR to level 2 (cost: 1× resources)') + }) + + it('EXPAND: lands exactly what expandSystem lands', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + await buildShipFixture(a) + await buildShipFixture(b) + await addItem(a, 'resource', 'CRYSTAL DUST', 2) // capacity 2 → cost 2 + await addItem(b, 'resource', 'CRYSTAL DUST', 2) + + const hullId = systemOf(await getShipState(a), 'HULL').id + await expandSystem(a, hullId) + await spendItems(b, 1, 'resource', 2) + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveShipExpandTask(b, task('SHIP_EXPAND', { systemId: hullId })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'expand')! + expect(ev.turn).toBe(1) + expect(ev.message).toBe('Expanded HULL capacity to 3 (cost: 2× resources)') + }) + + it('BUILD: lands exactly what buildSystem lands', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + await buildShipFixture(a) + await buildShipFixture(b) + await addItem(a, 'part', 'SERVO ACTUATOR', 3) + await addItem(b, 'part', 'SERVO ACTUATOR', 3) + + await buildSystem(a, 'WEAPONS') + await spendItems(b, 1, 'part', 3) + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveShipBuildTask(b, task('SHIP_BUILD', { type: 'WEAPONS' })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'build')! + expect(ev.turn).toBe(1) + expect(ev.message).toBe('Built WEAPONS at base values (cost: 3× parts)') + }) + + it('guards: 404 unknown, 409 unbuilt / max level / max capacity / gate / already built', async () => { + const { db } = await freshFile() + await buildShipFixture(db) + const state = await getShipState(db) + const reactorId = systemOf(state, 'REACTOR').id + const hullId = systemOf(state, 'HULL').id + const weaponsId = systemOf(state, 'WEAPONS').id + await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + + await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' }) + await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: weaponsId }))).rejects.toMatchObject({ status: 409, message: 'WEAPONS is not built' }) + await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) + await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: reactorId }))).rejects.toMatchObject({ status: 409, message: `REACTOR is at max level (${MAX_LEVEL})` }) + + await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' }) + await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId)) + await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: hullId }))).rejects.toMatchObject({ status: 409, message: `HULL is at max capacity (${MAX_CAPACITY})` }) + + await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'SHIELDS' }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system type SHIELDS' }) + await db.db.update(schema.tech).set({ researched: false }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS')) + await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'WEAPONS' }))).rejects.toMatchObject({ status: 409, message: 'Building WEAPONS requires the WEAPONS SYSTEMS tech' }) + await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'HULL' }))).rejects.toMatchObject({ status: 409, message: 'HULL is already built' }) + }) + }) + ``` + + Add to `lib/game/research.test.ts`: + + ```ts + // --- A/B: resolver vs the legacy immediate action (Task 4a) --------------------------- + const T0 = '2026-10-01T00:00:00.000Z' + + // Stamped save (no backfill), the full tech tree, nothing researched yet. Identical build + // per world → ids line up. + async function buildResearchFixture(db: GameDb): Promise { + const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning() + for (const def of TECHS) { + await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect }) + } + } + + const task = (payload: object): Task => + ({ id: 0, saveId: 0, type: 'RESEARCH', payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }) + + async function snapshot(db: GameDb): Promise { + const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([ + db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves), + db.db.select().from(schema.solarSystems), + db.db.select().from(schema.jumpRoutes), + db.db.select().from(schema.planets), + db.db.select().from(schema.inventoryItems), + db.db.select().from(schema.crew), + db.db.select().from(schema.shipSystems), + db.db.select().from(schema.tech), + db.db.select().from(schema.missions), + db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events), + ]) + return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events }) + } + + describe('resolveResearchTask (A/B against researchTech)', () => { + // A = legacy immediate action (validates, deducts the cost, +1 turn, marks researched, + // event). B = the queued shape: the cost was deducted at queue time (Task 3), the tick + // advanced the turn (Task 4b), then the resolver marks the tech. Same starting world → + // byte-identical end states. + + it('lands exactly what researchTech lands', async () => { + const { db: a } = await freshFile() + const { db: b } = await freshFile() + await buildResearchFixture(a) + await buildResearchFixture(b) + await addItem(a, 'resource', 'IRON ORE', 2) // NAVIGATION AI costs 2 resources + await addItem(b, 'resource', 'IRON ORE', 2) + + const [navA] = await a.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) + const [navB] = await b.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) + await researchTech(a, navA.id) + await spendItems(b, 1, 'resource', 2) // the queue-time deduction + await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + await resolveResearchTask(b, task({ techId: navB.id })) + + expect(await snapshot(b)).toEqual(await snapshot(a)) + expect((await b.db.select().from(schema.tech)).find((t) => t.id === navB.id)?.researched).toBe(true) + const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'research')! + expect(ev.turn).toBe(1) + expect(ev.message).toBe('Researched NAVIGATION AI (cost: 2× resources)') + }) + + it('guards: 404 unknown tech, 409 already researched (prereqs are monotone — no guard needed)', async () => { + const { db } = await freshFile() + await buildResearchFixture(db) + const [nav] = await db.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI')) + await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) + + await expect(resolveResearchTask(db, task({ techId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown tech 999' }) + await resolveResearchTask(db, task({ techId: nav.id })) + await expect(resolveResearchTask(db, task({ techId: nav.id }))).rejects.toMatchObject({ status: 409, message: 'NAVIGATION AI is already researched' }) + }) + }) + ``` + +2. - [ ] **Run — expect failure**: `npx vitest run lib/game/save.test.ts lib/game/ship.test.ts lib/game/research.test.ts` → red: all three files fail at module load because `resolveJumpTask` / `resolveExploreTask` / `resolveShip*Task` / `resolveResearchTask` are not exported yet. No pre-existing test logic is touched — the old describes fail only on the import, and stay green once step 3 lands. +3. - [ ] **Implement** — (a) `lib/game/save.ts`: add the `./tasks` type-only import, the private `syntheticTask` and the two resolvers, and rewrite `jumpToSystem` / `explorePlanet` as shown below (code): + + ```ts + // --- Per-type resolvers (PR A, spec §3) ------------------------------------------- + // + // The effect bodies of the instant actions, extracted so the turn tick (Task 4b) can + // resolve a completed task row against whatever the world looks like at resolution time. + // Contract (all six resolvers across save.ts / ship.ts / research.ts — see the Interfaces + // block of this plan): effects land on the PAYLOAD target, never on the current location; + // no turn advance (the caller owns the turn); no cost deduction (queueTask deducted at + // queue time — Task 3; the immediate public functions deduct before delegating); a minimal + // guard against a stale or double-resolved target (existing error class + exact message — + // a throw is the signal for the 4b tick's re-validation); the existing event text formats + // at the current turn; persist at the end. + + // A stand-in task row for the immediate public functions delegating to a resolver: the + // resolvers only read `type` + `payload`, so the identity fields are zeroed. + function syntheticTask(type: TaskType, payload: object): Task { + return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } + } + + // JUMP — move the ship to the payload's system: flip isCurrent, discover the target, and + // when it was undiscovered grow the chart ring off the same (seed, turn) stream as before. + export async function resolveJumpTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { systemId } = JSON.parse(task.payload) as { systemId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const systems = await db.db.select().from(schema.solarSystems) + + const target = systems.find((s) => s.id === systemId) + if (!target) throw new JumpError(404, `Unknown system ${systemId}`) + if (target.isCurrent) throw new JumpError(409, 'Already at this system') + const current = systems.find((s) => s.isCurrent)! + + await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id)) + await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id)) + + if (!target.discovered) { + // Growing the chart: a ring of new (undiscovered) neighbours around the newly reached + // system, so there is always somewhere to jump next and a route back. + const rng = expansionRng(save.seed, save.turn) + const takenNames = new Set(systems.map((s) => s.name)) + const ring = generateRing(rng, target.x, target.y, takenNames, systems) + const ringIds: number[] = [] + for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed)) + const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1]) + await insertRoutes(db, ring.edges, resolve) + } + + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() }) + await checkMissionCompletion(db, save.id, save.turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination + await topUpMissionBoard(db) + await persist(db) + } + + // EXPLORE — scan the payload's planet (any system — the UI only offered the current + // system's rows; a queued scan may resolve while the ship is elsewhere): deterministic + // haul into the inventory, planet marked explored, crew-join roll on the same + // (seed, planet) stream. + export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outcome: FoundItem[]; crewFound: CrewDto | null }> { + await ensureSave(db) + const { planetId } = JSON.parse(task.payload) as { planetId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const planets = await db.db.select().from(schema.planets) + const target = planets.find((p) => p.id === planetId) + if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`) + if (target.explored) throw new ExploreError(409, `${target.name} was already explored`) + + const rng = explorationRng(save.seed, target.id) + const outcome = rollExploration(rng) // resource/part haul first… + const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name)) + const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream + + await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id)) + for (const item of outcome) { + // Accumulate: the same kind+name on this save bumps qty instead of adding a row. + const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === item.kind && i.name === item.name) + if (existing) { + await db.db.update(schema.inventoryItems).set({ qty: existing.qty + item.qty }).where(eq(schema.inventoryItems.id, existing.id)) + } else { + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty }) + } + } + + let crewFound: CrewDto | null = null + if (foundMember) { + const systems = await db.db.select().from(schema.solarSystems) + const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system' + const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning() + crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin } + } + + const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' + const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : '' + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() }) + await checkMissionCompletion(db, save.id, save.turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed + await topUpMissionBoard(db) + await persist(db) + + return { outcome, crewFound } + } + + export async function jumpToSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const systems = await db.db.select().from(schema.solarSystems) + + const target = systems.find((s) => s.id === systemId) + if (!target) throw new JumpError(404, `Unknown system ${systemId}`) + const current = systems.find((s) => s.isCurrent)! + if (current.id === target.id) throw new JumpError(409, 'Already at this system') + + const routes = await db.db.select().from(schema.jumpRoutes) + // #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop + // jump costs one turn per hop, so single-hop jumps keep their existing cost. + const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched) + const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id) + if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`) + + // The immediate jump keeps its own turn cost (the tick owns turn advances for queued + // tasks): advance it before delegating so the resolver's event lands at exactly the + // turn the old code wrote. + const turn = save.turn + hopCount + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + await resolveJumpTask(db, syntheticTask('JUMP', { systemId, hops: hopCount })) + + return getWorldState(db) + } + + export async function explorePlanet(db: GameDb, planetId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const planets = await db.db.select().from(schema.planets) + const target = planets.find((p) => p.id === planetId) + if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`) + if (target.explored) throw new ExploreError(409, `${target.name} was already explored`) + + // The immediate scan keeps its one-turn cost; the resolver applies everything else at that turn. + const turn = save.turn + 1 + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + const { outcome, crewFound } = await resolveExploreTask(db, syntheticTask('EXPLORE', { planetId })) + + return { ...(await readSystemState(db)), outcome, crewFound } + } + ``` + + (b) `lib/game/ship.ts`: add the `./tasks` type-only import, the private `syntheticTask`, the three resolvers, and rewrite the three public actions (same shape — validation and `spendItems` stay; the turn advance moves before the delegation; `bumpTurnAndEvent` is deleted): + + ```ts + function syntheticTask(type: TaskType, payload: object): Task { + return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } + } + + // SHIP_UPGRADE — level a built system by one, at whatever level it sits at resolution + // time. No cost here (deducted at queue time — Task 3 — or by upgradeSystem before + // delegating); no mission hooks (the current action has none). + export async function resolveShipUpgradeTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { systemId } = JSON.parse(task.payload) as { systemId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) + if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) + if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) + const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) + if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) + + const cost = upgradeCost(target.level) + await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'upgrade', message: `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() }) + await persist(db) + } + + // SHIP_EXPAND — same shape for capacity (cost: `capacity` units of any resource, deducted). + export async function resolveShipExpandTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { systemId } = JSON.parse(task.payload) as { systemId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) + if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) + if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) + const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) + if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) + + const cost = expandCost(target.capacity) + await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'expand', message: `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() }) + await persist(db) + } + + // SHIP_BUILD — build an unbuilt type at base values. The flat parts cost is already + // deducted; some types are gated behind a researched tech (see EFFECTS in lib/game/tech.ts). + export async function resolveShipBuildTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { type } = JSON.parse(task.payload) as { type: string } + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type) + if (!target) throw new ShipError(404, `Unknown ship system type ${type}`) + if (target.level > 0) throw new ShipError(409, `${target.type} is already built`) + const gate = buildGate(type, await researchedTechSlugs(db)) + if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) + + await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'build', message: `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`, createdAt: new Date().toISOString() }) + await persist(db) + } + + // UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). + export async function upgradeSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) + if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) + if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) + const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) + if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) + + await spendItems(db, save.id, 'resource', upgradeCost(target.level)) + const turn = save.turn + 1 + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + await resolveShipUpgradeTask(db, syntheticTask('SHIP_UPGRADE', { systemId })) + return readShipState(db) + } + + // EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). + export async function expandSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) + if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) + if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) + const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) + if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) + + await spendItems(db, save.id, 'resource', expandCost(target.capacity)) + const turn = save.turn + 1 + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + await resolveShipExpandTask(db, syntheticTask('SHIP_EXPAND', { systemId })) + return readShipState(db) + } + + // BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. + export async function buildSystem(db: GameDb, type: string): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type) + if (!target) throw new ShipError(404, `Unknown ship system type ${type}`) + if (target.level > 0) throw new ShipError(409, `${target.type} is already built`) + const gate = buildGate(type, await researchedTechSlugs(db)) + if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) + + await spendItems(db, save.id, 'part', BUILD_PART_COST) + const turn = save.turn + 1 + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + await resolveShipBuildTask(db, syntheticTask('SHIP_BUILD', { type })) + return readShipState(db) + } + ``` + + (c) `lib/game/research.ts`: add the `./tasks` type-only import, the private `syntheticTask` and the resolver, and rewrite `researchTech` (spend + re-throw and validation stay; turn advance moves before the delegation): + + ```ts + function syntheticTask(type: TaskType, payload: object): Task { + return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } + } + + // RESEARCH — mark the payload's tech researched at the current turn. No cost here (deducted + // at queue time — Task 3 — or by researchTech before delegating); no mission hooks (the + // current action has none). + export async function resolveResearchTask(db: GameDb, task: Task): Promise { + await ensureSave(db) + const { techId } = JSON.parse(task.payload) as { techId: number } + const [save] = await db.db.select().from(schema.saves).limit(1) + const rows = await db.db.select().from(schema.tech) + const target = rows.find((t) => t.id === techId) + if (!target) throw new ResearchError(404, `Unknown tech ${techId}`) + + const def = TECHS.find((t) => t.slug === target.slug)! + if (target.researched) throw new ResearchError(409, `${def.name} is already researched`) + + await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() }) + await persist(db) + } + + // Research one tech: validates prerequisites (409, naming what is missing) and the cost (422), + // spends it from the cargo hold, then delegates the unlock to the resolver at the new turn. + export async function researchTech(db: GameDb, techId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const rows = await db.db.select().from(schema.tech) + const target = rows.find((t) => t.id === techId) + if (!target) throw new ResearchError(404, `Unknown tech ${techId}`) + + const def = TECHS.find((t) => t.slug === target.slug)! + const researched = await researchedSlugs(db) + if (researched.has(def.slug)) throw new ResearchError(409, `${def.name} is already researched`) + const missing = def.prereqs.filter((p) => !researched.has(p)).map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug) + if (missing.length > 0) throw new ResearchError(409, `${def.name} requires ${missing.join(' and ')} first`) + + try { + await spendItems(db, save.id, def.costKind, def.costQty) + } catch (err) { + // spendItems throws ShipError(422) on a short hold — re-throw as this domain's error. + if (err instanceof ShipError) throw new ResearchError(err.status, err.message) + throw err + } + + const turn = save.turn + 1 + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + await resolveResearchTask(db, syntheticTask('RESEARCH', { techId })) + + return getTechState(db) + } + ``` + +4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (all six pre-existing action describes in save/ship/research stay green — the delegated public functions are behaviour-identical — plus the new A/B + guard blocks), and `npx tsc --noEmit` clean. +5. - [ ] **Commit (same PR A branch)**: `feat(game): per-type resolvers — jump/explore/ship/research effect extraction for task resolution (#44)` + +### Task 4b: tickTurn (tasks.ts) + +**Files** + +- `lib/game/tasks.ts` — add `TickResult` and `tickTurn` after the private `deduct` (code in step 3). No new imports — the tick consumes the existing `QueueState` / `Task` and calls nothing outside the module, so it stays pure (no Date, no rng, no lib/db — global constraint). +- `lib/game/save.ts` — add the orchestrator `runTurn` plus the private `rowToTask` mapping and the `TASK_RESOLVERS` dispatch (code in step 3); replace 4a's type-only `import type { Task, TaskType } from './tasks'` with `import { tickTurn, type QueueState, type Task, type TaskStatus, type TaskType } from './tasks'` and add `import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'` and `import { resolveResearchTask, ResearchError } from './research'`. Import-graph note: ship.ts and research.ts already import save.ts at runtime (`ensureSave`, `InventoryItemDto`), so this adds the reverse edge — cycles save↔ship and save→research→ship. Safe here: every cross-module reference is a function call made at request time, none at module-evaluation time (each file's top level only declares functions/classes) — the standard function-only cycle shape ESM / webpack / Turbopack / Vitest handle. +- `lib/game/tasks.test.ts` — add the `describe('tickTurn')` block below (code in step 1); reuses the `queueTask` block's file-scope `CREW` / `state` / `task` fixture helpers; add `tickTurn` to the existing `./tasks` import. +- `lib/game/save.test.ts` — add the `describe('runTurn (tick on the DB)')` block below (code in step 1); reuses the 4a block's file-scope `buildWorldFixture` and the existing `freshFile`, but NOT the 4a `snapshot` helper (it does not select the `tasks` table — this block asserts the queue rows directly); add `runTurn` to the existing `./save` import and a new `import { MIN_AVAILABLE } from './missions'`. +- Untouched: `app/api/*` (`POST /api/turn` is Task 5), `lib/openapi.ts` (no endpoint changes yet), `lib/db/*` (the `tasks` DDL / registry is Task 2's), the 4a resolvers and public functions. + +**Interfaces** + +`tickTurn(state: QueueState): TickResult` — the pure tick over the same plain state `queueTask` consumes (Task 3's `QueueState`, reused as-is — the tick needs no fields beyond it). It reads only `state.turn` and `state.tasks` (it never parses `task.payload` — the target is the resolver's business — and never consults the roster — busyness is derived entirely from the task rows); the other `QueueState` fields pass through untouched, so one row→state mapping at the save.ts boundary serves both queueing and the tick. Never mutates its input (house style — `deduct` sets the pattern). + +```ts +export interface TickResult { + turn: number // state.turn + 1 — the tick IS the turn increment + tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE + completed: Task[] // the tasks that hit 0 this tick, in resolution order +} + +export function tickTurn(state: QueueState): TickResult +``` + +The tick does exactly one turn per call, in this order: + +- **(a) Decrement.** Every `ACTIVE` task's `remaining` −= 1. `PENDING` and `DONE` rows do not age — a task's countdown starts when it starts, and `duration` is never modified after queueing. +- **(b) Resolve.** Every task that reached 0 is marked `DONE` — that frees the whole team (busyness is derived from the `tasks` table; there is no crew-status column to update) — and returned in `completed` sorted by `(queuedAtTurn ASC, id ASC)` (identical to `id` order, since `queuedAtTurn` is non-decreasing in `id`). The check is `remaining <= 0`, not `=== 0`, so a corrupt row at or below zero still completes on the next tick instead of dead-ending the queue (spec focus 4). `tickTurn` itself applies no effect: it returns WHICH tasks completed, and the orchestrator calls each type's 4a resolver in that order. +- **(c) Auto-start.** ONE forward pass over `PENDING` in the same `(queuedAtTurn ASC, id ASC)` order. A task starts (PENDING → ACTIVE, `remaining` unchanged — it keeps its full duration; its first decrement lands on the next tick) when its FULL team (lead + all helpers) is free — no member of it is on an `ACTIVE` task and no member is on a `PENDING` task other than this one — evaluated after this tick's completions and any earlier start in this pass. One pass is complete: starting a task only makes its team busier, never frees anyone, so a task that cannot start at its position cannot start later in the same pass. Under `queueTask`'s invariants (one PENDING per member; no one on an ACTIVE holds a PENDING unless they lead both) a freed member has at most one successor, so at most ONE of their PENDINGs starts per tick — the FIFO chain of spec §3, earliest `queuedAtTurn` wins — and the "no other PENDING" clause only bites on admin-corrupted rows, where it keeps the pass deterministic anyway. +- **(d) Mission hooks.** NOT in `tickTurn` — they stay where `checkMissionCompletion` is called today: inside the save.ts action machinery, at the new turn, before persist. In the task world that machinery IS the 4a resolvers (their locked contract: JUMP → `checkMissionCompletion(db, saveId, turn, { jumpedTo })` + `topUpMissionBoard(db)`; EXPLORE → `checkMissionCompletion(db, saveId, turn, { outcome })` + `topUpMissionBoard(db)`; SHIP_* / RESEARCH → none), so the orchestrator gets the hooks for free by calling the resolvers, at the tick's new turn. A tick with no resolution checks no missions and tops up nothing. +- **(e) The tick IS the turn.** `tickTurn` returns `state.turn + 1`, and nothing in the pure layer increments it; the orchestrator writes it to `saves.turn` BEFORE the resolvers run (so the resolvers' events and mission hooks land at the new turn — the same placement as today's actions) and persists the outcome. The full state the snapshot endpoints need is exactly the DB after that persist: `getWorldState` / `getSystemState` / `getCrewState` / `getShipState` / `getMissions` / `getEvents` / `getTechState` all re-read their tables and carry `turn`, so they reflect the tick without new plumbing. After Task 5 retires the immediate actions, `runTurn` is the sole writer of `save.turn`; until then the legacy immediate functions keep their existing (unchanged) turn advance. + +Resolver dispatch — the 4a contract names the six resolvers 1:1 exactly for this; the record is exhaustive by type (a `TaskType` without a resolver is a compile error): + +```ts +const TASK_RESOLVERS: Record Promise> = { + JUMP: resolveJumpTask, + EXPLORE: resolveExploreTask, + SHIP_UPGRADE: resolveShipUpgradeTask, + SHIP_EXPAND: resolveShipExpandTask, + SHIP_BUILD: resolveShipBuildTask, + RESEARCH: resolveResearchTask, +} +``` + +The resolvers receive the real `Task` rows from the tick (they only read `type` + `payload` — 4a). + +Failure semantics (from the 4a contract — "a resolver throw is the signal for the 4b tick's re-validation, which fails the task"): a resolver throw is its minimal guard (unknown target → 404, already-resolved target → 409 — a queue-time check can go stale between queueing and resolution). The orchestrator catches exactly the four domain error classes (`JumpError` / `ExploreError` / `ShipError` / `ResearchError`) and fails the task: it stays `DONE` (the tick already freed the team — the turn is spent), the effect simply does not land, no event is written for the failure, and the remaining `completed` tasks still resolve. Non-domain errors rethrow (a crash is a real bug). + +`runTurn(db: GameDb): Promise` — the orchestrator in save.ts, matching the existing route→save pattern (`ensureSave` first, unscoped single-player reads, `persist` last). It returns the pure `TickResult` unchanged — the full mutated state the snapshot endpoints need; Task 5's route responds with it, composing spec §7's response (the active+pending task array) from it and the existing readers. + +1. `ensureSave(db)`; read the saves row + the crew / tasks / inventory / shipSystems / tech rows; map them into `QueueState` (the row→Task mapping lives here at the save.ts boundary — `JSON.parse` on `helpers`; the inverse, Task→row at insert, stays with the routes' queueing path). +2. `tickTurn(state)`. +3. `saves.turn = result.turn` — written BEFORE the resolvers (see (d) / (e) above). +4. For each task of `result.completed` (in the returned order): `TASK_RESOLVERS[task.type](db, task)` with the failure semantics above. Each resolver ends in its own `persist(db)` (4a) — harmless mid-stream, since `persist` is a full-DB export. +5. Write the tick's queue changes back to `tasks` — every row whose `status` or `remaining` changed — then a final `persist(db)` (the turn's one atomic on-disk write). + +1. - [ ] **Write the failing tests** — add to `lib/game/tasks.test.ts` (below the `queueTask` block) and to `lib/game/save.test.ts` (below the 4a A/B blocks): + + ```ts + // --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) ------------------- + // + // tickTurn is the whole §3 tick over the same plain QueueState queueTask consumes. It owns + // the turn increment and nothing else: it applies no effect and runs no mission logic — + // the save.ts orchestrator (runTurn) calls the 4a resolvers for the returned `completed` + // tasks, in the order returned. + + export interface TickResult { + turn: number // state.turn + 1 — the tick IS the turn increment + tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE + completed: Task[] // the tasks that hit 0 this tick, in resolution order + } + + const byQueueOrder = (a: Task, b: Task) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id + + export function tickTurn(state: QueueState): TickResult { + const turn = state.turn + 1 + const tasks = state.tasks.map((t) => ({ ...t })) // never mutate the input (house style — deduct) + + // (a) every ACTIVE task ages by one; PENDING/DONE rows do not age. + for (const t of tasks) if (t.status === 'ACTIVE') t.remaining -= 1 + + // (b) everything that hit 0 resolves this tick. Marking DONE frees the whole team + // (busyness is derived from the tasks table — there is no crew status column to update). + // <= 0, not === 0: a corrupt row at or below zero still completes on the next tick + // instead of dead-ending the queue (spec focus 4: no dead ends). + const completed = tasks + .filter((t) => t.status === 'ACTIVE' && t.remaining <= 0) + .sort(byQueueOrder) + for (const t of completed) t.status = 'DONE' + + // (c) auto-start: one forward pass over PENDING in (queuedAtTurn ASC, id ASC). Starting a + // task only makes its team busier — never frees anyone — so a task that cannot start at + // its position cannot start later in the same pass: one pass is complete. A just-started + // task keeps its full duration; its first decrement lands on the next tick. + for (const t of tasks.filter((t) => t.status === 'PENDING').sort(byQueueOrder)) { + const free = (id: number) => + !tasks.some((o) => o.status === 'ACTIVE' && (o.leadCrewId === id || o.helpers.includes(id))) && + !tasks.some((o) => o.status === 'PENDING' && o.id !== t.id && (o.leadCrewId === id || o.helpers.includes(id))) + if ([t.leadCrewId, ...t.helpers].every(free)) t.status = 'ACTIVE' + } + + return { turn, tasks, completed } + } + ``` + + ```ts + describe('tickTurn', () => { + // The queueTask block's file-scope fixture helpers (above): CREW = [1 AVA PILOT, + // 2 BEN ENGINEER, 3 CASS SCIENTIST]; state() → { turn: 10, crew: CREW, tasks: [], … }; + // task() → a one-turn row. The tick never parses payloads — the targets below are inert. + const JUMP = (id: number, over: Partial = {}): Task => task({ id, type: 'JUMP', payload: '{"systemId":2,"hops":1}', ...over }) + const EXPLORE = (id: number, planetId: number, over: Partial = {}): Task => task({ id, type: 'EXPLORE', payload: `{"planetId":${planetId}}`, ...over }) + + it('decrements every ACTIVE task by one; a task that hits 0 is DONE (its team freed); PENDING rows do not age; the input is never mutated', () => { + const s = state({ + tasks: [ + JUMP(1, { duration: 3, remaining: 3, leadCrewId: 1, queuedAtTurn: 8 }), + EXPLORE(2, 5, { leadCrewId: 3, helpers: [2], duration: 1, remaining: 1, queuedAtTurn: 9 }), + EXPLORE(3, 6, { leadCrewId: 1, duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 10 }), // member 1 is on ACTIVE 1 + ], + }) + + const r = tickTurn(s) + expect(r.turn).toBe(11) // the tick is the increment + expect(r.tasks.find((t) => t.id === 1)!.remaining).toBe(2) + expect(r.tasks.find((t) => t.id === 1)!.status).toBe('ACTIVE') + expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE') + expect(r.completed.map((t) => t.id)).toEqual([2]) + expect(r.tasks.find((t) => t.id === 3)!.status).toBe('PENDING') + expect(r.tasks.find((t) => t.id === 3)!.remaining).toBe(4) // pending does not age + expect(s.tasks.find((t) => t.id === 1)!.remaining).toBe(3) // input state untouched + }) + + it('a 3-deep PENDING chain on one lead: when the lead task completes, exactly ONE successor starts — earliest queuedAtTurn wins (Review Focus 2)', () => { + let s = state({ + tasks: [ + JUMP(9, { duration: 3, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }), + EXPLORE(10, 5, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), + EXPLORE(11, 6, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 9 }), + EXPLORE(12, 7, { leadCrewId: 1, duration: 1, remaining: 1, status: 'PENDING', queuedAtTurn: 10 }), + ], + }) + let r = tickTurn(s) + expect(r.completed.map((t) => t.id)).toEqual([9]) + expect(r.tasks.find((t) => t.id === 9)!.status).toBe('DONE') + expect(r.tasks.find((t) => t.id === 10)!.status).toBe('ACTIVE') // earliest queuedAtTurn starts + expect(r.tasks.find((t) => t.id === 10)!.remaining).toBe(2) // full duration — first decrement next tick + expect(r.tasks.find((t) => t.id === 11)!.status).toBe('PENDING') // the others wait for their own tick + expect(r.tasks.find((t) => t.id === 12)!.status).toBe('PENDING') + + // Walk the chain to the end: each successor starts on exactly the tick its predecessor completes. + const EXPECT: [completed: number[], active: number[]][] = [ + [[], [10]], + [[10], [11]], + [[], [11]], + [[11], [12]], + [[12], []], + ] + for (const [done, active] of EXPECT) { + s = { ...s, turn: r.turn, tasks: r.tasks } + r = tickTurn(s) + expect(r.completed.map((t) => t.id)).toEqual(done) + expect(r.tasks.filter((t) => t.status === 'ACTIVE').map((t) => t.id)).toEqual(active) + } + expect(r.turn).toBe(16) // 10 + 1 (the first tick) + 5 (the walk) + expect(r.tasks.every((t) => t.status === 'DONE')).toBe(true) + }) + + it('same queuedAtTurn ties break by id, and a started task blocks a later one sharing a member — at most one start per freed member (Review Focus 2, corrupt-row half)', () => { + // queueTask forbids a member committed to two PENDINGs (rule 2), so this shape only + // exists on admin-corrupted rows — the tick must still be deterministic about it. + const s = state({ + tasks: [ + JUMP(19, { duration: 1, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }), + EXPLORE(20, 5, { leadCrewId: 2, helpers: [3], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), + EXPLORE(21, 6, { leadCrewId: 3, helpers: [2], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }), + ], + }) + const r = tickTurn(s) + expect(r.tasks.find((t) => t.id === 20)!.status).toBe('ACTIVE') // (8, 20) < (8, 21) — starts first and takes members 2 and 3 + expect(r.tasks.find((t) => t.id === 21)!.status).toBe('PENDING') + }) + + it('a JUMP and an EXPLORE can both resolve on the same tick — completion is location-independent (Review Focus 3, tick half)', () => { + const s = state({ + tasks: [ + EXPLORE(1, 5, { leadCrewId: 1, duration: 1, remaining: 1, queuedAtTurn: 8 }), + JUMP(2, { leadCrewId: 2, duration: 1, remaining: 1, queuedAtTurn: 8 }), + ], + }) + const r = tickTurn(s) + expect(r.completed.map((t) => t.id)).toEqual([1, 2]) // both this tick, queue order (explore first) + expect(r.tasks.find((t) => t.id === 1)!.status).toBe('DONE') + expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE') + }) + + it('free skip: an empty queue advances exactly one turn and changes nothing; a fully-free PENDING still starts (Review Focus 4)', () => { + const r = tickTurn(state()) + expect(r.turn).toBe(11) + expect(r.tasks).toEqual([]) + expect(r.completed).toEqual([]) + + // No ACTIVE tasks and a startable PENDING (a corrupt-row shape — a legal PENDING always + // has a busy member): the pass still starts it. Skipping means "no resolution", not + // "no starts". + const r2 = tickTurn(state({ tasks: [EXPLORE(30, 5, { leadCrewId: 1, helpers: [2, 3], duration: 3, remaining: 3, status: 'PENDING', queuedAtTurn: 9 })] })) + expect(r2.turn).toBe(11) + expect(r2.tasks.find((t) => t.id === 30)!.status).toBe('ACTIVE') + }) + }) + ``` + + ```ts + // --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) ----------------- + // + // The route→save pattern: ensureSave first, unscoped single-player reads, persist last. + // The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the + // effects and the mission hooks (their contract — invoked here, at the tick's new turn, + // before the final persist); this function owns the table writes. + + // The 4a contract names the resolvers 1:1 exactly for this dispatch; exhaustive by type — + // a TaskType without a resolver is a compile error. + const TASK_RESOLVERS: Record Promise> = { + JUMP: resolveJumpTask, + EXPLORE: resolveExploreTask, + SHIP_UPGRADE: resolveShipUpgradeTask, + SHIP_EXPAND: resolveShipExpandTask, + SHIP_BUILD: resolveShipBuildTask, + RESEARCH: resolveResearchTask, + } + + // Row→domain at the save.ts boundary (the inverse — Task→row at insert — stays with the + // routes' queueing path). + function rowToTask(row: { id: number; saveId: number; type: string; payload: string; leadCrewId: number; helpers: string; duration: number; remaining: number; status: string; queuedAtTurn: number }): Task { + return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn } + } + + export async function runTurn(db: GameDb): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([ + db.db.select().from(schema.crew), + db.db.select().from(schema.tasks), + db.db.select().from(schema.inventoryItems), + db.db.select().from(schema.shipSystems), + db.db.select().from(schema.tech), + ]) + const state: QueueState = { + turn: save.turn, + crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })), + tasks: taskRows.map(rowToTask), + inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })), + shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })), + tech: tech.map((t) => ({ id: t.id, slug: t.slug })), + } + + const result = tickTurn(state) + + // (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events + // and the checkMissionCompletion / topUpMissionBoard hooks (4a contract) land at the new + // turn, exactly where today's actions place them. + await db.db.update(schema.saves).set({ turn: result.turn }).where(eq(schema.saves.id, save.id)) + + // (b) + (d) resolve in the order tickTurn returned. A resolver throw is its 4a guard + // (stale or double-resolved target): the task fails — it is already DONE (the tick freed + // the team; the turn is spent), the effect simply does not land, and no event is written + // for it. Only the four domain error classes fail a task; anything else rethrows. + for (const task of result.completed) { + try { + await TASK_RESOLVERS[task.type](db, task) + } catch (err) { + if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue + throw err + } + } + + // Persist the tick's queue changes (the resolvers already persisted their own effects) — + // one final whole-DB write makes the turn atomic on disk. + for (const before of state.tasks) { + const after = result.tasks.find((t) => t.id === before.id) + if (after && (after.status !== before.status || after.remaining !== before.remaining)) { + await db.db.update(schema.tasks).set({ status: after.status, remaining: after.remaining }).where(eq(schema.tasks.id, after.id)) + } + } + await persist(db) + + return result + } + ``` + + ```ts + describe('runTurn (tick on the DB)', () => { + // buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no + // backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) / + // Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so + // this block adds its own rows. + async function addCrew(db: GameDb, count: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const ids: number[] = [] + for (let i = 0; i < count; i++) { + const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id }) + ids.push(row.id) + } + return ids + } + + async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id }) + return row.id + } + + it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const count = (t: Parameters[0]) => db.db.select().from(t).then((rows) => rows.length) + const before = { + systems: await count(schema.solarSystems), + planets: await count(schema.planets), + inventory: await count(schema.inventoryItems), + missions: await count(schema.missions), + events: await count(schema.events), + } + + const r = await runTurn(db) + expect(r.turn).toBe(1) + expect(r.tasks).toEqual([]) + expect(r.completed).toEqual([]) + + const [save] = await db.db.select().from(schema.saves).limit(1) + expect(save.turn).toBe(1) // the tick is the increment — the only write of the call + expect(await count(schema.solarSystems)).toBe(before.systems) + expect(await count(schema.planets)).toBe(before.planets) + expect(await count(schema.inventoryItems)).toBe(before.inventory) + expect(await count(schema.missions)).toBe(before.missions) // no top-up without a resolution + expect(await count(schema.events)).toBe(before.events) // no event for a skip + expect(await count(schema.tasks)).toBe(0) + }) + + it('a resolved JUMP completes the matching DELIVER mission, grants the reward, and tops the board up — the 4a hooks, at the tick\'s new turn', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) // s0 current; s1 'Outpost' undiscovered + const [c1] = await addCrew(db, 1) + const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 4 }) + const [save] = await db.db.select().from(schema.saves).limit(1) + await db.db.update(schema.saves).set({ turn: 5 }).where(eq(schema.saves.id, save.id)) + const [missionId] = await db.db.insert(schema.missions).values({ saveId: save.id, type: 'DELIVER', description: 'Jump to Outpost', targetSystemId: f.sys1, progressTarget: 1, rewardKind: 'resource', rewardQty: 2, state: 'ACTIVE' }).returning({ id: schema.missions.id }) + + const r = await runTurn(db) + expect(r.turn).toBe(6) + expect(r.completed.map((t) => t.id)).toEqual([jumpId]) + expect((await db.db.select().from(schema.tasks)).find((t) => t.id === jumpId)!.status).toBe('DONE') + + // the mission resolved at the new turn: DONE + reward in the hold + both events at turn 6 + expect((await db.db.select().from(schema.missions)).find((m) => m.id === missionId)).toMatchObject({ state: 'DONE', progress: 1 }) + expect(await db.db.select().from(schema.inventoryItems)).toEqual([{ id: expect.any(Number), saveId: save.id, kind: 'resource', name: 'MISSION REWARD', qty: 2 }]) + const events = (await db.db.select().from(schema.events)).map((e) => [e.turn, e.type] as const) + expect(events).toHaveLength(2) + expect(events).toContainEqual([6, 'jump']) + expect(events).toContainEqual([6, 'mission']) + + // the board is topped up after the arrival (the 4a jump-resolver hook) to exactly the floor + const available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length + expect(available).toBe(MIN_AVAILABLE) + + // the effect half: the ship is at the payload system + expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true) + }) + + it('a JUMP and an EXPLORE both resolving on one tick: the explore effect lands on its payload planet, not the newly arrived system (Review Focus 3)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) // p0 'Alder' in s0 (Origin); p1 'Basalt' in s1 (Outpost) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) + await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) + const [save] = await db.db.select().from(schema.saves).limit(1) + await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id)) + + const r = await runTurn(db) + expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE', 'JUMP']) // both this tick — the resolvers are payload-targeted (4a), so resolution order cannot change what lands + + const planets = await db.db.select().from(schema.planets) + expect(planets.find((p) => p.id === f.p0)!.explored).toBe(true) // the payload planet — in the OLD system + expect(planets.find((p) => p.id === f.p1)!.explored).toBe(false) // nothing in the new system + expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true) // the jump still landed + // the haul is the payload planet's deterministic stream (seed '42', p0) — never the new system's + expect((await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind, name: i.name, qty: i.qty }))).toEqual(rollExploration(explorationRng('42', f.p0))) + // the ~18% crew roll rides the same stream — at most one member joins (PR C pins the stream) + expect((await db.db.select().from(schema.crew)).length).toBeGreaterThanOrEqual(2) + }) + + it('a resolver guard failure fails just that task — the turn still lands, the sibling still resolves, no event for the failure', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + const badJumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: 999, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) + const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) + const [save] = await db.db.select().from(schema.saves).limit(1) + await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id)) + + const r = await runTurn(db) // must not throw — the 4a guard throw is a failure signal, not an error + expect(r.turn).toBe(5) + expect(r.completed.map((t) => t.id)).toEqual([badJumpId, exploreId]) // both reported; only the sibling's effect lands + expect((await db.db.select().from(schema.tasks)).find((t) => t.id === badJumpId)!.status).toBe('DONE') // failed task: done — the turn is spent, the crew freed + expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(true) // the sibling resolved + const types = (await db.db.select().from(schema.events)).map((e) => e.type) + expect(types.filter((t) => t === 'jump')).toHaveLength(0) // the 404 guard threw before the jump effect/event + expect(types.filter((t) => t === 'explore')).toHaveLength(1) + expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move + }) + }) + ``` + +2. - [ ] **Run — expect failure**: `npx vitest run lib/game/tasks.test.ts` → the five new `tickTurn` its red (`tickTurn` is not exported), the `queueTask` block stays green; `npx vitest run lib/game/save.test.ts` → the new `runTurn` describe red (`runTurn` is not exported), everything else — including 4a's A/B blocks — stays green. +3. - [ ] **Implement** — append `TickResult` + `byQueueOrder` + `tickTurn` to `lib/game/tasks.ts` (code above); in `lib/game/save.ts` add the two resolver imports, replace the 4a type-only `./tasks` import with `import { tickTurn, type QueueState, type Task, type TaskStatus, type TaskType } from './tasks'`, and add `TASK_RESOLVERS` / `rowToTask` / `runTurn` (code above). +4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (queueTask, 4a's A/B + guard blocks, and the new tick blocks), and `npx tsc --noEmit` clean. +5. - [ ] **Commit (same PR A branch)**: `feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)` + +### Task 5a: Action endpoints → queueing (jump, explore) + +The two instant-action endpoints become queueing endpoints: same body fields plus the crew team, same pre-checks with the same codes, then a `queueTask` and a row insert. Effects move to the tick (4a's resolvers), and **these two endpoints no longer advance the turn** — from this task on, `runTurn` is the sole writer of `saves.turn` (tick section). + +**Files** + +- `app/api/jump/route.ts` — rewrite `POST`. Body: `{ systemId, leadCrewId, helperCrewIds? }` — `leadCrewId` a required integer, `helperCrewIds` an optional integer array (default `[]`), each validated in the existing 400 style. Keep the target pre-checks in the route with their existing codes (unknown system, unreachable) — extracted from `jumpToSystem` into a small route-callable helper (JC-1). Compute `hops` with the existing reachability logic (`reachableWithin` + NAV AI — what `jumpToSystem` does today). Build the `QueueState` (Task 3) via `queueStateFromSave`, call `queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId, helperCrewIds })`, persist the returned row via the new `saveTaskRow` (inventory is unchanged for JUMP — Task 3 — so no inventory write), return the same snapshot the route returns today (`getWorldState`). Map `TaskError` exactly like today's `JumpError`: `Response.json({ error: err.message }, { status: err.status })`. +- `app/api/explore/route.ts` — same for EXPLORE: keep the existing pre-check (already scanned) before `queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId, helperCrewIds })`; return the same system snapshot as today (JC-2). +- `lib/game/save.ts` — add `saveTaskRow(db, save, task: Omit): Promise` — the route's DB-boundary insert (the codebase's route→save pattern): fills `saveId: save.id`, returns the new row id (`QueueResult.task.payload` is already a JSON string — passed through; reuse Tasks 1–4's row-insert export, the tests' `insertTaskRow`, if one already exists). Add `queueStateFromSave(db, save): Promise` — maps schema rows into Task 3's `QueueState` (crew, tasks, inventory in id order, shipSystems, tech). `jumpToSystem` / `explorePlanet` stay untouched (4a's resolvers still use them) — only the routes stop calling them for the action itself. +- `lib/openapi.ts` — add `leadCrewId` (required) and `helperCrewIds` (optional array) to the jump + explore request schemas (AGENTS.md: the spec must track endpoint changes). +- `app/api/jump/route.test.ts`, `app/api/explore/route.test.ts` — handler-level unit tests calling `POST(new Request(...))` against the same fresh-file fixture helpers as the 4b tests (`freshFile`, `addCrew`, `buildWorldFixture`, `insertTaskRow`). (JC-3: if the CI-only e2e specs POST to these endpoints, update those bodies to carry the crew fields in the same commit — not verifiable from the route files.) + +**Interfaces** + +- Consumes: Task 3's `queueTask` / `QueueState` / `QueueResult` / `TaskError`; the existing `JumpError` / `ExploreError` pre-checks and the snapshot readers (`getWorldState` / `getSystemState`) in `save.ts`. +- Produces: `POST /api/jump` `{ systemId, leadCrewId, helperCrewIds? }` → a `JUMP {systemId, hops}` row; `POST /api/explore` `{ planetId, leadCrewId, helperCrewIds? }` → an `EXPLORE {planetId}` row; `ACTIVE` when the whole team is free, else `PENDING` (lead chaining behind its own ACTIVE — Task 3). Same snapshot response shape as today; statuses: today's plus TaskError's 404/409; **no turn increment in either endpoint**. + +1. - [ ] **Write failing tests** — `app/api/jump/route.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { schema } from '@/lib/db/schema' +import { getWorldState } from '@/lib/game/save' +import { POST } from './route' +// freshFile / addCrew / buildWorldFixture / insertTaskRow — the 4b tests' fixture helpers + +const post = (body: unknown) => POST(new Request('http://localhost/api/jump', { method: 'POST', body: JSON.stringify(body) })) + +describe('POST /api/jump (5a)', () => { + it('queues a JUMP: row with type/payload/status, turn unchanged, snapshot preserved, ship not moved', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + const [before] = await db.db.select().from(schema.saves) + + const res = await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(await getWorldState(db)) // the same fresh snapshot as today — queueing changes nothing else + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'JUMP', leadCrewId: c1, helpers: [c2], status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.payload)).toEqual({ systemId: f.sys1, hops: 1 }) // route-computed via the existing reachability logic + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment + expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the effect waits for the tick + }) + + it('unknown crew → 404 (queueTask)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + expect((await post({ systemId: f.sys1, leadCrewId: 999 })).status).toBe(404) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) + + it('busy helper → 409, nothing queued', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 }) + expect((await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(1) + }) + + it('a jump in transit → 409 (blocks any new task)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 3, remaining: 3, status: 'ACTIVE', queuedAtTurn: 1 }) + expect((await post({ systemId: f.sys1, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(1) + }) + + it('a jump already pending → second jump 409', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 }) + await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 1 }) // lead chains behind its own ACTIVE + expect((await post({ systemId: f.sys1, leadCrewId: c2 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(2) + }) + + it('unreachable target → 409 (existing pre-check, existing code)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + expect((await post({ systemId: f.farSystem, leadCrewId: c1 })).status).toBe(409) // JC-4: a beyond-reach system in the fixture; add it to buildWorldFixture if absent + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) +``` + +and `app/api/explore/route.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { schema } from '@/lib/db/schema' +import { getSystemState } from '@/lib/game/save' +import { POST } from './route' +// same fixture helpers + +const post = (body: unknown) => POST(new Request('http://localhost/api/explore', { method: 'POST', body: JSON.stringify(body) })) + +describe('POST /api/explore (5a)', () => { + it('queues an EXPLORE: row with type/payload, turn unchanged, planet not yet scanned, no haul', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + const [before] = await db.db.select().from(schema.saves) + const invBefore = (await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const) + + const res = await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] }) + expect(res.status).toBe(200) + const body = await res.json() + const sys = await getSystemState(db) + for (const k of Object.keys(sys)) expect(body[k]).toEqual(sys[k]) // today's system snapshot part unchanged; the findings key stays, empty (JC-2) + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'EXPLORE', leadCrewId: c1, helpers: [c2], status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.payload)).toEqual({ planetId: f.p0 }) + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) + expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(false) // the effect waits for the tick + expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)).toEqual(invBefore) // no haul yet + }) + + it('unknown crew → 404 (queueTask)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + expect((await post({ planetId: f.p0, leadCrewId: 999 })).status).toBe(404) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) + + it('busy helper → 409, nothing queued', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2] = await addCrew(db, 2) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 }) + expect((await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(1) + }) + + it('already scanned → 409 (existing pre-check, existing code)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, f.p0)) + expect((await post({ planetId: f.p0, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) +``` + +2. - [ ] **Run — expect failure**: `npx vitest run app/api/jump/route.test.ts app/api/explore/route.test.ts` → the new blocks red: the current routes ignore the crew fields and resolve instantly (happy paths fail — the turn advances, no row, snapshot differs) and the 404/409 crew cases return 200 today; the unreachable / already-scanned pre-check cases may already pass — expected, those codes do not move. +3. - [ ] **Implement** — rewrite both routes per Files (pre-check extraction into the small save.ts helper, `queueStateFromSave` + `queueTask`, `saveTaskRow`, `TaskError` → `.status` mapping identical to the existing `*Error` mappings, no turn increment in either endpoint); add `saveTaskRow` + `queueStateFromSave` to `lib/game/save.ts`; update the two request schemas in `lib/openapi.ts`; update any e2e spec bodies that POST to these endpoints (JC-3). +4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (5a's route tests plus 4a/4b's tick and resolver blocks — the resolvers are untouched), and `npx tsc --noEmit` clean. +5. - [ ] **Commit (same PR branch as Task 4b)**: `feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)` + +### Task 5a (ii): Action endpoints → queueing (ship, research) + mission accept + +**Files** + +- `app/api/ship/upgrade/route.ts` — keep the `systemId` body check (integer, 400); add `leadCrewId` (integer, required, 400) + `helperCrewIds` (array of integers, default `[]`) with the same 400 style as Task 5a's crew parse. Keep the current `upgradeSystem` pre-checks (not built / max level) with their existing `ShipError` codes, then `queueStateFromSave` → `queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId, helperCrewIds })` → persist the row + deducted hold via `saveTaskRow`. Returns the same ship-state shape as today. No turn increment, no immediate effect. +- `app/api/ship/expand/route.ts` — same treatment: pre-checks not built / max capacity, `ShipError` codes, payload `{ systemId }`. +- `app/api/ship/build/route.ts` — same treatment: `type` body check (non-empty string, 400), pre-checks already built / build gate, payload `{ type }`. +- `app/api/research/route.ts` — same treatment: `techId` body check (integer, 400), pre-checks already researched / prereqs / level cap with `ResearchError` codes, payload `{ techId }`. +- `lib/game/ship.ts` + `lib/game/research.ts` — split the four functions: the pre-checks move into small pre-check-only helpers (e.g. `checkUpgradePreconditions(save, systemId)`) that throw exactly the same `*Error` codes the current functions throw (JC-5); cost deduction, turn increment and immediate effect come out of them (the cost is now `queueTask`'s, at queue time; the effect waits for the tick). +- `app/api/missions/[id]/accept/route.ts` — becomes instant and free per the approved spec: no body (no crew params); `lib/game/missions.ts` `acceptMission` keeps its validation (the 400 non-integer id stays in the route; the current `MissionError` codes), its AVAILABLE→active state change and its board return, but drops the turn increment. +- `lib/openapi.ts` — add `leadCrewId` + `helperCrewIds` to the four action request schemas (Task 5a's pattern); accept's schema unchanged (no body). +- Tests — `app/api/ship/{upgrade,expand,build}/route.test.ts`, `app/api/research/route.test.ts` (append to the existing file, create it if absent — the same note as Task 3), `app/api/missions/[id]/accept/route.test.ts` — the blocks below; update any e2e specs that POST to these endpoints (JC-3). + +**Interfaces** + +- Consumes: `queueTask` / `TaskError` / `QueueState` (Task 3); `queueStateFromSave` + `saveTaskRow` (Task 5a); the 4a/5a test fixture suite (`freshFile`, `buildWorldFixture`, `addCrew`, `insertTaskRow`). +- Produces: SHIP_UPGRADE / SHIP_EXPAND / SHIP_BUILD / RESEARCH queueing — costs deducted at queue time, `TaskError` 422 with the existing `spendItems` message when unaffordable; the four endpoints accept `leadCrewId` + `helperCrewIds` (openapi); mission accept costs no turn. None of the five endpoints advances the turn. + +1. - [ ] **Write failing tests** — three blocks per action file (upgrade in full below; expand/build/research the same shape, deltas in the note after the code block) + one block for accept. Fixtures from the 4a/5a suite; `seedHold` inserts into `schema.inventoryItems` (column set per the fixture suite — reuse its seeder if it has one): + +```ts +// app/api/ship/upgrade/route.test.ts — imports/helpers per Task 5a's route tests +import { upgradeCost } from '@/lib/game/ship-costs' + +const post = (body: object) => + fetch('/api/ship/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + +const seedHold = (db: Db, rows: { kind: string; name: string; qty: number }[]) => + rows.forEach((r) => db.db.insert(schema.inventoryItems).values({ saveId: , ...r })) // JC-6 + +describe('POST /api/ship/upgrade', () => { + it('happy: row with type/payload, turn unchanged, cost deducted, no level change yet', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = /* built, not-maxed system row from f (JC-6) */ + const cost = upgradeCost(sys.level) // exactly the existing cost logic + await seedHold(db, [{ kind: 'resource', name: 'resource', qty: cost }]) + const [before] = await db.db.select().from(schema.saves) + const res = await post({ systemId: sys.id, leadCrewId: c1 }) + expect(res.status).toBe(200) + const [row] = await db.db.select().from(schema.tasks) + expect(row).toMatchObject({ type: 'SHIP_UPGRADE', leadCrewId: c1, helpers: [], status: 'ACTIVE', queuedAtTurn: before.turn }) + expect(JSON.parse(row.payload)).toEqual({ systemId: sys.id }) + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment + expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted (if the fixture seeds a hold, assert the resource total drops by exactly cost) + expect((await db.db.select().from(schema.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(sys.level) // the effect waits for the tick + }) + + it('unaffordable → 422 (existing spendItems message), nothing queued', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const sys = /* the same built system (JC-6) */ + await seedHold(db, [{ kind: 'resource', name: 'resource', qty: upgradeCost(sys.level) - 1 }]) + const res = await post({ systemId: sys.id, leadCrewId: c1 }) + expect(res.status).toBe(422) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) + + it('max level → 409 (existing pre-check, existing code), nothing queued', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + await db.db.update(schema.shipSystems).set({ level: 99 }) // above any max + expect((await post({ systemId: /* built system id (JC-6) */, leadCrewId: c1 })).status).toBe(409) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) + }) +}) +``` + +- expand file: same three blocks — payload `{ systemId }`, cost `expandCost(sys.capacity)` resources, pre-check sets `capacity` to 99. +- build file: body `{ type, leadCrewId }` (`type` = an unbuilt type from the fixture), cost `BUILD_PART_COST` parts seeded exactly, pre-check marks a system row already built. +- research file: body `{ techId, leadCrewId }` (`techId` = an unresearched tech row's id), cost = that tech's `TECHS` def (`costKind`/`costQty`), pre-check marks the tech row already researched; the 422 seeds `costQty - 1`. +- accept file: + +```ts +describe('POST /api/missions/[id]/accept', () => { + it('happy: mission goes active, board returned, turn unchanged', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [before] = await db.db.select().from(schema.saves) + const res = await fetch(`/api/missions/${f.mission0}/accept`, { method: 'POST' }) // f.mission0 = an AVAILABLE mission (JC-6) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(await getMissionBoard(db)) // same board shape as today (JC-6) + // existing state change: the mission row goes AVAILABLE → active (assert with the schema's status value, as the existing tests do) + expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // free — no turn cost + }) + // the existing validation cases (400 non-integer id, each current MissionError case) stay as-is, + // except expectations asserting a turn increment — those now expect the turn unchanged +}) +``` + +2. - [ ] **Run — expect failure**: `npx vitest run app/api/ship/upgrade/route.test.ts app/api/ship/expand/route.test.ts app/api/ship/build/route.test.ts app/api/research/route.test.ts "app/api/missions/[id]/accept/route.test.ts"` → red: the action happy paths (today the routes resolve instantly — the turn advances, no task row) and accept's no-turn assertion (it costs a turn today); the 422 / pre-check 409 cases may already pass — expected, those codes do not move (queueTask's own busy / in-transit / second-jump rules are NOT re-tested here — Task 3 owns them). +3. - [ ] **Implement** — rewrite the four action routes per Files (crew parse with the 400s; the pre-check helpers with unchanged codes; `queueStateFromSave` + `queueTask` + `saveTaskRow` + persisting `QueueResult.inventory`; return the same snapshot as today; the catch block maps both `*Error` and `TaskError` to `{ error: err.message }` / `err.status` like today); `acceptMission` drops the turn increment (update its existing unit tests accordingly); the four openapi schemas; the e2e spec bodies (JC-3). +4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (including the existing ship/research/missions lib tests with their turn-related expectations updated) and `npx tsc --noEmit` clean. +5. - [ ] **Commit (same PR branch as Tasks 4b/5a)**: `feat(api): ship + research endpoints queue tasks with lead + helpers; mission accept is instant and free (#44)` + +### Task 5b: POST /api/turn — the manual tick + +**Files** + +- `app/api/turn/route.ts` — new: `POST /api/turn` (code in step 3). The route is the world-route one-liner plus a tick: call 4b's `runTurn` (one turn: decrement, resolve, FIFO auto-start, mission hooks — all already 4b) and return the same snapshot `GET /api/world` returns, composed with the tick's queue array. +- `lib/openapi.ts` — add the `POST /api/turn` schema (no request body; response = world snapshot + `tasks` array) — the spec-in-code rule (AGENTS.md API 3). +- `app/api/turn/route.test.ts` — new: the `describe('POST /api/turn')` block in step 1; the same helpers as the 5a route tests (`freshFile`, `buildWorldFixture`, `addCrew` — imported from the same source they use). +- Untouched: `lib/game/save.ts` (`runTurn` is 4b's; this route only calls it), `app/api/world/route.ts`, the 5a routes. + +**Interfaces** + +`POST /api/turn` — no body (the handler never reads one): + +```ts +const db = await getGameDb() +const tick = await runTurn(db) // 4b's orchestrator: decrement → resolve → FIFO auto-start → mission hooks; persists the turn +return Response.json({ ...(await getWorldState(db)), tasks: tick.tasks }) +``` + +- `runTurn(db: GameDb): Promise` (4b) returns the pure `TickResult` unchanged: `{ turn, tasks, completed }` with `turn = before + 1` (the tick IS the turn increment) and `tasks` = every task row after the tick. +- Snapshot call: `getWorldState(db)` — the exact call `GET /api/world` makes (its route file is this one's template); the snapshot readers all re-read their tables after the tick's final persist and carry the new `turn`, so no new plumbing (4b Interfaces (e)). +- Response = the world snapshot plus a `tasks` field composed from `tick.tasks` — spec §7's active+pending queue array: the 4b contract pins that the route "responds with it, composing spec §7's response (the active+pending task array) from it and the existing readers", and the snapshot readers do not select the `tasks` table, so the array comes from the TickResult, not a re-read. +- An empty queue is a FREE SKIP: `runTurn` returns `turn + 1` with an empty `tasks`, and the route returns 200 with nothing else changed — the route never rejects an empty tick (the UI tick button is never disabled). +- Errors: no domain errors escape `runTurn` — 4b already catches `JumpError` / `ExploreError` / `ShipError` / `ResearchError` and fails the task instead; only a non-domain rethrow (a real bug) reaches the route, and the existing error-mapping pattern applies (`{ error: err.message }` / status, 500 when absent). The route adds no 4xx paths of its own. + +1. - [ ] **Write the failing tests** — new `app/api/turn/route.test.ts`: + +```ts +describe('POST /api/turn', () => { + it('empty queue → free skip: 200, turn +1, nothing else changes', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + const [before] = await db.db.select().from(schema.saves) + const res = await fetch('/api/turn', { method: 'POST' }) + expect(res.status).toBe(200) + const snap = await res.json() + expect(snap.turn).toBe(before.turn + 1) + expect(await db.db.select().from(schema.tasks)).toHaveLength(0) // the tick creates nothing + expect(snap.tasks).toEqual([]) // the composed queue array is empty + }) + + it('one ACTIVE task → 200, remaining decrements by exactly 1 in the returned snapshot', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + const [before] = await db.db.select().from(schema.saves) + // Fixture task: one ACTIVE row, mid-countdown — a direct insert on the queue table + // (the 4b save.test pattern: it works with the queue rows directly); it never reaches 0 + // this tick, so no resolver and no mission hook runs + await db.db.insert(schema.tasks).values({ + id: /* fresh id, per the fixture's convention (JC-6) */, + type: 'JUMP', + payload: /* a valid JUMP payload at a fixture system (JC-6) */, + leadCrewId: c1, + helpers: '[]', // JSON string — the save.ts boundary JSON.parses it + duration: 5, + remaining: 3, + queuedAtTurn: before.turn, + status: 'ACTIVE', + }) + const res = await fetch('/api/turn', { method: 'POST' }) + expect(res.status).toBe(200) + const snap = await res.json() + expect(snap.turn).toBe(before.turn + 1) + expect(snap.tasks).toHaveLength(1) + expect(snap.tasks[0].remaining).toBe(2) // 3 − 1: exactly one decrement + expect((await db.db.select().from(schema.tasks))[0].remaining).toBe(2) // the persisted row matches the snapshot + }) +}) +``` + +(Row field names per Task 2's `tasks` DDL / the `Task` row type — only `helpers` is a JSON-encoded column; `id` / `payload` are fixture-dependent, JC-6.) + +2. - [ ] **Run — expect failure**: `npx vitest run app/api/turn/route.test.ts` → red: `POST /api/turn` does not exist yet (404 / route not found). +3. - [ ] **Implement** — the route per Interfaces (world-route import pattern + the `runTurn` call + the composed response; a catch mapping any thrown error to `{ error: err.message }` / `(err as { status?: number }).status ?? 500` — only 4b's non-domain rethrow can reach it); the `POST /api/turn` openapi schema in `lib/openapi.ts` (response reuses the world-snapshot shape + the `tasks` array of queue rows). +4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes; `npx tsc --noEmit` clean. +5. - [ ] **Commit (same PR branch as Tasks 4b/5a)**: `feat(api): POST /api/turn ticks the queue one turn; an empty queue is a free skip (#44)` + +### Task 6: Snapshots gain the active + pending tasks array + +**Files** + +- `lib/game/save.ts` — add the `QueueDto` / `CrewState` interfaces and the private `taskQueue(db)` reader (code in step 3); add `tasks: QueueDto[]` to `WorldState` and `SystemState`, returned by `getWorldState` / `readSystemState`; `getCrewState` returns the new `CrewState` wrapper instead of the bare `CrewDto[]` (JC-7). Pure read — `taskQueue` selects only the `tasks` / `crew` / `solarSystems` / `planets` / `tech` tables and 4b's tick stays untouched. +- `lib/game/save.test.ts` — new `describe('snapshot task queue (world / system / crew)')` block (code in step 1); hoist the 4b block's `addCrew` / `insertTaskRow` helpers to file scope so both blocks share them; update the two existing blocks that assert the old crew-array shape (`getCrewState`, `explorePlanet crew finds`) to read `.crew` off the wrapper. +- `app/api/crew/route.ts` — no code change (it forwards whatever the builder returns); update its header comment to the object shape. `app/api/world/route.ts` / `app/api/system/route.ts` — unchanged (they already forward their builders). `app/api/turn/route.ts` — unchanged (JC-8). +- `components/crew-panel.tsx` — parse the response as `CrewState` and render `state.crew` (JC-7) — without this the crew tab breaks the moment the builder wraps. +- `components/mission-control.tsx` — the shell's own `/api/crew` consumer: change its bare-array parse to the `{crew, tasks}` `CrewState` object (JC-7's second consumer, flagged in JC-15). +- `lib/openapi.ts` — new `TaskDto` + `CrewState` component schemas; `WorldState` / `SystemState` gain the `tasks` property (and required entry) — `ExploreResult` inherits it through its `SystemState` allOf; the `/api/crew` 200 response becomes the `CrewState` object instead of the `CrewMember` array (JC-7). + +**Interfaces** + +- Consumes: Task 2's `tasks` table (`schema.tasks`) and Task 1's `TaskType` (`lib/game/tasks.ts`); the existing `crew` / `solarSystems` / `planets` / `tech` tables for name resolution. +- Produces: `QueueDto { id: number; type: TaskType; targetLabel: string; leadName: string; helperNames: string[]; remaining: number }` and `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` in `lib/game/save.ts`; `WorldState.tasks` / `SystemState.tasks: QueueDto[]`; `getCrewState(db): Promise`. +- Array shape: ACTIVE rows first, then PENDING rows, each block ordered by (queuedAtTurn ASC, id ASC); DONE rows never appear. `remaining` is the stored row value — a pure read, no duration recomputation. `leadName` / `helperNames` resolve from the crew roster by id, falling back to `unknown` when an id references a deleted member (admin-corruption tolerance — the same spirit as 4b's `<= 0` check). +- `targetLabel` is resolved from the 4a payload shapes, per type: + +| TaskType | payload (4a) | targetLabel | +|---|---|---| +| JUMP | `{ systemId, hops }` | the `solarSystems.name` of `payload.systemId` — e.g. `Outpost` | +| EXPLORE | `{ planetId }` | the `planets.name` of `payload.planetId` — e.g. `Basalt` | +| SHIP_UPGRADE | `{ systemId }` | ` — upgrade` — e.g. `Origin — upgrade` | +| SHIP_EXPAND | `{ systemId }` | ` — expand` — e.g. `Origin — expand` | +| SHIP_BUILD | `{ type }` | `payload.type` as-is — the SHIP_TYPES strings are display names (e.g. `WEAPONS`) | +| RESEARCH | `{ techId }` | the `tech.name` display column of `payload.techId` — e.g. `NAVIGATION AI` | + + A lookup miss (a target id deleted by the admin) falls back to `unknown`; SHIP_BUILD never misses, its label lives in the payload. + +1. - [ ] **Write the failing tests** — add the block below to `lib/game/save.test.ts` (`insertTaskRow` = the 4b helper hoisted to file scope in this task; fixtures = the 4a block's `buildWorldFixture` + the file-scope `addCrew`): + +```ts +describe('snapshot task queue (world / system / crew)', () => { + // buildWorldFixture (4a): a save (seed '42') + Origin (s0, current) / Outpost (s1) + + // Alder (p0) / Basalt (p1) + the route pair — no crew, no tasks; this block adds its own. + + it('empty queue → every GET snapshot carries tasks: []', async () => { + const { db } = await freshFile() + await buildWorldFixture(db) + expect((await getWorldState(db)).tasks).toEqual([]) + expect((await getSystemState(db)).tasks).toEqual([]) + const crew = await getCrewState(db) + expect(crew.tasks).toEqual([]) + expect(crew.crew).toEqual([]) // the new wrapper: the roster moves under .crew (JC-7) + }) + + it('one ACTIVE + one PENDING row → exactly those two, ACTIVE first, in all three snapshots', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2, c3] = await addCrew(db, 3) + const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 }) + const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [c3], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 3 }) + const expected = [ + { id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 }, + { id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4 }, + ] + expect((await getWorldState(db)).tasks).toEqual(expected) + expect((await getSystemState(db)).tasks).toEqual(expected) + expect((await getCrewState(db)).tasks).toEqual(expected) + }) + + it('DONE rows never appear — even when they sort first by (queuedAtTurn, id)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1] = await addCrew(db, 1) + await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 0, status: 'DONE', queuedAtTurn: 1 }) // oldest, lowest id in the queue + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 2 }) + const snap = await getWorldState(db) + expect(snap.tasks).toHaveLength(1) + expect(snap.tasks[0].type).toBe('EXPLORE') // the DONE row is filtered out, not reordered to the back + }) +}) +``` + + (Test 2's two rows share queuedAtTurn 3 — the ACTIVE→PENDING block order, not (queuedAtTurn, id), is what puts the jump first; test 3's DONE row sorts first by (queuedAtTurn, id), so its absence proves the filter.) + +2. - [ ] **Run — expect failure**: `npx vitest run lib/game/save.test.ts` → the new block is red: `WorldState` / `SystemState` carry no `tasks` property (undefined ≠ []) and `getCrewState` still returns the bare array (no `.crew` / `.tasks`). The pre-existing blocks stay green — the shape change lands only in step 3. +3. - [ ] **Implement** — per Interfaces: the private `taskQueue(db)` (one select of the `tasks` table filtered to status ACTIVE/PENDING + one select each of `crew` / `solarSystems` / `planets` / `tech`; map every row through the table above — crew names by id, `unknown` on a miss; concat the ACTIVE block, then the PENDING block, each sorted by (queuedAtTurn, id)); wire it into `getWorldState` / `readSystemState` / `getCrewState`; hoist the test helpers and point the two existing crew-array assertions at `.crew`; the crew-panel parse; the openapi schemas. +4. - [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (4a/4b stay green — their A/B blocks normalize the raw tables through `snapshot()`, no test asserts on a full builder object); `npx tsc --noEmit` clean. +5. - [ ] **The shell's own /api/crew consumer**: change its bare-array parse (it casts the response to CrewDto[] and reads .length for the roster count) to the new object shape — parse as the {crew, tasks} CrewState and read crew.length (JC-7's second consumer, flagged in JC-15). +6. - [ ] **Commit (same PR branch as Tasks 4b/5a/5a-ii)**: `feat(api): world/system/crew snapshots expose the active + pending task queue (#44)` + +**Judgment calls (Task 6):** +- **JC-7:** `getCrewState` returns a bare `CrewDto[]` today — a bare array can't carry a sibling `tasks` field, so `GET /api/crew` becomes `{ crew: CrewDto[]; tasks: QueueDto[] }`; the one UI consumer (`components/crew-panel.tsx`) and the two existing save.test crew-array blocks update in the same commit. +- **JC-8:** 5b's route composes from `tick.tasks` (all statuses per 4b) vs the GET builders' ACTIVE+PENDING filter; they agree on every suite fixture (5b tests never create a DONE row) and 5b is locked, so it stays as-is — noted as a possible 5b-level follow-up. + +### Task 7: UI — NEXT TURN button + turn counter + +**Files** + +- `components/next-turn-button.tsx` — new: the fixed bottom-right control, the current turn (`TURN `) above the `NEXT TURN` button (code in step 3). House style throughout: `Button variant="EXEC"` (the green/glow variant), the panels' inline CSS-variable styling (`--surface`, `--border`, `--text-muted`, `--color-red`, mono letter-spaced uppercase labels) and event-log.tsx's inline error line. +- `components/mission-control.tsx` — extract the four mount fetches (the effect at lines 65–106: `/api/ship`, `/api/system`, `/api/tech`, `/api/crew`) into a `refreshAll` callback — same fetch → `throw` on `!res.ok` → setState pattern, same `.catch(() => {})` keep-old-state failure policy — and have the mount effect call it; then mount `` as the last child of the root div: `position: fixed` takes it out of layout, and being outside the tab grid it stays visible on every tab. +- Untouched: the tab panels — Radix unmounts inactive tabs and each panel refetches on mount, so each re-reads its own endpoint when it opens again; the button only refreshes the snapshots the shell owns. `components/ui/button.tsx` is consumed as-is. + +**Interfaces** + +- Consumes: 5b's `POST /api/turn` (no body; always 200 — an empty queue is a FREE SKIP, the route never rejects); the shell's `ShipState.turn` — the same value the VESSEL METRICS TURN row already renders, so the counter has a single source of truth and no new fetch. +- Produces: `NextTurnButton({ turn: number | null; onTicked: () => void })` in `components/next-turn-button.tsx`. Click → `POST /api/turn` → on success `onTicked()` (the shell's `refreshAll`); the transient in-flight flag makes the handler ignore further clicks while the request is out. The button never takes a `disabled` attribute — Review Focus 4: it is NEVER disabled by game state (an empty queue is a free skip); a failed request only shows the inline error and clears in-flight. + +1. - [ ] **Write the failing tests — SKIPPED (no component-level test setup)** — the repo has no `components/*.test.tsx` (verified by glob) and no jsdom / `@testing-library/react` in `package.json`; its unit suite is pure-TS only. No test file in this task — the button's behaviour (the click, the counter, the free skip on an empty queue) is covered by the e2e task later in the plan (CI-only per AGENTS.md). +2. - [ ] **Implement** — per Files / Interfaces: + +```tsx +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' + +// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from +// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell +// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an +// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an +// in-flight request, which the handler ignores. `disabled` is never set, so the cva +// disabled:opacity-40 styling never applies either. + +export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) { + const [inFlight, setInFlight] = useState(false) + const [error, setError] = useState(null) + + const tick = () => { + if (inFlight) return // the only guard — a transient, never a game-state disable + setInFlight(true) + setError(null) + fetch('/api/turn', { method: 'POST' }) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + onTicked() // the same app-wide snapshot refresh the shell uses after every other action + }) + .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setInFlight(false)) + } + + return ( +
+ {error && ( + {error.toUpperCase()} + )} + + TURN {turn ?? '—'} + + +
+ ) +} +``` + + And the shell: `refreshAll` = the extracted four-fetch block as a `useCallback` (the per-fetch `cancelled` flag moves into it, so unmount still cancels setStates), the mount effect becomes `useEffect(() => { refreshAll() }, [refreshAll])`, and the `NextTurnButton` element appended after the footer (position/z-index do the work; the DOM spot is only for reading order). + +3. - [ ] **Verify build**: `npx tsc --noEmit` clean — the whole unit suite stays untouched (`npx vitest run` still green, `npx playwright test --list` still lists); the button itself is exercised by the e2e task in CI. +4. - [ ] **Commit (same PR A branch as Tasks 4b/5a/5b/6)**: `feat(ui): NEXT TURN button (bottom-right) + turn counter; empty queue is a free skip (#44)` + +**Judgment calls** + +- JC-9 — the locked design says the counter reads from "the world snapshot the shell already holds"; the shell holds no `/api/world` state (WorldMapPanel owns it internally — it fetches on tab mount), and the only shell-held snapshot it already renders a turn from is `ShipState.turn` (the VESSEL METRICS TURN row). The button therefore takes `turn={ship?.turn ?? null}` and shows `TURN —` while the ship fetch is in flight — the shell's own `—` loading convention, and the same value the shell already displays, so the two counters can never disagree. +- JC-10 — "the same app-wide snapshot refresh the shell uses after every other action": the shell's only explicit post-action refresh today is `refreshSystem` (#30, jump only), while its full snapshot set is the four mount fetches. A tick can change all four (the turn always; inventory on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research completion), so `onTicked` re-runs the four mount fetches (extracted into `refreshAll`) — a strict superset of `refreshSystem`, which stays for the jump callback. + +### Task 8: UI — team builder (lead + helpers, live duration) + +**Files** + +- `components/team-builder.tsx` — new: a self-contained crew picker shared by the four action panels (code in step 3). House style: the panels' inline CSS-variable look (`--surface`, `--border`, `--text-muted`, `--color-red`, mono letter-spaced uppercase) and next-turn-button's `Button variant="EXEC"` for the dispatch control. +- `components/world-map.tsx` — the JUMP panel: a node click ARMS the target instead of POSTing; the builder is mounted while armed (a floating card over the full-bleed chart, #36); the existing `/api/jump` fetch gains the team fields in its body. +- `components/local-system.tsx` — the EXPLORE panel: the per-planet EXPLORE button arms the planet; the builder is the first child of the column; the `/api/explore` body gains the team fields. +- `components/skill-tree.tsx` — the RESEARCH panel: the card RESEARCH button arms the tech; same placement; the `/api/research` body gains the team fields. +- `components/ship-systems.tsx` — the SHIP panel: the card UPGRADE/EXPAND/BUILD button arms that action; same placement; the local `act(url, body)` fetch gains the team fields. +- `lib/game/save.ts` — `QueueDto` gains `status` / `leadCrewId` / `helperCrewIds` (JC-11) — Task 6's reader already selects all three (it filters + sorts on status and resolves the names from the ids); its snapshot fixtures and the openapi `TaskDto` schema gain the fields in the same commit. + +**Interfaces** + +- Consumes: Task 6's `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` from `GET /api/crew` — roster and queue in ONE response, which is why the builder takes both; Task 1's pure `computeDuration(type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf?)` from `lib/game/tasks.ts` (no DB import — client-safe, the tech.ts / ship-costs.ts precedent); the six `TaskType`s. +- Produces: `TeamBuilder({ type: TaskType; hopCount?: number; roster: CrewDto[]; tasks: QueueDto[]; onQueue: (leadCrewId: number, helperCrewIds: number[]) => void })` in `components/team-builder.tsx` — no other exports. `hopCount` is JUMP-only: the hops to the armed target from the panel's `reachableWithin` (the same logic the route recomputes, #35); the other types omit it → 0, the value 5a's route passes for non-JUMP, and `computeDuration` ignores it off-JUMP (Task 1's doc comment). + +- One row per roster member: a LEAD radio + a HELPER checkbox. The checkbox is disabled while that member is the selected lead — a lead is never also a helper of the same task (picking a new lead drops the member from the helper selection). With a lead picked, the LIVE preview reads `will take N turn(s)` = `computeDuration(type, hopCount ?? 0, lead, helpers)` — the roster objects pass through as-is (`CrewDto { id, name, role, traitGood, traitBad, origin }` is a structural superset of `CrewMember { name, role, traitGood, traitBad }`) and the default `powerOf` (constant 1) is what 5a's route calls, so the preview equals the stored duration exactly. A single `DISPATCH` button (EXEC variant) is enabled only when a lead is picked, that lead is not itself blocked, and the builder is not blocked; it fires `onQueue(leadId, helperIds)`. +- Availability (mirrors Task 3's queueTask — the UI never offers a 409): a member is selectable as LEAD unless they LEAD a PENDING row (an ACTIVE lead may chain one PENDING behind it — stays enabled); a member is selectable as HELPER unless they are lead or helper on any ACTIVE or PENDING row — the disabled state shows the reason, e.g. `busy: EXPLORE` (first matching row in array order, the ACTIVE block first); an ACTIVE JUMP row blocks the whole builder with `in transit // ` (all types). +- Panels: each keeps its target selection and its existing fetch/response flow (world-map's `setWorld(body)` + `onJumped`; local-system's `onChanged`; skill-tree's `onResearched`; ship-systems's `onChanged`) and adds (a) a local armed-target state set by clicking its existing action controls, and (b) a `/api/crew` fetch (the `CrewState` above) on mount and after every successful dispatch — the action response updates the panel's own state, not the roster, so the crew refetch keeps the builder current (keep-old-on-failure, the house `.catch(() => {})` pattern). Dispatching fires the panel's existing fetch with the body extended — the routes already accept both fields (Task 5a): `/api/jump` `{ systemId, leadCrewId, helperCrewIds }` · `/api/explore` `{ planetId, … }` · `/api/research` `{ techId, … }` · `/api/ship/{upgrade|expand|build}` `{ systemId | type, … }`. + +1. - [ ] **Write the failing tests — SKIPPED (no component-level test setup)** — as in Task 7: the repo has no `components/*.test.tsx` and no jsdom / `@testing-library/react` in `package.json`; its unit suite is pure-TS only. The builder's row states, the live preview, the 409-mirror blocking and the four wired panels are covered by the e2e task later in the plan (the one Task 7's note points at), CI-only per AGENTS.md. +2. - [ ] **QueueDto gains `status` / `leadCrewId` / `helperCrewIds` (JC-11)** — as specified, Task 6's display-oriented `QueueDto` can't drive the mirror (names only, no status, no crew ids): add the three fields to the interface, the `taskQueue` mapper and the openapi `TaskDto`, and update Task 6's snapshot `toEqual` expected objects (the two queue rows in its second test). `npx vitest run lib/game/save.test.ts` → green, `npx tsc --noEmit` clean. If Task 6 is still unimplemented when this runs, fold the fields into its implementation. +3. - [ ] **Implement** — per Files / Interfaces: + +```tsx +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import type { CrewDto, QueueDto } from '@/lib/game/save' +import { computeDuration, type TaskType } from '@/lib/game/tasks' + +// Shared crew picker for the four action panels — PR A: an action is queued as a lead + +// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState +// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel +// performs its own fetch with the team fields in the body (Task 5a). The live preview is +// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call +// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask +// so the UI never offers a 409 (JC-11's fields make the member↔row check exact). +export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { + type: TaskType + hopCount?: number + roster: CrewDto[] + tasks: QueueDto[] + onQueue: (leadCrewId: number, helperCrewIds: number[]) => void +}) { + const [leadId, setLeadId] = useState(null) + const [helperIds, setHelperIds] = useState([]) + + // Whole-builder blocks: an ACTIVE jump is in transit (all types); in the jump panel any + // queued jump is — the second-jump 409 (JC-12). + const inTransit = tasks.find((t) => t.type === 'JUMP' && t.status === 'ACTIVE') + const blocked = inTransit ?? (type === 'JUMP' ? tasks.find((t) => t.type === 'JUMP') : null) + + const lead = roster.find((c) => c.id === leadId) ?? null + const helpers = roster.filter((c) => helperIds.includes(c.id)) + const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember + + const leadReason = (c: CrewDto) => { + const t = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING') + return t ? `${t.type} ALREADY QUEUED` : null + } + const helperReason = (c: CrewDto) => { + const t = tasks.find((x) => (x.leadCrewId === c.id || x.helperCrewIds.includes(c.id)) && (x.status === 'ACTIVE' || x.status === 'PENDING')) + return t ? `BUSY: ${t.type}` : null + } + + return ( +
+ PICK TEAM // {type} + {blocked ? ( + + {inTransit ? `IN TRANSIT // ${inTransit.targetLabel}` : 'A JUMP IS ALREADY QUEUED'} + + ) : ( + roster.map((c) => ( + + )) + )} +
+ + {duration !== null ? `WILL TAKE ${duration} TURN(S)` : 'PICK A LEAD'} + + +
+
+ ) +} +``` + + And the four panels per the Panels bullet: the armed-target state, the `` mount (world-map: a floating card over the chart; the others: first child of the column), the `/api/crew` mount + post-dispatch fetches and the extended bodies — each panel's existing response handling stays as-is. + +4. - [ ] **Verify build**: `npx tsc --noEmit` clean — the unit suite stays green (`npx vitest run`, including the updated Task 6 fixtures) and `npx playwright test --list` still lists; the panels themselves are exercised by the e2e task in CI. +5. - [ ] **Commit (same PR A branch as Tasks 4b/5a/5b/6/7)**: `feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)` + +**Judgment calls** + +- JC-11 — the locked design mirrors Task 3's validation, but Task 6's `QueueDto` as specified is display-oriented (names only, no status, no crew ids): the ACTIVE→PENDING array order doesn't expose the block boundary and name-matching breaks on the `unknown` fallback. The builder therefore needs per-row `status` (lead-PENDING vs lead-ACTIVE chaining, in-transit) and `leadCrewId` / `helperCrewIds` (exact member↔row matching) — all three already selected by Task 6's reader, added in step 2 with the fixture + schema updates. +- JC-12 — the locked design blocks the whole builder on an ACTIVE (in-transit) JUMP; Task 3's second-jump 409 also rejects a new JUMP while one is merely PENDING, so the JUMP panel's builder disables on ANY queued jump (reason `A JUMP IS ALREADY QUEUED`) while the other three panels are unaffected by a PENDING jump. +- JC-13 — "pick a team → the existing fetch gains the team fields" needs a target first: the one JUMP hopCount is per-target (the `reachableWithin` map the panel already computes) and the explore / research / ship payloads are per-row. Each existing action control therefore becomes the armer (a click selects the target and shows the builder) and the builder's DISPATCH performs the fetch; the panels' in-flight locks stay as-is. +- JC-14 — the locked design says the roster + tasks come "from the snapshot"; the only response carrying both is Task 6's `CrewState` from `GET /api/crew`, so each panel fetches it on mount and after each dispatch — self-contained like world-map's self-fetch; the shell's `refreshAll` (Task 7) covers the shell-owned snapshots, and Radix unmounts the panels, which refetch on reopen. + +### Task 9: UI — active-tasks strip + +**Files** + +- `components/task-strip.tsx` — new: the fixed bottom-left strip rendering the save's task queue (code in step 3). House style throughout: the panels' inline CSS-variable look (`--surface`, `--border`, `--text-muted`, `--text-secondary`, mono letter-spaced small type) and next-turn-button's `position: fixed` + `zIndex: 60` convention. No state of its own — a pure display of the array it is handed; an empty array returns `null` (no empty box). +- `components/mission-control.tsx` — import `TaskStrip` and mount it as the last child of the root div, right after the `NextTurnButton` element (Task 7): `position: fixed` takes it out of layout, and being outside the tab grid it stays visible on every tab. It receives `tasks={system?.tasks ?? []}` — the shell's existing `SystemState` already carries Task 6's `tasks` array, and Task 7's `refreshAll` re-runs the `/api/system` fetch on every run (as does #30's `refreshSystem` after a jump), so the strip updates on every shell refresh with no new state and no fetch of its own. + +**Interfaces** + +- Consumes: Task 6's `QueueDto` (with JC-11's `status` — the only JC-11 field this component reads; the array arrives pre-ordered: ACTIVE block, then PENDING block, each by (queuedAtTurn, id)) and the shell's existing `system: SystemState | null` state (the `/api/system` fetch, run on mount and by Task 7's `refreshAll`); Task 7's root-level mount convention. +- Produces: `TaskStrip({ tasks: QueueDto[] })` in `components/task-strip.tsx` — no other exports. Each row, in array order: type label + `targetLabel` (left); lead name, ` +`-joined helper names (if any), and the status (right: `N turn(s) left` for ACTIVE, `queued` for PENDING). PENDING rows render dimmed (45% opacity) — the order is the array's, never re-sorted. + +1. - [ ] **Write the failing tests — SKIPPED (no component-level test setup)** — the repo has no `components/*.test.tsx` and no jsdom / `@testing-library/react` in `package.json` (confirmed in Task 7); its unit suite is pure-TS only. No test file in this task — the strip's behaviour (active first, pending dimmed, hidden on an empty queue, updates on every refresh) is covered by the e2e task later in the plan (CI-only per AGENTS.md). +2. - [ ] **Implement** — per Files / Interfaces: + +```tsx +'use client' + +import type { QueueDto } from '@/lib/game/save' + +// TASK STRIP — the save's task queue, fixed bottom-left next to the NEXT TURN button (#44). +// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING +// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows, +// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on +// every refreshAll (Task 7) — a pure display of the array it is handed. + +export function TaskStrip({ tasks }: { tasks: QueueDto[] }) { + if (tasks.length === 0) return null + + return ( +
+ + ACTIVE TASKS + + {tasks.map((t) => ( +
+ + {t.type} // {t.targetLabel} + + + {t.leadName} + {t.helperNames.length > 0 ? ` +${t.helperNames.join(' +')}` : ''} + {' — '} + {t.status === 'ACTIVE' ? `${t.remaining} turn(s) left` : 'queued'} + +
+ ))} +
+ ) +} +``` + + And the shell: the import next to the `NextTurnButton` one, and `` appended right after the button element — position/z-index do the work; the DOM spot only keeps reading order with the button. + +3. - [ ] **Verify build**: `npx tsc --noEmit` clean — the unit suite stays untouched (`npx vitest run` still green, `npx playwright test --list` still lists); the strip itself is exercised by the e2e task in CI. +4. - [ ] **Commit (same PR A branch as Tasks 4b/5a/5b/6/7/8)**: `feat(ui): active-tasks strip bottom-left — active first, pending dimmed (#44)` + +**Judgment calls** + +- JC-15 — "the shell passes the array down" fixes the data source but not which snapshot the shell hands over: after Task 6 the shell holds two that carry a `tasks` array — its `SystemState` (CARGO HOLD) and, via its roster-count fetch, the new `CrewState` object (JC-7). The strip takes `system?.tasks ?? []`: the shell already fetches `/api/system` on mount, and Task 7's `refreshAll` (and #30's `refreshSystem` after a jump) re-run it, so "updates whenever the shell's refresh runs" holds with zero new state and zero new fetches, and the crew fetch stays count-only. Reality note: the shell's own `/api/crew` fetch is a second consumer of JC-7's object response — Task 6's parse-update names only `crew-panel.tsx`; if its bare-array cast is still there when this lands, its `.length` read silently becomes `undefined` (fix belongs to Task 6/8, not this task). + +### Task 10: E2E rework — queue + tick model + +**Files** + +- `e2e/helpers.ts` — the linchpin: `jumpToFreshSystem` is called by every spec that jumps (smoke + state) and today is a single raw `POST /api/jump { systemId }` that landed instantly. Rework it to the queue+tick model (code in step 1) and add a general `resolveAction` it shares with the scan-loop tests: queue an action `POST` carrying a default one-person crew, read the new task's `remaining` (its live duration, Task 6), then `POST /api/turn` that many times and return the final snapshot. Add a minimal `CrewDto` (id + role) next to the existing `WorldState`. +- `e2e/smoke.spec.ts` — the action tests reworked from instant-resolution to queue+tick. This is where the jump / explore / ship / research flow tests already live, so it is the natural home for the new scenarios too (step 3). Uniform recipe for every affected test: each action `POST` now carries `leadCrewId` + `helperCrewIds` and returns a *queued* state (200, no turn bump, effect not yet visible); the effect is observed only after the NEXT TURN button (Task 7, bottom-right) is clicked N times, N = the team builder's (Task 8) live duration for that team. Affected tests: both jump tests, both explore tests, the SHIP SYSTEMS test, both SKILL TREE research tests, the MISSIONS test, the EVENT LOG test. +- `e2e/state.spec.ts` — the mid-run `docker compose restart` spec (AGENTS.md). Two instant-resolution assumptions break: the raw `POST /api/explore` no longer flips `explored` in its response, and no turn bumps on queue — so the `sysExplored` baseline it captures (then compares byte-identical after the restart) must be re-derived *after* ticking the explore to completion, not straight off the queue response (step 4). The other baselines (crew / ship / tech / missions / events) don't depend on that explore resolving and stay as-is. + +**Interfaces** + +- Consumes: Task 3's queue-time validation (busy / in-transit / second-jump → 409 — the API half of the in-transit-block scenario), Task 4b's `tickTurn` effect resolution, Task 5a/5b's reworked action endpoints (crew params) and Task 5b's `POST /api/turn`, Task 6's `tasks` array in every snapshot, Task 7's NEXT TURN button + `TURN N` counter, Task 8's team builder (source of N), Task 9's task strip (dimmed PENDING rows the FIFO scenario reads). +- Produces: a reworked `e2e/` suite that compiles and lists cleanly (`npx playwright test --list`); the new scenarios assert the queue+tick model end-to-end. No unit tests are added or changed (the 409 validation and the duration formula are unit-covered in Tasks 1/3) and no app code is touched — test-only, landing in the same PR A batch as Tasks 4b–9. + +1. - [ ] **Rework the shared jump helper** — `e2e/helpers.ts`. `jumpToFreshSystem` keeps its target choice (prefer an undiscovered neighbour) but queues, then ticks, before returning; `resolveAction` generalizes that for the scan loops. Code: + +```ts +const TASK_TYPE: Record = { + '/api/jump': 'JUMP', '/api/explore': 'EXPLORE', + '/api/research': 'RESEARCH', '/api/ship/upgrade': 'SHIP', + '/api/ship/expand': 'SHIP', '/api/ship/build': 'SHIP', +}; + +// Queue an action POST with a default one-person crew, then tick until that task resolves. +export async function resolveAction(page: Page, path: string, data: Record): Promise { + const roster = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[] } | CrewDto[]; + const members = Array.isArray(roster) ? roster : roster.crew; // Task 6 / JC-7 CrewState shape + const res = await page.request.post(path, { data: { ...data, leadCrewId: members[0].id, helperCrewIds: [] } }); + expect(res.status()).toBe(200); // queued — a 409 here is a busy / in-transit rejection (Task 3) + const snap = (await res.json()) as { tasks?: { type: string; remaining: number }[] }; + const n = snap.tasks?.find((t) => t.type === TASK_TYPE[path])?.remaining ?? 1; + for (let i = 0; i < n; i++) expect((await page.request.post('/api/turn')).status()).toBe(200); + return (await page.request.get(path === '/api/jump' ? '/api/world' : path)).json(); +} + +// jumpToFreshSystem: pick the target as before, then: +// const after = (await resolveAction(page, '/api/jump', { systemId: target.id })) as WorldState; +// expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued +// return after; +``` + +2. - [ ] **Rework the existing action tests** — apply the recipe to each; the per-test assertion deltas are the whole of the rework: + - *both jump tests* — after queueing, the current system is still the origin (no instant landing); only after N ticks does `/api/world` report the target as `current`. The `#30` test's "wait for `TURN ${before.turn + 1}`" becomes "wait for `TURN ${before.turn + N}`" — the tick, not the queue, advances the counter. + - *both explore tests* — the scan no longer flips `explored` in the POST response and bumps no turn; assert `explored=true` + `SCAN RESULT` only after N ticks. The *yields finds* loop reads the haul from the post-tick `/api/system` snapshot (inventory), not from the queue response's `outcome`. + - *SHIP SYSTEMS* — UPGRADE/BUILD queue, then tick; the `LV n+1` / `ONLINE` card flips and the turn counter reflect the ticks, not the click. + - *both SKILL TREE research tests* — RESEARCH queues; the card flips to `RESEARCHED` and the gated build unlocks only after N ticks. The `#35` two-hop test's `turn === turnBefore + 2` (flat hop-scaled cost) becomes `turnBefore + N` where N is that jump's team-builder duration — the hop count now feeds the duration model, not a flat turn delta. + - *MISSIONS* — accepting a contract is the one action in the suite §7 does not list as a queueing op (JC-19); completion still rides queue+tick through the explore/jump calls in its loop. + - *EVENT LOG* — the jump + scan it logs now carry the tick's turn (when they resolved), not the queue's turn; the "newer first" ordering assertion still holds. + +3. - [ ] **Add the five new scenarios** — appended to `e2e/smoke.spec.ts` (its existing jump/explore/ship/research tests make it the natural home): + 1. **Assign → tick → effect** — queue a one-person JUMP; read N from the new task's `remaining` (= the team builder's live duration, Task 8). Assert the current system is unchanged immediately after the queue POST (never instant), unchanged after N-1 ticks, and changed to the target exactly on the Nth tick. + 2. **Parallel tasks** — queue a JUMP (crew A) and an EXPLORE (disjoint crew B); tick max(Na, Nb) times; assert both resolved at their respective durations (jump landed + planet marked) with no interleaving clicks (JC-17: a non-jump action is legal while a jump is in transit). + 3. **FIFO pre-queueing** — queue two tasks for the same crew member (both accepted while it holds no ACTIVE task — JC-16); the strip (Task 9) shows the first and the second dimmed (PENDING); tick until the first resolves and assert the second auto-starts (ACTIVE) on that tick with no new click. + 4. **In-transit block** — queue a JUMP and leave it active (no ticks); attempting to queue a second JUMP is refused `409` at the API (Task 3's "in-transit or second jump" rule). The UI variant (the jump builder/target disabled while in transit) and the full 409 matrix are unit-covered by Task 3, so this single API case stays in e2e. + 5. **Free skip** — with an empty queue, click NEXT TURN; assert the turn counter increments by one and no task appears in the strip. + +4. - [ ] **Fix `state.spec.ts`** — after its raw `POST /api/explore` baseline, tick the explore to completion via `resolveAction` before capturing `sysExplored`, so the `explored=true` and the turn it pins are the resolved values; the byte-identical post-restart comparisons (crew / ship / tech / missions / events) are otherwise unaffected and stay as-is. + +5. - [ ] **Verify (CI-only — never run locally)** — `npx tsc --noEmit` clean (the specs are TS) and `npx playwright test --list` shows every reworked + new test compiles and lists. The actual run happens in CI at this PR's Ship task against the dind stack (`E2E_BASE_URL` / `E2E_DEPLOY_CMD` / `PW_REMOTE_BROWSER=0` per AGENTS.md). Do NOT run the suite locally. + +6. - [ ] **Commit (same PR A branch as Tasks 4b/5a/5b/6/7/8/9)**: `test(e2e): rework the suite to the queue + NEXT TURN model (#44)` + +**Judgment calls** + +- JC-16 — Scenario 3 (FIFO) vs §7's "busy/committed crew → 409": read "busy" as "has an *ACTIVE* task." A member holding only a *PENDING* (queued, not yet started) task can still be given a second queued task, so the second shows dimmed in the strip (Task 9's PENDING row) and auto-starts when the predecessor resolves on a tick. If Task 3 instead 409s on any queued task, this scenario must queue the second task *before* the first tick — reconcile with Task 3's validation, don't re-decide here. +- JC-17 — The in-transit block is scoped to *jump* actions: §7's "in-transit or second jump → 409" blocks a second JUMP while one is in transit, not a non-jump action. That is why scenario 2's parallel JUMP + EXPLORE is legal — the 409 doesn't reach a different-crew, different-type action. +- JC-18 — `GET /api/crew` shape: the specs today cast it to `CrewDto[]`, but Task 6 / JC-7 make it a `CrewState` object. `resolveAction` reads both (`Array.isArray` guard) so the helper is correct under either; the specs' inline `CrewDto[]` casts (CREW tab, state baseline) should be updated to the object shape in the same edit to keep `tsc` clean. +- JC-19 — mission-accept is a free, non-queued action per Task 5a (ii) and spec §3 — no crew params, no turn increment; e2e asserts it resolves on the next interaction, not on a NEXT TURN click. + +### Task 11: Ship v0.10.0 (PR A — Gitea issue #44) + +**Files touched (this ship step only — no app code, no failing-test step):** +- `docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md` — line-4 `Status:` → `approved`; §10 issue-table renumbered to reality (#44 = crew-driven actions v0.10.0, #45 = skill progression v0.11.0, #43 = trait outcome effects v0.12.0). +- `docs/superpowers/plans/2026-10-01-crew-driven-actions.md` — this plan. +- `package.json` (version bump) and `CHANGELOG.md` (new top entry). + +This is a process task that ships the PR A already built by Tasks 1–10 (all code + tests already live on the same branch). It has no failing-test step and ends in a verification gate. + +1. - [ ] **Commit the docs on the PR A branch** (the branch created in Task 1, e.g. `feat/crew-driven-actions`) — in the spec file, first flip line-4 `Status:` to `approved` and correct its §10 issue-table numbers to match reality: `#44` = crew-driven actions engine (v0.10.0), `#45` = skill progression (v0.11.0), `#43` = trait outcome effects (v0.12.0). Then commit the spec and this plan: `git add ... && git commit -m "docs: approved crew-driven-actions spec + implementation plan (#44)"`. + +2. - [ ] **Version bump** — `package.json` → `"version": "0.10.0"` (from the current `0.9.12`). `CHANGELOG.md`: new entry at the very top in the repo's format `## v0.10.0 — YYYY-MM-DD HH:MM` (fill the date/time at execution) with a bullet summary of the PR: queue-based actions (every action becomes a queued task with a lead + helpers), manual time via `POST /api/turn` + the NEXT TURN button, crew teaming with live duration, trait duration modifiers, and the new `tasks` table (schema v7, idempotent backfill). + +3. - [ ] **Local verify — ONLY these three (e2e is CI-only, never run locally):** `npx tsc --noEmit` (clean), `npx vitest run` (all pass), `npx playwright test --list` (every spec compiles + lists). All three must pass. Do NOT run the e2e suite (`npx playwright test`) — CI-only per AGENTS.md. + +4. - [ ] **Push + open the PR to master** — `git push -u origin `, then open a pull request to `master` on Gitea (repo `OpenCode/SpaceWanderer` at git.kkhost.pl). Gitea API access: the CI (`.gitea/workflows/ci.yml`) carries **no** credentials — it only runs build + unit + e2e. Per ORCHESTRATOR.md, prefer the pre-configured **gitea MCP tools** to create the PR; if you must use raw REST/curl, `source` the repo-root `.env` (`GITEA_TOKEN`, gitignored) — but a fresh checkout has no `.env`, so in that case use the gitea MCP tools or your own Gitea API token from the environment. + +5. - [ ] **Auto-merge on green** — trigger Gitea's auto-merge: `POST /repos/OpenCode/SpaceWanderer/pulls//merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`. The PR merges automatically once the required status check `CI / build + unit + e2e (pull request)` passes (branch protection; CI runs the full e2e suite in an isolated dind stack, ~7 min). Poll the PR until `merged_at` is set (max ~15 min; poll the API every ~30s — do **not** read files or re-check code while waiting). If CI is red, **STOP and report the failure** instead of fixing it. + +6. - [ ] **Tag on master** — after pulling the merge on `master`: `git tag v0.10.0 && git push origin v0.10.0`. + +7. - [ ] **Manual deploy** (CI never deploys) — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` returns the 0.10.0 payload. + +8. - [ ] **Close the issue** — Gitea API: close issue #44 with a comment summarizing the shipped state (version 0.10.0, tag v0.10.0, live deploy verified). + +**Verification gate (whole task):** `/api/version` on the live URL (`https://space-wanderer.dev.kkhost.pl`) reports 0.10.0 **AND** Gitea issue #44 is closed. + +**Judgment calls** + +- JC-20 — Gitea credentials: the CI (`.gitea/workflows/ci.yml`) contains **no** Gitea API token — it is only a test runner (checkout → build → unit → e2e), so "the way the CI does it" yields nothing. The repo's documented source is ORCHESTRATOR.md: prefer the pre-configured gitea MCP tools; otherwise raw REST/curl reads `GITEA_TOKEN` from the repo-root `.env` (gitignored, never committed). A fresh checkout has no `.env`, so when absent the path is the gitea MCP tools or your own Gitea API token from the environment. This step states that fallback explicitly rather than assuming CI carries a token. diff --git a/docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md b/docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md new file mode 100644 index 0000000..a00d444 --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md @@ -0,0 +1,121 @@ +# Crew-Driven Actions & Skill Progression — Design Spec + +- Date: 2026-10-01 +- Status: approved +- Tracking: Gitea issues #43–#45 (created after spec approval) +- Repo rules: AGENTS.md change process applies to every issue below + +## 1. Background + +SpaceWanderer is a single-player, turn-based space adventure (dungeon-crawler flavor; reference: Out There). As of v0.9.12 the loop is jump → scan planets → collect hauls → spend on ship upgrades & research → take missions for generic rewards. Every action resolves instantly and costs exactly 1 turn (jumps cost hop count, 1–2 with NAV AI tech). Crew members carry a role and two traits but change nothing mechanically — ORCHESTRATOR.md's "manage crew of flawed humans" is unimplemented. + +This iteration makes **crew the central resource**: every action requires an assigned team, takes variable duration based on that team's roles/skills/traits, and time advances only when the player presses NEXT TURN. Doing jobs builds skill levels, so crews specialise over a playthrough. + +## 2. Goals / Non-goals + +Goals: +1. Every game action (jump, explore, ship work, research) becomes a queued task with an assigned team (lead + optional helpers). +2. Duration is computed from work points ÷ team power, modified by role mismatch and the lead's traits — min 1 turn. +3. Manual time: NEXT TURN button (bottom-right) advances exactly 1 turn; empty queue = free skip. +4. Skill progression: 4 skills keyed by action type; XP from completed tasks; levels 0–5 with power bands; role-based starting specialisation; titles at L3/L5. +5. All 20 traits get mechanical meaning (duration modifiers now; two outcome effects in #43; three combat traits marked STANDBY for the future encounters iteration). + +Non-goals (explicitly out of scope): +- Random encounters, combat, damage, death (next roadmap item per ORCHESTRATOR.md §7) +- Real-time mode — but the work-point model is time-independent by design to ease that migration later +- Crew injury/departure, supplies/hunger systems, task cancellation + +## 3. Core Model: Queued Tasks + Turn Tick + +- New `tasks` table (schema v7): one row per queued/active task. Fields: id, saveId, type (`JUMP|EXPLORE|SHIP_UPGRADE|SHIP_EXPAND|SHIP_BUILD|RESEARCH`), payload (JSON — targetSystemId / planetId / systemType+action / techSlug), leadCrewId, helpers (JSON array of crew ids), duration, remaining, status (`ACTIVE|PENDING|DONE`). +- **One active task per crew member** (as lead or helper). A crew member may hold a chain of PENDING tasks (FIFO pre-queueing); when their current task completes on a turn tick, the next pending auto-starts. Helper slots are fixed at queue time; a committed helper is busy for that task's duration and cannot be added elsewhere meanwhile. +- **Costs deduct at queue time** (ship work & research consume resources/parts immediately — prevents mid-turn affordability races); effects apply on completion. Jumps/explores have no resource cost. +- `save.turn` increments only via `POST /api/turn`. The tick: for every ACTIVE task, remaining−=1; tasks reaching 0 resolve (per-type resolvers reusing existing logic from save.ts / ship / research), grant XP (§6), write events, free the team, auto-start any FIFO successor, then run mission-completion checks for what just resolved. +- **In-transit rule:** while any JUMP task is ACTIVE, no new tasks can be queued (409). Pending jumps do not block; already-active/pending non-jump work continues through the jump and resolves against its target regardless of current system location. Only one JUMP may exist at a time (active or pending) — prevents two arrivals on one tick. +- **Mission accept becomes instant/free** (paperwork: no crew, no turn cost). Completion checks run when the satisfying task completes (jump arrival → DELIVER; scan → EXPLORE/FIND), same as today's hooks moved into tick resolution. + +## 4. Duration Model: Work Points & Team Power + +`duration = max(1, ceil(work(action) / totalPower) + mismatchPenalty + traitModifierSum)` + +- `work(action)` — work points (balance knobs, §11): EXPLORE=4 · JUMP=hops×3 (single 3 / two-hop 6) · SHIP work=6 · RESEARCH=9. Durations are deliberately much longer than today; teams and skill levels claw back the time. +- `totalPower` — sum over lead + helpers of each member's power band in that action type's skill: L0–L2 → 1, L3–L4 → 2, L5 → 3. (Until #45 lands there is no skill table, so every crew member contributes power 1 — teamwork already works; progression layers on top.) +- `mismatchPenalty` — +1 turn if the LEAD's role is not a preferred role for the action type (preferred: JUMP → PILOT/NAVIGATOR · EXPLORE → SCIENTIST · SHIP work → ENGINEER/TECHNICIAN · RESEARCH → SCIENTIST). Helpers are never penalised. STEADY UNDER PRESSURE cancels it. +- `traitModifierSum` — sum of the LEAD's trait modifiers for this action type (§5 table). Helper traits do not apply. + +Solo durations (power 1, no mismatch/traits): EXPLORE 4 · JUMP single 3 / two-hop 6 · SHIP work 6 · RESEARCH 9. A maxed specialist + helpers converges back to ~1–2 turns (§6 examples). + +## 5. Trait Mechanics — all 20 traits + +Duration modifiers (lead only, additive in §4 formula): + +| Good trait | Effect | +|---|---| +| STEADY UNDER PRESSURE | Cancels role-mismatch penalty on any action | +| RECKLESS COURAGE | JUMP −1 turn | +| CALM IN A CRISIS | EXPLORE −1 turn | +| FIXES ANYTHING | SHIP work −1 turn | +| READS STARS BLIND | JUMP −1 turn | +| QUICK HANDS | Any action −1 turn | +| IRON WILL | RESEARCH −1 turn | + +| Bad trait | Effect | +|---|---| +| RIGID — SLOW TO ADAPT | RESEARCH +1 turn | +| OVERCAUTIOUS | Any action +1 turn | +| SLOWS THE CREW DOWN | Any action +1 turn | +| CARELESS WITH FUSES | SHIP work +1 turn | +| HOARDS RATIONS | SHIP work +1 turn | +| FORGETS TO EAT | EXPLORE +1 turn | +| ARGUES WITH EVERYONE | RESEARCH +1 turn | +| DOUBTS THE CAPTAIN | JUMP +1 turn | + +Outcome effects (#43): FINDS HIDDEN THINGS → exploration hauls gain +1 item (extra roll on the same seeded per-planet stream) · TALKS TO ANYONE → crew-recruit chance 18% → 30%. +Encounter-ready STANDBY (activate when random encounters land; shown as "STANDBY" in UI): SHARP SHOOTER, SHORT FUSE, JINXED IN A DOGFIGHT. + +## 6. Skill Progression & Specialisation + +- **Skills** — one per action type: NAVIGATION (jumps), SCANNING (exploration), ENGINEERING (ship work), RESEARCH. Stored in a new `crew_skills` table (schema v8): saveId, crewId, skill, level, xp — extensible for future COMBAT/MEDICAL skills. +- **Roles keep their names** — the existing 8 roles are unchanged; skills are a separate progression axis. A role matters only for: starting levels and the lead mismatch penalty. +- **Starting specialisation (role → L2 in its skill(s)):** PILOT/NAVIGATOR → NAVIGATION · SCIENTIST → SCANNING + RESEARCH · ENGINEER/TECHNICIAN → ENGINEERING · COMMANDER/MEDIC/GUNNER → all skills L0 (generalists; MEDIC/GUNNER become relevant with encounters). +- **Buildup by doing the job:** on task completion, every assigned crew member (lead and helpers) gains +1 XP in that action type's skill. Level thresholds (cumulative completions): L1=5 · L2=10 · L3=15 · L4=20 · L5=25 (cap). +- **Power bands:** L0–L2 → 1 · L3–L4 → 2 · L5 → 3 (§4). +- **Titles** (display only, role name preserved): highest skill level ≥5 → "MASTER ", ≥3 → "SENIOR ". Level-ups write an event ("BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3"). + +Duration examples (work values §4): EXPLORE solo rookie 4 turns → solo master 2 → master+rookie helper 1 · JUMP two-hop solo rookie 6 → solo master 2 → two masters 1 · SHIP work solo rookie 6 → solo master 2 · RESEARCH solo rookie 9 → solo master 3 (stays the long pole by design). + +## 7. API Changes + +- All action endpoints become queueing operations and gain crew params: `POST /api/jump {targetSystemId, leadCrewId, helperCrewIds[]}` · `POST /api/explore {planetId, ...}` · `POST /api/ship/{upgrade|expand|build} {...}` · `POST /api/research {techSlug, ...}`. Validation at queue time: unknown crew → 404; busy/committed crew → 409; in-transit or second jump → 409; unreachable target / unaffordable cost / build gate → existing error codes. No turn increment on queue. +- New `POST /api/turn` — advances exactly one turn (§3 tick), returns full snapshot. +- Snapshots (world/system/crew responses) gain an active+pending `tasks` array: {id, type, targetLabel, leadName, helperNames[], remaining} so the UI can render the task strip and disable busy crew. The admin CRUD panel picks up new tables automatically. +- OpenAPI spec in lib/openapi.ts updated accordingly; DDL in lib/db/client.ts kept in sync with lib/db/schema.ts (repo rule). + +## 8. UI + +- **NEXT TURN button fixed bottom-right** with current turn counter ("TURN 12") — the game's heartbeat; pressing it with an empty queue is a free skip. +- **Crew picker:** action buttons open an inline team builder — pick lead (radio), toggle helpers (checkboxes); live-computed duration updates as the team changes ("Lead AVA VANCE · PILOT L2 + 1 helper → 3 turns"); busy/committed members disabled with reason. +- **Active-tasks strip** (bottom-left): each task's type, target, team, remaining turns; pending FIFO tasks shown dimmed. +- **CREW tab:** per member — role (+title), traits, and the 4 skill levels; STANDBY traits labelled as encounter-ready. +- **Event log:** task-completion and level-up events. + +## 9. Determinism & Testing + +- Duration is a pure function `(actionType, hopCount, lead, helpers) → number` — unit-tested across work values × power bands × mismatch × trait matrix. +- XP/level-ups are deterministic (no rng). Trait outcome effects (#43) draw from the existing seeded per-planet streams (same save+planet → same haul), keeping everything reproducible. +- Unit tests (colocated *.test.ts): duration matrix; queue validation rules (busy 409, in-transit 409, second jump 409); tick resolution (completion effects, XP grant, level-up event, FIFO auto-start, mission checks); cost deduction at queue time. +- E2E (CI-only per AGENTS.md): assign team → NEXT TURN ×N → effect lands; parallel tasks complete independently; pre-queued FIFO starts after predecessor completes; in-transit queuing blocked; empty-queue skip works. + +## 10. Delivery Plan — Gitea issues as TODO list + +One issue = one feature branch/PR/version bump, per AGENTS.md loop (branch off master → version bump + CHANGELOG entry → local verify `tsc --noEmit` / `vitest run` / `playwright test --list` → push, CI auto-merge on green → tag → manual deploy → close issue with comment). + +| Issue | Version | Scope | +|---|---|---| +| #44 Crew-driven actions + NEXT TURN engine | v0.10.0 | tasks table (schema v7; backfill creates empty table for existing saves), POST /api/turn tick, all action endpoints → queueing with lead+helpers, duration model (§4; power 1 for everyone until #45), trait duration modifiers (§5), UI: NEXT TURN button bottom-right + turn counter, crew picker with live duration, task strip. OpenAPI update. Rework existing unit/e2e assertions from instant-resolution to the queue+tick model | +| #45 Skill progression & specialisation | v0.11.0 | crew_skills table (schema v8; idempotent backfill seeds rows per role mapping for existing saves), XP on completion for all assigned crew, level-up events, power bands active (§6), UI: skill display in CREW tab + titles, picker shows per-member contribution | +| #43 Trait outcome effects | v0.12.0 | FINDS HIDDEN THINGS → hauls +1 item; TALKS TO ANYONE → recruit chance 30% | + +## 11. Balance Knobs (single tuning surface) + +All constants live in one new module `lib/game/crew-costs.ts` (alongside ship-costs.ts): WORK_POINTS per action type, POWER_BANDS, XP_THRESHOLDS, STARTING_LEVELS per role, recruit chance values. Tuning a number = editing one file; the duration formula and resolvers read from it. -- 2.54.0 From bf5ac8c8a884d015ca698ddf0448775a0680422f Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 3 Oct 2026 00:57:43 +0000 Subject: [PATCH 20/20] =?UTF-8?q?chore(release):=20v0.10.0=20=E2=80=94=20c?= =?UTF-8?q?rew-driven=20actions=20engine=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d45b445..ff1496f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.10.0 — 2026-10-03 00:57 +- Queue-based actions (#44): every game action (jump, explore, ship upgrade/expand/build, research) is now a queued task with an assigned team — a lead + optional helpers. Validation and cost deduction happen at queue time (unknown crew → 404, busy/committed crew → 409, in-transit or second jump → 409); effects apply when the task completes, mission accept is instant and free, and mission-completion checks run when the satisfying task resolves. +- Manual time: new `POST /api/turn` advances exactly one turn — each active task ticks down, completions resolve via per-type resolvers (reusing the existing jump/explore/ship/research logic), FIFO successors auto-start; an empty queue is a free skip. `save.turn` now only increments here, and the UI gets the NEXT TURN button (bottom-right) with the turn counter. +- Crew teaming with a live duration preview: action buttons open an inline team builder — pick a lead (radio), toggle helpers (checkboxes) — and show the computed duration as the team changes ("Lead AVA VANCE · PILOT L2 + 1 helper → 3 turns"); busy/committed members are disabled with a reason. A new active-tasks strip (bottom-left) lists active tasks first, pending FIFO tasks dimmed. +- Trait duration modifiers: all 15 non-combat traits now affect duration (7 good — cancel the role-mismatch penalty or −1 turn for a specific action type; 8 bad — +1 turn for a specific type or any action), applied to the lead only; the 3 combat traits are labelled STANDBY until the future encounters iteration (the 2 outcome-effect traits ship in #43). +- New `tasks` table (schema v7; idempotent backfill for existing saves — existing saves gain an empty table, turn/crew/inventory untouched) and a single balance module `lib/game/crew-costs.ts` (work points, power bands, trait modifiers); OpenAPI spec updated accordingly. +- Tests: unit matrix covers the duration formula, queue validation rules and tick resolution (completion effects, FIFO auto-start, in-transit blocking); the whole e2e suite was reworked from instant-resolution to the queue + NEXT TURN model (assign team → NEXT TURN ×N → assert the effect). + ## v0.9.12 — 2026-09-29 21:40 - ADMIN button in the mission-control header (top right, next to the SHIP NOMINAL / SECTOR SCAN badges) opens a DB CRUD panel (#38): pick any of the ten game tables from `lib/db/schema.ts`, browse its rows in an HTML table, and add/edit/delete rows via a per-column form. Driven by new `/api/admin/` endpoints — `GET /api/admin/tables` (columns + row counts), `GET|POST|PATCH|DELETE /api/admin/tables/{name}/rows` — built off a name→table registry so there is no per-table code; unknown table names get 404, malformed payloads 400, constraint violations 409. Deliberately unauthenticated by explicit request (marked with a `ponytail:` comment). - e2e: new read-only spec — click ADMIN, assert the panel lists every table the API reports, open `saves` and assert its rows render matching `/api/admin/tables/saves/rows` (no mutations in specs; CRUD is covered by unit tests in lib/db/admin.test.ts). diff --git a/package.json b/package.json index d801706..e9b6db7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.9.12", + "version": "0.10.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0