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.
373 lines
15 KiB
JavaScript
373 lines
15 KiB
JavaScript
// 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 <path> [-v|--verbose]
|
|
// rounds default 20, delayMs default 200
|
|
// --out <path> = 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 <path> [-v|--verbose]
|
|
// --out <path> 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 <path> [-v]');
|
|
console.error(' --out <path> 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); });
|