tests: round-rotation audit, dup-id fail, replay rewrite

- turn.round-rotation.test.js: 7 tests, full round visits each active
  participant once (pure nextTurn clean). Green.
- turn.characterization.test.js: RED 'addParticipant rejects duplicate id'.
  Validates current behavior allows dup ids (self-inflicted in audit via
  loop spin-while-paused re-adding same id; unreachable in app via
  crypto.randomUUID, but documents gap).
- audit-rotation.js: pure turn.js simulation of replay op sequence.
  Detects rotation violations (skip/dupe per round). Pause disabled = 0
  violations across 100 rounds. Pause enabled = 56-77 violations starting
  round 20. Pinpoints addParticipant+pause interaction.
- repro-pause-bug.js: minimal repro scripts.
- replay-combat.js: rewritten for real rounds (full initiative cycles),
  visible damage each turn, all conditions, toggleActive, remove,
  reinforce, edit, pause/resume, reorder, endEncounter. HP bumped for
  100-round sustain + revive dead each round.

No feature code changed.
This commit is contained in:
david raistrick
2026-06-29 15:49:39 -04:00
parent 7866dec83b
commit 13490fe3de
5 changed files with 643 additions and 60 deletions
+152
View File
@@ -0,0 +1,152 @@
// scripts/audit-rotation.js
// Pure turn.js simulation of replay op sequence. Detects first round where
// rotation breaks (skip or dupe). Prints minimal repro + preceding ops.
// No backend, no WS, no sleep. Fast.
const shared = require('../shared');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave,
toggleCondition, reorderParticipants, endEncounter,
} = shared;
function makeParticipant(opts) { return shared.makeParticipant(opts); }
const ps = [
makeParticipant({ id: 'c1', name: 'Fighter', type: 'character', initiative: 14, maxHp: 200, currentHp: 200 }),
makeParticipant({ id: 'c2', name: 'Cleric', type: 'character', initiative: 10, maxHp: 180, currentHp: 180 }),
makeParticipant({ id: 'c3', name: 'Rogue', type: 'character', initiative: 15, maxHp: 160, currentHp: 160 }),
makeParticipant({ id: 'm1', name: 'Goblin1', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }),
makeParticipant({ id: 'm2', name: 'Goblin2', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }),
makeParticipant({ id: 'm3', name: 'OrcBoss', type: 'monster', initiative: 11, maxHp: 500, currentHp: 500 }),
makeParticipant({ id: 'm4', name: 'Wolf', type: 'monster', initiative: 13, maxHp: 120, currentHp: 120 }),
makeParticipant({ id: 'n1', name: 'Merchant', type: 'monster', initiative: 8, maxHp: 150, currentHp: 150, isNpc: true }),
];
let enc = {
name: 'audit', participants: ps,
isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
const opLog = [];
function log(label) { opLog.push({ round: enc.round, turn: currentName(enc), label }); }
function apply(result, label) {
if (!result || !result.patch) return;
enc = { ...enc, ...result.patch };
log(label);
}
function currentName(e) {
if (!e.currentTurnParticipantId) return '(none)';
const p = e.participants.find(x => x.id === e.currentTurnParticipantId);
return p ? p.name : '(missing)';
}
// start
apply(startEncounter(enc), 'startEncounter');
console.log(`start: order=${enc.turnOrderIds.join(',')} first=${currentName(enc)}`);
const ROUNDS = 100;
let totalTurns = 0;
let violations = [];
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
const startRound = enc.round;
const seenThisRound = [];
// record starting turn (already current at top of round)
seenThisRound.push(enc.currentTurnParticipantId);
const cap = (enc.participants.length + 2) * 2;
let guard = 0;
// BISECT: testing damage+heal+pause
const actor = enc.participants.find(p => p.id === enc.currentTurnParticipantId);
if (actor) {
const foes = enc.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false);
if (foes.length > 0) {
const tgt = foes[Math.floor(Math.random() * foes.length)];
const dmg = 1 + Math.floor(Math.random() * 5);
apply(applyHpChange(enc, tgt.id, 'damage', dmg), `damage ${actor.name}${tgt.name} -${dmg}`);
}
}
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
const wounded = enc.participants.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp).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);
apply(applyHpChange(enc, tgt.id, 'heal', amt), `heal ${tgt.name} +${amt}`);
}
}
if (totalTurns % 4 === 0) {
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
if (living.length > 0) {
const tgt = living[Math.floor(Math.random()*living.length)];
apply(toggleCondition(enc, tgt.id, 'stunned'), `condition stunned on ${tgt.name}`);
}
}
if (totalTurns % 9 === 0 && totalTurns > 0) {
const living = enc.participants.filter(p => p.currentHp > 0);
if (living.length > 0) {
const tgt = living[Math.floor(Math.random()*living.length)];
apply(toggleParticipantActive(enc, tgt.id), `toggleActive ${tgt.name}`);
}
}
if (totalTurns % 5 === 0 && totalTurns > 0) {
const dead = enc.participants.find(p => p.currentHp <= 0);
if (dead) apply(removeParticipant(enc, dead.id), `remove ${dead.name}`);
}
if (totalTurns % 10 === 0 && totalTurns > 0) {
const newP = makeParticipant({ id: `r${totalTurns}`, name: `R${totalTurns}`, type: 'monster', initiative: 9, maxHp: 100, currentHp: 100 });
apply(addParticipant(enc, newP), `add ${newP.name}`);
}
//REMOVED
//REMOVED
// 9. pause — re-enabled, isolating interaction
if (totalTurns % 12 === 0 && totalTurns > 0) {
apply(togglePause(enc), 'pause');
}
while (enc.round === startRound && guard < cap) {
// advance FIRST, then check wrap before recording
let t;
try { t = nextTurn(enc); } catch (e) { log(`nextTurn ERR: ${e.message}`); break; }
apply(t, 'nextTurn');
// stop at round wrap — nextTurn just rolled into new round
if (enc.round !== startRound) break;
totalTurns++;
seenThisRound.push(enc.currentTurnParticipantId);
guard++;
if (!enc.isStarted) break;
}
// audit this round
const uniq = new Set(seenThisRound);
const dupes = seenThisRound.filter(id => seenThisRound.indexOf(id) !== seenThisRound.lastIndexOf(id));
if (dupes.length > 0 || uniq.size < seenThisRound.length) {
violations.push({ round: roundN, seen: seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id), dupes });
if (violations.length <= 3) {
console.log(`\n=== VIOLATION round ${roundN} ===`);
console.log(` seen: ${seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id).join(' → ')}`);
console.log(` dupes: ${[...new Set(dupes)].map(id => enc.participants.find(p=>p.id===id)?.name||id).join(', ')}`);
// print op log for this round
const roundOps = opLog.filter(o => o.round === startRound || o.round === roundN);
console.log(` ops: ${roundOps.map(o => o.label).join(' | ')}`);
}
}
if (!enc.isStarted) { console.log('encounter ended'); break; }
// revive dead
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of dead) {
if (d.isActive === false) apply(toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
apply(applyHpChange(enc, d.id, 'heal', d.maxHp), `revive-heal ${d.name}${d.maxHp}`);
}
}
console.log(`\ntotal violations: ${violations.length} / ${ROUNDS} rounds`);
if (violations.length > 0) {
console.log('first 5:', violations.slice(0,5).map(v => `r${v.round}`));
}
+230 -60
View File
@@ -1,24 +1,40 @@
// scripts/replay-combat.js
// Drive a full combat through the LIVE backend (generic KV REST) so the player
// display window (subscribed via WS) live-updates as combat progresses.
// 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 800
// rounds default 100, delayMs default 200
'use strict';
const shared = require('../shared');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, applyHpChange, toggleCondition,
endEncounter,
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) || 10;
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';
@@ -34,15 +50,29 @@ const getPath = {
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Use the ADAPTER as the contract boundary (same as App). No raw REST, no
// hand-built paths — adapter normalizes internally. Catches path-shape drift
// that the earlier raw-REST replay caused.
// 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}]`);
return { ...enc, ...result.patch };
}
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}`);
// campaign + encounter. Adapter takes firebase-prefixed paths (same as App).
const campaignId = crypto.randomUUID();
const encounterId = crypto.randomUUID();
@@ -52,28 +82,27 @@ async function main() {
ownerId: 'replay',
createdAt: new Date().toISOString(),
players: [
{ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2 },
{ id: 'c2', name: 'Cleric', defaultMaxHp: 24, defaultInitMod: 1 },
{ id: 'c3', name: 'Rogue', defaultMaxHp: 22, defaultInitMod: 3 },
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
],
});
// build participants (roll initiative via shared)
const chars = [
{ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2 },
{ id: 'c2', name: 'Cleric', defaultMaxHp: 24, defaultInitMod: 1 },
{ id: 'c3', name: 'Rogue', defaultMaxHp: 22, 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: 8, initMod: 2 },
{ name: 'Goblin2', maxHp: 8, initMod: 2 },
{ name: 'OrcBoss', maxHp: 60, initMod: 1 },
{ name: 'Wolf', maxHp: 14, initMod: 3 },
{ name: 'Merchant', maxHp: 12, initMod: 0, isNpc: true },
{ 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 = [
...chars.map(c => buildCharacterParticipant(c).participant),
...charSpecs.map(c => buildCharacterParticipant(c).participant),
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
];
@@ -91,86 +120,227 @@ async function main() {
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
// point active display so player view shows it
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
hidePlayerHp: false,
});
await sleep(1000);
await sleep(800);
const encounterPath = getPath.encounter(campaignId, encounterId);
const activeDisplayPath = getPath.activeDisplay();
// start
let enc = await storage.getDoc(encounterPath);
const start = startEncounter(enc);
await storage.updateDoc(encounterPath, start.patch);
enc = { ...enc, ...start.patch };
enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter');
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
await sleep(DELAY);
// main loop: ROUNDS = full initiative cycles (each round = all participants act).
const DAMAGERS = ['Fighter', 'Cleric', 'Rogue', 'Wolf', 'Goblin1', 'Goblin2', 'OrcBoss'];
const TARGETS = ['Goblin1', 'Goblin2', 'Wolf', 'OrcBoss', 'Fighter', 'Cleric', 'Rogue', 'Merchant'];
let turnInRound = 0;
let prevRound = enc.round;
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 + 1) * 2;
const cap = (enc.participants.length + 2) * 2;
let guard = 0;
while (enc.round < roundN + 1 && guard < cap) {
enc = await storage.getDoc(encounterPath);
const t = nextTurn(enc);
await storage.updateDoc(encounterPath, t.patch);
enc = { ...enc, ...t.patch };
totalTurns++;
turnInRound++;
// visible action: current turn actor damages a random living target.
// 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 livingTargets = enc.participants.filter(
p => p.currentHp > 0 && p.name !== actorName && TARGETS.includes(p.name)
);
if (livingTargets.length > 0 && DAMAGERS.includes(actorName)) {
const tgt = livingTargets[Math.floor(Math.random() * livingTargets.length)];
const dmg = 3 + Math.floor(Math.random() * 8); // 3-10
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})`);
const actor = currentParticipant(enc);
// 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})`);
}
}
}
console.log(` turn ${turnInRound} (round ${enc.round}): ${actorName}`);
// 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 */ }
}
}
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName}`);
await sleep(DELAY);
guard++;
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
}
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
console.log(`--- round ${roundN} complete (${turnInRound} turns total) ---`);
turnInRound = 0;
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) {
const end = endEncounter(enc);
if (end.patch) await storage.updateDoc(encounterPath, end.patch);
}
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 = enc.participants.find(x => x.id === enc.currentTurnParticipantId);
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); });
+76
View File
@@ -0,0 +1,76 @@
// scripts/repro-pause-bug.js
// Minimal repro: pause+resume causes nextTurn to repeat same participant forever.
'use strict';
const shared = require('../shared');
const { makeParticipant, startEncounter, nextTurn, togglePause, addParticipant } = shared;
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 100, currentHp: 100 });
}
let e = {
name: 't', participants: [p('a', 20), p('b', 15), p('c', 10)],
isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
e = { ...e, ...startEncounter(e).patch };
console.log('start:', { current: e.currentTurnParticipantId, order: e.turnOrderIds, round: e.round });
// advance 1 turn
e = { ...e, ...nextTurn(e).patch };
console.log('turn1:', { current: e.currentTurnParticipantId, round: e.round });
// pause then resume immediately
e = { ...e, ...togglePause(e).patch };
console.log('paused:', { isPaused: e.isPaused, current: e.currentTurnParticipantId, order: e.turnOrderIds });
e = { ...e, ...togglePause(e).patch };
console.log('resumed:', { isPaused: e.isPaused, current: e.currentTurnParticipantId, order: e.turnOrderIds });
// advance 5 turns — should visit b, c, a, b, c
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < 5; i++) {
e = { ...e, ...nextTurn(e).patch };
visited.push(e.currentTurnParticipantId);
}
console.log('5 turns after resume:', visited);
// now repro with addParticipant while paused
let e2 = {
name: 't', participants: [p('a', 20), p('b', 15), p('c', 10)],
isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
e2 = { ...e2, ...startEncounter(e2).patch };
e2 = { ...e2, ...nextTurn(e2).patch }; // current=b
const newP = makeParticipant({ id: 'x', name: 'x', type: 'monster', initiative: 25, maxHp: 100, currentHp: 100 });
e2 = { ...e2, ...addParticipant(e2, newP).patch };
console.log('\nadded x while running:', { current: e2.currentTurnParticipantId, order: e2.turnOrderIds });
e2 = { ...e2, ...togglePause(e2).patch };
e2 = { ...e2, ...togglePause(e2).patch };
console.log('after pause/resume:', { current: e2.currentTurnParticipantId, order: e2.turnOrderIds });
const v2 = [e2.currentTurnParticipantId];
for (let i = 0; i < 5; i++) {
e2 = { ...e2, ...nextTurn(e2).patch };
v2.push(e2.currentTurnParticipantId);
}
console.log('5 turns after add+pause/resume:', v2);
// repro 3: addParticipant WHILE paused, then resume
let e3 = {
name: 't', participants: [p('a', 20), p('b', 15), p('c', 10)],
isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
e3 = { ...e3, ...startEncounter(e3).patch };
e3 = { ...e3, ...nextTurn(e3).patch }; // current=b
console.log('\n--- add while PAUSED ---');
e3 = { ...e3, ...togglePause(e3).patch }; // pause
console.log('paused:', { current: e3.currentTurnParticipantId, order: e3.turnOrderIds });
const np = makeParticipant({ id: 'x', name: 'x', type: 'monster', initiative: 25, maxHp: 100, currentHp: 100 });
e3 = { ...e3, ...addParticipant(e3, np).patch };
console.log('add-while-paused:', { current: e3.currentTurnParticipantId, order: e3.turnOrderIds });
e3 = { ...e3, ...togglePause(e3).patch }; // resume (rebuilds order)
console.log('resumed:', { current: e3.currentTurnParticipantId, order: e3.turnOrderIds });
const v3 = [e3.currentTurnParticipantId];
for (let i = 0; i < 5; i++) {
e3 = { ...e3, ...nextTurn(e3).patch };
v3.push(e3.currentTurnParticipantId);
}
console.log('5 turns after add-while-paused+resume:', v3);