// 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*order=\[(.*)\](?:\s*cur=.*)?)?\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 REORDER_RE = /^\s*\[reorder\s+(.+?)→before\s+(.+?)\]\s*$/; const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/; function parseLine(line) { if (TURN_RE.test(line)) { const m = line.match(TURN_RE); const orderStr = m[4] || ''; // parse Name:init pairs const order = orderStr.split(',').map(s => s.trim()).filter(Boolean).map(pair => { const [name, init] = pair.split(':'); return { name: name.trim(), init: init !== undefined ? +init : null }; }); return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim(), order }; } 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 (REORDER_RE.test(line)) { const m = line.match(REORDER_RE); return { kind: 'reorder', dragged: m[1].trim(), target: m[2].trim() }; } 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 === 'reorder') { 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, }; } // ---------- order-shift detection ---------- // Compare order+init between consecutive turn lines. Flag shifts NOT explained // by: logged reorder, add/remove (roster change), or initiative change. // DM drag-reorder = legit (logged reorder line). Phantom shifts = display/rotation // divergence bug (invariant: display === turnOrderIds === nextTurn). function detectOrderShifts(events) { const shifts = []; let prev = null; let prevTurnNo = null; // mutations since last turn (reorder/add/remove/reactivate/pointer) let pending = []; let initMap = {}; // name -> last known initiative for (const ev of events) { if (ev.kind === 'turn' && ev.order && ev.order.length) { const curNames = ev.order.map(o => o.name); const curInits = {}; ev.order.forEach(o => { curInits[o.name] = o.init; }); if (prev) { const sameRoster = prev.length === curNames.length && prev.every((n, i) => n === curNames[i]); if (!sameRoster) { // roster change (add/remove) — skip, expected order shift } else { // same roster, different order → explainable by reorder OR init change? const orderChanged = JSON.stringify(prev) !== JSON.stringify(curNames); const initChanged = ev.order.some(o => initMap[o.name] !== null && initMap[o.name] !== undefined && initMap[o.name] !== o.init); const hasReorder = pending.some(p => p.kind === 'reorder'); if (orderChanged && !hasReorder && !initChanged) { shifts.push({ turn: ev.turn, from: prev, to: curNames, reason: 'no logged reorder/init change' }); } } } prev = curNames; curInits && Object.keys(curInits).forEach(k => { initMap[k] = curInits[k]; }); pending = []; prevTurnNo = ev.turn; } else if (ev.kind === 'reorder' || ev.kind === 'add' || ev.kind === 'remove' || ev.kind === 'reactivate' || ev.kind === 'pointer') { pending.push(ev); } } return shifts; } // ---------- 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(' -> ')}`); } // order-shift detection: flag unexplained display/rotation divergence const shifts = detectOrderShifts(events); if (shifts.length) { console.log(`\n--- order shifts (${shifts.length}) ---`); for (const s of shifts.slice(0, 10)) { console.log(` turn ${s.turn}: [${s.from.join(',')}] → [${s.to.join(',')}] (${s.reason})`); } if (shifts.length > 10) console.log(` ... +${shifts.length - 10} more`); } console.log(`\n=== ${reports.length} rounds analyzed ===`); console.log(`real skips: ${totalSkips}`); console.log(`double-acts: ${totalDoubles}`); console.log(`order shifts: ${shifts.length}`); const clean = totalSkips === 0 && totalDoubles === 0 && shifts.length === 0; console.log(clean ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND'); process.exit(clean ? 0 : 1); } main();