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
+9 -5
View File
@@ -4,6 +4,14 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open
### REview undo/redo around death stuff. doesnt work.
suspect not writing to undo log?
### bug - start/end/resume combat should not be in undo I think...
## dying>stabe state - add +1hp in 1d4 hours DISPLAY (maybe show a rolled hours too) for while stable
## dying>stabe state - add +1hp in 1d4 hours DISPLAY (maybe show a rolled hours too) for while stable
### FEAT: critical damage button for dying/stable participants
- Shared logic already supports `applyHpChange(..., { isCriticalHit:true })`.
- UI only has normal Damage, which adds 1 failure at 0 HP.
@@ -13,15 +21,11 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
### dm list - keep active particpant in view (scroll)
### combat.js doesnt seem to actually exercise the add characters. there are no characters in the campaign. only in the encounter..
#### debug feature - button to delet all campaigns - only in dev builds
### bug - start/end/resume combat should not be in undo I think...
### combat.js doesnt seem to actually exercise the add characters. there are no characters in the campaign. only in the encounter..
## dying>stabe state - add +1hp in 1d4 hours DISPLAY (maybe show a rolled hours too) for while stable
### FEAT: player display fade transitions for inactive state
- Inactive is DM-triggered via Mark Inactive.
+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;
}
}
+84 -6
View File
@@ -27,6 +27,8 @@ const {
toggleParticipantActive: combatToggleActive,
applyHpChange: combatApplyHpChange,
deathSave: combatDeathSave,
stabilizeParticipant,
reviveParticipant,
toggleCondition: combatToggleCondition,
reorderParticipants,
} = require('../../shared');
@@ -110,10 +112,12 @@ 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) {
async function applyDamage(name, amount, options = null) {
const p = any(name);
if (!p || p.currentHp <= 0) return;
enc = await combatApplyHpChange(enc, p.id, 'damage', amount, ctx);
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);
@@ -146,6 +150,16 @@ async function deathSaveAction(name, outcome) {
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');
@@ -170,6 +184,72 @@ beforeEach(() => {
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));
@@ -182,9 +262,7 @@ test('full 100-round combat scenario (shared, no React)', async () => {
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
await record('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
await record('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
await record('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
await record('addAllChars', () => addAllCharacters());
await record('addAllChars Cleric/Rogue', () => addAllCharacters());
await record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
await record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());