94b62dc5ab
replay-combat.js: - Turn line now dumps order=[Name:init,...] (both, not names only) - reorderParticipants call fixed: real drag (dragged→before target), correct signature (ids not array). Was broken (passed array, func wants ids, swallowed by try/catch silent no-op). analyze-turns.js: - Parse order=[Name:init,...] from turn lines - detectOrderShifts: compare order+init between consecutive turns. Flag shifts NOT explained by logged reorder, roster change (add/remove), or init change. Catches display/rotation divergence (invariant: display===turnOrderIds===nextTurn). - Report order shifts count + sample. CLEAN requires 0 shifts. Result: 100-round replay CLEAN (0 skips, 0 doubles, 0 shifts). Note: shift detector reads turnOrderIds dump. reorder still leaves turnOrderIds unchanged (BUG-6) — Path A (step 3) aligns display+rotation, then shift detector catches true divergence.
376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
// scripts/replay-combat.js
|
|
// Drive a full combat through the LIVE backend via the ws 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)
|
|
//
|
|
// Run: node scripts/replay-combat.js [rounds] [delayMs]
|
|
// rounds default 100, delayMs default 200
|
|
|
|
'use strict';
|
|
|
|
const shared = require('../shared');
|
|
const {
|
|
buildCharacterParticipant, buildMonsterParticipant,
|
|
startEncounter, nextTurn, togglePause,
|
|
addParticipant, updateParticipant, removeParticipant,
|
|
toggleParticipantActive, applyHpChange, deathSave,
|
|
toggleCondition, reorderParticipants, endEncounter,
|
|
} = shared;
|
|
const { createWsStorage } = require('../src/storage/ws');
|
|
|
|
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
|
const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws';
|
|
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
|
const DELAY = parseInt(process.argv[3], 10) || 200;
|
|
|
|
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`,
|
|
};
|
|
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
|
|
// Use the ADAPTER as the contract boundary (same as App). No raw REST.
|
|
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL });
|
|
|
|
// 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',
|
|
];
|
|
|
|
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 pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
|
|
|
async function main() {
|
|
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
|
|
|
|
const campaignId = crypto.randomUUID();
|
|
const encounterId = crypto.randomUUID();
|
|
|
|
await storage.setDoc(getPath.campaign(campaignId), {
|
|
name: 'Replay Campaign',
|
|
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',
|
|
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();
|
|
|
|
// 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 totalTurns = 0;
|
|
const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5);
|
|
let reinforcementsAdded = 0;
|
|
let lastPaused = false;
|
|
let lastReorder = 0;
|
|
|
|
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
|
// 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).
|
|
|
|
// 9. resume if paused: must happen BEFORE nextTurn or it throws.
|
|
if (lastPaused) {
|
|
enc = await patch(encounterPath, enc, togglePause(enc), 'resume');
|
|
lastPaused = false;
|
|
}
|
|
|
|
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(',');
|
|
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] 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})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
try {
|
|
const c = toggleCondition(enc, tgt.id, cond);
|
|
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
// 4. toggleParticipantActive: randomly mark someone inactive, or reactivate.
|
|
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 */ }
|
|
}
|
|
}
|
|
|
|
// 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 */ }
|
|
}
|
|
|
|
// 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'));
|
|
if (dead) {
|
|
try {
|
|
const r = removeParticipant(enc, dead.id);
|
|
enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`);
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
// 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 */ }
|
|
}
|
|
}
|
|
|
|
// 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 next (DM reorder).
|
|
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
|
if (living.length >= 3) {
|
|
// drag first past second (same-or-cross init, exercises reorder).
|
|
const dragged = living[0];
|
|
const target = living[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) { /* same-init only — try same-init pair */
|
|
const sameInit = living.find(p => p !== dragged && p.initiative === dragged.initiative);
|
|
if (sameInit) {
|
|
const r = reorderParticipants(enc, dragged.id, sameInit.id);
|
|
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${sameInit.name}`);
|
|
lastReorder = totalTurns;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await sleep(DELAY);
|
|
guard++;
|
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
|
}
|
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
|
const alive = enc.participants.filter(p => p.currentHp > 0).length;
|
|
console.log(`--- round ${roundN} complete (turns=${totalTurns}, alive=${alive}) ---`);
|
|
|
|
// 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 (d.isActive === false) {
|
|
enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
|
|
}
|
|
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}`); }
|
|
}
|
|
}
|
|
|
|
console.log(`replay: ${totalTurns} total turns across ${ROUNDS} rounds`);
|
|
|
|
// end
|
|
enc = await storage.getDoc(encounterPath);
|
|
if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter');
|
|
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
|
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); });
|