494327ff17
Extract shared nextActiveAfter() advance core. Both nextTurn and computeTurnOrderAfterRemoval delegate to it — single source of truth, eliminates drift risk where one path changes and the other doesn't. Previously two separate advance implementations computed the same target, but any future edit to one would silently desync deact-current advance from normal nextTurn advance. Replay (scripts/replay-combat.js): - Move turn-line print before mutations (event order = reality) - Emit [pointer X→Y] lines when a mutation advances currentTurnParticipantId - Emit [pointer X→Y wrap] when round bumps (removal-wrap case) - Skip pointer emission for nextTurn (label=null) — already logged via turn line Parser (scripts/analyze-turns.js): - Parse [pointer X→Y wrap] events - Credit pointer-target as acted (deact-current advance = turn pointer) - Wrap pointer credits NEXT round (not current) — fixes cross-round false skip - Drop currentRemoved special-case — pointer lines make skip check precise Tests: - shared/tests/turn.dry.test.js: 3 tests lock deact-current advance == nextTurn advance (mid-round, inactive-skipper, wrap+round-bump). RED catches future drift. Results: 500-round replay now 0 real skips, 0 double-acts (was 5+3). Shared suite: 79 green + 1 RED (BUG-6 reorder, intentional).
364 lines
15 KiB
JavaScript
364 lines
15 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);
|
|
|
|
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${enc.turnOrderIds.map(id=>enc.participants.find(p=>p.id===id)?.name||id).join(',')}] 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, shuffle initiative slightly.
|
|
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
|
if (living.length >= 2) {
|
|
// bump first participant's initiative by +1 (deterministic reorder).
|
|
const tgt = living[0];
|
|
const newInit = (tgt.initiative || 0) + 1;
|
|
try {
|
|
const reordered = [...enc.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p);
|
|
const r = reorderParticipants(enc, reordered);
|
|
enc = await patch(encounterPath, enc, r, `reorder (${tgt.name} init→${newInit})`);
|
|
lastReorder = totalTurns;
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
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); });
|