// scripts/replay-combat.js // 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). // // 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. // // 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 [-v|--verbose] // rounds default 20, delayMs default 200 // --out = 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, 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'; // args: [rounds] [delayMs] --out [-v|--verbose] // --out 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 [-v]'); console.error(' --out 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`; 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; // ---------- 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() { 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 }; 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; let lastRound = enc.round; // --- 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`); 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; } // 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; } // 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; } // 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; } // 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); if (VERBOSE) console.log(` toggleActive ${tgt.name}`); const res = await callStep('toggleParticipantActive', { participant: tgt.name }, (e) => toggleParticipantActive(e, tgt.id, ctx)); enc = res.enc; } } // 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; } // remove dead monster every 13 turns 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}`); const res = await callStep('removeParticipant', { participant: dead.name, dead: true }, (e) => removeParticipant(e, dead.id, ctx)); enc = res.enc; } } // 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; } } } } if (DELAY > 0) await sleep(DELAY); // advance turn (unless combat auto-ended) 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: 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) { 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; } 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} actor-turns, reached round ${enc.round} (cap ${ROUNDS})`); // end encounter enc = await storage.getDoc(encounterPath); 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'); } main().catch(err => { console.error('replay failed:', err); process.exit(1); });