Exercise death-save edge cases in combat replay and scenario tests

Replay (scripts/combat/replay.js):
- Merchant now type=npc via asNpc (was stale isNpc)
- Death-save eligibility keyed on type (character|npc), not removed isNpc
- Rotate outcomes success/fail/nat1/nat20 (was success-only)
- Import stabilizeParticipant, reviveParticipant
- Between-round revive: dead -> reviveParticipant, stable -> heal,
  inactive -> reactivate (heal was no-op on dead, leaving active-dead monsters)
- Fix deathSave return unwrap: it returns {enc, status}; callStep runner
  must extract .enc or state corrupts (round undefined, combat auto-ends)
- describe() fix: death-save arg is outcome, not type

Scenario (src/tests/Combat.scenario.test.js):
- Import stabilizeParticipant, reviveParticipant
- applyDamage accepts options (crit at 0 HP)
- Add stabilizeAction, reviveAction helpers
- addAllCharacters now actually exercises batch add (was no-op)
- New deterministic edge-case test: monster death (dead+inactive),
  NPC nat20, NPC nat1+damage+revive+heal, character stable->damage->crit,
  massive damage
This commit is contained in:
david raistrick
2026-07-06 17:10:01 -04:00
parent 1d4ec873dc
commit c80ac6882f
3 changed files with 123 additions and 28 deletions
+30 -17
View File
@@ -16,6 +16,7 @@ const {
startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave,
stabilizeParticipant, reviveParticipant,
toggleCondition, reorderParticipants,
} = shared;
const { createServerStorage } = require('../../src/storage/server');
@@ -51,7 +52,7 @@ function buildMonsters() {
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
{ name: 'Wolf', maxHp: 40, initMod: 3 },
{ name: 'Merchant', maxHp: 30, initMod: 0, isNpc: true },
{ name: 'Merchant', maxHp: 30, initMod: 0, asNpc: true },
];
}
@@ -221,7 +222,7 @@ module.exports = async function replay(args) {
: `${a.target} took ${a.amount} damage`;
case 'toggleCondition': return `${a.participant} ${a.condition} toggled`;
case 'updateParticipant': return `${a.participant} edited`;
case 'deathSave': return `${a.participant} death save (${a.type})`;
case 'deathSave': return `${a.participant} death save (${a.outcome})`;
case 'toggleParticipantActive':
return a.revive ? `${a.participant} reactivated` : `${a.participant} toggled`;
case 'togglePause': return a.to === 'paused' ? 'Combat paused' : 'Combat resumed';
@@ -301,11 +302,14 @@ module.exports = async function replay(args) {
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc;
}
if (actor.status === 'dying' && !actor.isNpc) {
if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
enc = (await callStep('deathSave',
{ participant: actor.name, outcome: 'success' },
(e) => deathSave(e, actor.id, 'success', ctx))).enc;
if (actor.status === 'dying' && (actor.type === 'character' || actor.type === 'npc')) {
const outcomes = ['success', 'fail', 'nat1', 'nat20'];
const outcome = outcomes[totalTurns % outcomes.length];
if (VERBOSE) console.log(` deathSave ${actor.name} ${outcome}`);
const dsRes = await callStep('deathSave',
{ participant: actor.name, outcome },
async (e) => (await deathSave(e, actor.id, outcome, ctx)).enc);
enc = dsRes.enc;
}
if (totalTurns % 9 === 0) {
@@ -376,19 +380,28 @@ module.exports = async function replay(args) {
}
console.log(`--- round ${enc.round} ---`);
lastRound = enc.round;
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of dead) {
const down = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of down) {
if (interrupted) break;
if (d.isActive === false) {
if (VERBOSE) console.log(` revive ${d.name}`);
enc = (await callStep('toggleParticipantActive',
if (d.status === 'dead') {
if (VERBOSE) console.log(` revive dead ${d.name}`);
enc = (await callStep('reviveParticipant',
{ participant: d.name, revive: true },
(e) => toggleParticipantActive(e, d.id, ctx))).enc;
(e) => reviveParticipant(e, d.id, ctx))).enc;
}
if (d.status === 'stable') {
if (VERBOSE) console.log(` heal stable ${d.name} +${d.maxHp}`);
enc = (await callStep('applyHpChange',
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx))).enc;
}
const latest = (enc.participants || []).find(p => p.id === d.id);
if (latest && latest.isActive === false) {
if (VERBOSE) console.log(` reactivate ${latest.name}`);
enc = (await callStep('toggleParticipantActive',
{ participant: latest.name, revive: true },
(e) => toggleParticipantActive(e, latest.id, ctx))).enc;
}
if (VERBOSE) console.log(` heal ${d.name} +${d.maxHp}`);
enc = (await callStep('applyHpChange',
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx))).enc;
}
}