2026-07-01 11:42:43 -04:00
|
|
|
|
// scripts/analyze-turns.js
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// Invariant checker for combat rotation. Source-agnostic.
|
2026-07-01 11:42:43 -04:00
|
|
|
|
//
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// 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.
|
2026-07-01 11:42:43 -04:00
|
|
|
|
//
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// 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).
|
2026-07-01 11:42:43 -04:00
|
|
|
|
//
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// Exit 0 clean, 1 issues found.
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// ---------- 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);
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-07-01 14:22:02 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-07-01 15:37:56 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
|
|
|
|
|
|
return fs.readFileSync(arg, 'utf8');
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// 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);
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
flush();
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
return out;
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// ---------- 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// 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 };
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// solo active = stays itself (turn.js would throw; treat as no-advance)
|
|
|
|
|
|
return { nextId: null, wrapped: false };
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// ---------- 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);
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// ---- 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;
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
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})` });
|
2026-07-01 15:37:56 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// ---- 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));
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
return { issues, rounds };
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// 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` });
|
|
|
|
|
|
}
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
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.
|
|
|
|
|
|
}
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// 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` });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-01 15:37:56 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
prevOrder = cur;
|
|
|
|
|
|
prevStep = s.step;
|
2026-07-01 15:37:56 -04:00
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
return violations;
|
|
|
|
|
|
}
|
2026-07-01 15:37:56 -04:00
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
// ---------- reporting ----------
|
2026-07-01 11:42:43 -04:00
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
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;
|
2026-07-01 11:42:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
|
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);
|