// 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, stabilizeParticipant, reviveParticipant, 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, asNpc: 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.outcome})`; 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.status === 'dying' && (actor.type === 'character' || actor.type === 'npc')) { const outcomes = ['success', 'fail', 'nat1', 'nat20']; const outcome = outcomes[totalTurns % outcomes.length]; if (VERBOSE) console.log(` deathSave ${actor.name} ${outcome}`); const dsRes = await callStep('deathSave', { participant: actor.name, outcome }, async (e) => (await deathSave(e, actor.id, outcome, ctx)).enc); enc = dsRes.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 down = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false); for (const d of down) { if (interrupted) break; if (d.status === 'dead') { if (VERBOSE) console.log(` revive dead ${d.name}`); enc = (await callStep('reviveParticipant', { participant: d.name, revive: true }, (e) => reviveParticipant(e, d.id, ctx))).enc; } if (d.status === 'stable') { if (VERBOSE) console.log(` heal stable ${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; } const latest = (enc.participants || []).find(p => p.id === d.id); if (latest && latest.isActive === false) { if (VERBOSE) console.log(` reactivate ${latest.name}`); enc = (await callStep('toggleParticipantActive', { participant: latest.name, revive: true }, (e) => toggleParticipantActive(e, latest.id, ctx))).enc; } } } if (!enc.isStarted) { console.log('combat auto-ended'); break; } } console.log(`replay: ${totalTurns} turns, reached round ${enc.round} (cap ${rounds})`); await finishAndExit(); };