feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)

This commit is contained in:
2026-10-02 14:30:49 +00:00
parent bbcd77c41b
commit d7faf739a9
4 changed files with 385 additions and 14 deletions
+52
View File
@@ -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 }
}