fix(scenario): run 100 rounds not turns, exercise all combat paths

Previous test lied: called nextTurn 100x = ~100 turns = ~12 rounds (see
docs/GLOSSARY.md turn vs round). Now loops by actual round-wrap count.

100 rounds = 975 turns verified via console log + expect(roundsDone).toBe(100).

Exercises ALL shared combat paths deterministically (vs replay random):
startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange
(damage+heal), deathSave (1/2/3-success → isDying path), toggleCondition,
reorderParticipants (drag same-init tie), endEncounter, activateDisplay,
clearDisplay, toggleHidePlayerHp.

Rotation integrity checked every 10 rounds: no dup turnOrderIds, currentTurn
in order, turnOrderIds === participants.map(id).

Run time: 19ms (was 72s React, lieing).
This commit is contained in:
david raistrick
2026-07-03 18:29:35 -04:00
parent e650cd2710
commit 7f60ec140e
+151 -102
View File
@@ -1,14 +1,20 @@
// Combat.scenario.test.js // Combat.scenario.test.js
// Full combat scenario driven through shared/turn.js directly — NO React. // Full combat scenario driven through shared/turn.js directly — NO React.
// Same actions + invariants as the old UI version, but pure functions = fast.
// Purpose (verbatim from original): exercise full feature surface
// (damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove)
// in one long combat, surfacing behavioral bugs characterization tests miss.
// //
// Two storage docs modeled exactly like App writes: // Does 100 ROUNDS (not turns). A round = one full pass through initiative
// - encounter doc (combat state) // (every active participant acts once); round counter increments at wrap.
// - activeDisplay/status doc (display lifecycle + hide flags) // See docs/GLOSSARY.md. Previous version lied: called nextTurn 100× (~100
// Both written via shared funcs — single source of truth, no React. // turns, ~12 rounds). Now loops by actual round-wrap count.
//
// Exercises ALL combat paths deterministically (less random than replay):
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
// updateParticipant, removeParticipant, toggleParticipantActive,
// applyHpChange (damage/heal), deathSave (incl. 3-success → isDying),
// toggleCondition, reorderParticipants (drag same-init tie), endEncounter,
// activateDisplay, clearDisplay, toggleHidePlayerHp.
//
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
// failures collected, final expect reports all.
const { const {
makeParticipant, makeParticipant,
@@ -25,12 +31,21 @@ const {
applyHpChange, applyHpChange,
deathSave, deathSave,
toggleCondition, toggleCondition,
reorderParticipants,
endEncounter, endEncounter,
activateDisplay, activateDisplay,
clearDisplay, clearDisplay,
toggleHidePlayerHp, toggleHidePlayerHp,
} = require('../../shared'); } = require('../../shared');
// aliases for combat funcs that clash with local helper names
const {
applyHpChange: combatApplyHpChange,
toggleParticipantActive: combatToggleActive,
toggleCondition: combatToggleCondition,
deathSave: combatDeathSave,
} = require('../../shared');
// ---------- scenario state (mirrors two firestore docs) ---------- // ---------- scenario state (mirrors two firestore docs) ----------
const ROUNDS = 100; const ROUNDS = 100;
@@ -52,17 +67,14 @@ let display = {
hidePlayerHp: true, hidePlayerHp: true,
hideNpcHp: false, hideNpcHp: false,
}; };
// campaign roster (for addCharParticipant / addAll)
const CAMPAIGN_ID = 'camp-1'; const CAMPAIGN_ID = 'camp-1';
const ENCOUNTER_ID = 'enc-1'; const ENCOUNTER_ID = 'enc-1';
const roster = []; const roster = [];
// apply encounter patch
function applyEnc(patch) { function applyEnc(patch) {
if (!patch) return; if (!patch) return;
for (const [k, v] of Object.entries(patch)) enc[k] = v; for (const [k, v] of Object.entries(patch)) enc[k] = v;
} }
// apply display patch
function applyDisplay(patch) { function applyDisplay(patch) {
if (!patch) return; if (!patch) return;
for (const [k, v] of Object.entries(patch)) display[k] = v; for (const [k, v] of Object.entries(patch)) display[k] = v;
@@ -78,42 +90,34 @@ async function recordAsync(phase, fn) {
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); } catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
} }
// ---------- UI-equivalent helpers (call shared, no React) ---------- // ---------- helpers (shared, no React) ----------
const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
const any = (name) => enc.participants.find(p => p.name === name);
const idOf = (name) => { const p = any(name); return p ? p.id : null; };
// addChar to campaign roster (mirrors CharacterManager.addCharacter)
function addCharacterViaUI(name, maxHp, initMod) { function addCharacterViaUI(name, maxHp, initMod) {
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod }); roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
} }
// add monster to encounter (mirrors ParticipantManager add monster)
function addMonsterParticipant(name, maxHp, initMod, isNpc = false) { function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc }); const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
applyEnc(addParticipant(enc, participant).patch); applyEnc(addParticipant(enc, participant).patch);
} }
// add a campaign character into encounter (mirrors addCharacterParticipant)
function addCharacterParticipant(charName) { function addCharacterParticipant(charName) {
const character = roster.find(c => c.name === charName); const character = roster.find(c => c.name === charName);
if (!character) throw new Error(`char not found: ${charName}`); if (!character) throw new Error(`char not found: ${charName}`);
if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) { if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
throw new Error(`${charName} already in encounter`); // mirrors App alert-guard throw new Error(`${charName} already in encounter`);
} }
const { participant } = buildCharacterParticipant(character); const { participant } = buildCharacterParticipant(character);
applyEnc(addParticipant(enc, participant).patch); applyEnc(addParticipant(enc, participant).patch);
} }
// add all campaign chars (mirrors Add All button → addParticipants)
function addAllCharacters() { function addAllCharacters() {
const toAdd = roster const toAdd = roster
.filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id)) .filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
.map(c => buildCharacterParticipant(c).participant); .map(c => buildCharacterParticipant(c).participant);
applyEnc(addParticipants(enc, toAdd).patch); applyEnc(addParticipants(enc, toAdd).patch);
} }
// toggles
function toggleHidePlayerHpAction() {
applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
}
function startCombat() { function startCombat() {
applyEnc(startEncounter(enc).patch); applyEnc(startEncounter(enc).patch);
applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch); applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch);
@@ -122,62 +126,53 @@ function endCombat() {
applyEnc(endEncounter(enc).patch); applyEnc(endEncounter(enc).patch);
applyDisplay(clearDisplay().patch); applyDisplay(clearDisplay().patch);
} }
function nextTurnAction() { function nextTurnAction() { applyEnc(nextTurn(enc).patch); }
applyEnc(nextTurn(enc).patch); function pauseCombat() { applyEnc(togglePause(enc).patch); if (!enc.isPaused) throw new Error('not paused'); }
} function resumeCombat() { applyEnc(togglePause(enc).patch); if (enc.isPaused) throw new Error('not resumed'); }
function pauseCombat() {
applyEnc(togglePause(enc).patch);
if (!enc.isPaused) throw new Error('not paused');
}
function resumeCombat() {
applyEnc(togglePause(enc).patch);
if (enc.isPaused) throw new Error('not resumed');
}
// per-participant actions
function applyDamage(name, amount) { function applyDamage(name, amount) {
const p = enc.participants.find(x => x.name === name); const p = any(name);
if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI) if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI)
applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch); applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch);
} }
function applyHeal(name, amount) { function applyHeal(name, amount) {
const p = enc.participants.find(x => x.name === name); const p = any(name);
if (!p) throw new Error(`no heal target: ${name}`); if (!p) return; // removed (death) = skip (no button in UI)
applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch); applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch);
} }
function toggleActive(name) { function toggleActive(name) {
const p = enc.participants.find(x => x.name === name); const p = any(name);
if (!p) throw new Error(`no active target: ${name}`); if (!p) throw new Error(`no active target: ${name}`);
applyEnc(combatToggleActive(enc, p.id).patch); applyEnc(combatToggleActive(enc, p.id).patch);
} }
function toggleConditionAction(name, label) { function toggleConditionAction(name, label) {
const p = enc.participants.find(x => x.name === name); const p = any(name);
if (!p) throw new Error(`no condition target: ${name}`); if (!p) throw new Error(`no condition target: ${name}`);
applyEnc(combatToggleCondition(enc, p.id, label).patch); applyEnc(combatToggleCondition(enc, p.id, label).patch);
} }
function editParticipant(name, patch) { function editParticipant(name, patch) {
const p = enc.participants.find(x => x.name === name); const p = any(name);
if (!p) throw new Error(`no edit target: ${name}`); if (!p) throw new Error(`no edit target: ${name}`);
applyEnc(updateParticipant(enc, p.id, patch).patch); applyEnc(updateParticipant(enc, p.id, patch).patch);
} }
function removeParticipantByName(name) { function removeParticipantByName(name) {
const p = enc.participants.find(x => x.name === name); const p = any(name);
if (!p) throw new Error(`no remove target: ${name}`); if (!p) throw new Error(`no remove target: ${name}`);
applyEnc(removeParticipant(enc, p.id).patch); applyEnc(removeParticipant(enc, p.id).patch);
} }
function deathSaveAction(name, saveNum) { function deathSaveAction(name, saveNum) {
const p = enc.participants.find(x => x.name === name); const p = any(name);
if (!p) throw new Error(`no deathsave target: ${name}`); if (!p) throw new Error(`no deathsave target: ${name}`);
applyEnc(combatDeathSave(enc, p.id, saveNum).patch); applyEnc(combatDeathSave(enc, p.id, saveNum).patch);
} }
function dragTie(draggedName, targetName) {
// aliases for combat funcs that clash with local helper names const d = any(draggedName), t = any(targetName);
const { if (!d || !t) throw new Error('drag target missing');
applyHpChange: combatApplyHpChange, applyEnc(reorderParticipants(enc, d.id, t.id).patch);
toggleParticipantActive: combatToggleActive, }
toggleCondition: combatToggleCondition, function toggleHidePlayerHpAction() {
deathSave: combatDeathSave, applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
} = require('../../shared'); }
// ---------- scenario ---------- // ---------- scenario ----------
@@ -198,26 +193,100 @@ test('full 100-round combat scenario (shared, no React)', async () => {
await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter')); await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric')); await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue')); await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
await recordAsync('addAllChars', () => addAllCharacters()); await recordAsync('addAllChars', () => addAllCharacters()); // bulk add path
// hidden hp toggle (x2 = back to default) // hidden hp toggle x2 (back to default)
record('toggleHidePlayerHp', () => toggleHidePlayerHpAction()); record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction()); record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
await recordAsync('startCombat', () => startCombat()); await recordAsync('startCombat', () => startCombat());
// 100 rounds of mixed actions // ---------- 100 ROUNDS (loop by actual round-wrap count) ----------
for (let r = 1; r <= ROUNDS; r++) { let roundsDone = 0;
await recordAsync(`round ${r} nextTurn`, () => nextTurnAction()); let prevRound = enc.round;
let turnInRound = 0;
let turnTotal = 0;
// rotation integrity: turnOrderIds no dup, currentTurn valid, mirrors participants while (roundsDone < ROUNDS) {
if (r % 10 === 0) { turnTotal++;
record(`round ${r} rotation-check`, () => { const actor = anyById(enc.currentTurnParticipantId);
const r = enc.round;
// per-turn deterministic actions keyed by round + actor type
// damage OrcBoss every even round
if (r % 2 === 0 && turnInRound === 0) {
await recordAsync(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
}
// heal Cleric every 3 rounds
if (r % 3 === 0 && turnInRound === 1) {
record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
}
// condition on Fighter every 5 rounds
if (r % 5 === 0 && turnInRound === 2) {
record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
}
// toggleActive Goblin2 every 7 rounds (toggle off, next 7 toggle on)
if (r % 7 === 0 && turnInRound === 3) {
record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
}
// edit Wolf initiative every 13 rounds
if (r % 13 === 0 && turnInRound === 4) {
record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
}
// pause/resume + add reinforcement + edit every 10 rounds
if (r % 10 === 0 && turnInRound === 0) {
await recordAsync(`r${r} pause`, () => pauseCombat());
await recordAsync(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
await recordAsync(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
await recordAsync(`r${r} resume`, () => resumeCombat());
}
// create a same-init tie + drag every 15 rounds (exercises reorderParticipants)
if (r % 15 === 0 && turnInRound === 5) {
const g1 = any('Goblin1');
if (g1 && alive('Goblin2')) {
record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
}
}
// death scenarios: drop a PC to 0, death saves, revive
if (r === 25 && turnInRound === 0) {
await recordAsync(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 1));
record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
// round 50: full 3-success death-save path (isDying) on Cleric, then remove
if (r === 50 && turnInRound === 0) {
await recordAsync(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
await recordAsync(`r${r} deathSave Cleric x3 (isDying)`, async () => {
deathSaveAction('Cleric', 1);
deathSaveAction('Cleric', 2);
deathSaveAction('Cleric', 3); // → isDying true
});
const cl = any('Cleric');
if (cl && cl.isDying) record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
}
// remove a reinforcement every 30 rounds
if (r % 30 === 0 && turnInRound === 1) {
const rein = any(`Reinforce${r - 10}`);
if (rein) record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
}
// toggle hide hp every 20 rounds
if (r % 20 === 0 && turnInRound === 0) {
record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
}
// rotation integrity check every 10 rounds at round start
if (turnInRound === 0 && r % 10 === 0) {
record(`r${r} rotation-check`, () => {
const order = enc.turnOrderIds || []; const order = enc.turnOrderIds || [];
const uniq = new Set(order); const uniq = new Set(order);
if (uniq.size !== order.length) { if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
}
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) { if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`); throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
} }
@@ -228,57 +297,33 @@ test('full 100-round combat scenario (shared, no React)', async () => {
}); });
} }
// damage front monster every other round // advance turn
if (r % 2 === 0) await recordAsync(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3)); await recordAsync(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2)); turnInRound++;
if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
// pause/resume every 10 rounds, add a participant, edit, resume // round wrap detected
if (r % 10 === 0) { if (enc.round > prevRound) {
await recordAsync(`round ${r} pause`, () => pauseCombat()); roundsDone++;
await recordAsync(`round ${r} addReinforcement`, () => prevRound = enc.round;
addMonsterParticipant(`Reinforce${r}`, 10, 1)); turnInRound = 0;
await recordAsync(`round ${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
await recordAsync(`round ${r} resume`, () => resumeCombat());
}
// edit initiative on Wolf every 13
if (r % 13 === 0) record(`round ${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
// damage-to-0 + death save on Rogue around round 25
if (r === 25) {
await recordAsync(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
record(`round ${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 1));
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
if (r === 50) {
await recordAsync(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
await recordAsync(`round ${r} deathSave Cleric x2`, async () => {
deathSaveAction('Cleric', 1);
deathSaveAction('Cleric', 2);
});
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
}
// remove a reinforcement late
if (r === 30) {
await recordAsync(`round ${r} pause`, () => pauseCombat());
record(`round ${r} remove Reinforce20`, () => removeParticipantByName('Reinforce20'));
await recordAsync(`round ${r} resume`, () => resumeCombat());
} }
} }
await recordAsync('endCombat', () => endCombat()); await recordAsync('endCombat', () => endCombat());
// sanity log: prove we actually ran ROUNDS rounds, not ~ROUNDS turns
// eslint-disable-next-line no-console
console.log(`\nscenario: ${roundsDone} rounds, ${turnTotal} turns\n`);
// ---------- report ---------- // ---------- report ----------
const failed = RESULTS.filter(r => !r.ok); const failed = RESULTS.filter(x => !x.ok);
if (failed.length > 0) { if (failed.length > 0) {
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n'); const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`); console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
} }
expect(failed).toEqual([]); expect(failed).toEqual([]);
expect(roundsDone).toBe(ROUNDS);
// lifecycle assertions (activeDisplay mirrors combat state) // lifecycle assertions (activeDisplay mirrors combat state)
expect(enc.isStarted).toBe(false); expect(enc.isStarted).toBe(false);
@@ -287,3 +332,7 @@ test('full 100-round combat scenario (shared, no React)', async () => {
expect(display.activeCampaignId).toBeNull(); expect(display.activeCampaignId).toBeNull();
expect(display.activeEncounterId).toBeNull(); expect(display.activeEncounterId).toBeNull();
}); });
function anyById(id) {
return enc.participants.find(p => p.id === id);
}