From 7f60ec140ee30144ad5259f69fe171f11660b51f Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:29:35 -0400 Subject: [PATCH] fix(scenario): run 100 rounds not turns, exercise all combat paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/tests/Combat.scenario.test.js | 253 ++++++++++++++++++------------ 1 file changed, 151 insertions(+), 102 deletions(-) diff --git a/src/tests/Combat.scenario.test.js b/src/tests/Combat.scenario.test.js index b8091d4..435eebe 100644 --- a/src/tests/Combat.scenario.test.js +++ b/src/tests/Combat.scenario.test.js @@ -1,14 +1,20 @@ // Combat.scenario.test.js // 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: -// - encounter doc (combat state) -// - activeDisplay/status doc (display lifecycle + hide flags) -// Both written via shared funcs — single source of truth, no React. +// Does 100 ROUNDS (not turns). A round = one full pass through initiative +// (every active participant acts once); round counter increments at wrap. +// See docs/GLOSSARY.md. Previous version lied: called nextTurn 100× (~100 +// 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 { makeParticipant, @@ -25,12 +31,21 @@ const { applyHpChange, deathSave, toggleCondition, + reorderParticipants, endEncounter, activateDisplay, clearDisplay, toggleHidePlayerHp, } = 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) ---------- const ROUNDS = 100; @@ -52,17 +67,14 @@ let display = { hidePlayerHp: true, hideNpcHp: false, }; -// campaign roster (for addCharParticipant / addAll) const CAMPAIGN_ID = 'camp-1'; const ENCOUNTER_ID = 'enc-1'; const roster = []; -// apply encounter patch function applyEnc(patch) { if (!patch) return; for (const [k, v] of Object.entries(patch)) enc[k] = v; } -// apply display patch function applyDisplay(patch) { if (!patch) return; 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 }); } } -// ---------- 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) { 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) { const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc }); applyEnc(addParticipant(enc, participant).patch); } - -// add a campaign character into encounter (mirrors addCharacterParticipant) function addCharacterParticipant(charName) { const character = roster.find(c => c.name === charName); if (!character) throw new Error(`char not found: ${charName}`); 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); applyEnc(addParticipant(enc, participant).patch); } - -// add all campaign chars (mirrors Add All button → addParticipants) function addAllCharacters() { const toAdd = roster .filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id)) .map(c => buildCharacterParticipant(c).participant); applyEnc(addParticipants(enc, toAdd).patch); } - -// toggles -function toggleHidePlayerHpAction() { - applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch); -} function startCombat() { applyEnc(startEncounter(enc).patch); applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch); @@ -122,62 +126,53 @@ function endCombat() { applyEnc(endEncounter(enc).patch); applyDisplay(clearDisplay().patch); } -function nextTurnAction() { - 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 nextTurnAction() { 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'); } -// per-participant actions 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) applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch); } function applyHeal(name, amount) { - const p = enc.participants.find(x => x.name === name); - if (!p) throw new Error(`no heal target: ${name}`); + const p = any(name); + if (!p) return; // removed (death) = skip (no button in UI) applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch); } 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}`); applyEnc(combatToggleActive(enc, p.id).patch); } 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}`); applyEnc(combatToggleCondition(enc, p.id, label).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}`); applyEnc(updateParticipant(enc, p.id, patch).patch); } 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}`); applyEnc(removeParticipant(enc, p.id).patch); } 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}`); applyEnc(combatDeathSave(enc, p.id, saveNum).patch); } - -// aliases for combat funcs that clash with local helper names -const { - applyHpChange: combatApplyHpChange, - toggleParticipantActive: combatToggleActive, - toggleCondition: combatToggleCondition, - deathSave: combatDeathSave, -} = require('../../shared'); +function dragTie(draggedName, targetName) { + const d = any(draggedName), t = any(targetName); + if (!d || !t) throw new Error('drag target missing'); + applyEnc(reorderParticipants(enc, d.id, t.id).patch); +} +function toggleHidePlayerHpAction() { + applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch); +} // ---------- scenario ---------- @@ -198,26 +193,100 @@ test('full 100-round combat scenario (shared, no React)', async () => { await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter')); await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric')); 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 back', () => toggleHidePlayerHpAction()); await recordAsync('startCombat', () => startCombat()); - // 100 rounds of mixed actions - for (let r = 1; r <= ROUNDS; r++) { - await recordAsync(`round ${r} nextTurn`, () => nextTurnAction()); + // ---------- 100 ROUNDS (loop by actual round-wrap count) ---------- + let roundsDone = 0; + let prevRound = enc.round; + let turnInRound = 0; + let turnTotal = 0; - // rotation integrity: turnOrderIds no dup, currentTurn valid, mirrors participants - if (r % 10 === 0) { - record(`round ${r} rotation-check`, () => { + while (roundsDone < ROUNDS) { + turnTotal++; + 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 uniq = new Set(order); - if (uniq.size !== order.length) { - throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`); - } + if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`); if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) { 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 - if (r % 2 === 0) await recordAsync(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3)); - if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2)); - if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned')); - if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2')); + // advance turn + await recordAsync(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction()); + turnInRound++; - // pause/resume every 10 rounds, add a participant, edit, resume - if (r % 10 === 0) { - await recordAsync(`round ${r} pause`, () => pauseCombat()); - await recordAsync(`round ${r} addReinforcement`, () => - addMonsterParticipant(`Reinforce${r}`, 10, 1)); - 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()); + // round wrap detected + if (enc.round > prevRound) { + roundsDone++; + prevRound = enc.round; + turnInRound = 0; } } 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 ---------- - const failed = RESULTS.filter(r => !r.ok); + const failed = RESULTS.filter(x => !x.ok); if (failed.length > 0) { const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n'); // eslint-disable-next-line no-console console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`); } expect(failed).toEqual([]); + expect(roundsDone).toBe(ROUNDS); // lifecycle assertions (activeDisplay mirrors combat state) 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.activeEncounterId).toBeNull(); }); + +function anyById(id) { + return enc.participants.find(p => p.id === id); +}