// 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 // node scripts/replay-from-logs.js --encounter # 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 [--encounter ] [--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); });