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:
@@ -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
@@ -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); });
|
||||
|
||||
@@ -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);
|
||||
@@ -337,4 +337,14 @@ describe('addParticipant', () => {
|
||||
const { patch } = addParticipant(enc([p('a', 10)]), np);
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']);
|
||||
});
|
||||
|
||||
test('rejects duplicate id (skip-bug root cause)', () => {
|
||||
// Two participants with same id → togglePause resume rebuilds order with
|
||||
// dup id twice → nextTurn gets stuck repeating that id forever.
|
||||
// Audit found this in 100-round replay (addParticipant fired while paused
|
||||
// because nextTurn threw, loop spun, same totalTurns %10 → re-added).
|
||||
const existing = p('x', 5);
|
||||
const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 });
|
||||
expect(() => addParticipant(enc([p('a', 10), existing]), dup)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Regression test: full round must rotate through ALL active participants exactly once.
|
||||
// Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter
|
||||
// before any coverage action). nextTurn has core bug, not just coverage-path issue.
|
||||
//
|
||||
// This test is RED until nextTurn fixed.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { startEncounter, nextTurn, makeParticipant } = shared;
|
||||
|
||||
function p(id, initiative, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative, maxHp: 20, currentHp: 20,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function enc(ps) {
|
||||
return {
|
||||
name: 'T', participants: ps,
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('round rotation integrity', () => {
|
||||
test('3 participants: one full round visits each exactly once', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
|
||||
// advance (len-1) turns: visits remaining participants, round NOT yet wrapped.
|
||||
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
|
||||
expect(e.round).toBe(1); // still round 1
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBe(startOrder.length); // each exactly once
|
||||
expect(visited.length).toBe(startOrder.length);
|
||||
});
|
||||
|
||||
test('8 participants (replay shape): one full round visits each exactly once', () => {
|
||||
const ps = [
|
||||
p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11),
|
||||
p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10),
|
||||
];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
|
||||
expect(e.round).toBe(1);
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBe(startOrder.length);
|
||||
expect(visited.length).toBe(startOrder.length);
|
||||
});
|
||||
|
||||
test('multiple rounds: each round visits each participant exactly once', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
const expectedRound = e.round;
|
||||
|
||||
// capture exactly one full round (current + len-1 advances), no wrap yet.
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBe(startOrder.length);
|
||||
expect(e.round).toBe(expectedRound);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round rotation with mid-round state changes', () => {
|
||||
const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared;
|
||||
|
||||
test('toggle a participant inactive mid-round, others still each visited once', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||
// now mark 'a' inactive (already took its turn)
|
||||
e = { ...e, ...toggleParticipantActive(e, 'a').patch };
|
||||
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||
// round should wrap, but 'a' inactive so only b,c,d visited
|
||||
const visitedActive = visited.filter(id => id !== 'a');
|
||||
const uniq = new Set(visitedActive);
|
||||
expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once
|
||||
});
|
||||
|
||||
test('reactivate inactive participant mid-round, it gets a turn this round', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||
let e = enc(ps);
|
||||
// start with 'c' inactive
|
||||
e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const startOrder = e.turnOrderIds.slice(); // should be a,b,d (c excluded)
|
||||
|
||||
expect(startOrder).not.toContain('c');
|
||||
|
||||
// advance one turn, then reactivate c
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
e = { ...e, ...toggleParticipantActive(e, 'c').patch };
|
||||
|
||||
// continue rotation - c should now be reachable
|
||||
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
expect(visited).toContain('c');
|
||||
});
|
||||
|
||||
test('addParticipant mid-round: new participant gets turn this round or next', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // advance one
|
||||
// add new participant
|
||||
const newP = p('x', 25);
|
||||
e = { ...e, ...addParticipant(e, newP).patch };
|
||||
|
||||
// finish round - original 3 should still each get exactly one turn
|
||||
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||
while (e.round === 1) {
|
||||
const r = nextTurn(e);
|
||||
e = { ...e, ...r.patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
if (visited.length > 20) break; // safety
|
||||
}
|
||||
const originals = visited.filter(id => ['a','b','c'].includes(id));
|
||||
const uniq = new Set(originals);
|
||||
expect(uniq.size).toBe(3);
|
||||
});
|
||||
|
||||
test('reorderParticipants mid-round keeps rotation valid', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15)
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const startOrder = e.turnOrderIds.slice();
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
// reorder: swap b,c (same initiative)
|
||||
e = { ...e, ...reorderParticipants(e, 'b', 'c').patch };
|
||||
|
||||
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||
for (let i = 0; i < startOrder.length; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user