Old model broken: single deathSaves counter, 3=revive. No fails tracked,
successes conflated with fails. DM couldn't model real death save outcomes.
New model (D&D 5e):
- deathSave(enc, id, type, n) — type='success'|'fail', n=1|2|3
- Participant fields: deathSaves (successes), deathFails, isStable, isDying
- 3 successes = stable (0hp unconscious, safe until healed)
- 3 failures = dead (isDying, caller animates removal)
- Toggling same pip = undo that pip
- Returns { patch, log, status } — status: 'stable'|'dead'|'pending'
isDying back-compat flag kept for App.js removal animation
App.js:
- UI split into green ✓ saves row + red ✕ fails row
- Status badges: Stabilized (emerald) / Dying (red pulse)
- handleDeathSaveChange(participantId, type, saveNumber) — new sig
- makeParticipant init: deathFails + isStable defaults
Data loss: old single-counter participants lose saves (feature didn't work,
no real state to preserve). User confirmed acceptable.
Tests:
- turn.deathsave.test.js: RED-then-GREEN, 3-success stable, 3-fail dead,
independent tracking, new signature
- Updated 6 callers across characterization/combat/dead-skip/logging/scenario
- Logs UI tests: new title selectors (Success N / Fail N)
243 tests green.
335 lines
13 KiB
JavaScript
335 lines
13 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.
|
||
// See docs/GLOSSARY.md. Previous version lied: called nextTurn 100× (~100
|
||
// turns, ~12 rounds). Now loops by actual round-wrap count.
|
||
//
|
||
// Exercises ALL combat paths deterministically (less random than replay):
|
||
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
|
||
// updateParticipant, removeParticipant, toggleParticipantActive,
|
||
// applyHpChange (damage/heal), deathSave (incl. 3-success → isDying),
|
||
// toggleCondition, reorderParticipants (drag same-init tie), endEncounter,
|
||
// activateDisplay, clearDisplay, toggleHidePlayerHp.
|
||
//
|
||
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
|
||
// failures collected, final expect reports all.
|
||
|
||
const {
|
||
makeParticipant,
|
||
buildCharacterParticipant,
|
||
buildMonsterParticipant,
|
||
startEncounter,
|
||
nextTurn,
|
||
togglePause,
|
||
addParticipant,
|
||
addParticipants,
|
||
updateParticipant,
|
||
removeParticipant,
|
||
toggleParticipantActive,
|
||
applyHpChange,
|
||
deathSave,
|
||
toggleCondition,
|
||
reorderParticipants,
|
||
endEncounter,
|
||
activateDisplay,
|
||
clearDisplay,
|
||
toggleHidePlayerHp,
|
||
} = require('../../shared');
|
||
|
||
// aliases for combat funcs that clash with local helper names
|
||
const {
|
||
applyHpChange: combatApplyHpChange,
|
||
toggleParticipantActive: combatToggleActive,
|
||
toggleCondition: combatToggleCondition,
|
||
deathSave: combatDeathSave,
|
||
} = require('../../shared');
|
||
|
||
// ---------- 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,
|
||
};
|
||
const CAMPAIGN_ID = 'camp-1';
|
||
const ENCOUNTER_ID = 'enc-1';
|
||
const roster = [];
|
||
|
||
function applyEnc(patch) {
|
||
if (!patch) return;
|
||
for (const [k, v] of Object.entries(patch)) enc[k] = v;
|
||
}
|
||
function applyDisplay(patch) {
|
||
if (!patch) return;
|
||
for (const [k, v] of Object.entries(patch)) display[k] = v;
|
||
}
|
||
|
||
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 }); }
|
||
}
|
||
|
||
// ---------- helpers (shared, no React) ----------
|
||
|
||
const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
|
||
const any = (name) => enc.participants.find(p => p.name === name);
|
||
const idOf = (name) => { const p = any(name); return p ? p.id : null; };
|
||
|
||
function addCharacterViaUI(name, maxHp, initMod) {
|
||
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
|
||
}
|
||
function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
|
||
applyEnc(addParticipant(enc, participant).patch);
|
||
}
|
||
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);
|
||
applyEnc(addParticipant(enc, participant).patch);
|
||
}
|
||
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);
|
||
}
|
||
function startCombat() {
|
||
applyEnc(startEncounter(enc).patch);
|
||
applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch);
|
||
}
|
||
function endCombat() {
|
||
applyEnc(endEncounter(enc).patch);
|
||
applyDisplay(clearDisplay().patch);
|
||
}
|
||
function nextTurnAction() { applyEnc(nextTurn(enc).patch); }
|
||
function pauseCombat() { applyEnc(togglePause(enc).patch); if (!enc.isPaused) throw new Error('not paused'); }
|
||
function resumeCombat() { applyEnc(togglePause(enc).patch); if (enc.isPaused) throw new Error('not resumed'); }
|
||
|
||
function applyDamage(name, amount) {
|
||
const p = any(name);
|
||
if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI)
|
||
applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch);
|
||
}
|
||
function applyHeal(name, amount) {
|
||
const p = any(name);
|
||
if (!p) return; // removed (death) = skip (no button in UI)
|
||
applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch);
|
||
}
|
||
function toggleActive(name) {
|
||
const p = any(name);
|
||
if (!p) throw new Error(`no active target: ${name}`);
|
||
applyEnc(combatToggleActive(enc, p.id).patch);
|
||
}
|
||
function toggleConditionAction(name, label) {
|
||
const p = any(name);
|
||
if (!p) throw new Error(`no condition target: ${name}`);
|
||
applyEnc(combatToggleCondition(enc, p.id, label).patch);
|
||
}
|
||
function editParticipant(name, patch) {
|
||
const p = any(name);
|
||
if (!p) throw new Error(`no edit target: ${name}`);
|
||
applyEnc(updateParticipant(enc, p.id, patch).patch);
|
||
}
|
||
function removeParticipantByName(name) {
|
||
const p = any(name);
|
||
if (!p) throw new Error(`no remove target: ${name}`);
|
||
applyEnc(removeParticipant(enc, p.id).patch);
|
||
}
|
||
function deathSaveAction(name, type, saveNum) {
|
||
const p = any(name);
|
||
if (!p) throw new Error(`no deathsave target: ${name}`);
|
||
applyEnc(combatDeathSave(enc, p.id, type, saveNum).patch);
|
||
}
|
||
function dragTie(draggedName, targetName) {
|
||
const d = any(draggedName), t = any(targetName);
|
||
if (!d || !t) throw new Error('drag target missing');
|
||
applyEnc(reorderParticipants(enc, d.id, t.id).patch);
|
||
}
|
||
function toggleHidePlayerHpAction() {
|
||
applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
|
||
}
|
||
|
||
// ---------- scenario ----------
|
||
|
||
test('full 100-round combat scenario (shared, no React)', async () => {
|
||
// 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()); // bulk add path
|
||
|
||
// hidden hp toggle x2 (back to default)
|
||
record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
||
record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
||
|
||
await recordAsync('startCombat', () => startCombat());
|
||
|
||
// ---------- 100 ROUNDS (loop by actual round-wrap count) ----------
|
||
let roundsDone = 0;
|
||
let prevRound = enc.round;
|
||
let turnInRound = 0;
|
||
let turnTotal = 0;
|
||
|
||
while (roundsDone < ROUNDS) {
|
||
turnTotal++;
|
||
const actor = anyById(enc.currentTurnParticipantId);
|
||
const r = enc.round;
|
||
|
||
// per-turn deterministic actions keyed by round + actor type
|
||
// damage OrcBoss every even round
|
||
if (r % 2 === 0 && turnInRound === 0) {
|
||
await recordAsync(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||
}
|
||
// heal Cleric every 3 rounds
|
||
if (r % 3 === 0 && turnInRound === 1) {
|
||
record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||
}
|
||
// condition on Fighter every 5 rounds
|
||
if (r % 5 === 0 && turnInRound === 2) {
|
||
record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
||
}
|
||
// toggleActive Goblin2 every 7 rounds (toggle off, next 7 toggle on)
|
||
if (r % 7 === 0 && turnInRound === 3) {
|
||
record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||
}
|
||
// edit Wolf initiative every 13 rounds
|
||
if (r % 13 === 0 && turnInRound === 4) {
|
||
record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||
}
|
||
|
||
// pause/resume + add reinforcement + edit every 10 rounds
|
||
if (r % 10 === 0 && turnInRound === 0) {
|
||
await recordAsync(`r${r} pause`, () => pauseCombat());
|
||
await recordAsync(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||
await recordAsync(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||
await recordAsync(`r${r} resume`, () => resumeCombat());
|
||
}
|
||
|
||
// create a same-init tie + drag every 15 rounds (exercises reorderParticipants)
|
||
if (r % 15 === 0 && turnInRound === 5) {
|
||
const g1 = any('Goblin1');
|
||
if (g1 && alive('Goblin2')) {
|
||
record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
|
||
record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
|
||
}
|
||
}
|
||
|
||
// death scenarios: drop a PC to 0, death saves, revive
|
||
if (r === 25 && turnInRound === 0) {
|
||
await recordAsync(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
||
record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
|
||
record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||
}
|
||
// round 50: full 3-success death-save path (isDying) on Cleric, then remove
|
||
if (r === 50 && turnInRound === 0) {
|
||
await recordAsync(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||
await recordAsync(`r${r} deathSave Cleric x3 (isDying)`, async () => {
|
||
deathSaveAction('Cleric', 'fail', 1);
|
||
deathSaveAction('Cleric', 'fail', 2);
|
||
deathSaveAction('Cleric', 'fail', 3); // → dead
|
||
});
|
||
const cl = any('Cleric');
|
||
if (cl && cl.isDying) record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
|
||
}
|
||
|
||
// remove a reinforcement every 30 rounds
|
||
if (r % 30 === 0 && turnInRound === 1) {
|
||
const rein = any(`Reinforce${r - 10}`);
|
||
if (rein) record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
|
||
}
|
||
|
||
// toggle hide hp every 20 rounds
|
||
if (r % 20 === 0 && turnInRound === 0) {
|
||
record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
|
||
record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
|
||
}
|
||
|
||
// rotation integrity check every 10 rounds at round start
|
||
if (turnInRound === 0 && r % 10 === 0) {
|
||
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)}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// advance turn
|
||
await recordAsync(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
|
||
turnInRound++;
|
||
|
||
// round wrap detected
|
||
if (enc.round > prevRound) {
|
||
roundsDone++;
|
||
prevRound = enc.round;
|
||
turnInRound = 0;
|
||
}
|
||
}
|
||
|
||
await recordAsync('endCombat', () => endCombat());
|
||
|
||
// ---------- report ----------
|
||
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);
|
||
|
||
// 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();
|
||
});
|
||
|
||
function anyById(id) {
|
||
return enc.participants.find(p => p.id === id);
|
||
}
|