Files

437 lines
18 KiB
JavaScript
Raw Permalink Normal View History

// 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 <trace.jsonl | logs.json | replay.log>');
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 <trace.jsonl | logs.json | replay.log>');
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);