Files
ttrpg-initiative-tracker/scripts/combat/replay.js
T
david raistrick 2569cc4497 Builds replayable combat logs with first-class undo/redo, unified verification tooling, fast indexed log queries, and stricter CI.
feat(combat): add first-class undo and redo controls

Add undo and redo controls to the combat UI so the DM can recover from recent
actions without leaving the encounter view.

Undo and redo operate on the current encounter's combat history. Empty stacks
produce clear feedback instead of failing silently. Redo order follows normal
stack behavior after multiple undos.

This makes combat history actionable during play, not just visible in the log.

feat(logs): make combat logs replayable

Replace plain combat log messages with structured combat events that can be
used by the UI, exported as JSON, replayed, and verified.

Each new log entry records the action type, encounter identity, participant
identity, a small action delta, undo intent, and a turn snapshot. Download and
copy now export the event stream as JSON so a saved combat log is useful for
offline analysis and debugging.

Legacy logs remain viewable, but new logs use the structured event format.

feat(logs): make undo and redo transactional

Apply undo and redo as single storage operations so the encounter state and log
state cannot drift apart.

Server storage applies the encounter update and the log undone flag inside one
SQLite transaction. Firebase storage uses a batch write for the same behavior.
The storage contract now includes undo/redo semantics.

This replaces fragile multi-write undo behavior where a failure could update
the encounter without marking the log, or mark the log without updating the
encounter.

feat(combat): add unified replay and verification tool

Add one combat CLI for replaying live combat and verifying combat logs.

Replay drives the live backend through the same shared combat logic used by the
app, writes a JSON event log to an explicit output path, and automatically
verifies the result. Verification checks for DM-visible combat problems such as
skipped turns, double actions, bad round changes, and unexpected turn order
changes.

The tool uses the same JSON event stream produced by log downloads, supports
verbose turn output, and handles Ctrl-C by ending the encounter, writing the
partial log, and verifying what was captured.

fix(perf): keep long combat logging fast

Remove the combat-time log query bottleneck that made long replays slow as log
volume grew.

Combat controls no longer subscribe to the log collection just to keep undo and
redo state warm. Undo and redo now query the latest matching encounter log only
when clicked. Server collection queries support exact filters, ordering,
limits, and offsets, and SQLite indexes keep latest-log and per-encounter log
lookups fast.

Also fix duplicate WebSocket handler registration so realtime updates do not
double-fire under write load.

fix(turns): make toggle active a status change

Make toggle active a roster/status edit instead of a turn advance.

Deactivating the current participant no longer passes the turn or increments
the round. The current turn stays where it is until the DM explicitly clicks
Next Turn, and Next Turn skips inactive participants during normal rotation.

This matches the initiative design: slot order is stable, toggle active does
not move participants, and round changes only come from explicit turn advance.

chore(ci): make warnings and hangs fail fast

Tighten test and build checks so failures are visible instead of noisy or
silent.

Builds run with CI enabled so warnings fail production builds. The full test
command runs app, shared, and server suites with hard timeouts so hangs fail
quickly. Static eslint coverage fails on warnings as well as errors.

Tests were updated around the new async combat logging flow, structured log
events, transactional undo, replay verification, and toggle-active semantics.
2026-07-06 10:33:28 -04:00

402 lines
16 KiB
JavaScript

// 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,
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, 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;
// 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.type})`;
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.currentHp <= 0 && !actor.isNpc) {
if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
enc = (await callStep('deathSave',
{ participant: actor.name, type: 'success', n: 1 },
(e) => deathSave(e, actor.id, 'success', 1, ctx))).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 dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of dead) {
if (interrupted) break;
if (d.isActive === false) {
if (VERBOSE) console.log(` revive ${d.name}`);
enc = (await callStep('toggleParticipantActive',
{ participant: d.name, revive: true },
(e) => toggleParticipantActive(e, d.id, ctx))).enc;
}
if (VERBOSE) console.log(` heal ${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;
}
}
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
}
console.log(`replay: ${totalTurns} turns, reached round ${enc.round} (cap ${rounds})`);
await finishAndExit();
};