Files
ttrpg-initiative-tracker/scripts/analyze-turns.js
T
david raistrick 2569cc4497 Builds replayable combat logs with first-class undo/redo, unified verification tooling, fast indexed log queries, and stricter CI.
feat(combat): add first-class undo and redo controls

Add undo and redo controls to the combat UI so the DM can recover from recent
actions without leaving the encounter view.

Undo and redo operate on the current encounter's combat history. Empty stacks
produce clear feedback instead of failing silently. Redo order follows normal
stack behavior after multiple undos.

This makes combat history actionable during play, not just visible in the log.

feat(logs): make combat logs replayable

Replace plain combat log messages with structured combat events that can be
used by the UI, exported as JSON, replayed, and verified.

Each new log entry records the action type, encounter identity, participant
identity, a small action delta, undo intent, and a turn snapshot. Download and
copy now export the event stream as JSON so a saved combat log is useful for
offline analysis and debugging.

Legacy logs remain viewable, but new logs use the structured event format.

feat(logs): make undo and redo transactional

Apply undo and redo as single storage operations so the encounter state and log
state cannot drift apart.

Server storage applies the encounter update and the log undone flag inside one
SQLite transaction. Firebase storage uses a batch write for the same behavior.
The storage contract now includes undo/redo semantics.

This replaces fragile multi-write undo behavior where a failure could update
the encounter without marking the log, or mark the log without updating the
encounter.

feat(combat): add unified replay and verification tool

Add one combat CLI for replaying live combat and verifying combat logs.

Replay drives the live backend through the same shared combat logic used by the
app, writes a JSON event log to an explicit output path, and automatically
verifies the result. Verification checks for DM-visible combat problems such as
skipped turns, double actions, bad round changes, and unexpected turn order
changes.

The tool uses the same JSON event stream produced by log downloads, supports
verbose turn output, and handles Ctrl-C by ending the encounter, writing the
partial log, and verifying what was captured.

fix(perf): keep long combat logging fast

Remove the combat-time log query bottleneck that made long replays slow as log
volume grew.

Combat controls no longer subscribe to the log collection just to keep undo and
redo state warm. Undo and redo now query the latest matching encounter log only
when clicked. Server collection queries support exact filters, ordering,
limits, and offsets, and SQLite indexes keep latest-log and per-encounter log
lookups fast.

Also fix duplicate WebSocket handler registration so realtime updates do not
double-fire under write load.

fix(turns): make toggle active a status change

Make toggle active a roster/status edit instead of a turn advance.

Deactivating the current participant no longer passes the turn or increments
the round. The current turn stays where it is until the DM explicitly clicks
Next Turn, and Next Turn skips inactive participants during normal rotation.

This matches the initiative design: slot order is stable, toggle active does
not move participants, and round changes only come from explicit turn advance.

chore(ci): make warnings and hangs fail fast

Tighten test and build checks so failures are visible instead of noisy or
silent.

Builds run with CI enabled so warnings fail production builds. The full test
command runs app, shared, and server suites with hard timeouts so hangs fail
quickly. Static eslint coverage fails on warnings as well as errors.

Tests were updated around the new async combat logging flow, structured log
events, transactional undo, replay verification, and toggle-active semantics.
2026-07-06 10:33:28 -04:00

437 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);