// 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. // // Exercises ALL combat paths deterministically: // startEncounter, nextTurn, togglePause, addParticipant, addParticipants, // updateParticipant, removeParticipant, toggleParticipantActive, // applyHpChange (damage/heal), deathSave, toggleCondition, // reorderParticipants, endEncounter. // // New API: funcs async, take ctx {storage, encPath, logPath, displayPath}, // write encounter + log internally, return newEnc. Mock storage captures writes. // Display lifecycle now async too — but test keeps inline display writes (mirrors App.js). // // Failing assertions do NOT abort the run: each phase wrapped in try/catch, // failures collected, final expect reports all. const path = require('path'); const { createMockStorage, mockCtx, readyEnc } = require('../../shared/tests/_helpers'); const { buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, endEncounter, addParticipant, addParticipants, updateParticipant, removeParticipant, toggleParticipantActive: combatToggleActive, applyHpChange: combatApplyHpChange, deathSave: combatDeathSave, stabilizeParticipant, reviveParticipant, toggleCondition: combatToggleCondition, reorderParticipants, } = require('../../shared'); // ---------- scenario state ---------- const ROUNDS = 100; const ENCOUNTER_ID = 'enc-1'; let enc = { id: ENCOUNTER_ID, name: 'BigBoss', participants: [], isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], }; // display tracked via mock storage displayPath doc; simple local mirror. let display = { activeCampaignId: null, activeEncounterId: null, hidePlayerHp: true, hideNpcHp: false, }; const CAMPAIGN_ID = 'camp-1'; const CAMPAIGN_PATH = `campaigns/${CAMPAIGN_ID}`;const roster = []; let storage, ctx; const DISPLAY_PATH = 'activeDisplay/status'; const RESULTS = []; async function record(phase, fn) { try { await fn(); RESULTS.push({ phase, ok: true }); } catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); } } // ---------- helpers ---------- const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0); const any = (name) => enc.participants.find(p => p.name === name); const anyById = (id) => enc.participants.find(p => p.id === id); function addCharacterViaUI(name, maxHp, initMod) { const character = { id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod }; roster.push(character); // persist to campaign doc (mirrors app: players array) if (storage) { const existing = (storage.docs.get(CAMPAIGN_PATH) || {}).players || []; storage.setDoc(CAMPAIGN_PATH, { id: CAMPAIGN_ID, name: 'Scenario Campaign', createdAt: Date.now(), players: [...existing, character], }); } } async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) { const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc }); enc = await addParticipant(enc, participant, ctx); } async 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); enc = await addParticipant(enc, participant, ctx); } async function addAllCharacters() { const toAdd = roster .filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id)) .map(c => buildCharacterParticipant(c).participant); enc = await addParticipants(enc, toAdd, ctx); } async function startCombat() { enc = await startEncounter(enc, ctx); display.activeCampaignId = CAMPAIGN_ID; display.activeEncounterId = ENCOUNTER_ID; await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: CAMPAIGN_ID, activeEncounterId: ENCOUNTER_ID }); } async function endCombat() { enc = await endEncounter(enc, ctx); display.activeCampaignId = null; display.activeEncounterId = null; await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: null, activeEncounterId: null }); } async function nextTurnAction() { enc = await nextTurn(enc, ctx); } async function pauseCombat() { enc = await togglePause(enc, ctx); if (!enc.isPaused) throw new Error('not paused'); } async function resumeCombat() { enc = await togglePause(enc, ctx); if (enc.isPaused) throw new Error('not resumed'); } async function applyDamage(name, amount, options = null) { const p = any(name); if (!p || (p.currentHp <= 0 && p.status === 'dead')) return; enc = options ? await combatApplyHpChange(enc, p.id, 'damage', amount, options, ctx) : await combatApplyHpChange(enc, p.id, 'damage', amount, ctx); } async function applyHeal(name, amount) { const p = any(name); if (!p) return; enc = await combatApplyHpChange(enc, p.id, 'heal', amount, ctx); } async function toggleActive(name) { const p = any(name); if (!p) throw new Error(`no active target: ${name}`); enc = await combatToggleActive(enc, p.id, ctx); } async function toggleConditionAction(name, label) { const p = any(name); if (!p) throw new Error(`no condition target: ${name}`); enc = await combatToggleCondition(enc, p.id, label, ctx); } async function editParticipant(name, patch) { const p = any(name); if (!p) throw new Error(`no edit target: ${name}`); enc = await updateParticipant(enc, p.id, patch, ctx); } async function removeParticipantByName(name) { const p = any(name); if (!p) throw new Error(`no remove target: ${name}`); enc = await removeParticipant(enc, p.id, ctx); } async function deathSaveAction(name, outcome) { const p = any(name); if (!p) throw new Error(`no deathsave target: ${name}`); const r = await combatDeathSave(enc, p.id, outcome, ctx); enc = r.enc; } async function stabilizeAction(name) { const p = any(name); if (!p) throw new Error(`no stabilize target: ${name}`); enc = await stabilizeParticipant(enc, p.id, ctx); } async function reviveAction(name) { const p = any(name); if (!p) throw new Error(`no revive target: ${name}`); enc = await reviveParticipant(enc, p.id, ctx); } async function dragTie(draggedName, targetName) { const d = any(draggedName), t = any(targetName); if (!d || !t) throw new Error('drag target missing'); enc = await reorderParticipants(enc, d.id, t.id, ctx); } async function toggleHidePlayerHpAction() { display.hidePlayerHp = !display.hidePlayerHp; await storage.updateDoc(DISPLAY_PATH, { hidePlayerHp: display.hidePlayerHp }); } // ---------- scenario ---------- beforeEach(() => { ({ storage, ctx } = mockCtx()); enc = { id: ENCOUNTER_ID, name: 'BigBoss', participants: [], isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], }; display = { activeCampaignId: null, activeEncounterId: null, hidePlayerHp: true, hideNpcHp: false }; roster.length = 0; RESULTS.length = 0; }); test('death-save edge cases in combat scenario helpers', async () => { await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2)); await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1)); await record('addAllChars', () => addAllCharacters()); await record('addMonster Goblin', () => addMonsterParticipant('Goblin', 8, 2)); await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true)); await record('startCombat', () => startCombat()); await record('monster drops dead inactive', async () => { await applyDamage('Goblin', any('Goblin').currentHp); const g = any('Goblin'); expect(g.status).toBe('dead'); expect(g.isActive).toBe(false); }); await record('npc nat20 returns conscious', async () => { await applyDamage('Merchant', any('Merchant').currentHp); expect(any('Merchant').status).toBe('dying'); await deathSaveAction('Merchant', 'nat20'); expect(any('Merchant').status).toBe('conscious'); expect(any('Merchant').currentHp).toBe(1); }); await record('npc nat1 plus damage kills, revive/heal restores', async () => { await applyDamage('Merchant', any('Merchant').currentHp); await deathSaveAction('Merchant', 'nat1'); expect(any('Merchant').deathSaveFailures).toBe(2); await applyDamage('Merchant', 1); expect(any('Merchant').status).toBe('dead'); expect(any('Merchant').isActive).toBe(true); await reviveAction('Merchant'); expect(any('Merchant').status).toBe('stable'); await applyHeal('Merchant', 5); expect(any('Merchant').status).toBe('conscious'); expect(any('Merchant').currentHp).toBe(5); }); await record('character stable then crit damage dies', async () => { await applyDamage('Fighter', any('Fighter').currentHp); await deathSaveAction('Fighter', 'success'); await deathSaveAction('Fighter', 'success'); await deathSaveAction('Fighter', 'success'); expect(any('Fighter').status).toBe('stable'); await applyDamage('Fighter', 1); expect(any('Fighter').status).toBe('dying'); expect(any('Fighter').deathSaveFailures).toBe(1); await applyDamage('Fighter', 1, { isCriticalHit: true }); expect(any('Fighter').status).toBe('dead'); expect(any('Fighter').isActive).toBe(true); }); await record('massive damage kills character but does not deactivate', async () => { await applyDamage('Cleric', any('Cleric').currentHp + any('Cleric').maxHp); expect(any('Cleric').status).toBe('dead'); expect(any('Cleric').isActive).toBe(true); }); 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([]); }); test('full 100-round combat scenario (shared, no React)', async () => { await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2)); await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1)); await record('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3)); await record('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2)); await record('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2)); await record('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1)); await record('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3)); await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true)); await record('addCharParticipant Fighter', () => addCharacterParticipant('Fighter')); await record('addAllChars Cleric/Rogue', () => addAllCharacters()); await record('toggleHidePlayerHp', () => toggleHidePlayerHpAction()); await record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction()); await record('startCombat', () => startCombat()); // ---------- 100 ROUNDS ---------- let roundsDone = 0; let prevRound = enc.round; let turnInRound = 0; while (roundsDone < ROUNDS) { const r = enc.round; if (r % 2 === 0 && turnInRound === 0) await record(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3)); if (r % 3 === 0 && turnInRound === 1) await record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2)); if (r % 5 === 0 && turnInRound === 2) await record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned')); if (r % 7 === 0 && turnInRound === 3) await record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2')); if (r % 13 === 0 && turnInRound === 4) await record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 })); if (r % 10 === 0 && turnInRound === 0) { await record(`r${r} pause`, () => pauseCombat()); await record(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1)); await record(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 })); await record(`r${r} resume`, () => resumeCombat()); } if (r % 15 === 0 && turnInRound === 5) { const g1 = any('Goblin1'); if (g1 && alive('Goblin2')) { await record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative })); await record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1')); } } if (r === 25 && turnInRound === 0) { await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp)); await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail')); await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22)); } if (r === 50 && turnInRound === 0) { await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp)); await record(`r${r} deathSave Cleric x3 (dead)`, async () => { await deathSaveAction('Cleric', 'fail'); await deathSaveAction('Cleric', 'fail'); await deathSaveAction('Cleric', 'fail'); }); const cl = any('Cleric'); if (cl) { expect(cl.status).toBe('dead'); expect(enc.participants.map(p => p.name)).toContain('Cleric'); } } if (r % 30 === 0 && turnInRound === 1) { const rein = any(`Reinforce${r - 10}`); if (rein) await record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`)); } if (r % 20 === 0 && turnInRound === 0) { await record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction()); await record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction()); } if (turnInRound === 0 && r % 10 === 0) { await 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)}`); } }); } await record(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction()); turnInRound++; if (enc.round > prevRound) { roundsDone++; prevRound = enc.round; turnInRound = 0; } } await record('endCombat', () => endCombat()); 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); expect(enc.isStarted).toBe(false); expect(enc.round).toBe(0); expect(enc.currentTurnParticipantId).toBeNull(); });