diff --git a/TODO.md b/TODO.md index 9d1cc5e..6e42105 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/scripts/combat/replay.js b/scripts/combat/replay.js index 41a6a85..255feb7 100644 --- a/scripts/combat/replay.js +++ b/scripts/combat/replay.js @@ -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; } } diff --git a/src/tests/Combat.scenario.test.js b/src/tests/Combat.scenario.test.js index ab491a7..db7fe76 100644 --- a/src/tests/Combat.scenario.test.js +++ b/src/tests/Combat.scenario.test.js @@ -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());