feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)
This commit is contained in:
@@ -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