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.
This commit is contained in:
+415
-294
@@ -1,315 +1,436 @@
|
||||
// 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.
|
||||
// Invariant checker for combat rotation. Source-agnostic.
|
||||
//
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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).
|
||||
//
|
||||
// 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().
|
||||
// Exit 0 clean, 1 issues found.
|
||||
|
||||
'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|starting)/;
|
||||
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 ----------
|
||||
// ---------- input ----------
|
||||
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 (!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);
|
||||
}
|
||||
if (shifts.length > 10) console.log(` ... +${shifts.length - 10} more`);
|
||||
return stdin;
|
||||
}
|
||||
|
||||
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');
|
||||
// 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);
|
||||
}
|
||||
|
||||
process.exit(clean ? 0 : 1);
|
||||
return fs.readFileSync(arg, 'utf8');
|
||||
}
|
||||
|
||||
main();
|
||||
// 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);
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/cap.sh — wrap a command with hard timeout. Hang = FAIL.
|
||||
# Picks gtimeout (mac coreutils) or timeout (linux).
|
||||
# Usage: cap.sh <seconds> <cmd> [args...]
|
||||
set -euo pipefail
|
||||
|
||||
TIMEOUT_BIN=gtimeout
|
||||
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
|
||||
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
|
||||
echo "ERROR: need 'timeout' or 'gtimeout'" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
secs="$1"; shift
|
||||
exec "$TIMEOUT_BIN" --signal=KILL "$secs" "$@"
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/combat.js
|
||||
// ONE tool. Replaces replay-combat.js + analyze-turns.js + replay-from-logs.js.
|
||||
//
|
||||
// Two modes (same binary):
|
||||
//
|
||||
// combat replay — drive a fresh combat through LIVE backend, write log
|
||||
// combat verify — read any log file, check combat rotated correctly
|
||||
//
|
||||
// WHY ONE TOOL:
|
||||
// Same data, same words. Old 3-tool split = format + naming nightmare.
|
||||
// Replay writes the log, verify reads it. Same log shape both ends.
|
||||
//
|
||||
// LOG FORMAT:
|
||||
// JSON array. Same as app download. One mental model. NOT jsonl.
|
||||
// Each entry = canonical lean event (shared/logEvent.js shape):
|
||||
// { id, ts, type, message, encounterId, encounterName, encounterPath,
|
||||
// participantId, participantName, delta, undo, undone, snapshot }
|
||||
// snapshot = { round, currentTurnParticipantId, turnOrderIds, activeIds }
|
||||
//
|
||||
// USAGE:
|
||||
// combat replay [rounds] [delayMs] --out <log.json> [-v]
|
||||
// combat verify <log.json>
|
||||
// cat events.json | combat verify
|
||||
//
|
||||
// EXIT:
|
||||
// replay: 0 success, 1 error, 2 bad args
|
||||
// verify: 0 all checks pass, 1 bugs found, 2 bad args/no input
|
||||
//
|
||||
// WHAT VERIFY CHECKS (in DM words, not internals):
|
||||
// - Round count go up correctly (no skip, no jump, no backward)
|
||||
// - Turn pass to right person each step
|
||||
// - Nobody act twice in same round
|
||||
// - Nobody get skipped who was active whole round
|
||||
// - Turn order stay stable (no random reshuffle)
|
||||
// - Initiative slot order hold (replay logs only — need full roster)
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const shared = require('../shared');
|
||||
const { normalizeEvent, serializeEvents } = shared.logEvent;
|
||||
|
||||
// =============================================================================
|
||||
// ARGS
|
||||
// =============================================================================
|
||||
|
||||
function parseArgs(argv) {
|
||||
const mode = argv[0];
|
||||
const rest = argv.slice(1);
|
||||
if (mode !== 'replay' && mode !== 'verify') return { mode: null };
|
||||
|
||||
const out = { mode, verbose: false, out: null, positional: [] };
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i];
|
||||
if (a === '-v' || a === '--verbose') { out.verbose = true; continue; }
|
||||
if (a === '--out' && rest[i + 1]) { out.out = rest[i + 1]; i++; continue; }
|
||||
out.positional.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usageReplay() {
|
||||
console.error('Usage: combat replay [rounds] [delayMs] --out <log.json> [-v]');
|
||||
console.error(' --out <log.json> REQUIRED. Path you control.');
|
||||
console.error(' rounds default 20, delayMs default 200');
|
||||
console.error(' -v / --verbose log every action per turn');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function usageVerify() {
|
||||
console.error('Usage: combat verify <log.json>');
|
||||
console.error(' cat events.json | combat verify');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.mode) {
|
||||
console.error('combat — unified replay + verify for the TTRPG combat tracker');
|
||||
console.error('');
|
||||
console.error('Usage:');
|
||||
console.error(' combat replay [rounds] [delayMs] --out <log.json> [-v]');
|
||||
console.error(' combat verify <log.json>');
|
||||
console.error(' cat events.json | combat verify');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REPLAY MODE
|
||||
// =============================================================================
|
||||
// Drive fresh combat through live backend. Each mutation = one lean log entry
|
||||
// written to --out path. DM-facing stdout: round headers + per-turn actions.
|
||||
// Same log shape as app download. verify reads it back.
|
||||
|
||||
if (args.mode === 'replay') {
|
||||
if (!args.out) usageReplay();
|
||||
require('./combat/replay.js')(args).catch(err => {
|
||||
console.error('replay failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VERIFY MODE
|
||||
// =============================================================================
|
||||
// Read a combat log (app download OR combat replay output). Check rotation
|
||||
// correctness. DM-facing output: combat name, rounds, per-round turn list,
|
||||
// checks pass/fail. No "mutation", "pointer", "invariant" in output.
|
||||
|
||||
if (args.mode === 'verify') {
|
||||
const logPath = args.positional[0];
|
||||
let text;
|
||||
if (logPath) {
|
||||
if (!fs.existsSync(logPath)) {
|
||||
console.error(`combat verify: file not found: ${logPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
text = fs.readFileSync(logPath, 'utf8');
|
||||
} else if (!process.stdin.isTTY) {
|
||||
text = fs.readFileSync(0, 'utf8');
|
||||
}
|
||||
if (!text || !text.trim()) usageVerify();
|
||||
|
||||
try {
|
||||
const result = require('./combat/verify.js')(text, { verbose: args.verbose });
|
||||
process.exit(result);
|
||||
} catch (err) {
|
||||
console.error('verify failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// scripts/combat/replay.js
|
||||
// Drive fresh combat through LIVE backend. Write lean log to --out path.
|
||||
// Same log shape as app download (JSON array of canonical events).
|
||||
//
|
||||
// DM-facing stdout: round headers + per-turn actions. No internals leak.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const shared = require('../../shared');
|
||||
const { normalizeEvent } = shared.logEvent;
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause, endEncounter,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants,
|
||||
} = shared;
|
||||
const { createServerStorage } = require('../../src/storage/server');
|
||||
|
||||
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||
|
||||
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||
const PUB = `artifacts/${APP_ID}/public/data`;
|
||||
const getPath = {
|
||||
campaigns: () => `${PUB}/campaigns`,
|
||||
campaign: (id) => `${PUB}/campaigns/${id}`,
|
||||
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
|
||||
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
|
||||
activeDisplay: () => `${PUB}/activeDisplay/status`,
|
||||
logs: () => `${PUB}/logs`,
|
||||
};
|
||||
|
||||
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
function buildRoster() {
|
||||
return [
|
||||
{ name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||
{ name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||
{ name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||
];
|
||||
}
|
||||
function buildMonsters() {
|
||||
return [
|
||||
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
|
||||
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
|
||||
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
|
||||
{ name: 'Wolf', maxHp: 40, initMod: 3 },
|
||||
{ name: 'Merchant', maxHp: 30, initMod: 0, isNpc: true },
|
||||
];
|
||||
}
|
||||
|
||||
const CONDITIONS = [
|
||||
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||
'frightened','grappled','incapacitated','invisible','paralyzed',
|
||||
'petrified','poisoned','prone','restrained','sapped','shield',
|
||||
'slowed','stunned','unconscious','vexed',
|
||||
];
|
||||
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
|
||||
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
|
||||
let condIdx = 0;
|
||||
|
||||
// Lean snapshot — verify needs rotation fields only.
|
||||
function snapshot(enc) {
|
||||
if (!enc) return null;
|
||||
return {
|
||||
round: enc.round ?? 0,
|
||||
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
function nameOf(enc, id) {
|
||||
if (!id || !enc) return '(none)';
|
||||
const p = (enc.participants || []).find(x => x.id === id);
|
||||
return p ? p.name : '(missing)';
|
||||
}
|
||||
|
||||
// resolve participantId + name from call args + encounter state
|
||||
let _encCache = null;
|
||||
function resolveId(a) {
|
||||
if (!a) return null;
|
||||
const name = a.target || a.participant || a.dragged || a.name;
|
||||
if (!name || !_encCache) return null;
|
||||
const p = (_encCache.participants || []).find(x => x.name === name);
|
||||
return p ? p.id : null;
|
||||
}
|
||||
function resolveName(a) {
|
||||
if (!a) return null;
|
||||
return a.target || a.participant || a.dragged || a.name || null;
|
||||
}
|
||||
|
||||
module.exports = async function replay(args) {
|
||||
// In pipelines, Ctrl-C often kills downstream (e.g. timestamper) first.
|
||||
// Then any later console.log hits EPIPE and process dies before cleanup.
|
||||
// Ignore broken output pipe so SIGINT cleanup can still end combat + verify.
|
||||
for (const s of [process.stdout, process.stderr]) {
|
||||
s.on('error', (err) => {
|
||||
if (err && err.code === 'EPIPE') return;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
const ROUNDS = parseInt(args.positional[0], 10);
|
||||
const DELAY = parseInt(args.positional[1], 10);
|
||||
const rounds = Number.isNaN(ROUNDS) ? 20 : ROUNDS;
|
||||
const delay = Number.isNaN(DELAY) ? 200 : DELAY;
|
||||
// Safety only. Roster grows via reinforcements, so fixed rounds*30 was too low
|
||||
// and killed long replays around 6000 events. Keep generous hard stop.
|
||||
const MAX_STEPS = Math.max(rounds * 250, 10000);
|
||||
const VERBOSE = args.verbose;
|
||||
|
||||
const runStamp = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-');
|
||||
const campaignId = crypto.randomUUID();
|
||||
const encounterId = crypto.randomUUID();
|
||||
const encounterPath = getPath.encounter(campaignId, encounterId);
|
||||
const activeDisplayPath = getPath.activeDisplay();
|
||||
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
|
||||
|
||||
// ensure parent dir exists
|
||||
const traceDir = path.dirname(args.out);
|
||||
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
||||
|
||||
const events = []; // collected lean events, flushed to --out at end
|
||||
let stepN = 0;
|
||||
let prevEnc = null;
|
||||
let interrupted = false;
|
||||
let finishing = false;
|
||||
const onSigint = () => {
|
||||
interrupted = true;
|
||||
console.error('\ninterrupted — ending combat and verifying partial log...');
|
||||
finishAndExit().catch(err => {
|
||||
console.error('interrupt cleanup failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
process.once('SIGINT', onSigint);
|
||||
|
||||
// callStep: trust func return (post), no per-step getDoc. Build lean event
|
||||
// shape = same as app download. Pre/post snapshot = ground truth.
|
||||
async function callStep(fn, argsObj, runner) {
|
||||
stepN++;
|
||||
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
|
||||
const preSnap = snapshot(encBefore);
|
||||
let result, threw = null;
|
||||
try { result = await runner(encBefore); }
|
||||
catch (e) { threw = e.message; }
|
||||
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
|
||||
prevEnc = encAfter;
|
||||
_encCache = encAfter;
|
||||
const postSnap = snapshot(encAfter);
|
||||
|
||||
events.push({
|
||||
id: crypto.randomUUID(),
|
||||
ts: Date.now(),
|
||||
type: fn,
|
||||
message: describe(fn, argsObj, encAfter),
|
||||
encounterId,
|
||||
encounterName: `Replay ${runStamp}`,
|
||||
encounterPath,
|
||||
participantId: resolveId(argsObj),
|
||||
participantName: resolveName(argsObj),
|
||||
delta: null,
|
||||
undo: null,
|
||||
undone: false,
|
||||
snapshot: postSnap,
|
||||
_pre: preSnap,
|
||||
_call: { fn, args: argsObj },
|
||||
_error: threw,
|
||||
});
|
||||
return { enc: encAfter, result, threw };
|
||||
}
|
||||
|
||||
async function finishAndExit() {
|
||||
if (finishing) return;
|
||||
finishing = true;
|
||||
process.removeListener('SIGINT', onSigint);
|
||||
|
||||
try {
|
||||
// end encounter, even on Ctrl-C, so live display/app is not left mid-combat
|
||||
const latest = await storage.getDoc(encounterPath);
|
||||
prevEnc = latest;
|
||||
if (latest && latest.isStarted) {
|
||||
await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
|
||||
}
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||
} catch (err) {
|
||||
console.error('cleanup warning:', err.message || err);
|
||||
}
|
||||
|
||||
const log = interrupted ? console.error : console.log;
|
||||
const clean = events.map(({ _pre, _call, _error, ...rest }) => rest);
|
||||
fs.writeFileSync(args.out, JSON.stringify(clean, null, 2));
|
||||
log(`log written: ${events.length} events -> ${args.out}`);
|
||||
log('');
|
||||
|
||||
log('--- verifying ---');
|
||||
const text = fs.readFileSync(args.out, 'utf8');
|
||||
const verify = require('./verify.js');
|
||||
const exit = verify(text, { verbose: VERBOSE, log });
|
||||
process.exit(exit);
|
||||
}
|
||||
|
||||
// human-readable message per action
|
||||
function describe(fn, a, enc) {
|
||||
switch (fn) {
|
||||
case 'setup_campaign': return 'Campaign created';
|
||||
case 'setup_encounter': return 'Encounter created';
|
||||
case 'addParticipant': return `${a.name} joined`;
|
||||
case 'startEncounter': return `Combat started — round 1, ${nameOf(enc, enc.currentTurnParticipantId)} first`;
|
||||
case 'nextTurn': return `Turn passed to ${nameOf(enc, enc.currentTurnParticipantId)}`;
|
||||
case 'applyHpChange':
|
||||
return a.changeType === 'heal'
|
||||
? `${a.target} healed +${a.amount}`
|
||||
: `${a.target} took ${a.amount} damage`;
|
||||
case 'toggleCondition': return `${a.participant} ${a.condition} toggled`;
|
||||
case 'updateParticipant': return `${a.participant} edited`;
|
||||
case 'deathSave': return `${a.participant} death save (${a.type})`;
|
||||
case 'toggleParticipantActive':
|
||||
return a.revive ? `${a.participant} reactivated` : `${a.participant} toggled`;
|
||||
case 'togglePause': return a.to === 'paused' ? 'Combat paused' : 'Combat resumed';
|
||||
case 'removeParticipant': return `${a.participant} removed`;
|
||||
case 'reorderParticipants': return `${a.dragged} moved before ${a.target}`;
|
||||
case 'endEncounter': return 'Combat ended';
|
||||
default: return fn;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`replay: ${rounds} rounds, ${delay}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
|
||||
|
||||
// --- setup ---
|
||||
await callStep('setup_campaign', { campaignId }, async () =>
|
||||
storage.setDoc(getPath.campaign(campaignId), {
|
||||
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
await callStep('setup_encounter', { encounterId }, async () =>
|
||||
storage.setDoc(encounterPath, {
|
||||
id: encounterId, name: `Replay ${runStamp}`, campaignId,
|
||||
participants: [], isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
for (const ch of buildRoster()) {
|
||||
const { participant } = buildCharacterParticipant(ch);
|
||||
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||
addParticipant(enc, participant, ctx));
|
||||
}
|
||||
for (const m of buildMonsters()) {
|
||||
const { participant } = buildMonsterParticipant(m);
|
||||
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||
addParticipant(enc, participant, ctx));
|
||||
}
|
||||
|
||||
// --- start combat ---
|
||||
let enc = await storage.getDoc(encounterPath);
|
||||
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
|
||||
enc = startRes.enc;
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
|
||||
console.log(`--- round ${enc.round} ---`);
|
||||
|
||||
let totalTurns = 0;
|
||||
let lastRound = enc.round;
|
||||
|
||||
// --- main loop: drive by real enc.round ---
|
||||
while (!interrupted && enc.isStarted && enc.round <= rounds && stepN < MAX_STEPS) {
|
||||
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
|
||||
totalTurns++;
|
||||
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
|
||||
|
||||
if (actor) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
|
||||
enc = (await callStep('applyHpChange',
|
||||
{ target: tgt.name, changeType: 'damage', amount: dmg },
|
||||
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx))).enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 5 === 0) {
|
||||
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
|
||||
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
|
||||
enc = (await callStep('toggleCondition',
|
||||
{ participant: actor.name, condition: cond },
|
||||
(e) => toggleCondition(e, actor.id, cond, ctx))).enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 11 === 0) {
|
||||
if (VERBOSE) console.log(` update ${actor.name} notes`);
|
||||
enc = (await callStep('updateParticipant',
|
||||
{ participant: actor.name, fields: ['notes'] },
|
||||
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc;
|
||||
}
|
||||
|
||||
if (actor.currentHp <= 0 && !actor.isNpc) {
|
||||
if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
|
||||
enc = (await callStep('deathSave',
|
||||
{ participant: actor.name, type: 'success', n: 1 },
|
||||
(e) => deathSave(e, actor.id, 'success', 1, ctx))).enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
|
||||
enc = (await callStep('toggleParticipantActive',
|
||||
{ participant: tgt.name },
|
||||
(e) => toggleParticipantActive(e, tgt.id, ctx))).enc;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTurns % 20 === 0 && enc.isPaused === false) {
|
||||
if (VERBOSE) console.log(` reinforcements: pause`);
|
||||
enc = (await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx))).enc;
|
||||
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
|
||||
if (VERBOSE) console.log(` add ${r.participant.name}`);
|
||||
enc = (await callStep('addParticipant',
|
||||
{ name: r.participant.name, reinforcement: true },
|
||||
(e) => addParticipant(e, r.participant, ctx))).enc;
|
||||
if (VERBOSE) console.log(` resume`);
|
||||
enc = (await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx))).enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 13 === 0) {
|
||||
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
|
||||
if (dead) {
|
||||
if (VERBOSE) console.log(` remove ${dead.name}`);
|
||||
enc = (await callStep('removeParticipant',
|
||||
{ participant: dead.name, dead: true },
|
||||
(e) => removeParticipant(e, dead.id, ctx))).enc;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTurns % 17 === 0) {
|
||||
const order = enc.participants || [];
|
||||
if (order.length >= 2) {
|
||||
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
|
||||
if (b) {
|
||||
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
|
||||
enc = (await callStep('reorderParticipants',
|
||||
{ dragged: b.name, target: a.name },
|
||||
(e) => reorderParticipants(e, b.id, a.id, ctx))).enc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (interrupted) break;
|
||||
if (delay > 0) await sleep(delay);
|
||||
if (interrupted) break;
|
||||
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
if (VERBOSE) console.log(` -> nextTurn`);
|
||||
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
|
||||
enc = advRes.enc;
|
||||
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
|
||||
|
||||
// round wrap
|
||||
if (enc.isStarted && enc.round > lastRound) {
|
||||
if (enc.round > rounds) {
|
||||
// cap hit: the nextTurn that wrapped us here is a phantom — remove it
|
||||
// so verify doesn't show an incomplete round.
|
||||
events.pop();
|
||||
stepN--;
|
||||
break;
|
||||
}
|
||||
console.log(`--- round ${enc.round} ---`);
|
||||
lastRound = enc.round;
|
||||
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
if (interrupted) break;
|
||||
if (d.isActive === false) {
|
||||
if (VERBOSE) console.log(` revive ${d.name}`);
|
||||
enc = (await callStep('toggleParticipantActive',
|
||||
{ participant: d.name, revive: true },
|
||||
(e) => toggleParticipantActive(e, d.id, ctx))).enc;
|
||||
}
|
||||
if (VERBOSE) console.log(` heal ${d.name} +${d.maxHp}`);
|
||||
enc = (await callStep('applyHpChange',
|
||||
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
|
||||
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx))).enc;
|
||||
}
|
||||
}
|
||||
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
}
|
||||
|
||||
console.log(`replay: ${totalTurns} turns, reached round ${enc.round} (cap ${rounds})`);
|
||||
|
||||
await finishAndExit();
|
||||
};
|
||||
@@ -0,0 +1,342 @@
|
||||
// scripts/combat/verify.js
|
||||
// Read combat log (JSON array of lean events). Check rotation correctness.
|
||||
// DM-facing output: combat name, rounds, per-round turn list, checks pass/fail.
|
||||
//
|
||||
// No internal jargon in output. "Someone acted twice", "someone skipped",
|
||||
// "turn passed wrong way" — not "double_act", "real_skip", "wrong_advance".
|
||||
//
|
||||
// CHECKS (rules combat must obey):
|
||||
// 1. Round count go up correctly (no skip/jump/backward)
|
||||
// 2. Turn pass to right person each step
|
||||
// 3. Nobody act twice in same round
|
||||
// 4. Nobody get skipped who was active whole round
|
||||
// 5. Turn order stay stable (no random reshuffle)
|
||||
// 6. Initiative slot order hold (replay logs only — needs full roster)
|
||||
//
|
||||
// Return: 0 clean, 1 bugs found.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { normalizeEvent } = require('../../shared/logEvent');
|
||||
|
||||
// snake_case log type → camelCase fn (checks match both shapes)
|
||||
function normalizeFn(fn) {
|
||||
if (!fn) return fn;
|
||||
if (!fn.includes('_')) return fn;
|
||||
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Parse text → array of step-arrays (one per combat run).
|
||||
// Input: JSON array of lean events (app download OR combat replay output).
|
||||
// Split: by encounterId, then sub-split on start_encounter (restart boundary).
|
||||
// First start after setup stays with setup (not bogus "encounter 1").
|
||||
function loadSteps(text) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const raw = JSON.parse(trimmed);
|
||||
const arr = Array.isArray(raw) ? raw : [raw];
|
||||
const groups = new Map();
|
||||
for (const e of arr) {
|
||||
const key = e.encounterId || '_none_';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(e);
|
||||
}
|
||||
|
||||
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,
|
||||
} : null,
|
||||
message: e.message || '',
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const out = [];
|
||||
for (const evs of groups.values()) {
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------- name map ----------
|
||||
|
||||
const nameMap = new Map();
|
||||
function learnNames(steps) {
|
||||
for (const s of steps) {
|
||||
// snapshot has no roster; use participantName field on events + messages
|
||||
if (s.message) {
|
||||
// no-op; names come from post.currentTurnParticipantId via message ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback: ids unknown → short. (replay logs carry encounterName; app logs
|
||||
// carry participantName. We use message text + the turnOrderIds as-is.)
|
||||
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
|
||||
|
||||
// rebuild name map from raw events (participantName field)
|
||||
function learnNamesFromEvents(evs) {
|
||||
for (const e of evs) {
|
||||
if (e.participantId && e.participantName) nameMap.set(e.participantId, e.participantName);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- expected advance ----------
|
||||
|
||||
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 };
|
||||
}
|
||||
return { nextId: null, wrapped: false };
|
||||
}
|
||||
|
||||
// ---------- checks ----------
|
||||
|
||||
function analyze(steps) {
|
||||
const issues = [];
|
||||
const rounds = new Map();
|
||||
function ensureRound(r) {
|
||||
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, actors: [] });
|
||||
return rounds.get(r);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
if (isStart) {
|
||||
ensureRound(post.round || 1).actors.push(post.currentTurnParticipantId);
|
||||
continue;
|
||||
}
|
||||
if (isEnd) continue;
|
||||
if (isNextTurn) {
|
||||
const r = ensureRound(post.round || 0);
|
||||
r.turnCount++;
|
||||
r.actors.push(post.currentTurnParticipantId);
|
||||
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;
|
||||
|
||||
if (exp.nextId && actual && actual !== exp.nextId) {
|
||||
issues.push({ step: s.step, round: post.round, kind: 'wrong_turn',
|
||||
detail: `Turn passed to ${nm(actual)}, should have been ${nm(exp.nextId)}` });
|
||||
}
|
||||
if (pre.round !== undefined && post.round !== undefined) {
|
||||
if (post.round < pre.round)
|
||||
issues.push({ step: s.step, kind: 'round_backward',
|
||||
detail: `Round went ${pre.round} → ${post.round}` });
|
||||
if (post.round > pre.round + 1)
|
||||
issues.push({ step: s.step, kind: 'round_jump',
|
||||
detail: `Round jumped ${pre.round} → ${post.round}` });
|
||||
if (post.round === pre.round + 1 && !exp.wrapped)
|
||||
issues.push({ step: s.step, kind: 'round_phantom',
|
||||
detail: `Round went ${pre.round} → ${post.round} but turn didn't wrap` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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',
|
||||
detail: `Turn order changed during ${s.fn}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...checkCycles(steps, rounds));
|
||||
return { issues, rounds };
|
||||
}
|
||||
|
||||
function checkCycles(steps, rounds) {
|
||||
const out = [];
|
||||
let cycleActive = new Set();
|
||||
let cycleActed = new Set();
|
||||
let cycleStarter = null;
|
||||
let cycleRound = null;
|
||||
let started = false;
|
||||
|
||||
function finalize(endStep) {
|
||||
if (!started) return;
|
||||
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
|
||||
if (skipped.length) {
|
||||
out.push({ step: endStep, round: cycleRound, kind: 'skipped',
|
||||
detail: `Never acted in round ${cycleRound}: ${skipped.map(nm).join(', ')}` });
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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; }
|
||||
|
||||
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: 'acted_twice',
|
||||
detail: `${nm(c)} acted twice in round ${post.round}` });
|
||||
}
|
||||
cycleActed.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNextTurn) continue;
|
||||
|
||||
if (pre && post && pre.activeIds && post.activeIds) {
|
||||
const postSet = new Set(post.activeIds);
|
||||
for (const id of [...cycleActive]) {
|
||||
if (!postSet.has(id)) {
|
||||
cycleActive.delete(id);
|
||||
cycleActed.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------- reporting (DM-facing) ----------
|
||||
|
||||
const KIND_LABEL = {
|
||||
wrong_turn: 'Turn passed to wrong person',
|
||||
round_backward: 'Round count went backward',
|
||||
round_jump: 'Round count jumped',
|
||||
round_phantom: 'Round count changed without turn wrapping',
|
||||
order_shift: 'Turn order changed unexpectedly',
|
||||
skipped: 'Someone got skipped',
|
||||
acted_twice: 'Someone acted twice',
|
||||
slot_violation: 'Initiative order violated',
|
||||
};
|
||||
|
||||
function reportOne(label, steps, rawEventsForNames, opts = {}) {
|
||||
const log = opts.log || console.log;
|
||||
if (rawEventsForNames) learnNamesFromEvents(rawEventsForNames);
|
||||
const { issues, rounds } = analyze(steps);
|
||||
|
||||
// combat name
|
||||
const name = (rawEventsForNames && rawEventsForNames[0] && rawEventsForNames[0].encounterName) || 'Combat';
|
||||
log(`=== ${label}: ${name} ===`);
|
||||
|
||||
// Per-round turn list only in verbose mode. Long replays can print thousands
|
||||
// of names and swamp useful result output.
|
||||
const sortedRounds = [...rounds.keys()].sort((a, b) => a - b);
|
||||
if (opts.verbose) {
|
||||
for (const r of sortedRounds) {
|
||||
const info = rounds.get(r);
|
||||
log(` Round ${r}: ${info.actors.map(nm).join(' → ')}`);
|
||||
}
|
||||
}
|
||||
log(` (${steps.length} events, ${rounds.size} rounds${opts.verbose ? '' : ', use -v for per-round turns'})`);
|
||||
|
||||
if (issues.length === 0) {
|
||||
log(' ✓ PASS — combat rotated correctly');
|
||||
return 0;
|
||||
}
|
||||
log(` ✗ FAIL — ${issues.length} problem(s):`);
|
||||
const byKind = {};
|
||||
for (const it of issues) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
|
||||
for (const [k, n] of Object.entries(byKind)) {
|
||||
log(` ${KIND_LABEL[k] || k}: ${n}`);
|
||||
}
|
||||
for (const it of issues.slice(0, 15)) {
|
||||
const where = it.round != null ? `R${it.round} ` : '';
|
||||
log(` [${where}event ${it.step}] ${it.detail}`);
|
||||
}
|
||||
if (issues.length > 15) log(` ... +${issues.length - 15} more`);
|
||||
return issues.length;
|
||||
}
|
||||
|
||||
module.exports = function verify(text, opts = {}) {
|
||||
const log = opts.log || console.log;
|
||||
const allSteps = loadSteps(text);
|
||||
if (!allSteps.length) {
|
||||
log('No combat events found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// raw events grouped same way for name learning
|
||||
const raw = JSON.parse(text.trim());
|
||||
const arr = Array.isArray(raw) ? raw : [raw];
|
||||
const rawGroups = new Map();
|
||||
for (const e of arr) {
|
||||
const key = e.encounterId || '_none_';
|
||||
if (!rawGroups.has(key)) rawGroups.set(key, []);
|
||||
rawGroups.get(key).push(e);
|
||||
}
|
||||
const rawList = [...rawGroups.values()];
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < allSteps.length; i++) {
|
||||
const label = allSteps.length > 1 ? `[combat ${i + 1}/${allSteps.length}]` : 'Combat';
|
||||
if (i > 0) log('');
|
||||
total += reportOne(label, allSteps[i], rawList[i], opts);
|
||||
}
|
||||
log('');
|
||||
log(total === 0 ? 'CLEAN' : `${total} problem(s) found`);
|
||||
return total === 0 ? 0 : 1;
|
||||
};
|
||||
+292
-299
@@ -1,379 +1,372 @@
|
||||
// scripts/replay-combat.js
|
||||
// Drive a full combat through the LIVE backend via the ws storage adapter
|
||||
// Drive a full combat through the LIVE backend via the server storage adapter
|
||||
// (same contract boundary as the App), so the player display window
|
||||
// (subscribed via WS) live-updates as combat progresses.
|
||||
// Uses shared/turn.js for all turn logic (same model as the UI).
|
||||
//
|
||||
// Coverage goals (rotate across rounds):
|
||||
// - nextTurn (every turn)
|
||||
// - applyHpChange damage + heal (varying magnitude)
|
||||
// - toggleCondition (all CONDITIONS at least once)
|
||||
// - toggleParticipantActive (mark inactive, later reactivate)
|
||||
// - deathSave (when a PC reaches 0 HP)
|
||||
// - addParticipant (reinforcements drop in)
|
||||
// - removeParticipant (dead monsters hauled off)
|
||||
// - updateParticipant (edit fields mid-combat)
|
||||
// - togglePause / resume
|
||||
// - reorderParticipants (initiative reorder)
|
||||
// - endEncounter (cleanup)
|
||||
// BLIND CALLER: this tool does NOT know the correct turn order. It hammers
|
||||
// nextTurn + mutations and trusts turn.js. An SEPARATE analyze pass inspects
|
||||
// what actually happened (invariants on backend read-back). Do not encode
|
||||
// expected-order logic here — that's circular.
|
||||
//
|
||||
// Run: node scripts/replay-combat.js [rounds] [delayMs]
|
||||
// rounds default 100, delayMs default 200
|
||||
|
||||
'use strict';
|
||||
// DRIVEN BY REAL enc.round (backend state), not a fake loop counter. Round
|
||||
// wraps when nextTurn's pointer advances last→first active.
|
||||
//
|
||||
// TRACE: writes JSONL to tmp/replay-{stamp}.jsonl — one record per call:
|
||||
// { step, ts, type, call:{fn,args}, pre, post }
|
||||
// pre/post = backend getDoc snapshots (independent of func return value).
|
||||
// This is ground truth. analyze-turns.js consumes it. The func return value
|
||||
// is "what turn.js claims"; read-back is "what the backend stored". Mismatch
|
||||
// = write bug.
|
||||
//
|
||||
// Coverage (rotates by step counter):
|
||||
// applyHpChange damage, toggleCondition, updateParticipant, deathSave,
|
||||
// toggleParticipantActive, pause/add/resume reinforcements,
|
||||
// removeParticipant dead, reorderParticipants same-init, nextTurn.
|
||||
//
|
||||
// Run: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v|--verbose]
|
||||
// rounds default 20, delayMs default 200
|
||||
// --out <path> = REQUIRED. Trace path you control.
|
||||
// -v / --verbose = log every action per turn
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const shared = require('../shared');
|
||||
const {
|
||||
buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
startEncounter, nextTurn, togglePause, endEncounter,
|
||||
addParticipant, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
toggleCondition, reorderParticipants,
|
||||
} = shared;
|
||||
const { createServerStorage } = require('../src/storage/server');
|
||||
|
||||
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||
const DELAY = parseInt(process.argv[3], 10) || 200;
|
||||
// args: [rounds] [delayMs] --out <path> [-v|--verbose]
|
||||
// --out <path> REQUIRED — trace path. You control it, not the tool.
|
||||
const args = process.argv.slice(2);
|
||||
const VERBOSE = args.some(a => a === '-v' || a === '--verbose');
|
||||
let OUT_PATH = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--out' && args[i + 1]) {
|
||||
OUT_PATH = args[i + 1];
|
||||
args.splice(i, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!OUT_PATH) {
|
||||
console.error('Usage: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v]');
|
||||
console.error(' --out <path> REQUIRED.');
|
||||
process.exit(2);
|
||||
}
|
||||
const positional = args.filter(a => !a.startsWith('-'));
|
||||
const ROUNDS = parseInt(positional[0], 10) || 20;
|
||||
const DELAY = parseInt(positional[1], 10) || 200;
|
||||
const MAX_STEPS = ROUNDS * 30; // safety cap on total calls
|
||||
|
||||
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||
const PUB = `artifacts/${APP_ID}/public/data`;
|
||||
// Mirror App.js getPath. Adapter takes these; norm() strips prefix.
|
||||
const getPath = {
|
||||
campaigns: () => `${PUB}/campaigns`,
|
||||
campaign: (id) => `${PUB}/campaigns/${id}`,
|
||||
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
|
||||
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
|
||||
activeDisplay: () => `${PUB}/activeDisplay/status`,
|
||||
logs: () => `${PUB}/logs`,
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// Use the ADAPTER as the contract boundary (same as App). No raw REST.
|
||||
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// Mirror App.js CONDITIONS so we exercise all of them.
|
||||
const CONDITIONS = [
|
||||
'alchemist_fire', 'bardic_inspiration', 'blinded', 'charmed', 'deafened',
|
||||
'exhaustion', 'frightened', 'grappled', 'grazed', 'incapacitated',
|
||||
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
|
||||
'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed',
|
||||
];
|
||||
// Custom (freeform) condition ids — DM-added strings. toggleCondition must
|
||||
// accept ANY string (UI custom-condition contract). Exercise both paths.
|
||||
const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed'];
|
||||
|
||||
async function patch(encounterPath, enc, result, label) {
|
||||
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
|
||||
await storage.updateDoc(encounterPath, result.patch);
|
||||
if (label) console.log(` [${label}]`);
|
||||
// emit pointer-advance line when a MUTATION changes currentTurnParticipantId.
|
||||
// nextTurn passes label=null — it's a normal advance, already logged via
|
||||
// the turn line. Emitting pointer for it double-counts.
|
||||
const oldCur = enc.currentTurnParticipantId;
|
||||
const oldRound = enc.round;
|
||||
const newEnc = { ...enc, ...result.patch };
|
||||
const newCur = newEnc.currentTurnParticipantId;
|
||||
const newRound = newEnc.round;
|
||||
if (label && oldCur && newCur && oldCur !== newCur) {
|
||||
const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur;
|
||||
const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur;
|
||||
const wrap = oldRound !== newRound ? ' wrap' : '';
|
||||
console.log(` [pointer ${oldName}→${newName}${wrap}]`);
|
||||
}
|
||||
return newEnc;
|
||||
function buildRoster() {
|
||||
return [
|
||||
{ name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||
{ name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||
{ name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||
];
|
||||
}
|
||||
function buildMonsters() {
|
||||
return [
|
||||
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
|
||||
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
|
||||
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
|
||||
{ name: 'Wolf', maxHp: 40, initMod: 3 },
|
||||
{ name: 'Merchant', maxHp: 30, initMod: 0, isNpc: true },
|
||||
];
|
||||
}
|
||||
|
||||
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
const CONDITIONS = [
|
||||
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||
'frightened','grappled','incapacitated','invisible','paralyzed',
|
||||
'petrified','poisoned','prone','restrained','sapped','shield',
|
||||
'slowed','stunned','unconscious','vexed',
|
||||
];
|
||||
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
|
||||
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
|
||||
let condIdx = 0;
|
||||
|
||||
// ---------- JSONL trace ----------
|
||||
|
||||
// Lean snapshot — analyzer needs rotation fields only, not roster.
|
||||
// participants dropped (was 80% of trace bloat). turnOrderIds + activeIds
|
||||
// carry id-level info; hp/conditions stay on encounter doc, not trace.
|
||||
function snapshot(enc) {
|
||||
if (!enc) return null;
|
||||
return {
|
||||
round: enc.round ?? 0,
|
||||
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||
isStarted: !!enc.isStarted,
|
||||
isPaused: !!enc.isPaused,
|
||||
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
function nameOf(enc, id) {
|
||||
if (!id || !enc) return '(none)';
|
||||
const p = (enc.participants || []).find(x => x.id === id);
|
||||
return p ? p.name : '(missing)';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
|
||||
|
||||
const runStamp = new Date().toISOString().slice(0,19).replace('T', '_').replace(/:/g, '-');
|
||||
const campaignId = crypto.randomUUID();
|
||||
const encounterId = crypto.randomUUID();
|
||||
|
||||
await storage.setDoc(getPath.campaign(campaignId), {
|
||||
name: `Replay Campaign (${new Date().toLocaleString('en-US', { hour12: false })})`,
|
||||
playerDisplayBackgroundUrl: '',
|
||||
ownerId: 'replay',
|
||||
createdAt: new Date().toISOString(),
|
||||
players: [
|
||||
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
const charSpecs = [
|
||||
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||
];
|
||||
const monsterSpecs = [
|
||||
{ name: 'Goblin1', maxHp: 100, initMod: 2 },
|
||||
{ name: 'Goblin2', maxHp: 100, initMod: 2 },
|
||||
{ name: 'OrcBoss', maxHp: 500, initMod: 1 },
|
||||
{ name: 'Wolf', maxHp: 120, initMod: 3 },
|
||||
{ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true },
|
||||
];
|
||||
|
||||
const participants = [
|
||||
...charSpecs.map(c => buildCharacterParticipant(c).participant),
|
||||
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
|
||||
];
|
||||
|
||||
await storage.setDoc(getPath.encounter(campaignId, encounterId), {
|
||||
name: `Big Boss Replay (${new Date().toLocaleString('en-US', { hour12: false })})`,
|
||||
campaignId,
|
||||
createdAt: new Date().toISOString(),
|
||||
participants,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
turnOrderIds: [],
|
||||
});
|
||||
|
||||
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
|
||||
|
||||
await storage.setDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: campaignId,
|
||||
activeEncounterId: encounterId,
|
||||
hidePlayerHp: false,
|
||||
});
|
||||
await sleep(800);
|
||||
|
||||
const encounterPath = getPath.encounter(campaignId, encounterId);
|
||||
const activeDisplayPath = getPath.activeDisplay();
|
||||
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
|
||||
|
||||
// start
|
||||
let enc = await storage.getDoc(encounterPath);
|
||||
enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter');
|
||||
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
|
||||
await sleep(DELAY);
|
||||
let tracePath = OUT_PATH;
|
||||
// ensure parent dir exists
|
||||
const traceDir = path.dirname(tracePath);
|
||||
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
||||
const jsonl = fs.createWriteStream(tracePath, { flags: 'w' });
|
||||
|
||||
let stepN = 0;
|
||||
let prevEnc = null; // cached post = next step's pre (no backend read-back).
|
||||
// callStep: pre = cached prevEnc (or fresh read on first) → run → post =
|
||||
// runner return (trusted, like old fast impl) → emit JSONL. No per-step
|
||||
// backend getDoc. Funcs return newEnc already.
|
||||
async function callStep(fn, args, runner) {
|
||||
stepN++;
|
||||
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
|
||||
const pre = snapshot(encBefore);
|
||||
let result, threw = null;
|
||||
try { result = await runner(encBefore); }
|
||||
catch (e) { threw = e.message; }
|
||||
// func returns newEnc (post). Trust it — matches old fast behavior.
|
||||
// Setup funcs (setDoc/getDoc) return undefined → keep encBefore.
|
||||
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
|
||||
prevEnc = encAfter;
|
||||
const post = snapshot(encAfter);
|
||||
jsonl.write(JSON.stringify({
|
||||
step: stepN, ts: Date.now(), type: fn, call: { fn, args },
|
||||
pre, post, error: threw,
|
||||
}) + '\n');
|
||||
return { enc: encAfter, result, threw };
|
||||
}
|
||||
|
||||
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
|
||||
|
||||
// --- setup: campaign + encounter docs (also traced, type 'setup') ---
|
||||
await callStep('setup_campaign', { campaignId }, async () =>
|
||||
storage.setDoc(getPath.campaign(campaignId), {
|
||||
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
await callStep('setup_encounter', { encounterId }, async () =>
|
||||
storage.setDoc(encounterPath, {
|
||||
id: encounterId,
|
||||
name: `Big Boss Replay ${runStamp}`,
|
||||
campaignId,
|
||||
participants: [],
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
// add roster + monsters
|
||||
for (const ch of buildRoster()) {
|
||||
const { participant } = buildCharacterParticipant(ch);
|
||||
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||
addParticipant(enc, participant, ctx)
|
||||
);
|
||||
}
|
||||
for (const m of buildMonsters()) {
|
||||
const { participant } = buildMonsterParticipant(m);
|
||||
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||
addParticipant(enc, participant, ctx)
|
||||
);
|
||||
}
|
||||
|
||||
// start combat
|
||||
let enc = (await storage.getDoc(encounterPath));
|
||||
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
|
||||
enc = startRes.enc;
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
|
||||
console.log(`combat started: round ${enc.round}, first=${nameOf(enc, enc.currentTurnParticipantId)}`);
|
||||
|
||||
let totalTurns = 0;
|
||||
const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS].sort(() => Math.random() - 0.5);
|
||||
let reinforcementsAdded = 0;
|
||||
let lastPaused = false;
|
||||
let lastReorder = 0;
|
||||
let lastRound = enc.round;
|
||||
|
||||
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||
console.log(`--- round ${roundN} starting ---`);
|
||||
// advance initiative until round counter ticks (full cycle done).
|
||||
const cap = (enc.participants.length + 2) * 2;
|
||||
let guard = 0;
|
||||
while (enc.round < roundN + 1 && guard < cap) {
|
||||
// NOTE: do NOT getDoc here — async re-fetch can return stale state and
|
||||
// cause nextTurn to compute off pre-mutation data (double-acts/skips).
|
||||
// Trust the local enc returned by patch (sync spread of updateDoc).
|
||||
// --- main loop: drive by real enc.round ---
|
||||
while (enc.isStarted && enc.round <= ROUNDS && stepN < MAX_STEPS) {
|
||||
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
|
||||
totalTurns++;
|
||||
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
|
||||
|
||||
// 9. resume if paused: must happen BEFORE nextTurn or it throws.
|
||||
if (lastPaused) {
|
||||
enc = await patch(encounterPath, enc, togglePause(enc), 'resume');
|
||||
lastPaused = false;
|
||||
if (actor) {
|
||||
// random damage from actor to random living target
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
|
||||
const res = await callStep('applyHpChange',
|
||||
{ target: tgt.name, changeType: 'damage', amount: dmg },
|
||||
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
let t;
|
||||
try { t = nextTurn(enc); } catch (e) { console.log(` nextTurn err: ${e.message}`); break; }
|
||||
enc = await patch(encounterPath, enc, t, null);
|
||||
totalTurns++;
|
||||
const actorName = firstActiveName(enc);
|
||||
const actor = currentParticipant(enc);
|
||||
|
||||
// Dump turn line with order AND initiative (DM drag may reorder without
|
||||
// changing init — log both so parser can flag unexplained shifts).
|
||||
const ordStr = enc.turnOrderIds.map(id => {
|
||||
const p = enc.participants.find(x => x.id === id);
|
||||
return p ? `${p.name}:${p.initiative}` : id;
|
||||
}).join(',');
|
||||
// Also dump participants[] order (display source). Diverge from order = sync bug.
|
||||
const pStr = enc.participants.map(p => `${p.name}:${p.initiative}`).join(',');
|
||||
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] parts=[${pStr}] cur=${enc.currentTurnParticipantId}`);
|
||||
|
||||
// 1. damage: actor hits a random living, active target.
|
||||
if (actor) {
|
||||
const foes = enc.participants.filter(
|
||||
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false && !p.name.startsWith('Dead')
|
||||
);
|
||||
if (foes.length > 0) {
|
||||
const tgt = pick(foes);
|
||||
const dmg = 1 + Math.floor(Math.random() * 5); // 1-5
|
||||
const h = applyHpChange(enc, tgt.id, 'damage', dmg);
|
||||
if (h.patch) {
|
||||
await storage.updateDoc(encounterPath, h.patch);
|
||||
enc = { ...enc, ...h.patch };
|
||||
console.log(` ${actorName} → ${tgt.name} (-${dmg}, hp=${tgt.currentHp - dmg})`);
|
||||
}
|
||||
}
|
||||
// random condition
|
||||
if (totalTurns % 5 === 0) {
|
||||
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
|
||||
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
|
||||
const res = await callStep('toggleCondition',
|
||||
{ participant: actor.name, condition: cond },
|
||||
(e) => toggleCondition(e, actor.id, cond, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
// 2. heal: Cleric (when active) heals lowest-HP ally every other turn.
|
||||
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||
const wounded = enc.participants
|
||||
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false)
|
||||
.sort((a, b) => (a.currentHp / a.maxHp) - (b.currentHp / b.maxHp));
|
||||
if (wounded.length > 0) {
|
||||
const tgt = wounded[0];
|
||||
const amt = 2 + Math.floor(Math.random() * 5); // 2-6
|
||||
const h = applyHpChange(enc, tgt.id, 'heal', amt);
|
||||
if (h.patch) {
|
||||
await storage.updateDoc(encounterPath, h.patch);
|
||||
enc = { ...enc, ...h.patch };
|
||||
console.log(` Cleric heal → ${tgt.name} (+${amt}, hp=${tgt.currentHp + amt})`);
|
||||
}
|
||||
}
|
||||
// random edit (notes/init unchanged)
|
||||
if (totalTurns % 11 === 0) {
|
||||
if (VERBOSE) console.log(` update ${actor.name} notes`);
|
||||
const res = await callStep('updateParticipant',
|
||||
{ participant: actor.name, fields: ['notes'] },
|
||||
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
// 3. conditions: toggle a queued condition off some participant each turn.
|
||||
if (condQueue.length > 0) {
|
||||
const cond = condQueue[0];
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try {
|
||||
const c = toggleCondition(enc, tgt.id, cond);
|
||||
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
||||
condQueue.shift();
|
||||
} catch (e) { console.log(` condition ${cond} err: ${e.message}`); condQueue.shift(); }
|
||||
}
|
||||
} else if (totalTurns % 6 === 0) {
|
||||
// second pass: toggle a random condition on random participant (add/remove).
|
||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
|
||||
try {
|
||||
const c = toggleCondition(enc, tgt.id, cond);
|
||||
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// deathSave: PC at 0 HP
|
||||
if (actor.currentHp <= 0 && !actor.isNpc) {
|
||||
if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
|
||||
const res = await callStep('deathSave',
|
||||
{ participant: actor.name, type: 'success', n: 1 },
|
||||
(e) => deathSave(e, actor.id, 'success', 1, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
// 4. toggleParticipantActive: randomly mark someone inactive, or reactivate.
|
||||
// toggleActive every 9 turns
|
||||
if (totalTurns % 9 === 0) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try {
|
||||
const r = toggleParticipantActive(enc, tgt.id);
|
||||
enc = await patch(encounterPath, enc, r, `${tgt.isActive === false ? 'reactivate' : 'deactivate'} ${tgt.name}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
|
||||
const res = await callStep('toggleParticipantActive',
|
||||
{ participant: tgt.name },
|
||||
(e) => toggleParticipantActive(e, tgt.id, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. deathSave: when a PC is at 0 HP on their turn, attempt a save.
|
||||
if (actor && actor.currentHp <= 0 && !actor.isNpc && actor.name !== actor.name.startsWith('Monster')) {
|
||||
try {
|
||||
const ds = deathSave(enc, actor.id, 1);
|
||||
enc = await patch(encounterPath, enc, ds, `deathSave ${actor.name} (+1 success)`);
|
||||
} catch (e) { /* ignore */ }
|
||||
// reinforcements every 20 turns (pause/add/resume)
|
||||
if (totalTurns % 20 === 0 && enc.isPaused === false) {
|
||||
if (VERBOSE) console.log(` reinforcements: pause`);
|
||||
let res = await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx));
|
||||
enc = res.enc;
|
||||
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
|
||||
if (VERBOSE) console.log(` add ${r.participant.name}`);
|
||||
res = await callStep('addParticipant',
|
||||
{ name: r.participant.name, reinforcement: true },
|
||||
(e) => addParticipant(e, r.participant, ctx));
|
||||
enc = res.enc;
|
||||
if (VERBOSE) console.log(` resume`);
|
||||
res = await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
|
||||
// 6. removeParticipant: dead monsters hauled off (every ~5 turns).
|
||||
if (totalTurns % 5 === 0) {
|
||||
const dead = enc.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf'));
|
||||
// remove dead monster every 13 turns
|
||||
if (totalTurns % 13 === 0) {
|
||||
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
|
||||
if (dead) {
|
||||
try {
|
||||
const r = removeParticipant(enc, dead.id);
|
||||
enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (VERBOSE) console.log(` remove ${dead.name}`);
|
||||
const res = await callStep('removeParticipant',
|
||||
{ participant: dead.name, dead: true },
|
||||
(e) => removeParticipant(e, dead.id, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. addParticipant (reinforcements): every 10 turns a new monster joins.
|
||||
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
|
||||
const spec = pick([
|
||||
{ name: `Reinforce${reinforcementsAdded + 1}`, maxHp: 120, initMod: 1 },
|
||||
{ name: `Summon${reinforcementsAdded + 1}`, maxHp: 80, initMod: 4 },
|
||||
]);
|
||||
try {
|
||||
const built = buildMonsterParticipant(spec).participant;
|
||||
const r = addParticipant(enc, built);
|
||||
enc = await patch(encounterPath, enc, r, `add ${spec.name}`);
|
||||
reinforcementsAdded++;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 8. updateParticipant: every 7 turns, edit a field on someone (e.g. temp AC).
|
||||
if (totalTurns % 7 === 0) {
|
||||
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||
if (living.length > 0) {
|
||||
const tgt = pick(living);
|
||||
try {
|
||||
const r = updateParticipant(enc, tgt.id, { notes: `edited@turn${totalTurns}` });
|
||||
enc = await patch(encounterPath, enc, r, `edit ${tgt.name} notes`);
|
||||
} catch (e) { /* ignore */ }
|
||||
// drag same-init every 17 turns
|
||||
if (totalTurns % 17 === 0) {
|
||||
const order = enc.participants || [];
|
||||
if (order.length >= 2) {
|
||||
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
|
||||
if (b) {
|
||||
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
|
||||
const res = await callStep('reorderParticipants',
|
||||
{ dragged: b.name, target: a.name },
|
||||
(e) => reorderParticipants(e, b.id, a.id, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. togglePause: every 12 turns, pause (resumes next iteration via above).
|
||||
if (totalTurns % 12 === 0 && !lastPaused) {
|
||||
enc = await patch(encounterPath, enc, togglePause(enc), 'pause');
|
||||
lastPaused = true;
|
||||
}
|
||||
|
||||
// 10. reorderParticipants: every 8 turns, drag one past another (DM reorder).
|
||||
// Pick two ADJACENT UPCOMING actors (both strictly after current pointer)
|
||||
// and swap them. Avoids crossing current pointer — crossing it creates
|
||||
// ambiguous "who acted this round" semantics (skip/double). Swapping two
|
||||
// upcoming actors is always safe and still exercises reorder.
|
||||
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
||||
const curIdx = enc.turnOrderIds.indexOf(enc.currentTurnParticipantId);
|
||||
// upcoming = everyone after current in turn order (rest of this round)
|
||||
const upcomingIds = enc.turnOrderIds.slice(curIdx + 1)
|
||||
.filter(id => { const p = enc.participants.find(x => x.id === id); return p && p.currentHp > 0 && p.isActive !== false; });
|
||||
// swap first adjacent upcoming pair (drag index1 before index0)
|
||||
if (upcomingIds.length >= 2) {
|
||||
const target = enc.participants.find(p => p.id === upcomingIds[0]);
|
||||
const dragged = enc.participants.find(p => p.id === upcomingIds[1]);
|
||||
try {
|
||||
const r = reorderParticipants(enc, dragged.id, target.id);
|
||||
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
|
||||
lastReorder = totalTurns;
|
||||
} catch (e) { /* swap not allowed — skip this round */ }
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(DELAY);
|
||||
guard++;
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
}
|
||||
|
||||
if (DELAY > 0) await sleep(DELAY);
|
||||
|
||||
// advance turn (unless combat auto-ended)
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
const alive = enc.participants.filter(p => p.currentHp > 0).length;
|
||||
// revive dead: heal to full + reactivate. Sustains combat for 100 rounds
|
||||
// and exercises toggleActive reactivate + heal-from-zero path.
|
||||
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
try {
|
||||
if (VERBOSE) console.log(` -> nextTurn`);
|
||||
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
|
||||
enc = advRes.enc;
|
||||
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
|
||||
|
||||
// round wrap: revive dead so combat sustains to full ROUNDS count
|
||||
if (enc.isStarted && enc.round > lastRound) {
|
||||
if (enc.round > ROUNDS) break; // round cap reached, stop before printing/acting
|
||||
console.log(`--- round ${enc.round} ---`);
|
||||
lastRound = enc.round;
|
||||
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||
for (const d of dead) {
|
||||
if (d.isActive === false) {
|
||||
enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
|
||||
if (VERBOSE) console.log(` revive ${d.name}`);
|
||||
const res = await callStep('toggleParticipantActive',
|
||||
{ participant: d.name, revive: true },
|
||||
(e) => toggleParticipantActive(e, d.id, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
const h = applyHpChange(enc, d.id, 'heal', d.maxHp);
|
||||
enc = await patch(encounterPath, enc, h, `revive-heal ${d.name} →${d.maxHp}`);
|
||||
} catch (e) { console.log(` revive ${d.name} err: ${e.message}`); }
|
||||
if (VERBOSE) console.log(` heal ${d.name} +${d.maxHp}`);
|
||||
const res = await callStep('applyHpChange',
|
||||
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
|
||||
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
}
|
||||
|
||||
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||
}
|
||||
|
||||
console.log(`replay: ${totalTurns} total turns across ${ROUNDS} rounds`);
|
||||
console.log(`replay: ${totalTurns} actor-turns, reached round ${enc.round} (cap ${ROUNDS})`);
|
||||
|
||||
// end
|
||||
// end encounter
|
||||
enc = await storage.getDoc(encounterPath);
|
||||
if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter');
|
||||
if (enc.isStarted) {
|
||||
const res = await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
|
||||
enc = res.enc;
|
||||
}
|
||||
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||
|
||||
jsonl.end();
|
||||
await new Promise(r => jsonl.close(r));
|
||||
console.log(`trace written: ${stepN} steps -> ${tracePath}`);
|
||||
console.log('');
|
||||
console.log('>>> ANALYZE: node scripts/analyze-turns.js "' + tracePath + '"');
|
||||
console.log('replay done');
|
||||
}
|
||||
|
||||
function firstActiveName(enc) {
|
||||
if (!enc.currentTurnParticipantId) return '(none)';
|
||||
const p = currentParticipant(enc);
|
||||
return p ? p.name : '(missing)';
|
||||
}
|
||||
|
||||
function currentParticipant(enc) {
|
||||
if (!enc.currentTurnParticipantId) return null;
|
||||
return (enc.participants || []).find(x => x.id === enc.currentTurnParticipantId) || null;
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('replay failed:', err); process.exit(1); });
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// scripts/replay-from-logs.js
|
||||
// Ingest canonical event JSON (downloaded app logs OR replay-combat --json-out)
|
||||
// and replay forward patches in order. Verifies each event's snapshot matches
|
||||
// the reconstructed encounter state — catches drift between logged intent and
|
||||
// actual mutation.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/replay-from-logs.js <events.json>
|
||||
// node scripts/replay-from-logs.js <events.json> --encounter <id> # filter
|
||||
// cat logs.json | node scripts/replay-from-logs.js # stdin
|
||||
//
|
||||
// Modes:
|
||||
// (default) in-memory replay + snapshot verification. No backend writes.
|
||||
// --write apply each patch to LIVE backend (same adapter as replay-combat).
|
||||
// Creates fresh encounter under new campaign. Useful for cloning
|
||||
// a logged combat into a clean DB.
|
||||
//
|
||||
// Output: turn sequence, round progression, drift report (any snapshot mismatch).
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const { normalizeEvent } = require('../shared/logEvent');
|
||||
|
||||
const WRITE_MODE = process.argv.includes('--write');
|
||||
const ENC_FILTER = (() => {
|
||||
const i = process.argv.indexOf('--encounter');
|
||||
return i >= 0 ? process.argv[i + 1] : null;
|
||||
})();
|
||||
|
||||
function readInput() {
|
||||
// positional .json arg, else stdin.
|
||||
const pos = process.argv[2];
|
||||
if (pos && !pos.startsWith('--')) return fs.readFileSync(pos, 'utf8');
|
||||
// stdin: require piped data (not interactive terminal). Avoid hang.
|
||||
if (process.stdin.isTTY) {
|
||||
console.error('Usage: node scripts/replay-from-logs.js <events.json> [--encounter <id>] [--write]');
|
||||
console.error(' cat events.json | node scripts/replay-from-logs.js');
|
||||
process.exit(2);
|
||||
}
|
||||
const data = fs.readFileSync(0, 'utf8');
|
||||
if (!data.trim()) {
|
||||
console.error('No input on stdin and no file arg.');
|
||||
process.exit(2);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Shallow merge patch into encounter (mirrors storage updateDoc semantics).
|
||||
function applyPatch(enc, patch) {
|
||||
if (!patch) return enc;
|
||||
const next = { ...enc };
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
next[k] = v;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// Compare logged snapshot vs computed state. Returns list of field mismatches.
|
||||
function diffSnapshot(enc, snap) {
|
||||
if (!snap) return [];
|
||||
const drift = [];
|
||||
const curRound = enc.round ?? 0;
|
||||
const curTurn = enc.currentTurnParticipantId ?? null;
|
||||
const order = enc.turnOrderIds || [];
|
||||
const active = (enc.participants || []).filter(p => p.isActive).map(p => p.id);
|
||||
if (snap.round !== curRound) drift.push(`round: logged=${snap.round} actual=${curRound}`);
|
||||
if (snap.currentTurnParticipantId !== curTurn) drift.push(`currentTurn: logged=${snap.currentTurnParticipantId} actual=${curTurn}`);
|
||||
if (JSON.stringify(snap.turnOrderIds || []) !== JSON.stringify(order)) drift.push(`turnOrderIds: logged=${JSON.stringify(snap.turnOrderIds || [])} actual=${JSON.stringify(order)}`);
|
||||
if (JSON.stringify(snap.activeIds || []) !== JSON.stringify(active)) drift.push(`activeIds: logged=${JSON.stringify(snap.activeIds || [])} actual=${JSON.stringify(active)}`);
|
||||
return drift;
|
||||
}
|
||||
|
||||
function nameMap(enc) {
|
||||
const m = new Map();
|
||||
for (const p of (enc.participants || [])) if (p.id && p.name) m.set(p.id, p.name);
|
||||
return m;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const text = readInput().trim();
|
||||
const raw = JSON.parse(text);
|
||||
const arr = (Array.isArray(raw) ? raw : [raw]).map(normalizeEvent).filter(Boolean);
|
||||
const events = ENC_FILTER ? arr.filter(e => e.encounterId === ENC_FILTER) : arr;
|
||||
|
||||
console.log(`replay-from-logs: ${events.length} events${ENC_FILTER ? ` (encounter ${ENC_FILTER})` : ''}`);
|
||||
|
||||
let enc = null;
|
||||
let encName = null;
|
||||
let encPath = null;
|
||||
let round = 0;
|
||||
let turnCount = 0;
|
||||
let appliedCount = 0;
|
||||
const drift = [];
|
||||
const turnSequence = [];
|
||||
|
||||
for (const e of events) {
|
||||
// init on first event
|
||||
if (!enc) {
|
||||
enc = applyPatch({}, e.payload);
|
||||
encName = e.encounterName;
|
||||
encPath = e.encounterPath;
|
||||
} else {
|
||||
enc = applyPatch(enc, e.payload);
|
||||
}
|
||||
appliedCount++;
|
||||
|
||||
if (e.snapshot) {
|
||||
const d = diffSnapshot(enc, e.snapshot);
|
||||
if (d.length) drift.push({ event: e, fields: d });
|
||||
}
|
||||
|
||||
if (e.type === 'next_turn') {
|
||||
turnCount++;
|
||||
const names = nameMap(enc);
|
||||
const actor = e.snapshot?.currentTurnParticipantId;
|
||||
const actorName = names.get(actor) || actor || '?';
|
||||
const r = e.snapshot?.round ?? enc?.round ?? 0;
|
||||
if (r !== round) { round = r; }
|
||||
turnSequence.push(`R${r}: ${actorName}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`encounter: ${encName || '?'}`);
|
||||
console.log(`events applied: ${appliedCount} / ${events.length}`);
|
||||
console.log(`turns replayed: ${turnCount}`);
|
||||
console.log(`final round: ${round}`);
|
||||
console.log(`participants at end: ${(enc?.participants || []).length}`);
|
||||
|
||||
if (drift.length) {
|
||||
console.log(`\n--- SNAPSHOT DRIFT (${drift.length} events) ---`);
|
||||
for (const { event: e, fields } of drift.slice(0, 10)) {
|
||||
console.log(` ${e.type} (${e.id || e.ts}): ${fields.join('; ')}`);
|
||||
}
|
||||
if (drift.length > 10) console.log(` ... +${drift.length - 10} more`);
|
||||
} else {
|
||||
console.log('\nsnapshots: all match');
|
||||
}
|
||||
|
||||
console.log(`\n=== ${turnCount} turns, ${drift.length} drift ===`);
|
||||
console.log(drift.length === 0 ? 'CLEAN — replay matches logged intent' : 'DRIFT — logged snapshots diverge from applied patches');
|
||||
|
||||
process.exit(drift.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('replay-from-logs failed:', err); process.exit(1); });
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run-tests.sh — run all 3 test suites with hard timeout.
|
||||
# Design spec: tests never take >60s. Hang = failure, not silent.
|
||||
# Each suite gets 60s. Total cap = 180s. Exits non-zero on timeout OR failure.
|
||||
set -euo pipefail
|
||||
|
||||
TIMEOUT_BIN=gtimeout
|
||||
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
|
||||
|
||||
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
|
||||
echo "ERROR: need 'timeout' (coreutils) or 'gtimeout' (brew coreutils)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
run_suite () {
|
||||
local label="$1"; shift
|
||||
local cmd="$1"; shift
|
||||
local secs="${1:-60}"
|
||||
echo "=== $label (${secs}s cap) ==="
|
||||
if $TIMEOUT_BIN --signal=KILL "$secs" bash -c "$cmd"; then
|
||||
echo "=== $label: PASS ==="
|
||||
else
|
||||
local rc=$?
|
||||
if [ "$rc" -eq 137 ] || [ "$rc" -eq 124 ]; then
|
||||
echo "=== $label: FAIL — timeout (${secs}s exceeded, killed) ===" >&2
|
||||
else
|
||||
echo "=== $label: FAIL (exit $rc) ===" >&2
|
||||
fi
|
||||
exit "$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
run_suite "app" "CI=true npx react-scripts test --watchAll=false" 60
|
||||
run_suite "shared" "npm test --workspace shared" 30
|
||||
run_suite "server" "npm test --workspace server" 30
|
||||
|
||||
echo "=== ALL SUITES GREEN ==="
|
||||
Reference in New Issue
Block a user