288 lines
8.0 KiB
Markdown
288 lines
8.0 KiB
Markdown
# Death Saves 5e - Implementation Plan
|
|||
|
|
|
||
|
|
TDD required. Tests first, implementation after.
|
||
|
|
|
||
|
|
## Gap Analysis
|
||
|
|
|
||
|
|
**Current:**
|
||
|
|
- ✅ Separate success/fail counters (deathSaves, deathFails)
|
||
|
|
- ✅ 3 success → stable, 3 fail → dead
|
||
|
|
- ✅ UI shows ✓/✕ buttons, status badges
|
||
|
|
|
||
|
|
**Missing (from DEATH_SAVES.md):**
|
||
|
|
- ❌ Nat 20: 1 HP + conscious, reset counters
|
||
|
|
- ❌ Nat 1: 2 failures
|
||
|
|
- ❌ Damage at 0 HP → add failures (normal=1, crit=2)
|
||
|
|
- ❌ Massive damage: damage >= currentHp + maxHp → instant dead
|
||
|
|
- ❌ Stabilize manual action
|
||
|
|
- ❌ Healing resets counters
|
||
|
|
- ❌ Damage breaks stable → back to dying
|
||
|
|
|
||
|
|
**Test Gap:**
|
||
|
|
- turn.deathsave.test.js: basic counters only
|
||
|
|
- turn.combat.test.js: single deathSave call, no coverage
|
||
|
|
- Combat.scenario.test.js: basic fail/death flow
|
||
|
|
- No nat20, nat1, damage-at-0, massive damage, stabilize, healing integration
|
||
|
|
|
||
|
|
## Phase 1: Extend Tests (TDD)
|
||
|
|
|
||
|
|
### File: `shared/tests/turn.deathsave.nat20.test.js`
|
||
|
|
|
||
|
|
Test cases:
|
||
|
|
- Nat20 → hp=1, deathSaves=0, deathFails=0, isDying=false, isStable=false, status="conscious"
|
||
|
|
- Nat20 overrides existing saves/fails
|
||
|
|
- Nat20 log entry correct
|
||
|
|
|
||
|
|
### File: `shared/tests/turn.deathsave.nat1.test.js`
|
||
|
|
|
||
|
|
Test cases:
|
||
|
|
- Nat1 → deathFails += 2
|
||
|
|
- Nat1 triggers instant death (3 fails total)
|
||
|
|
- Nat1 log entry correct
|
||
|
|
|
||
|
|
### File: `shared/tests/turn.deathsave.damage.test.js`
|
||
|
|
|
||
|
|
Test cases:
|
||
|
|
- Damage at 0 HP → deathFails += 1
|
||
|
|
- Crit damage at 0 HP → deathFails += 2
|
||
|
|
- Mixed: save success + damage → independent tracking works
|
||
|
|
- Damage log includes "while dying"
|
||
|
|
|
||
|
|
### File: `shared/tests/turn.deathsave.massive.test.js`
|
||
|
|
|
||
|
|
Test cases:
|
||
|
|
- Damage >= currentHp + maxHp → instant dead
|
||
|
|
- Bypasses death saves entirely
|
||
|
|
- Log entry shows "massive damage"
|
||
|
|
|
||
|
|
### File: `shared/tests/turn.deathsave.stabilize.test.js`
|
||
|
|
|
||
|
|
Test cases:
|
||
|
|
- Stabilize → hp=0, isStable=true, isDying=false, counters=0
|
||
|
|
- Stabilize from dying state
|
||
|
|
- Stabilize log entry correct
|
||
|
|
|
||
|
|
### File: `shared/tests/turn.deathsave.heal.test.js`
|
||
|
|
|
||
|
|
Test cases:
|
||
|
|
- Healing at 0 HP → reset counters, isDying=false, isStable=false
|
||
|
|
- Healing at stable → same reset
|
||
|
|
- Healing log includes "cleared death saves"
|
||
|
|
|
||
|
|
### File: `shared/tests/turn.deathsave.stable.test.js`
|
||
|
|
|
||
|
|
Test cases:
|
||
|
|
- Damage to stable → isStable=false, isDying=true, counters reset
|
||
|
|
- Stable takes damage → fresh death save loop
|
||
|
|
|
||
|
|
### File: `shared/tests/turn.deathsave.undo.test.js`
|
||
|
|
|
||
|
|
Test cases:
|
||
|
|
- Undo deathSave restores previous state
|
||
|
|
- Undo nat20 restores counters
|
||
|
|
- Undo damage at 0 HP restores fail count
|
||
|
|
- Undo stabilize restores dying state
|
||
|
|
|
||
|
|
## Phase 2: Combat Test Integration
|
||
|
|
|
||
|
|
### Update `shared/tests/turn.combat.test.js`
|
||
|
|
|
||
|
|
Add phases:
|
||
|
|
- Nat20 phase: roll nat20, verify 1 HP + conscious
|
||
|
|
- Nat1 phase: roll nat1, verify 2 failures
|
||
|
|
- Damage-at-zero phase: normal damage + crit damage at 0 HP
|
||
|
|
- Massive damage phase: instant death bypass
|
||
|
|
- Stabilize phase: stabilize dying character
|
||
|
|
- Healing-reset phase: heal at 0 HP, verify counters reset
|
||
|
|
- Assert death save state at each phase
|
||
|
|
|
||
|
|
### Update `src/tests/Combat.scenario.test.js`
|
||
|
|
|
||
|
|
Add action functions:
|
||
|
|
- deathSaveActionNat20(name)
|
||
|
|
- deathSaveActionNat1(name)
|
||
|
|
- stabilizeAction(name)
|
||
|
|
- applyDamageAtZero(name, amount, isCrit)
|
||
|
|
- applyMassiveDamage(name)
|
||
|
|
- healingResetAction(name, amount)
|
||
|
|
|
||
|
|
Add scenario phases:
|
||
|
|
- Round 10-20: nat20/nat1 scenarios
|
||
|
|
- Round 30-40: damage-at-zero scenarios (normal + crit)
|
||
|
|
- Round 50-60: stabilize scenarios
|
||
|
|
- Round 70-80: massive damage scenarios
|
||
|
|
- Round 90-100: healing-reset scenarios
|
||
|
|
- Verify log entries include all death save types
|
||
|
|
|
||
|
|
### Update `scripts/combat.js`
|
||
|
|
|
||
|
|
Verify death save events:
|
||
|
|
- Check death_save events in log
|
||
|
|
- Verify nat20, nat1, damage_at_zero, massive_damage log types
|
||
|
|
- Verify stabilize events
|
||
|
|
- Verify healing_reset events
|
||
|
|
|
||
|
|
## Phase 3: Extend `deathSave` Function
|
||
|
|
|
||
|
|
Add roll result parameter:
|
||
|
|
```ts
|
||
|
|
async function deathSave(encounter, participantId, type, n, rollResult, ctx) {
|
||
|
|
// rollResult: number (1-20) or null for manual input
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Handle nat20:
|
||
|
|
```ts
|
||
|
|
if (rollResult === 20) {
|
||
|
|
updates.hp = 1;
|
||
|
|
updates.deathSaves = 0;
|
||
|
|
updates.deathFails = 0;
|
||
|
|
updates.isDying = false;
|
||
|
|
updates.isStable = false;
|
||
|
|
status = 'conscious';
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Handle nat1:
|
||
|
|
```ts
|
||
|
|
if (rollResult === 1) {
|
||
|
|
const currentFails = participant.deathFails || 0;
|
||
|
|
updates.deathFails = Math.min(3, currentFails + 2);
|
||
|
|
// Check for death...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Phase 4: Extend `applyHpChange` Function
|
||
|
|
|
||
|
|
Add damage-at-zero logic:
|
||
|
|
```ts
|
||
|
|
if (changeType === 'damage' && participant.currentHp === 0) {
|
||
|
|
const failuresToAdd = isCritical ? 2 : 1;
|
||
|
|
const currentFails = participant.deathFails || 0;
|
||
|
|
updates.deathFails = Math.min(3, currentFails + failuresToAdd);
|
||
|
|
updates.isStable = false;
|
||
|
|
// Check for death...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add massive damage rule:
|
||
|
|
```ts
|
||
|
|
if (changeType === 'damage' && damage >= participant.currentHp + participant.maxHp) {
|
||
|
|
updates.hp = 0;
|
||
|
|
updates.isDying = true;
|
||
|
|
updates.deathSaves = 0;
|
||
|
|
updates.deathFails = 3;
|
||
|
|
updates.isStable = false;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add healing reset logic:
|
||
|
|
```ts
|
||
|
|
if (changeType === 'heal' && (participant.currentHp === 0 || participant.isStable || participant.isDying)) {
|
||
|
|
updates.deathSaves = 0;
|
||
|
|
updates.deathFails = 0;
|
||
|
|
updates.isDying = false;
|
||
|
|
updates.isStable = false;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Phase 5: New `stabilizeParticipant` Function
|
||
|
|
|
||
|
|
```ts
|
||
|
|
async function stabilizeParticipant(encounter, participantId, ctx) {
|
||
|
|
// hp=0, isStable=true, isDying=false, counters=0
|
||
|
|
// Log: "Stabilized"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Phase 6: UI Extensions
|
||
|
|
|
||
|
|
### Phase 6a: Roll Result Modal
|
||
|
|
|
||
|
|
Add roll result input:
|
||
|
|
- Modal when clicking death save button
|
||
|
|
- Input: d20 result (1-20) or "manual"
|
||
|
|
- Auto-detect nat20/nat1
|
||
|
|
|
||
|
|
### Phase 6b: Nat1/Nat20/Stabilize Buttons
|
||
|
|
|
||
|
|
Death save row layout:
|
||
|
|
```
|
||
|
|
[Saves: □ □ □] [Fails: □ □ □]
|
||
|
|
[Nat1] [Nat20] [Stabilize]
|
||
|
|
```
|
||
|
|
|
||
|
|
**Nat1 button:**
|
||
|
|
- Red icon (AlertTriangle)
|
||
|
|
- Click → deathSave(participantId, 'fail', null, 1, ctx)
|
||
|
|
- Adds 2 failures immediately
|
||
|
|
- Log: "Nat 1! [name] takes 2 death save failures"
|
||
|
|
- Disable if deathFails >= 2 (would cause death anyway)
|
||
|
|
|
||
|
|
**Nat20 button:**
|
||
|
|
- Gold icon (Zap)
|
||
|
|
- Click → deathSave(participantId, 'success', null, 20, ctx)
|
||
|
|
- Sets hp=1, resets counters, conscious
|
||
|
|
- Log: "Nat 20! [name] restored to 1 HP, conscious"
|
||
|
|
|
||
|
|
**Stabilize button:**
|
||
|
|
- Emerald icon (HeartPulse)
|
||
|
|
- Only show when dying (hp=0, !isStable, !dead)
|
||
|
|
- Click → stabilizeParticipant(participantId, ctx)
|
||
|
|
- Sets isStable=true, counters=0
|
||
|
|
- Log: "[name] stabilized at 0 HP"
|
||
|
|
- Hide if already isStable
|
||
|
|
|
||
|
|
**Conditional visibility:**
|
||
|
|
- All buttons: only when hp === 0 && !dead
|
||
|
|
- Stabilize: hide if already isStable
|
||
|
|
- Nat1: disable if deathFails >= 2
|
||
|
|
|
||
|
|
### Phase 6c: Crit Checkbox for Damage
|
||
|
|
|
||
|
|
Add crit checkbox:
|
||
|
|
- For damage at 0 HP
|
||
|
|
- Pass crit to applyHpChange
|
||
|
|
- UI: checkbox "Critical hit?"
|
||
|
|
|
||
|
|
### Phase 6d: Enhanced Log Messages
|
||
|
|
|
||
|
|
- Nat20: "Nat 20! [name] restored to 1 HP, conscious"
|
||
|
|
- Nat1: "Nat 1! [name] takes 2 death save failures"
|
||
|
|
- Damage at 0: "[name] takes [n] damage while dying"
|
||
|
|
- Massive damage: "Massive damage! [name] instantly killed"
|
||
|
|
- Stabilize: "[name] stabilized at 0 HP"
|
||
|
|
- Healing reset: "[name] healed to [n] HP, death saves cleared"
|
||
|
|
|
||
|
|
## Phase 7: Integration Tests
|
||
|
|
|
||
|
|
File: `shared/tests/turn.deathsave.flow.test.js`
|
||
|
|
|
||
|
|
Test full scenarios:
|
||
|
|
- Dying → nat20 → conscious → take damage → dying
|
||
|
|
- Dying → nat1 → 2 fails → another fail → dead
|
||
|
|
- Dying → stabilize → damage → dying again
|
||
|
|
- Massive damage bypasses all
|
||
|
|
- Healing resets mid-death-save loop
|
||
|
|
|
||
|
|
## Execution Order
|
||
|
|
|
||
|
|
1. Phase 1: Write all test files (fail expected)
|
||
|
|
2. Phase 2: Update combat tests (fail expected)
|
||
|
|
3. Phase 3: Extend deathSave, run tests
|
||
|
|
4. Phase 4: Extend applyHpChange, run tests
|
||
|
|
5. Phase 5: Add stabilizeParticipant, run tests
|
||
|
|
6. Phase 6a: Roll result modal
|
||
|
|
7. Phase 6b: Nat1/Nat20/Stabilize buttons
|
||
|
|
8. Phase 6c: Crit checkbox
|
||
|
|
9. Phase 6d: Enhanced log messages
|
||
|
|
10. Phase 7: Integration tests
|
||
|
|
11. Manual testing: full scenarios in dev server
|
||
|
|
|
||
|
|
## Risk Notes
|
||
|
|
|
||
|
|
- Roll result input: optional field (DM may not want to input every roll)
|
||
|
|
- Crit detection: manual checkbox (no way to auto-detect)
|
||
|
|
- Massive damage: optional rule (some tables ignore)
|
||
|
|
- Backward compat: existing API must still work with null rollResult
|
||
|
|
- Nat1/Nat20/Stabilize buttons: DM-optional, not required to use
|