2026-06-29 14:36:02 -04:00
|
|
|
// scripts/replay-combat.js
|
2026-07-06 10:31:46 -04:00
|
|
|
// Drive a full combat through the LIVE backend via the server storage adapter
|
2026-06-29 15:49:39 -04:00
|
|
|
// (same contract boundary as the App), so the player display window
|
|
|
|
|
// (subscribed via WS) live-updates as combat progresses.
|
2026-06-29 14:36:02 -04:00
|
|
|
// Uses shared/turn.js for all turn logic (same model as the UI).
|
|
|
|
|
//
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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.
|
2026-06-29 15:49:39 -04:00
|
|
|
//
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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');
|
2026-06-29 14:36:02 -04:00
|
|
|
const shared = require('../shared');
|
|
|
|
|
const {
|
|
|
|
|
buildCharacterParticipant, buildMonsterParticipant,
|
2026-07-06 10:31:46 -04:00
|
|
|
startEncounter, nextTurn, togglePause, endEncounter,
|
2026-06-29 15:49:39 -04:00
|
|
|
addParticipant, updateParticipant, removeParticipant,
|
|
|
|
|
toggleParticipantActive, applyHpChange, deathSave,
|
2026-07-06 10:31:46 -04:00
|
|
|
toggleCondition, reorderParticipants,
|
2026-06-29 14:36:02 -04:00
|
|
|
} = shared;
|
2026-07-04 15:47:13 -04:00
|
|
|
const { createServerStorage } = require('../src/storage/server');
|
2026-06-29 14:36:02 -04:00
|
|
|
|
|
|
|
|
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
2026-07-04 15:47:13 -04:00
|
|
|
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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
|
2026-06-29 14:36:02 -04:00
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
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`,
|
2026-07-06 10:31:46 -04:00
|
|
|
logs: () => `${PUB}/logs`,
|
2026-06-29 15:13:03 -04:00
|
|
|
};
|
2026-06-29 14:36:02 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
|
2026-06-29 15:13:03 -04:00
|
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
2026-07-06 10:31:46 -04:00
|
|
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
2026-06-29 14:36:02 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
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 },
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-06-29 14:36:02 -04:00
|
|
|
|
2026-06-29 15:49:39 -04:00
|
|
|
const CONDITIONS = [
|
2026-07-06 10:31:46 -04:00
|
|
|
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
|
|
|
|
'frightened','grappled','incapacitated','invisible','paralyzed',
|
|
|
|
|
'petrified','poisoned','prone','restrained','sapped','shield',
|
|
|
|
|
'slowed','stunned','unconscious','vexed',
|
2026-06-29 15:49:39 -04:00
|
|
|
];
|
2026-07-06 10:31:46 -04:00
|
|
|
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),
|
|
|
|
|
};
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
function nameOf(enc, id) {
|
|
|
|
|
if (!id || !enc) return '(none)';
|
|
|
|
|
const p = (enc.participants || []).find(x => x.id === id);
|
|
|
|
|
return p ? p.name : '(missing)';
|
|
|
|
|
}
|
2026-06-29 15:49:39 -04:00
|
|
|
|
2026-06-29 14:36:02 -04:00
|
|
|
async function main() {
|
2026-07-06 10:31:46 -04:00
|
|
|
const runStamp = new Date().toISOString().slice(0,19).replace('T', '_').replace(/:/g, '-');
|
2026-06-29 14:36:02 -04:00
|
|
|
const campaignId = crypto.randomUUID();
|
|
|
|
|
const encounterId = crypto.randomUUID();
|
2026-06-29 15:13:03 -04:00
|
|
|
const encounterPath = getPath.encounter(campaignId, encounterId);
|
|
|
|
|
const activeDisplayPath = getPath.activeDisplay();
|
2026-07-06 10:31:46 -04:00
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-29 14:36:02 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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)}`);
|
2026-06-29 14:36:02 -04:00
|
|
|
|
2026-06-29 15:21:48 -04:00
|
|
|
let totalTurns = 0;
|
2026-07-06 10:31:46 -04:00
|
|
|
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;
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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;
|
2026-06-29 14:36:02 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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;
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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;
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// toggleActive every 9 turns
|
2026-06-29 15:49:39 -04:00
|
|
|
if (totalTurns % 9 === 0) {
|
|
|
|
|
const living = enc.participants.filter(p => p.currentHp > 0);
|
|
|
|
|
if (living.length > 0) {
|
|
|
|
|
const tgt = pick(living);
|
2026-07-06 10:31:46 -04:00
|
|
|
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
|
|
|
|
|
const res = await callStep('toggleParticipantActive',
|
|
|
|
|
{ participant: tgt.name },
|
|
|
|
|
(e) => toggleParticipantActive(e, tgt.id, ctx));
|
|
|
|
|
enc = res.enc;
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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;
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// remove dead monster every 13 turns
|
|
|
|
|
if (totalTurns % 13 === 0) {
|
|
|
|
|
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
|
2026-06-29 15:49:39 -04:00
|
|
|
if (dead) {
|
2026-07-06 10:31:46 -04:00
|
|
|
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;
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
}
|
2026-06-29 15:49:39 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
if (DELAY > 0) await sleep(DELAY);
|
2026-06-29 15:49:39 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// advance turn (unless combat auto-ended)
|
2026-06-29 14:36:02 -04:00
|
|
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
2026-07-06 10:31:46 -04:00
|
|
|
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) {
|
2026-06-29 15:49:39 -04:00
|
|
|
if (d.isActive === false) {
|
2026-07-06 10:31:46 -04:00
|
|
|
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;
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
2026-07-06 10:31:46 -04:00
|
|
|
|
|
|
|
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
2026-06-29 14:36:02 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
console.log(`replay: ${totalTurns} actor-turns, reached round ${enc.round} (cap ${ROUNDS})`);
|
2026-06-29 15:21:48 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// end encounter
|
2026-06-29 15:13:03 -04:00
|
|
|
enc = await storage.getDoc(encounterPath);
|
2026-07-06 10:31:46 -04:00
|
|
|
if (enc.isStarted) {
|
|
|
|
|
const res = await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
|
|
|
|
|
enc = res.enc;
|
|
|
|
|
}
|
2026-06-29 15:13:03 -04:00
|
|
|
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
2026-06-29 14:36:02 -04:00
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
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');
|
2026-06-29 15:49:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 14:36:02 -04:00
|
|
|
main().catch(err => { console.error('replay failed:', err); process.exit(1); });
|