2026-06-29 13:51:32 -04:00
|
|
|
// 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';
|
2026-06-29 16:02:22 -04:00
|
|
|
import App from '../App';
|
2026-06-29 13:51:32 -04:00
|
|
|
import {
|
|
|
|
|
renderApp, createCampaignViaUI, selectCampaignByName,
|
|
|
|
|
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
|
|
|
|
|
} from './testHelpers';
|
2026-06-29 16:02:22 -04:00
|
|
|
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
2026-06-29 13:51:32 -04:00
|
|
|
|
|
|
|
|
// ---------- 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 <li> 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
|