Files
ttrpg-initiative-tracker/DEATH_SAVES_PLAN.md
david raistrick 1d4ec873dc Implement D&D 5e death-save state machine and cleanup combat ordering
- Add status-driven death-save model:
  - status: conscious | dying | stable | dead
  - deathSaveSuccesses/deathSaveFailures as display counters
  - remove old death-save fields as source of truth
- Add death-save actions:
  - success, fail, nat1, nat20
  - stabilize participant
  - revive dead participant to 0 HP, stable, unconscious
- Apply 5e HP transition rules:
  - characters and NPCs at 0 HP become dying
  - stable/dying participants gain unconscious condition
  - healing clears death saves and returns conscious
  - massive damage kills
  - non-NPC monsters at 0 HP become dead and inactive
- Split NPCs from monsters with type="npc":
  - NPCs use death saves like characters
  - NPCs remain DM-controlled/monster-colored in UI
  - new NPCs no longer persist isNpc flag
- Keep dead PCs/NPCs in encounter and initiative until DM removes or deactivates
- Allow DM active/inactive toggle for dead participants
- Hide inactive participants from player display
- Improve DM and player death-state UI:
  - show Dying/Dead/Unconscious states consistently
  - add revive button for dead participants
  - distinguish dead visual from inactive visual in DM view
- Fix initiative drag/reorder behavior:
  - downward drag now changes order instead of no-op
  - paused combat can reorder across current turn pointer
  - turnOrderIds stay synced to participants order
- Persist campaign collapse state in localStorage
- Update death-save docs and encounter builder docs
- Add/expand tests for:
  - death saves and HP transitions
  - dead/active/inactive behavior
  - NPC death-save behavior
  - player display visibility
  - drag reorder semantics
  - logging expectations

Tests:
- npm run test:all
- app: 94 passed
- shared: 158 passed, 5 skipped
- server: 32 passed
2026-07-06 16:48:50 -04:00

8.0 KiB

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:

async function deathSave(encounter, participantId, type, n, rollResult, ctx) {
  // rollResult: number (1-20) or null for manual input
}

Handle nat20:

if (rollResult === 20) {
  updates.hp = 1;
  updates.deathSaves = 0;
  updates.deathFails = 0;
  updates.isDying = false;
  updates.isStable = false;
  status = 'conscious';
}

Handle nat1:

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:

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:

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:

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

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