2026-07-01 11:42:43 -04:00
|
|
|
// 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 ----------
|
|
|
|
|
|
2026-07-01 14:22:02 -04:00
|
|
|
const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)(?:\s*\|.*)?\s*$/;
|
2026-07-01 11:42:43 -04:00
|
|
|
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*$/;
|
2026-07-01 14:22:02 -04:00
|
|
|
const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/;
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
|
|
|
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' };
|
2026-07-01 14:22:02 -04:00
|
|
|
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' };
|
|
|
|
|
}
|
2026-07-01 11:42:43 -04:00
|
|
|
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 });
|
2026-07-01 14:22:02 -04:00
|
|
|
} 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 });
|
2026-07-01 11:42:43 -04:00
|
|
|
} 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) {
|
2026-07-01 14:22:02 -04:00
|
|
|
// Carry active set + current-name forward round to round.
|
2026-07-01 11:42:43 -04:00
|
|
|
let activeCarry = new Set();
|
2026-07-01 14:22:02 -04:00
|
|
|
let currentCarry = null;
|
2026-07-01 11:42:43 -04:00
|
|
|
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
|
2026-07-01 14:22:02 -04:00
|
|
|
if (roundN === 1) { activeCarry = new Set(); currentCarry = null; }
|
|
|
|
|
const result = analyzeRoundWithCarry(roundN, r, activeCarry, currentCarry);
|
2026-07-01 11:42:43 -04:00
|
|
|
reports.push(result.report);
|
|
|
|
|
activeCarry = result.activeAfter;
|
2026-07-01 14:22:02 -04:00
|
|
|
currentCarry = result.currentAfter;
|
2026-07-01 11:42:43 -04:00
|
|
|
}
|
|
|
|
|
return reports;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 14:22:02 -04:00
|
|
|
// 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) {
|
2026-07-01 11:42:43 -04:00
|
|
|
// 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();
|
2026-07-01 14:22:02 -04:00
|
|
|
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)
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
|
|
|
for (const ev of r.events) {
|
|
|
|
|
if (ev.kind === 'turn') {
|
|
|
|
|
turns.push(ev.actor);
|
2026-07-01 14:22:02 -04:00
|
|
|
pointerTurns.add(ev.actor);
|
2026-07-01 11:42:43 -04:00
|
|
|
if (!active.has(ev.actor)) active.add(ev.actor); // first-ever sighting
|
2026-07-01 14:22:02 -04:00
|
|
|
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;
|
2026-07-01 11:42:43 -04:00
|
|
|
} else if (ev.kind === 'deactivate' || ev.kind === 'remove') {
|
2026-07-01 14:22:02 -04:00
|
|
|
// deact/REMOVE of current → code auto-advances (emitted as pointer line).
|
|
|
|
|
// Disqualify from whole-round (roster mutation = not "whole round").
|
2026-07-01 11:42:43 -04:00
|
|
|
activeWholeRound.delete(ev.name);
|
2026-07-01 14:22:02 -04:00
|
|
|
active.delete(ev.name);
|
2026-07-01 11:42:43 -04:00
|
|
|
} else if (ev.kind === 'reactivate' || ev.kind === 'add') {
|
2026-07-01 14:22:02 -04:00
|
|
|
activeWholeRound.delete(ev.name);
|
2026-07-01 11:42:43 -04:00
|
|
|
active.add(ev.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 14:22:02 -04:00
|
|
|
// 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]);
|
2026-07-01 11:42:43 -04:00
|
|
|
|
2026-07-01 14:22:02 -04:00
|
|
|
// 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).
|
2026-07-01 11:42:43 -04:00
|
|
|
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 }));
|
|
|
|
|
|
2026-07-01 14:22:02 -04:00
|
|
|
// 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));
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
report: {
|
|
|
|
|
round: roundN,
|
|
|
|
|
turnCount: turns.length,
|
|
|
|
|
uniqueActors: acted.size,
|
|
|
|
|
realSkips,
|
|
|
|
|
doubleActs,
|
|
|
|
|
turns,
|
|
|
|
|
},
|
2026-07-01 14:22:02 -04:00
|
|
|
activeAfter: active,
|
|
|
|
|
currentAfter: current,
|
2026-07-01 11:42:43 -04:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 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();
|