2026-06-29 13:51:32 -04:00
|
|
|
// Combat.scenario.test.js
|
2026-07-03 18:26:02 -04:00
|
|
|
// Full combat scenario driven through shared/turn.js directly — NO React.
|
|
|
|
|
// Same actions + invariants as the old UI version, but pure functions = fast.
|
|
|
|
|
// Purpose (verbatim from original): exercise full feature surface
|
|
|
|
|
// (damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove)
|
|
|
|
|
// in one long combat, surfacing behavioral bugs characterization tests miss.
|
2026-06-29 13:51:32 -04:00
|
|
|
//
|
2026-07-03 18:26:02 -04:00
|
|
|
// Two storage docs modeled exactly like App writes:
|
|
|
|
|
// - encounter doc (combat state)
|
|
|
|
|
// - activeDisplay/status doc (display lifecycle + hide flags)
|
|
|
|
|
// Both written via shared funcs — single source of truth, no React.
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
const {
|
|
|
|
|
makeParticipant,
|
|
|
|
|
buildCharacterParticipant,
|
|
|
|
|
buildMonsterParticipant,
|
|
|
|
|
startEncounter,
|
|
|
|
|
nextTurn,
|
|
|
|
|
togglePause,
|
|
|
|
|
addParticipant,
|
|
|
|
|
addParticipants,
|
|
|
|
|
updateParticipant,
|
|
|
|
|
removeParticipant,
|
|
|
|
|
toggleParticipantActive,
|
|
|
|
|
applyHpChange,
|
|
|
|
|
deathSave,
|
|
|
|
|
toggleCondition,
|
|
|
|
|
endEncounter,
|
|
|
|
|
activateDisplay,
|
|
|
|
|
clearDisplay,
|
|
|
|
|
toggleHidePlayerHp,
|
|
|
|
|
} = require('../../shared');
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// ---------- 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,
|
|
|
|
|
};
|
|
|
|
|
// campaign roster (for addCharParticipant / addAll)
|
|
|
|
|
const CAMPAIGN_ID = 'camp-1';
|
|
|
|
|
const ENCOUNTER_ID = 'enc-1';
|
|
|
|
|
const roster = [];
|
|
|
|
|
|
|
|
|
|
// apply encounter patch
|
|
|
|
|
function applyEnc(patch) {
|
|
|
|
|
if (!patch) return;
|
|
|
|
|
for (const [k, v] of Object.entries(patch)) enc[k] = v;
|
|
|
|
|
}
|
|
|
|
|
// apply display patch
|
|
|
|
|
function applyDisplay(patch) {
|
|
|
|
|
if (!patch) return;
|
|
|
|
|
for (const [k, v] of Object.entries(patch)) display[k] = v;
|
|
|
|
|
}
|
2026-06-29 13:51:32 -04:00
|
|
|
|
|
|
|
|
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 }); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// ---------- UI-equivalent helpers (call shared, no React) ----------
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// addChar to campaign roster (mirrors CharacterManager.addCharacter)
|
|
|
|
|
function addCharacterViaUI(name, maxHp, initMod) {
|
|
|
|
|
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// add monster to encounter (mirrors ParticipantManager add monster)
|
|
|
|
|
function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
|
|
|
|
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
|
|
|
|
|
applyEnc(addParticipant(enc, participant).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// add a campaign character into encounter (mirrors addCharacterParticipant)
|
|
|
|
|
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`); // mirrors App alert-guard
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
const { participant } = buildCharacterParticipant(character);
|
|
|
|
|
applyEnc(addParticipant(enc, participant).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// add all campaign chars (mirrors Add All button → addParticipants)
|
|
|
|
|
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);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// toggles
|
|
|
|
|
function toggleHidePlayerHpAction() {
|
|
|
|
|
applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function startCombat() {
|
|
|
|
|
applyEnc(startEncounter(enc).patch);
|
|
|
|
|
applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function endCombat() {
|
|
|
|
|
applyEnc(endEncounter(enc).patch);
|
|
|
|
|
applyDisplay(clearDisplay().patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function nextTurnAction() {
|
|
|
|
|
applyEnc(nextTurn(enc).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function pauseCombat() {
|
|
|
|
|
applyEnc(togglePause(enc).patch);
|
|
|
|
|
if (!enc.isPaused) throw new Error('not paused');
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function resumeCombat() {
|
|
|
|
|
applyEnc(togglePause(enc).patch);
|
|
|
|
|
if (enc.isPaused) throw new Error('not resumed');
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
|
|
|
|
|
// per-participant actions
|
|
|
|
|
function applyDamage(name, amount) {
|
|
|
|
|
const p = enc.participants.find(x => x.name === name);
|
|
|
|
|
if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI)
|
|
|
|
|
applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function applyHeal(name, amount) {
|
|
|
|
|
const p = enc.participants.find(x => x.name === name);
|
|
|
|
|
if (!p) throw new Error(`no heal target: ${name}`);
|
|
|
|
|
applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function toggleActive(name) {
|
|
|
|
|
const p = enc.participants.find(x => x.name === name);
|
|
|
|
|
if (!p) throw new Error(`no active target: ${name}`);
|
|
|
|
|
applyEnc(combatToggleActive(enc, p.id).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function toggleConditionAction(name, label) {
|
|
|
|
|
const p = enc.participants.find(x => x.name === name);
|
|
|
|
|
if (!p) throw new Error(`no condition target: ${name}`);
|
|
|
|
|
applyEnc(combatToggleCondition(enc, p.id, label).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function editParticipant(name, patch) {
|
|
|
|
|
const p = enc.participants.find(x => x.name === name);
|
|
|
|
|
if (!p) throw new Error(`no edit target: ${name}`);
|
|
|
|
|
applyEnc(updateParticipant(enc, p.id, patch).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function removeParticipantByName(name) {
|
|
|
|
|
const p = enc.participants.find(x => x.name === name);
|
|
|
|
|
if (!p) throw new Error(`no remove target: ${name}`);
|
|
|
|
|
applyEnc(removeParticipant(enc, p.id).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
function deathSaveAction(name, saveNum) {
|
|
|
|
|
const p = enc.participants.find(x => x.name === name);
|
|
|
|
|
if (!p) throw new Error(`no deathsave target: ${name}`);
|
|
|
|
|
applyEnc(combatDeathSave(enc, p.id, saveNum).patch);
|
2026-06-29 13:51:32 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// aliases for combat funcs that clash with local helper names
|
|
|
|
|
const {
|
|
|
|
|
applyHpChange: combatApplyHpChange,
|
|
|
|
|
toggleParticipantActive: combatToggleActive,
|
|
|
|
|
toggleCondition: combatToggleCondition,
|
|
|
|
|
deathSave: combatDeathSave,
|
|
|
|
|
} = require('../../shared');
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// ---------- scenario ----------
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
test('full 100-round combat scenario (shared, no React)', async () => {
|
2026-06-29 13:51:32 -04:00
|
|
|
// 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());
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// hidden hp toggle (x2 = back to default)
|
|
|
|
|
record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
|
|
|
|
record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
2026-06-29 13:51:32 -04:00
|
|
|
|
|
|
|
|
await recordAsync('startCombat', () => startCombat());
|
|
|
|
|
|
|
|
|
|
// 100 rounds of mixed actions
|
|
|
|
|
for (let r = 1; r <= ROUNDS; r++) {
|
2026-07-03 18:26:02 -04:00
|
|
|
await recordAsync(`round ${r} nextTurn`, () => nextTurnAction());
|
2026-06-29 13:51:32 -04:00
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// rotation integrity: turnOrderIds no dup, currentTurn valid, mirrors participants
|
2026-06-30 14:04:27 -04:00
|
|
|
if (r % 10 === 0) {
|
|
|
|
|
record(`round ${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`);
|
|
|
|
|
}
|
2026-07-03 18:26:02 -04:00
|
|
|
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)}`);
|
|
|
|
|
}
|
2026-06-30 14:04:27 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 13:51:32 -04:00
|
|
|
// damage front monster every other round
|
2026-07-02 18:40:38 -04:00
|
|
|
if (r % 2 === 0) await recordAsync(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
2026-06-29 13:51:32 -04:00
|
|
|
if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
2026-07-03 18:26:02 -04:00
|
|
|
if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
2026-06-29 13:51:32 -04:00
|
|
|
if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// pause/resume every 10 rounds, add a participant, edit, resume
|
2026-06-29 13:51:32 -04:00
|
|
|
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 }));
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
// damage-to-0 + death save on Rogue around round 25
|
2026-06-29 13:51:32 -04:00
|
|
|
if (r === 25) {
|
2026-07-02 18:40:38 -04:00
|
|
|
await recordAsync(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
2026-07-03 18:26:02 -04:00
|
|
|
record(`round ${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 1));
|
2026-06-29 13:51:32 -04:00
|
|
|
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
|
|
|
|
}
|
|
|
|
|
if (r === 50) {
|
2026-07-02 18:40:38 -04:00
|
|
|
await recordAsync(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
|
|
|
|
await recordAsync(`round ${r} deathSave Cleric x2`, async () => {
|
2026-07-03 18:26:02 -04:00
|
|
|
deathSaveAction('Cleric', 1);
|
|
|
|
|
deathSaveAction('Cleric', 2);
|
2026-06-29 13:51:32 -04:00
|
|
|
});
|
|
|
|
|
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// remove a reinforcement late
|
|
|
|
|
if (r === 30) {
|
|
|
|
|
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
2026-07-03 18:26:02 -04:00
|
|
|
record(`round ${r} remove Reinforce20`, () => removeParticipantByName('Reinforce20'));
|
2026-06-29 13:51:32 -04:00
|
|
|
await recordAsync(`round ${r} resume`, () => resumeCombat());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:26:02 -04:00
|
|
|
await recordAsync('endCombat', () => endCombat());
|
2026-06-29 13:51:32 -04:00
|
|
|
|
|
|
|
|
// ---------- 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`);
|
|
|
|
|
}
|
|
|
|
|
expect(failed).toEqual([]);
|
2026-07-03 18:26:02 -04:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
});
|