From d7faf739a961a936a2dbe8fd3b8f9111554235de Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 14:30:49 +0000 Subject: [PATCH] =?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 } +}