From 1d4c561c09d2c1a911289c8e0a10998a70e02571 Mon Sep 17 00:00:00 2001
From: david raistrick <1108844+keen99@users.noreply.github.com>
Date: Mon, 29 Jun 2026 13:51:32 -0400
Subject: [PATCH] M3: add full 100-round combat scenario test
Drives same UI buttons a DM clicks. Exercises:
- campaign + character roster (3 PCs)
- monsters, NPC, add-all, hidden hp toggle
- start combat, 100 rounds of nextTurn
- damage/heal, conditions (stunned), toggle-active
- edit initiative, edit name, remove participant
- pause/resume with reinforcement mid-combat
- damage-to-0 + death saves (x3) + revive
- end combat + confirm
Harness: each phase wrapped in recordAsync try/catch. Failures collected,
reported at end, do NOT abort run. Found no app bugs; selector bugs in harness
fixed (char form ids vs placeholders, encounter row scope via Init: marker,
conditions panel async render, dead-participant damage skip as expected game
state, end-encounter modal title 'End Encounter?' not 'End Combat').
289/289 phases ok. 58 other frontend + 39 shared + 19 ws-contract = 116 total green.
---
src/Combat.scenario.test.js | 324 ++++++++++++++++++++++++++++++++++++
1 file changed, 324 insertions(+)
create mode 100644 src/Combat.scenario.test.js
diff --git a/src/Combat.scenario.test.js b/src/Combat.scenario.test.js
new file mode 100644
index 0000000..31ff618
--- /dev/null
+++ b/src/Combat.scenario.test.js
@@ -0,0 +1,324 @@
+// 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());
+
+ // 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