Files
ttrpg-initiative-tracker/src/tests/Combat.scenario.test.js
T
david raistrick 532af1ecc4 Populate campaign players array in combat replay and scenario
Both combat.js replay and Combat.scenario.test.js built character participants
inline via buildCharacterParticipant without persisting to campaign doc. Real
app stores chars in campaign doc players array; encounter participants built
from that. Replay/scenario diverged from real flow — campaign had zero chars.

Fix:
- replay.js: roster chars now have id, persisted to campaign doc players array
  at setup_campaign step
- Combat.scenario.test.js: addCharacterViaUI writes char to campaign doc
  players array (mirrors app), CAMPAIGN_PATH const added
2026-07-06 18:48:29 -04:00

379 lines
15 KiB
JavaScript

// 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();
});