Files

147 lines
5.4 KiB
JavaScript
Raw Permalink Normal View History

// 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); });