fix(BUG-5): unify turn-advance core (DRY), 500 rounds skip-free

Extract shared nextActiveAfter() advance core. Both nextTurn and
computeTurnOrderAfterRemoval delegate to it — single source of truth,
eliminates drift risk where one path changes and the other doesn't.

Previously two separate advance implementations computed the same
target, but any future edit to one would silently desync deact-current
advance from normal nextTurn advance.

Replay (scripts/replay-combat.js):
- Move turn-line print before mutations (event order = reality)
- Emit [pointer X→Y] lines when a mutation advances currentTurnParticipantId
- Emit [pointer X→Y wrap] when round bumps (removal-wrap case)
- Skip pointer emission for nextTurn (label=null) — already logged via turn line

Parser (scripts/analyze-turns.js):
- Parse [pointer X→Y wrap] events
- Credit pointer-target as acted (deact-current advance = turn pointer)
- Wrap pointer credits NEXT round (not current) — fixes cross-round false skip
- Drop currentRemoved special-case — pointer lines make skip check precise

Tests:
- shared/tests/turn.dry.test.js: 3 tests lock deact-current advance ==
  nextTurn advance (mid-round, inactive-skipper, wrap+round-bump). RED
  catches future drift.

Results: 500-round replay now 0 real skips, 0 double-acts (was 5+3).
Shared suite: 79 green + 1 RED (BUG-6 reorder, intentional).
This commit is contained in:
david raistrick
2026-07-01 14:22:02 -04:00
parent c72b88f8bb
commit 494327ff17
4 changed files with 160 additions and 63 deletions
+47 -21
View File
@@ -20,7 +20,7 @@ const fs = require('fs');
// ---------- parsing ----------
const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)\s*$/;
const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)(?:\s*\|.*)?\s*$/;
const DEACTIVATE_RE = /^\s*\[(?:deactivate)\s+(.+?)\]\s*$/;
const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/;
const ADD_RE = /^\s*\[(?:add)\s+(.+?)\]\s*$/;
@@ -29,6 +29,7 @@ const PAUSE_RE = /^\s*\[pause\]\s*$/;
const RESUME_RE = /^\s*\[resume\]\s*$/;
const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+complete/;
const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/;
const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/;
function parseLine(line) {
if (TURN_RE.test(line)) {
@@ -45,6 +46,10 @@ function parseLine(line) {
if (REMOVE_RE.test(line)) return { kind: 'remove', name: line.match(REMOVE_RE)[1].trim() };
if (PAUSE_RE.test(line)) return { kind: 'pause' };
if (RESUME_RE.test(line)) return { kind: 'resume' };
if (POINTER_RE.test(line)) {
const m = line.match(POINTER_RE);
return { kind: 'pointer', from: m[1].trim(), to: m[2].trim(), wrap: m[3] === ' wrap' };
}
if (ROUND_COMPLETE_RE.test(line)) return { kind: 'round-complete', round: +line.match(ROUND_COMPLETE_RE)[1] };
return null;
}
@@ -81,6 +86,11 @@ function reconstruct(events) {
active.delete(ev.name);
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
r.events.push({ ...ev, idx: r.events.length });
} else if (ev.kind === 'pointer') {
// wrap pointer advances to next round — credit there.
if (ev.wrap) curRound += 1;
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
r.events.push({ ...ev, idx: r.events.length });
} else if (ev.kind === 'round-complete') {
if (rounds.has(ev.round)) rounds.get(ev.round).complete = true;
}
@@ -107,57 +117,72 @@ function analyze(rounds) {
// Re-run per-round with active-set carry-over across rounds (module scope).
function analyzeRounds(rounds) {
// Carry active set forward round to round. Reset at round 1 from scratch.
// Carry active set + current-name forward round to round.
let activeCarry = new Set();
let currentCarry = null;
const reports = [];
const sortedRounds = [...rounds.entries()].sort((a, b) => a[0] - b[0]);
for (const [roundN, r] of sortedRounds) {
if (!r.complete) continue; // incomplete final round — can't judge skips
if (roundN === 1) activeCarry = new Set();
const result = analyzeRoundWithCarry(roundN, r, activeCarry);
if (roundN === 1) { activeCarry = new Set(); currentCarry = null; }
const result = analyzeRoundWithCarry(roundN, r, activeCarry, currentCarry);
reports.push(result.report);
activeCarry = result.activeAfter;
currentCarry = result.currentAfter;
}
return reports;
}
function analyzeRoundWithCarry(roundN, r, activeAtStart) {
// When current participant is deactivated/removed, code advances current to
// next active. That target gets the turn pointer = acts. Parser can't see
// roster/order from stdout, so on deact-current the NEXT turn actor is the
// advance target and is credited an extra "pointer turn" (not a logged turn).
function analyzeRoundWithCarry(roundN, r, activeAtStart, currentAtStart) {
// activeAtStart: Set copy. Mutations during round adjust a working copy.
const active = new Set(activeAtStart);
const activeWholeRound = new Set(activeAtStart); // participants never toggled off/removed
const addedThisRound = new Set();
const turns = []; // ordered actor names
const turns = []; // ordered actor names (logged)
const pointerTurns = new Set(); // names that got the turn pointer this round
let current = currentAtStart; // current participant name (carry)
for (const ev of r.events) {
if (ev.kind === 'turn') {
turns.push(ev.actor);
pointerTurns.add(ev.actor);
if (!active.has(ev.actor)) active.add(ev.actor); // first-ever sighting
current = ev.actor;
} else if (ev.kind === 'pointer') {
// mutation advanced current pointer: ev.to now holds it = got the turn.
// Credit ev.to. Update tracking.
pointerTurns.add(ev.to);
current = ev.to;
} else if (ev.kind === 'deactivate' || ev.kind === 'remove') {
active.delete(ev.name);
// deact/REMOVE of current → code auto-advances (emitted as pointer line).
// Disqualify from whole-round (roster mutation = not "whole round").
activeWholeRound.delete(ev.name);
active.delete(ev.name);
} else if (ev.kind === 'reactivate' || ev.kind === 'add') {
activeWholeRound.delete(ev.name);
active.add(ev.name);
if (ev.kind === 'add') addedThisRound.add(ev.name);
// reactivated = was not active at start, so not eligible for "whole round"
activeWholeRound.add(ev.name); // gives benefit of doubt; refined below
}
}
// acted = unique names that took a turn this round
const acted = new Set(turns);
// acted = names that took a turn OR got pointer via mutation-advance
// (deact/remove of current advances to target — that target acts).
// Pointer lines from replay tell us the target explicitly.
const acted = new Set([...turns, ...pointerTurns]);
// double-acts: turns with count > 1
// double-acts: logged turns with count > 1 (pointer-credits excluded —
// a deact-advance target acting once via pointer then once via nextTurn
// is correct, not a bug).
const counts = {};
for (const n of turns) counts[n] = (counts[n] || 0) + 1;
const doubleActs = Object.entries(counts).filter(([_, c]) => c > 1).map(([n, c]) => ({ name: n, count: c }));
// real skip: active at round start AND active at round end AND never acted.
// (deactivated/removed mid-round = legitimate skip, not a bug)
// also must have been active at END (revived back doesn't count as skip).
// Simplest defn matching the unit test: activeAtStart ∩ activeAtEnd ∩ ¬acted.
const activeAtEnd = active;
const realSkips = [...activeAtStart]
.filter(n => activeAtEnd.has(n) && !acted.has(n));
// real skip: active for WHOLE round (no roster mutation) AND never got
// turn/pointer. Mutations disqualify from whole-round already.
const realSkips = [...activeWholeRound].filter(n => !acted.has(n));
return {
report: {
@@ -168,7 +193,8 @@ function analyzeRoundWithCarry(roundN, r, activeAtStart) {
doubleActs,
turns,
},
activeAfter: activeAtEnd,
activeAfter: active,
currentAfter: current,
};
}