// Combat.scenario.test.js // Full combat scenario driven through shared/turn.js directly --- 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, buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, addParticipant, addParticipants, updateParticipant, removeParticipant, toggleParticipantActive, 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; // encounter doc let enc = { name: 'BigBoss', participants: [], isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], }; // activeDisplay/status doc let display = { activeCampaignId: null, activeEncounterId: null, hidePlayerHp: true, hideNpcHp: false, }; const CAMPAIGN_ID = 'camp-1'; const ENCOUNTER_ID = 'enc-1'; const roster = []; function applyEnc(patch) { if (!patch) return; for (const [k, v] of Object.entries(patch)) enc[k] = v; } function applyDisplay(patch) { if (!patch) return; for (const [k, v] of Object.entries(patch)) display[k] = v; } const RESULTS = []; function record(phase, fn) { try { fn(); RESULTS.push({ phase, ok: true }); } catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); } } async function recordAsync(phase, fn) { try { await fn(); RESULTS.push({ phase, ok: true }); } catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); } } // ---------- 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; }; function addCharacterViaUI(name, maxHp, initMod) { roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod }); } function addMonsterParticipant(name, maxHp, initMod, isNpc = false) { const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc }); applyEnc(addParticipant(enc, participant).patch); } 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`); } const { participant } = buildCharacterParticipant(character); applyEnc(addParticipant(enc, participant).patch); } 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); } function startCombat() { applyEnc(startEncounter(enc).patch); applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch); } 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 applyDamage(name, amount) { 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 = 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 = any(name); if (!p) throw new Error(`no active target: ${name}`); applyEnc(combatToggleActive(enc, p.id).patch); } function toggleConditionAction(name, label) { 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 = any(name); if (!p) throw new Error(`no edit target: ${name}`); applyEnc(updateParticipant(enc, p.id, patch).patch); } function removeParticipantByName(name) { const p = any(name); if (!p) throw new Error(`no remove target: ${name}`); applyEnc(removeParticipant(enc, p.id).patch); } function deathSaveAction(name, type, saveNum) { const p = any(name); if (!p) throw new Error(`no deathsave target: ${name}`); applyEnc(combatDeathSave(enc, p.id, type, saveNum).patch); } 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 ---------- test('full 100-round combat scenario (shared, no React)', async () => { // roster await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2)); await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1)); await recordAsync('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3)); // monsters + npcs await recordAsync('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2)); await recordAsync('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2)); await recordAsync('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1)); await recordAsync('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3)); await recordAsync('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true)); // add chars into encounter await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter')); await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric')); await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue')); await recordAsync('addAllChars', () => addAllCharacters()); // bulk add path // hidden hp toggle x2 (back to default) record('toggleHidePlayerHp', () => toggleHidePlayerHpAction()); record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction()); await recordAsync('startCombat', () => startCombat()); // ---------- 100 ROUNDS (loop by actual round-wrap count) ---------- let roundsDone = 0; let prevRound = enc.round; let turnInRound = 0; let turnTotal = 0; 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', 'fail', 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', 'fail', 1); deathSaveAction('Cleric', 'fail', 2); deathSaveAction('Cleric', 'fail', 3); // → dead }); 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 (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) { throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`); } const ids = enc.participants.map(p => p.id); if (JSON.stringify(order) !== JSON.stringify(ids)) { throw new Error(`turnOrderIds drift: order=${JSON.stringify(order)} ids=${JSON.stringify(ids)}`); } }); } // advance turn await recordAsync(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction()); turnInRound++; // round wrap detected if (enc.round > prevRound) { roundsDone++; prevRound = enc.round; turnInRound = 0; } } await recordAsync('endCombat', () => endCombat()); // ---------- report ---------- 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); expect(enc.round).toBe(0); expect(enc.currentTurnParticipantId).toBeNull(); expect(display.activeCampaignId).toBeNull(); expect(display.activeEncounterId).toBeNull(); }); function anyById(id) { return enc.participants.find(p => p.id === id); }