// Combat.scenario.test.js // Full combat scenario: campaign -> encounter -> participants -> 100 rounds of // damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove. // Drives the SAME UI buttons a DM clicks. Failing assertions do NOT abort the run: // each phase wraps in try/catch, failures collected, final expect reports all. // // Purpose: exercise as much of the supported feature surface as possible in one // long combat, surfacing behavioral bugs characterization tests miss. import React from 'react'; import { screen, fireEvent, waitFor, within } from '@testing-library/react'; import '@testing-library/jest-dom'; import App from '../App'; import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady, } from './testHelpers'; import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db'; // ---------- scenario helpers (UI only, same buttons as human) ---------- 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 }); } } function getParticipantForm() { const heading = screen.getByText('Add Participants'); let node = heading; for (let i = 0; i < 6; i++) { node = node.parentElement; if (!node) break; if (node.querySelector('form')) return within(node); } return within(heading.parentElement); } // Find a participant's encounter
  • row by name. Scoped to the encounter // participant list (NOT the CharacterManager roster, which also shows names). // Encounter participant rows render an 'Init:' label; roster rows do not. function getParticipantRow(name) { const lis = document.querySelectorAll('li'); for (const li of lis) { const txt = li.textContent || ''; if (txt.includes('Init:') && txt.includes(name)) { return within(li); } } throw new Error(`encounter participant row not found: ${name}`); } // Character roster (CharacterManager). Assumes campaign selected. async function addCharacterViaUI(name, maxHp, initMod) { fireEvent.change(document.getElementById('characterName'), { target: { value: name } }); fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } }); fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } }); fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i })); await waitFor(() => { const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && Array.isArray(c.data.players) && c.data.players.some(p => p.name === name)); if (!call) throw new Error('char not persisted'); }); } function setParticipantType(type) { // The Type select is inside the Add Participants form. const form = getParticipantForm(); const selects = form.getAllByRole('combobox'); // first combobox in the participant form is Type fireEvent.change(selects[0], { target: { value: type } }); } async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) { const form = getParticipantForm(); setParticipantType('monster'); fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } }); fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } }); fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } }); if (isNpc) { const npcCheck = form.getByRole('checkbox', { name: /NPC/i }); if (!npcCheck.checked) fireEvent.click(npcCheck); } fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); await waitFor(() => { const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added'); }); } async function addCharacterParticipant(charName) { const form = getParticipantForm(); setParticipantType('character'); // character select is the 2nd combobox in the form after Type const charSelect = form.getAllByRole('combobox')[1]; // find option whose text includes the char name const opt = [...charSelect.querySelectorAll('option')].find(o => o.textContent.includes(charName)); if (!opt) throw new Error(`char option not found: ${charName}`); fireEvent.change(charSelect, { target: { value: opt.value } }); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); await waitFor(() => { const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; if (!last || !last.data.participants?.some(p => p.name === charName)) throw new Error('char not added'); }); } async function addAllCharacters() { fireEvent.click(screen.getByRole('button', { name: /Add All/i })); await waitFor(() => { const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; if (!last) throw new Error('add all no-op'); }); } function applyDamage(name, amount) { const row = getParticipantRow(name); const dmgBtn = row.queryByTitle('Damage'); if (!dmgBtn) { // participant dead (Damage button hidden when currentHp===0). Expected game // state over a long fight; not a bug. Skip silently. return; } fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } }); fireEvent.click(dmgBtn); } function applyHeal(name, amount) { const row = getParticipantRow(name); const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal'); if (!healBtn) throw new Error(`${name} has no Heal button`); fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } }); fireEvent.click(healBtn); } function toggleActive(name) { const row = getParticipantRow(name); const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive'); if (!btn) throw new Error(`${name} has no active toggle`); fireEvent.click(btn); } function openConditions(name) { const row = getParticipantRow(name); const btn = row.getByTitle('Conditions'); // idempotent: ensure panel open. Click toggles; if another participant's panel // was open it's already closed by this participant's row focus, so just click. fireEvent.click(btn); } function toggleCondition(name, label) { openConditions(name); // panel render is async (React state). Wait for button by title. return waitFor(() => { const condButtons = document.querySelectorAll('button[title]'); const target = [...condButtons].find(b => b.getAttribute('title') === label); if (!target) throw new Error(`condition button not found: ${label}`); fireEvent.click(target); }); } function editParticipant(name, patch) { const row = getParticipantRow(name); fireEvent.click(row.getByTitle('Edit')); // EditParticipantModal. Scope to the modal via its form inputs. const modal = document.querySelector('.fixed.inset-0') || document.body; const inputs = modal.querySelectorAll('input'); if (patch.name !== undefined) { fireEvent.change(inputs[0], { target: { value: patch.name } }); } if (patch.initiative !== undefined && inputs[1]) { fireEvent.change(inputs[1], { target: { value: String(patch.initiative) } }); } const saveBtn = modal.querySelector('button[type="submit"]') || [...modal.querySelectorAll('button')].find(b => /^Save$/i.test(b.textContent.trim())); fireEvent.click(saveBtn); } function removeParticipant(name) { fireEvent.click(getParticipantRow(name).getByTitle('Remove')); } async function deathSave(name, saveNum) { const row = getParticipantRow(name); const btn = row.getByTitle(`Death save ${saveNum}`); fireEvent.click(btn); await waitFor(() => { const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; if (!last) throw new Error('deathSave no write'); }); } async function nextTurn() { fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); await waitFor(() => { const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; if (!last) throw new Error('nextTurn no write'); }); } async function pauseCombat() { fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i })); await waitFor(() => { const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; if (!last?.data?.isPaused) throw new Error('not paused'); }); } async function resumeCombat() { fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i })); await waitFor(() => { const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; if (last?.data?.isPaused) throw new Error('not resumed'); }); } async function startCombat() { fireEvent.click(screen.getByRole('button', { name: /Start Combat/i })); await waitFor(() => { const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; if (!last?.data?.isStarted) throw new Error('not started'); }); } function toggleHidePlayerHp() { fireEvent.click(screen.getByRole('switch')); } function currentEncDoc() { const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); return calls[calls.length - 1]?.data; } // ---------- scenario ---------- const ROUNDS = 100; test('full 100-round combat scenario', async () => { await setupReady('ScenarioCamp', 'BigBoss'); // 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()); // hidden hp toggle record('toggleHidePlayerHp', () => toggleHidePlayerHp()); record('toggleHidePlayerHp back', () => toggleHidePlayerHp()); await recordAsync('startCombat', () => startCombat()); // 100 rounds of mixed actions for (let r = 1; r <= ROUNDS; r++) { await recordAsync(`round ${r} nextTurn`, () => nextTurn()); // rotation integrity: turnOrderIds no dup, currentTurn valid if (r % 10 === 0) { record(`round ${r} rotation-check`, () => { const enc = currentEncDoc(); if (!enc) throw new Error('no encounter doc'); 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`); } }); } // damage front monster every other round if (r % 2 === 0) record(`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`, () => toggleCondition('Fighter', 'Stunned')); if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2')); // pause/resume every 10 rounds, add a participant, 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 and 50 if (r === 25) { record(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99)); record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1)); record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22)); } if (r === 50) { record(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99)); record(`round ${r} deathSave Cleric x3`, async () => { await deathSave('Cleric', 1); await deathSave('Cleric', 2); await deathSave('Cleric', 3); }); 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`, () => removeParticipant('Reinforce20')); await recordAsync(`round ${r} resume`, () => resumeCombat()); } } await recordAsync('endCombat', async () => { fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); // End-combat ConfirmationModal has title 'End Encounter?'. Scope Confirm to it. const endConfirm = await screen.findByRole('heading', { name: /End Encounter/i }); const modal = endConfirm.closest('.fixed.inset-0') || document.body; const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim())); fireEvent.click(confirmBtn); await waitFor(() => { const last = currentEncDoc(); if (last?.isStarted !== false) throw new Error('not ended'); }); }); // ---------- report ---------- const failed = RESULTS.filter(r => !r.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`); } // eslint-disable-next-line no-console console.log(`\n=== SCENARIO: ${RESULTS.length - failed.length}/${RESULTS.length} phases ok ===\n`); expect(failed).toEqual([]); }, 240000); // long timeout: 100 rounds