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.
This commit is contained in:
david raistrick
2026-07-06 10:33:28 -04:00
parent 9f8b09c7d9
commit 2569cc4497
51 changed files with 4465 additions and 2164 deletions
+292 -299
View File
@@ -1,379 +1,372 @@
// scripts/replay-combat.js
// Drive a full combat through the LIVE backend via the ws storage adapter
// 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).
//
// Coverage goals (rotate across rounds):
// - nextTurn (every turn)
// - applyHpChange damage + heal (varying magnitude)
// - toggleCondition (all CONDITIONS at least once)
// - toggleParticipantActive (mark inactive, later reactivate)
// - deathSave (when a PC reaches 0 HP)
// - addParticipant (reinforcements drop in)
// - removeParticipant (dead monsters hauled off)
// - updateParticipant (edit fields mid-combat)
// - togglePause / resume
// - reorderParticipants (initiative reorder)
// - endEncounter (cleanup)
// 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.
//
// Run: node scripts/replay-combat.js [rounds] [delayMs]
// rounds default 100, delayMs default 200
'use strict';
// 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,
startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave,
toggleCondition, reorderParticipants, endEncounter,
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 ROUNDS = parseInt(process.argv[2], 10) || 100;
const DELAY = parseInt(process.argv[3], 10) || 200;
// 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`;
// Mirror App.js getPath. Adapter takes these; norm() strips prefix.
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 sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Use the ADAPTER as the contract boundary (same as App). No raw REST.
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)];
// Mirror App.js CONDITIONS so we exercise all of them.
const CONDITIONS = [
'alchemist_fire', 'bardic_inspiration', 'blinded', 'charmed', 'deafened',
'exhaustion', 'frightened', 'grappled', 'grazed', 'incapacitated',
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed',
];
// Custom (freeform) condition ids — DM-added strings. toggleCondition must
// accept ANY string (UI custom-condition contract). Exercise both paths.
const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed'];
async function patch(encounterPath, enc, result, label) {
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
await storage.updateDoc(encounterPath, result.patch);
if (label) console.log(` [${label}]`);
// emit pointer-advance line when a MUTATION changes currentTurnParticipantId.
// nextTurn passes label=null — it's a normal advance, already logged via
// the turn line. Emitting pointer for it double-counts.
const oldCur = enc.currentTurnParticipantId;
const oldRound = enc.round;
const newEnc = { ...enc, ...result.patch };
const newCur = newEnc.currentTurnParticipantId;
const newRound = newEnc.round;
if (label && oldCur && newCur && oldCur !== newCur) {
const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur;
const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur;
const wrap = oldRound !== newRound ? ' wrap' : '';
console.log(` [pointer ${oldName}${newName}${wrap}]`);
}
return newEnc;
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 },
];
}
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
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() {
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
const runStamp = new Date().toISOString().slice(0,19).replace('T', '_').replace(/:/g, '-');
const campaignId = crypto.randomUUID();
const encounterId = crypto.randomUUID();
await storage.setDoc(getPath.campaign(campaignId), {
name: `Replay Campaign (${new Date().toLocaleString('en-US', { hour12: false })})`,
playerDisplayBackgroundUrl: '',
ownerId: 'replay',
createdAt: new Date().toISOString(),
players: [
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
],
});
const charSpecs = [
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
];
const monsterSpecs = [
{ name: 'Goblin1', maxHp: 100, initMod: 2 },
{ name: 'Goblin2', maxHp: 100, initMod: 2 },
{ name: 'OrcBoss', maxHp: 500, initMod: 1 },
{ name: 'Wolf', maxHp: 120, initMod: 3 },
{ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true },
];
const participants = [
...charSpecs.map(c => buildCharacterParticipant(c).participant),
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
];
await storage.setDoc(getPath.encounter(campaignId, encounterId), {
name: `Big Boss Replay (${new Date().toLocaleString('en-US', { hour12: false })})`,
campaignId,
createdAt: new Date().toISOString(),
participants,
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
turnOrderIds: [],
});
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
hidePlayerHp: false,
});
await sleep(800);
const encounterPath = getPath.encounter(campaignId, encounterId);
const activeDisplayPath = getPath.activeDisplay();
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
// start
let enc = await storage.getDoc(encounterPath);
enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter');
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
await sleep(DELAY);
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;
const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS].sort(() => Math.random() - 0.5);
let reinforcementsAdded = 0;
let lastPaused = false;
let lastReorder = 0;
let lastRound = enc.round;
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
console.log(`--- round ${roundN} starting ---`);
// advance initiative until round counter ticks (full cycle done).
const cap = (enc.participants.length + 2) * 2;
let guard = 0;
while (enc.round < roundN + 1 && guard < cap) {
// NOTE: do NOT getDoc here — async re-fetch can return stale state and
// cause nextTurn to compute off pre-mutation data (double-acts/skips).
// Trust the local enc returned by patch (sync spread of updateDoc).
// --- 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`);
// 9. resume if paused: must happen BEFORE nextTurn or it throws.
if (lastPaused) {
enc = await patch(encounterPath, enc, togglePause(enc), 'resume');
lastPaused = false;
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;
}
let t;
try { t = nextTurn(enc); } catch (e) { console.log(` nextTurn err: ${e.message}`); break; }
enc = await patch(encounterPath, enc, t, null);
totalTurns++;
const actorName = firstActiveName(enc);
const actor = currentParticipant(enc);
// Dump turn line with order AND initiative (DM drag may reorder without
// changing init — log both so parser can flag unexplained shifts).
const ordStr = enc.turnOrderIds.map(id => {
const p = enc.participants.find(x => x.id === id);
return p ? `${p.name}:${p.initiative}` : id;
}).join(',');
// Also dump participants[] order (display source). Diverge from order = sync bug.
const pStr = enc.participants.map(p => `${p.name}:${p.initiative}`).join(',');
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] parts=[${pStr}] cur=${enc.currentTurnParticipantId}`);
// 1. damage: actor hits a random living, active target.
if (actor) {
const foes = enc.participants.filter(
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false && !p.name.startsWith('Dead')
);
if (foes.length > 0) {
const tgt = pick(foes);
const dmg = 1 + Math.floor(Math.random() * 5); // 1-5
const h = applyHpChange(enc, tgt.id, 'damage', dmg);
if (h.patch) {
await storage.updateDoc(encounterPath, h.patch);
enc = { ...enc, ...h.patch };
console.log(` ${actorName}${tgt.name} (-${dmg}, hp=${tgt.currentHp - dmg})`);
}
}
// 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;
}
// 2. heal: Cleric (when active) heals lowest-HP ally every other turn.
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
const wounded = enc.participants
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false)
.sort((a, b) => (a.currentHp / a.maxHp) - (b.currentHp / b.maxHp));
if (wounded.length > 0) {
const tgt = wounded[0];
const amt = 2 + Math.floor(Math.random() * 5); // 2-6
const h = applyHpChange(enc, tgt.id, 'heal', amt);
if (h.patch) {
await storage.updateDoc(encounterPath, h.patch);
enc = { ...enc, ...h.patch };
console.log(` Cleric heal → ${tgt.name} (+${amt}, hp=${tgt.currentHp + amt})`);
}
}
// 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;
}
// 3. conditions: toggle a queued condition off some participant each turn.
if (condQueue.length > 0) {
const cond = condQueue[0];
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
if (living.length > 0) {
const tgt = pick(living);
try {
const c = toggleCondition(enc, tgt.id, cond);
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
condQueue.shift();
} catch (e) { console.log(` condition ${cond} err: ${e.message}`); condQueue.shift(); }
}
} else if (totalTurns % 6 === 0) {
// second pass: toggle a random condition on random participant (add/remove).
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
if (living.length > 0) {
const tgt = pick(living);
const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
try {
const c = toggleCondition(enc, tgt.id, cond);
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
} catch (e) { /* ignore */ }
}
// 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;
}
// 4. toggleParticipantActive: randomly mark someone inactive, or reactivate.
// 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);
try {
const r = toggleParticipantActive(enc, tgt.id);
enc = await patch(encounterPath, enc, r, `${tgt.isActive === false ? 'reactivate' : 'deactivate'} ${tgt.name}`);
} catch (e) { /* ignore */ }
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
const res = await callStep('toggleParticipantActive',
{ participant: tgt.name },
(e) => toggleParticipantActive(e, tgt.id, ctx));
enc = res.enc;
}
}
// 5. deathSave: when a PC is at 0 HP on their turn, attempt a save.
if (actor && actor.currentHp <= 0 && !actor.isNpc && actor.name !== actor.name.startsWith('Monster')) {
try {
const ds = deathSave(enc, actor.id, 1);
enc = await patch(encounterPath, enc, ds, `deathSave ${actor.name} (+1 success)`);
} catch (e) { /* ignore */ }
// 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;
}
// 6. removeParticipant: dead monsters hauled off (every ~5 turns).
if (totalTurns % 5 === 0) {
const dead = enc.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf'));
// remove dead monster every 13 turns
if (totalTurns % 13 === 0) {
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
if (dead) {
try {
const r = removeParticipant(enc, dead.id);
enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`);
} catch (e) { /* ignore */ }
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;
}
}
// 7. addParticipant (reinforcements): every 10 turns a new monster joins.
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
const spec = pick([
{ name: `Reinforce${reinforcementsAdded + 1}`, maxHp: 120, initMod: 1 },
{ name: `Summon${reinforcementsAdded + 1}`, maxHp: 80, initMod: 4 },
]);
try {
const built = buildMonsterParticipant(spec).participant;
const r = addParticipant(enc, built);
enc = await patch(encounterPath, enc, r, `add ${spec.name}`);
reinforcementsAdded++;
} catch (e) { /* ignore */ }
}
// 8. updateParticipant: every 7 turns, edit a field on someone (e.g. temp AC).
if (totalTurns % 7 === 0) {
const living = enc.participants.filter(p => p.currentHp > 0);
if (living.length > 0) {
const tgt = pick(living);
try {
const r = updateParticipant(enc, tgt.id, { notes: `edited@turn${totalTurns}` });
enc = await patch(encounterPath, enc, r, `edit ${tgt.name} notes`);
} catch (e) { /* ignore */ }
// 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;
}
}
}
// 9. togglePause: every 12 turns, pause (resumes next iteration via above).
if (totalTurns % 12 === 0 && !lastPaused) {
enc = await patch(encounterPath, enc, togglePause(enc), 'pause');
lastPaused = true;
}
// 10. reorderParticipants: every 8 turns, drag one past another (DM reorder).
// Pick two ADJACENT UPCOMING actors (both strictly after current pointer)
// and swap them. Avoids crossing current pointer — crossing it creates
// ambiguous "who acted this round" semantics (skip/double). Swapping two
// upcoming actors is always safe and still exercises reorder.
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
const curIdx = enc.turnOrderIds.indexOf(enc.currentTurnParticipantId);
// upcoming = everyone after current in turn order (rest of this round)
const upcomingIds = enc.turnOrderIds.slice(curIdx + 1)
.filter(id => { const p = enc.participants.find(x => x.id === id); return p && p.currentHp > 0 && p.isActive !== false; });
// swap first adjacent upcoming pair (drag index1 before index0)
if (upcomingIds.length >= 2) {
const target = enc.participants.find(p => p.id === upcomingIds[0]);
const dragged = enc.participants.find(p => p.id === upcomingIds[1]);
try {
const r = reorderParticipants(enc, dragged.id, target.id);
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
lastReorder = totalTurns;
} catch (e) { /* swap not allowed — skip this round */ }
}
}
await sleep(DELAY);
guard++;
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
}
if (DELAY > 0) await sleep(DELAY);
// advance turn (unless combat auto-ended)
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
const alive = enc.participants.filter(p => p.currentHp > 0).length;
// revive dead: heal to full + reactivate. Sustains combat for 100 rounds
// and exercises toggleActive reactivate + heal-from-zero path.
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of dead) {
try {
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) {
enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
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;
}
const h = applyHpChange(enc, d.id, 'heal', d.maxHp);
enc = await patch(encounterPath, enc, h, `revive-heal ${d.name}${d.maxHp}`);
} catch (e) { console.log(` revive ${d.name} err: ${e.message}`); }
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} total turns across ${ROUNDS} rounds`);
console.log(`replay: ${totalTurns} actor-turns, reached round ${enc.round} (cap ${ROUNDS})`);
// end
// end encounter
enc = await storage.getDoc(encounterPath);
if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter');
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');
}
function firstActiveName(enc) {
if (!enc.currentTurnParticipantId) return '(none)';
const p = currentParticipant(enc);
return p ? p.name : '(missing)';
}
function currentParticipant(enc) {
if (!enc.currentTurnParticipantId) return null;
return (enc.participants || []).find(x => x.id === enc.currentTurnParticipantId) || null;
}
main().catch(err => { console.error('replay failed:', err); process.exit(1); });