// scripts/analyze-turns.js // Invariant checker for combat rotation. Source-agnostic. // // Input (autodetect): // .jsonl file — replay-combat trace: per-step {step,ts,type,call:{fn,args}, // pre,post}. pre/post = backend read-back snapshots. // .json array — downloaded log OR exported events. {ts,type,...,snapshot}. // snapshot = what turn.js logged (lighter: no participants[]). // .log file — replay stdout: extract trace path from 'trace written:' line. // stdin — either jsonl or json. // no arg = usage. User must specify path. // // INVARIANTS (define correctness; no prediction): // 1. round monotonically ascends; +1 only on pointer wrap (last→first active). // No backward, no double-increment, no skip. // 2. pointer advances forward in turnOrderIds (mod wrap), skipping inactive. // Never backward, never stationary except on pause/non-rotation mutations. // 3. no double-act: in one rotation cycle each active participant becomes // current ≤1 time. // 4. no real skip: participant active for full cycle, never removed/deactivated, // but never became current = skipped. // 5. order stable across non-reorder mutations. turnOrderIds shift without // add/remove/reorder = display divergence. // 6. slot order (initiative desc, tie-break stable) maintained except after // explicit reorder. Replay-trace only (needs participants[].initiative). // // Exit 0 clean, 1 issues found. 'use strict'; const fs = require('fs'); // ---------- input ---------- function readInput() { const arg = process.argv[2]; // No arg + no stdin = usage. if (!arg && process.stdin.isTTY) { console.error('Usage: node scripts/analyze-turns.js '); console.error(' cat events | node scripts/analyze-turns.js'); process.exit(2); } if (!arg) { const stdin = fs.readFileSync(0, 'utf8'); if (!stdin.trim()) { console.error('Usage: node scripts/analyze-turns.js '); console.error(' cat events | node scripts/analyze-turns.js'); process.exit(2); } return stdin; } // Replay stdout .log: extract trace path from `trace written:` line. if (/\.log$/i.test(arg)) { const logText = fs.readFileSync(arg, 'utf8'); const m = logText.match(/trace written: \d+ steps -> (.+)$/m); if (m) { const tracePath = m[1].trim(); if (fs.existsSync(tracePath)) { console.error(`[analyze] trace: ${tracePath}`); return fs.readFileSync(tracePath, 'utf8'); } console.error(`[analyze] trace path from .log not found: ${tracePath}`); process.exit(2); } console.error(`[analyze] no 'trace written:' line in ${arg}`); process.exit(2); } return fs.readFileSync(arg, 'utf8'); } // snake_case log type → camelCase fn (invariant checks match both shapes). // replay JSONL already camelCase; unchanged. function normalizeFn(fn) { if (!fn) return fn; if (!fn.includes('_')) return fn; return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); } // Parse input text → array of step-arrays (one per encounter for downloaded // logs, one for replay JSONL trace). Each step: // { step, ts, type, fn, args, pre, post, error } // JSONL: pre/post from backend read-back. JSON array: post=snapshot, no pre. function loadSteps(text) { const trimmed = text.trim(); if (!trimmed) return []; // JSONL: one JSON obj per line (each starts '{' + parses standalone). const looksJsonl = (() => { const lines = trimmed.split('\n'); if (lines.length < 2) return false; const first = lines[0].trim(); const second = lines[1].trim(); if (!first.startsWith('{') || !second.startsWith('{')) return false; try { JSON.parse(first); JSON.parse(second); return true; } catch { return false; } })(); if (looksJsonl) { const steps = trimmed.split('\n').filter(l => l.trim()).map(l => { const r = JSON.parse(l); return { step: r.step, ts: r.ts, type: r.type, fn: normalizeFn(r.call ? r.call.fn : r.type), args: r.call ? r.call.args : null, pre: r.pre || null, post: r.post || null, error: r.error || null, }; }); return [steps]; // single trace } // JSON array. Downloaded logs may merge multiple encounters → split by id. const raw = JSON.parse(trimmed); const arr = Array.isArray(raw) ? raw : [raw]; const groups = new Map(); // encounterId -> [] for (const e of arr) { const key = e.encounterId || '_none_'; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(e); } const out = []; const toSteps = (evs) => evs.map((e, i) => ({ step: i + 1, ts: e.ts || 0, type: e.type, fn: normalizeFn(e.type), args: null, pre: null, post: e.snapshot ? { round: e.snapshot.round, currentTurnParticipantId: e.snapshot.currentTurnParticipantId, isStarted: true, isPaused: false, turnOrderIds: e.snapshot.turnOrderIds || [], activeIds: e.snapshot.activeIds || [], participants: null, // downloaded logs lack full participant roster } : null, error: null, })); for (const evs of groups.values()) { // Same encounterId may span multiple combat runs (restart via // start_encounter). Sub-split so each rotation cycle analyzed within one // continuous run. start_encounter = run boundary — BUT only flush if a // prior run already started (i.e. true restart). First start_encounter // after pure setup (add_participant etc.) stays with its setup events. let cur = []; let started = false; const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; }; for (const e of evs) { if (e.type === 'start_encounter' && started) flush(); if (e.type === 'start_encounter') started = true; cur.push(e); } flush(); } return out; } // ---------- helpers ---------- const nameMap = new Map(); // id -> name (built lazily from snapshots) function learnNames(steps) { for (const s of steps) { for (const snap of [s.pre, s.post]) { if (snap && Array.isArray(snap.participants)) { for (const p of snap.participants) if (p.id && p.name) nameMap.set(p.id, p.name); } } } } function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; } // next active position after fromPos in order, skipping inactive. Mirrors // turn.js nextActiveAfter so we know what SHOULD have happened — but this is // invariant definition, not prediction: we check the ACTUAL post-current. function expectedAdvance(order, fromPos, isActive) { const n = order.length; if (n === 0) return { nextId: null, wrapped: false }; for (let step = 1; step < n; step++) { const idx = (fromPos + step) % n; const id = order[idx]; if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos }; } // solo active = stays itself (turn.js would throw; treat as no-advance) return { nextId: null, wrapped: false }; } // ---------- invariant checks ---------- // Split analysis into independent passes. Each invariant = own function. // Entangling cycle-skip tracking with per-step mutation handling caused // stale-set false positives (active-set rebuilt on every roster mutation // discarded the cycle-start snapshot). function analyze(steps) { const issues = []; const rounds = new Map(); function ensureRound(r) { if (!rounds.has(r)) rounds.set(r, { turnCount: 0, issues: [] }); return rounds.get(r); } // ---- per-step: round monotonic, advance direction, order stability ---- for (let i = 0; i < steps.length; i++) { const s = steps[i]; const pre = s.pre || (i > 0 ? steps[i - 1].post : null); const post = s.post; if (!post) continue; const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn'; const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter'; const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end'; if (isStart) { ensureRound(post.round || 1).turnCount++; continue; } if (isEnd) continue; if (isNextTurn) { ensureRound(post.round || 0).turnCount++; if (!pre) continue; const order = pre.turnOrderIds || []; const fromPos = order.indexOf(pre.currentTurnParticipantId); const isActive = id => (pre.activeIds || []).includes(id); const exp = expectedAdvance(order, fromPos, isActive); const actual = post.currentTurnParticipantId; // invariant 2: correct advance target if (exp.nextId && actual && actual !== exp.nextId) { issues.push({ step: s.step, round: post.round, kind: 'wrong_advance', expected: nm(exp.nextId), actual: nm(actual), detail: `nextTurn → ${nm(actual)}, expected ${nm(exp.nextId)}` }); } // invariant 1: round monotonic + no phantom/skip if (pre.round !== undefined && post.round !== undefined) { if (post.round < pre.round) issues.push({ step: s.step, kind: 'round_backward', from: pre.round, to: post.round, detail: `round backward ${pre.round}→${post.round}` }); if (post.round > pre.round + 1) issues.push({ step: s.step, kind: 'round_skip', from: pre.round, to: post.round, detail: `round jumped ${pre.round}→${post.round}` }); if (post.round === pre.round + 1 && !exp.wrapped) issues.push({ step: s.step, kind: 'round_phantom', from: pre.round, to: post.round, detail: `round incremented without pointer wrap` }); } continue; } // non-rotation mutation: invariant 5 order stability if (pre && post && !orderChangedByRosterOrReorder(s.fn)) { const before = JSON.stringify(pre.turnOrderIds || []); const after = JSON.stringify(post.turnOrderIds || []); if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) { issues.push({ step: s.step, kind: 'order_shift', fn: s.fn, detail: `turnOrderIds changed without add/remove/reorder (${s.fn})` }); } } } // ---- dedicated cycle pass: skip + double-act (invariants 3+4) ---- // Cycle = all-act-once between pointer wraps. Snapshot active-set at cycle // start. Track removals/deactivations as legitimate disqualifications. // Skip = in start-set, never disqualified, never acted. issues.push(...checkCycles(steps)); return { issues, rounds }; } // checkCycles: walk steps, maintain rotation cycle state. On wrap/end, // finalize: skip = activeAtStart minus (acted ∪ disqualified). Double-act = // current that became current >1 in cycle (excluding the legit starter). function checkCycles(steps) { const out = []; let cycleActive = new Set(); // snapshot at cycle start (immutable for cycle) let cycleActed = new Set(); let cycleRemoved = new Set(); // removed/disqualified mid-cycle (no skip flag) let cycleStarter = null; let cycleRound = null; let started = false; // disqualify by active-set delta, not fn name. Log types vary (deactivate, // reactivate, remove_participant, add_participant...). Any step where an id // leaves activeIds = disqualified. Any id entering = joins cycle. function finalize(endStep) { if (!started) return; // disqualify: anyone removed/deactivated mid-cycle is gone (legit) // skip = was active at start, never acted, never disqualified const skipped = [...cycleActive].filter(id => !cycleActed.has(id)); if (skipped.length) { out.push({ step: endStep, round: cycleRound, kind: 'real_skip', actors: skipped.map(nm), detail: `active full cycle, never acted` }); } } for (let i = 0; i < steps.length; i++) { const s = steps[i]; const pre = s.pre || (i > 0 ? steps[i - 1].post : null); const post = s.post; if (!post) continue; const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn'; const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter'; const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end'; if (isStart) { finalize(s.step); cycleRound = post.round || 1; cycleActive = new Set(post.activeIds || []); cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []); cycleStarter = post.currentTurnParticipantId; started = true; continue; } if (isEnd) { started = false; continue; } // end: abandon cycle, no skip verdict (incomplete) // CRITICAL: pointer (currentTurnParticipantId) changes via BOTH nextTurn // AND mutation-advance (toggleActive/remove of current auto-advances via // computeTurnOrderAfterRemoval). Any new current = got the turn = acted. // Only count this on nextTurn (normal) or when a mutation actually moved // the pointer (pre.current != post.current). if (started && pre && post.currentTurnParticipantId && pre.currentTurnParticipantId !== post.currentTurnParticipantId) { const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round; if (wrapped && isNextTurn) { finalize(s.step); cycleRound = post.round; cycleActive = new Set(post.activeIds || []); cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []); cycleStarter = post.currentTurnParticipantId; } else { const c = post.currentTurnParticipantId; if (cycleActed.has(c) && c !== cycleStarter) { out.push({ step: s.step, round: post.round, kind: 'double_act', actor: nm(c), detail: `${nm(c)} acted twice in round ${post.round} (via ${s.fn})` }); } cycleActed.add(c); } } if (isNextTurn) continue; // roster mutation mid-cycle: cycleActive = cycle-start snapshot (immutable). // invariant 4 = active FULL cycle → only removals disqualify (can't have // been full-cycle if removed). Mid-cycle additions don't qualify for skip // check, so never enroll them. Revivals: stay out (can act, not skip-flag). if (pre && post && pre.activeIds && post.activeIds) { const postSet = new Set(post.activeIds); for (const id of [...cycleActive]) { if (!postSet.has(id)) { cycleRemoved.add(id); cycleActive.delete(id); cycleActed.delete(id); } } // no mid-cycle enrollment: new ids weren't active at cycle start. } } // no final finalize: incomplete cycle can't be judged for skips. return out; } // roster/order-affecting fns where turnOrderIds change is EXPECTED. // Handle both camelCase (replay trace fn) + snake_case (log type). function orderChangedByRosterOrReorder(fn) { return [ 'addParticipant','addParticipants','removeParticipant','reorderParticipants', 'startEncounter','endEncounter','setup_encounter','setup_campaign', 'add_participant','add_participants','remove_participant','reorder', 'start_encounter','end_encounter', ].includes(fn); } // slot order (invariant 6) — replay trace only (needs participants[].initiative) function checkSlotOrder(steps) { const violations = []; let prevOrder = null; // [{id,init}] let prevStep = 0; const orderAffecting = new Set(['addParticipant','addParticipants','removeParticipant', 'reorderParticipants','startEncounter','setup_encounter','setup_campaign']); for (const s of steps) { if (!s.post || !Array.isArray(s.post.participants)) continue; const cur = s.post.participants.map(p => ({ id: p.id, init: p.initiative, name: p.name })); if (prevOrder && prevOrder.length === cur.length) { const sameIds = prevOrder.every((p, i) => p.id === cur[i].id); if (sameIds) { // same roster, same order — check initiative monotonic desc with stable ties for (let i = 1; i < cur.length; i++) { if (cur[i].initiative > cur[i - 1].initiative) { // initiative ascended — only ok if a reorder happened if (!orderAffecting.has(s.fn)) { violations.push({ step: s.step, kind: 'slot_violation', at: i, prev: nm(cur[i-1].id)+':'+cur[i-1].init, cur: nm(cur[i].id)+':'+cur[i].init, detail: `initiative ascended without reorder` }); } } } } } prevOrder = cur; prevStep = s.step; } return violations; } // ---------- reporting ---------- function reportOne(label, steps) { learnNames(steps); const { issues, rounds } = analyze(steps); const slotViolations = checkSlotOrder(steps); const all = [...issues, ...slotViolations].sort((a, b) => (a.step || 0) - (b.step || 0)); const byKind = {}; for (const it of all) byKind[it.kind] = (byKind[it.kind] || 0) + 1; console.log(`=== ${label} — ${steps.length} steps, ${rounds.size} rounds ===`); if (all.length === 0) { console.log('CLEAN'); return 0; } console.log(`--- ${all.length} issues ---`); for (const k of Object.keys(byKind)) console.log(` ${k}: ${byKind[k]}`); for (const it of all.slice(0, 30)) { const where = it.round != null ? `R${it.round} ` : ''; console.log(` step ${it.step} ${where}${it.kind}: ${it.detail || ''}`); } if (all.length > 30) console.log(` ... +${all.length - 30} more`); return all.length; } const text = readInput(); const allSteps = loadSteps(text); // array of step-arrays (one per encounter) let total = 0; for (let i = 0; i < allSteps.length; i++) { const label = allSteps.length > 1 ? `[encounter ${i + 1}/${allSteps.length}]` : 'trace'; if (i > 0) console.log(''); total += reportOne(label, allSteps[i]); } console.log(`\n=== ${allSteps.length} source(s), ${total} total issues ===`); process.exit(total === 0 ? 0 : 1);