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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user