147 lines
5.4 KiB
JavaScript
147 lines
5.4 KiB
JavaScript
// 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); });
|