feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)
This commit is contained in:
+129
-1
@@ -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<number[]> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const ids: number[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
|
||||
ids.push(row.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise<number> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id })
|
||||
return row.id
|
||||
}
|
||||
|
||||
it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => {
|
||||
const { db } = await freshFile()
|
||||
await buildWorldFixture(db)
|
||||
const count = (t: 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.
|
||||
|
||||
+79
-1
@@ -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<Explo
|
||||
|
||||
return { ...(await readSystemState(db)), outcome, crewFound }
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
// The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the
|
||||
// effects and the mission hooks (their contract — invoked here, at the tick's new turn,
|
||||
// before the final persist); this function owns the table writes.
|
||||
|
||||
// The 4a contract names the resolvers 1:1 exactly for this dispatch; exhaustive by type —
|
||||
// a TaskType without a resolver is a compile error.
|
||||
const TASK_RESOLVERS: Record<TaskType, (db: GameDb, task: Task) => Promise<unknown>> = {
|
||||
JUMP: resolveJumpTask,
|
||||
EXPLORE: resolveExploreTask,
|
||||
SHIP_UPGRADE: resolveShipUpgradeTask,
|
||||
SHIP_EXPAND: resolveShipExpandTask,
|
||||
SHIP_BUILD: resolveShipBuildTask,
|
||||
RESEARCH: resolveResearchTask,
|
||||
}
|
||||
|
||||
// Row→domain at the save.ts boundary (the inverse — Task→row at insert — stays with the
|
||||
// routes' queueing path).
|
||||
function rowToTask(row: { id: number; saveId: number; type: string; payload: string; leadCrewId: number; helpers: string; duration: number; remaining: number; status: string; queuedAtTurn: number }): Task {
|
||||
return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn }
|
||||
}
|
||||
|
||||
export async function runTurn(db: GameDb): Promise<TickResult> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([
|
||||
db.db.select().from(schema.crew),
|
||||
db.db.select().from(schema.tasks),
|
||||
db.db.select().from(schema.inventoryItems),
|
||||
db.db.select().from(schema.shipSystems),
|
||||
db.db.select().from(schema.tech),
|
||||
])
|
||||
const state: QueueState = {
|
||||
turn: save.turn,
|
||||
crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
|
||||
tasks: taskRows.map(rowToTask),
|
||||
inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })),
|
||||
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
|
||||
tech: tech.map((t) => ({ id: t.id, slug: t.slug })),
|
||||
}
|
||||
|
||||
const result = tickTurn(state)
|
||||
|
||||
// (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
|
||||
// and the checkMissionCompletion / topUpMissionBoard hooks (4a contract) land at the new
|
||||
// turn, exactly where today's actions place them.
|
||||
await db.db.update(schema.saves).set({ turn: result.turn }).where(eq(schema.saves.id, save.id))
|
||||
|
||||
// (b) + (d) resolve in the order tickTurn returned. A resolver throw is its 4a guard
|
||||
// (stale or double-resolved target): the task fails — it is already DONE (the tick freed
|
||||
// the team; the turn is spent), the effect simply does not land, and no event is written
|
||||
// for it. Only the four domain error classes fail a task; anything else rethrows.
|
||||
for (const task of result.completed) {
|
||||
try {
|
||||
await TASK_RESOLVERS[task.type](db, task)
|
||||
} catch (err) {
|
||||
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the tick's queue changes (the resolvers already persisted their own effects) —
|
||||
// one final whole-DB write makes the turn atomic on disk.
|
||||
for (const before of state.tasks) {
|
||||
const after = result.tasks.find((t) => t.id === before.id)
|
||||
if (after && (after.status !== before.status || after.remaining !== before.remaining)) {
|
||||
await db.db.update(schema.tasks).set({ status: after.status, remaining: after.remaining }).where(eq(schema.tasks.id, after.id))
|
||||
}
|
||||
}
|
||||
await persist(db)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
+125
-12
@@ -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> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
|
||||
const task = (over: Partial<Task> = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over })
|
||||
|
||||
describe('queueTask', () => {
|
||||
// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills
|
||||
// the CrewDto shape (unused by the duration model but part of the type).
|
||||
const CREW: CrewDto[] = [
|
||||
{ id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' },
|
||||
{ id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' },
|
||||
{ id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' },
|
||||
]
|
||||
|
||||
const state = (over: Partial<QueueState> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
|
||||
const task = (over: Partial<Task> = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over })
|
||||
|
||||
// Sync twin of the rejects.toMatchObject house style — queueTask throws instead of rejecting.
|
||||
const expectErr = (fn: () => unknown, status: number, message?: RegExp): void => {
|
||||
let err: unknown
|
||||
@@ -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 => task({ id, type: 'JUMP', payload: '{"systemId":2,"hops":1}', ...over })
|
||||
const EXPLORE = (id: number, planetId: number, over: Partial<Task> = {}): Task => task({ id, type: 'EXPLORE', payload: `{"planetId":${planetId}}`, ...over })
|
||||
|
||||
it('decrements every ACTIVE task by one; a task that hits 0 is DONE (its team freed); PENDING rows do not age; the input is never mutated', () => {
|
||||
const s = state({
|
||||
tasks: [
|
||||
JUMP(1, { duration: 3, remaining: 3, leadCrewId: 1, queuedAtTurn: 8 }),
|
||||
EXPLORE(2, 5, { leadCrewId: 3, helpers: [2], duration: 1, remaining: 1, queuedAtTurn: 9 }),
|
||||
EXPLORE(3, 6, { leadCrewId: 1, duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 10 }), // member 1 is on ACTIVE 1
|
||||
],
|
||||
})
|
||||
|
||||
const r = tickTurn(s)
|
||||
expect(r.turn).toBe(11) // the tick is the increment
|
||||
expect(r.tasks.find((t) => t.id === 1)!.remaining).toBe(2)
|
||||
expect(r.tasks.find((t) => t.id === 1)!.status).toBe('ACTIVE')
|
||||
expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE')
|
||||
expect(r.completed.map((t) => t.id)).toEqual([2])
|
||||
expect(r.tasks.find((t) => t.id === 3)!.status).toBe('PENDING')
|
||||
expect(r.tasks.find((t) => t.id === 3)!.remaining).toBe(4) // pending does not age
|
||||
expect(s.tasks.find((t) => t.id === 1)!.remaining).toBe(3) // input state untouched
|
||||
})
|
||||
|
||||
it('a 3-deep PENDING chain on one lead: when the lead task completes, exactly ONE successor starts — earliest queuedAtTurn wins (Review Focus 2)', () => {
|
||||
let s = state({
|
||||
tasks: [
|
||||
JUMP(9, { duration: 3, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }),
|
||||
EXPLORE(10, 5, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
|
||||
EXPLORE(11, 6, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 9 }),
|
||||
EXPLORE(12, 7, { leadCrewId: 1, duration: 1, remaining: 1, status: 'PENDING', queuedAtTurn: 10 }),
|
||||
],
|
||||
})
|
||||
let r = tickTurn(s)
|
||||
expect(r.completed.map((t) => t.id)).toEqual([9])
|
||||
expect(r.tasks.find((t) => t.id === 9)!.status).toBe('DONE')
|
||||
expect(r.tasks.find((t) => t.id === 10)!.status).toBe('ACTIVE') // earliest queuedAtTurn starts
|
||||
expect(r.tasks.find((t) => t.id === 10)!.remaining).toBe(2) // full duration — first decrement next tick
|
||||
expect(r.tasks.find((t) => t.id === 11)!.status).toBe('PENDING') // the others wait for their own tick
|
||||
expect(r.tasks.find((t) => t.id === 12)!.status).toBe('PENDING')
|
||||
|
||||
// Walk the chain to the end: each successor starts on exactly the tick its predecessor completes.
|
||||
const EXPECT: [completed: number[], active: number[]][] = [
|
||||
[[], [10]],
|
||||
[[10], [11]],
|
||||
[[], [11]],
|
||||
[[11], [12]],
|
||||
[[12], []],
|
||||
]
|
||||
for (const [done, active] of EXPECT) {
|
||||
s = { ...s, turn: r.turn, tasks: r.tasks }
|
||||
r = tickTurn(s)
|
||||
expect(r.completed.map((t) => t.id)).toEqual(done)
|
||||
expect(r.tasks.filter((t) => t.status === 'ACTIVE').map((t) => t.id)).toEqual(active)
|
||||
}
|
||||
expect(r.turn).toBe(16) // 10 + 1 (the first tick) + 5 (the walk)
|
||||
expect(r.tasks.every((t) => t.status === 'DONE')).toBe(true)
|
||||
})
|
||||
|
||||
it('same queuedAtTurn ties break by id, and a started task blocks a later one sharing a member — at most one start per freed member (Review Focus 2, corrupt-row half)', () => {
|
||||
// queueTask forbids a member committed to two PENDINGs (rule 2), so this shape only
|
||||
// exists on admin-corrupted rows — the tick must still be deterministic about it.
|
||||
const s = state({
|
||||
tasks: [
|
||||
JUMP(19, { duration: 1, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }),
|
||||
EXPLORE(20, 5, { leadCrewId: 2, helpers: [3], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
|
||||
EXPLORE(21, 6, { leadCrewId: 3, helpers: [2], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
|
||||
],
|
||||
})
|
||||
const r = tickTurn(s)
|
||||
expect(r.tasks.find((t) => t.id === 20)!.status).toBe('ACTIVE') // (8, 20) < (8, 21) — starts first and takes members 2 and 3
|
||||
expect(r.tasks.find((t) => t.id === 21)!.status).toBe('PENDING')
|
||||
})
|
||||
|
||||
it('a JUMP and an EXPLORE can both resolve on the same tick — completion is location-independent (Review Focus 3, tick half)', () => {
|
||||
const s = state({
|
||||
tasks: [
|
||||
EXPLORE(1, 5, { leadCrewId: 1, duration: 1, remaining: 1, queuedAtTurn: 8 }),
|
||||
JUMP(2, { leadCrewId: 2, duration: 1, remaining: 1, queuedAtTurn: 8 }),
|
||||
],
|
||||
})
|
||||
const r = tickTurn(s)
|
||||
expect(r.completed.map((t) => t.id)).toEqual([1, 2]) // both this tick, queue order (explore first)
|
||||
expect(r.tasks.find((t) => t.id === 1)!.status).toBe('DONE')
|
||||
expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE')
|
||||
})
|
||||
|
||||
it('free skip: an empty queue advances exactly one turn and changes nothing; a fully-free PENDING still starts (Review Focus 4)', () => {
|
||||
const r = tickTurn(state())
|
||||
expect(r.turn).toBe(11)
|
||||
expect(r.tasks).toEqual([])
|
||||
expect(r.completed).toEqual([])
|
||||
|
||||
// No ACTIVE tasks and a startable PENDING (a corrupt-row shape — a legal PENDING always
|
||||
// has a busy member): the pass still starts it. Skipping means "no resolution", not
|
||||
// "no starts".
|
||||
const r2 = tickTurn(state({ tasks: [EXPLORE(30, 5, { leadCrewId: 1, helpers: [2, 3], duration: 3, remaining: 3, status: 'PENDING', queuedAtTurn: 9 })] }))
|
||||
expect(r2.turn).toBe(11)
|
||||
expect(r2.tasks.find((t) => t.id === 30)!.status).toBe('ACTIVE')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user