494327ff17
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).
244 lines
10 KiB
JavaScript
244 lines
10 KiB
JavaScript
// scripts/analyze-turns.js
|
|
// Ingest replay-combat.js stdout (or any text matching its format), reconstruct
|
|
// rounds, report real skips + double-acts. Deterministic — no eyeballing.
|
|
//
|
|
// Usage:
|
|
// node scripts/analyze-turns.js [path] # analyze a saved log file
|
|
// node scripts/replay-combat.js 100 100 | node scripts/analyze-turns.js
|
|
// cat /tmp/replay.log | node scripts/analyze-turns.js
|
|
//
|
|
// Skip = participant active for WHOLE round (never deactivated/removed mid-round
|
|
// before their slot, never added mid-round) but never appeared as a turn actor.
|
|
// Double-act = same participant takes 2+ turns in one round.
|
|
//
|
|
// FEAT-2 (structured turn snapshot in app logs) will let this ingest live app
|
|
// logs too, not just replay stdout. Format-agnostic core lives in parseReplay().
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
|
|
// ---------- parsing ----------
|
|
|
|
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*$/;
|
|
const REMOVE_RE = /^\s*\[(?:remove dead|remove)\s+(.+?)\]\s*$/;
|
|
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)) {
|
|
const m = line.match(TURN_RE);
|
|
return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim() };
|
|
}
|
|
if (FIRST_RE.test(line)) {
|
|
const m = line.match(FIRST_RE);
|
|
return { kind: 'turn', turn: 0, round: 1, actor: m[1].trim() };
|
|
}
|
|
if (DEACTIVATE_RE.test(line)) return { kind: 'deactivate', name: line.match(DEACTIVATE_RE)[1].trim() };
|
|
if (REACTIVATE_RE.test(line)) return { kind: 'reactivate', name: line.match(REACTIVATE_RE)[1].trim() };
|
|
if (ADD_RE.test(line)) return { kind: 'add', name: line.match(ADD_RE)[1].trim() };
|
|
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;
|
|
}
|
|
|
|
// ---------- reconstruction ----------
|
|
|
|
// Build per-round timeline: round -> { turns: [actor], mutations: [{stepIdx,...}] }
|
|
// Then compute skips + double-acts.
|
|
function reconstruct(events) {
|
|
// global state: active set by name. Start populated lazily from first turn.
|
|
const active = new Set();
|
|
const rounds = new Map(); // round -> { turns: [name], events: [{...}] }
|
|
let curRound = 1;
|
|
let sawFirstTurn = false;
|
|
|
|
for (const ev of events) {
|
|
if (ev.kind === 'turn') {
|
|
sawFirstTurn = true;
|
|
curRound = ev.round;
|
|
if (!rounds.has(curRound)) rounds.set(curRound, { turns: [], events: [], complete: false });
|
|
const r = rounds.get(curRound);
|
|
r.turns.push(ev.actor);
|
|
r.events.push({ ...ev, idx: r.events.length });
|
|
if (!active.has(ev.actor)) active.add(ev.actor); // first sighting = active
|
|
} else if (ev.kind === 'deactivate') {
|
|
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 === 'reactivate' || ev.kind === 'add') {
|
|
active.add(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 === 'remove') {
|
|
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;
|
|
}
|
|
// pause/resume: rotation-affecting but no roster change; tracked in events
|
|
else if (ev.kind === 'pause' || ev.kind === 'resume') {
|
|
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
|
r.events.push({ ...ev, idx: r.events.length });
|
|
}
|
|
}
|
|
return rounds;
|
|
}
|
|
|
|
// For each round, recompute active-at-start and acted, then find real skips.
|
|
function analyze(rounds) {
|
|
const report = [];
|
|
for (const [roundN, r] of [...rounds.entries()].sort((a, b) => a[0] - b[0])) {
|
|
// Replay stdout doesn't dump roster, so infer "active at round start":
|
|
// walk events IN ORDER, snapshot active set at first turn of this round.
|
|
// We replay from a clean per-round pass using a carry-over active set.
|
|
report.push(analyzeRound(roundN, r));
|
|
}
|
|
return report;
|
|
}
|
|
|
|
// Re-run per-round with active-set carry-over across rounds (module scope).
|
|
function analyzeRounds(rounds) {
|
|
// 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(); currentCarry = null; }
|
|
const result = analyzeRoundWithCarry(roundN, r, activeCarry, currentCarry);
|
|
reports.push(result.report);
|
|
activeCarry = result.activeAfter;
|
|
currentCarry = result.currentAfter;
|
|
}
|
|
return reports;
|
|
}
|
|
|
|
// 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 (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') {
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// 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: 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 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: {
|
|
round: roundN,
|
|
turnCount: turns.length,
|
|
uniqueActors: acted.size,
|
|
realSkips,
|
|
doubleActs,
|
|
turns,
|
|
},
|
|
activeAfter: active,
|
|
currentAfter: current,
|
|
};
|
|
}
|
|
|
|
// ---------- CLI ----------
|
|
|
|
function readInput() {
|
|
const arg = process.argv[2];
|
|
if (arg) return fs.readFileSync(arg, 'utf8');
|
|
// stdin
|
|
return fs.readFileSync(0, 'utf8');
|
|
}
|
|
|
|
function main() {
|
|
const text = readInput();
|
|
const lines = text.split('\n');
|
|
const events = lines.map(parseLine).filter(Boolean);
|
|
const rounds = reconstruct(events);
|
|
const reports = analyzeRounds(rounds);
|
|
|
|
let totalSkips = 0;
|
|
let totalDoubles = 0;
|
|
const problemRounds = [];
|
|
|
|
for (const rep of reports) {
|
|
const hasIssue = rep.realSkips.length > 0 || rep.doubleActs.length > 0;
|
|
if (hasIssue) problemRounds.push(rep);
|
|
totalSkips += rep.realSkips.length;
|
|
totalDoubles += rep.doubleActs.length;
|
|
}
|
|
|
|
for (const rep of problemRounds) {
|
|
console.log(`R${rep.round}: turns=${rep.turnCount} unique=${rep.uniqueActors}`);
|
|
if (rep.realSkips.length) console.log(` REAL SKIPS: ${rep.realSkips.join(', ')}`);
|
|
if (rep.doubleActs.length) console.log(` DOUBLE-ACTS: ${rep.doubleActs.map(d => `${d.name}(${d.count}x)`).join(', ')}`);
|
|
console.log(` sequence: ${rep.turns.join(' -> ')}`);
|
|
}
|
|
|
|
console.log(`\n=== ${reports.length} rounds analyzed ===`);
|
|
console.log(`real skips: ${totalSkips}`);
|
|
console.log(`double-acts: ${totalDoubles}`);
|
|
console.log(totalSkips === 0 && totalDoubles === 0 ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND');
|
|
|
|
process.exit(totalSkips === 0 && totalDoubles === 0 ? 0 : 1);
|
|
}
|
|
|
|
main();
|