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
This commit is contained in:
david raistrick
2026-07-06 16:48:50 -04:00
parent 2569cc4497
commit 1d4ec873dc
22 changed files with 1617 additions and 531 deletions
+142
View File
@@ -0,0 +1,142 @@
# Death Saves (D&D 5e)
Concept and rule structure for the death-save state machine. Reference for how
combat tracking should behave at 0 HP. This is the 5e death-save loop, not a
code spec.
## Scope: tracker tracks, DM decides
This tracker is not a virtual tabletop. It does not roll dice or interpret the
table. The DM rolls at the table, decides what happened, and feeds the result
into the tracker through the UI. The tracker records the result and applies the
automatic pieces.
- DM does: rolls the d20, determines crits vs normal hits, declares damage
amounts, decides when to stabilize or heal.
- Tracker does: increments success/failure counters on DM input, resolves the
terminal states (3 successes → stable, 3 failures → dead), resets counters on
heal or stabilize, records HP changes.
The transitions below describe the rules the tracker must honor once the DM
hands it a result. The DM is the source of truth for what happened; the tracker
is the source of truth for what state that produces.
## State
A character or NPC at 0 HP enters a death-save loop. Track per death-save participant:
```ts
deathSaveSuccesses: 0 | 1 | 2 | 3
deathSaveFailures: 0 | 1 | 2 | 3
status: "conscious" | "dying" | "stable" | "dead"
hp: number
```
## Transitions
**Drop to 0 HP** (damage, character/NPC):
- If damage >= currentHp + maxHp → outright dead (massive damage). Equivalently: after reducing currentHp to 0, overflow damage >= maxHp.
- Otherwise → `status = "dying"`, counters reset to 0.
**Drop to 0 HP** (monster):
- Non-NPC monsters do not use death saves.
- `status = "dead"`, counters reset, `isActive = false`.
**Death save** (d20 roll on a dying character/NPC turn):
```ts
if roll === 1: failures += 2 // natural 1 = two failures
else if roll === 20: hp = 1; successes = 0; failures = 0; status = "conscious" // nat 20 = 1 HP, conscious
else if roll >= 10: successes += 1
else: failures += 1
```
Then resolve immediately on the click/action that reaches 3:
```ts
if failures >= 3: status = "dead"; successes = 0; failures = 0
if successes >= 3: status = "stable"; successes = 0; failures = 0
```
The UI must transition immediately. A third failure shows Dead state, not three
active failure boxes. A third success shows Stable state, not three active
success boxes.
**Damage while dying** (at 0 HP):
```ts
failures += isCriticalHit ? 2 : 1
if failures >= 3: status = "dead"
```
Attacks from within 5 ft against an unconscious creature are auto-crits, so
melee vs a dying character/NPC adds 2 failures.
**Healing while dying or stable**:
Any +HP received while `dying` or `stable` makes the character/NPC conscious.
This usually comes from magic, or from the stable-state time-delayed recovery
(1 HP after 1d4 hours, if the DM chooses to track it here).
```ts
hp = max(1, hp + amount)
successes = 0
failures = 0
status = "conscious"
```
Any HP regained from `dying` or `stable` clears all counters and ends the
death-save loop. This does **not** apply to `dead`; dead requires revive flow,
revival magic, or DM state override, not normal healing.
**Stabilize** (manual action, not a save):
```ts
hp = 0
successes = 0
failures = 0
status = "stable"
```
## Stable state
- Stays at 0 HP, unconscious, no longer makes death saves.
- 3 successes = **stable (unconscious)**, NOT conscious. NOT 1 HP.
1 HP only comes from a natural 20 or healing.
- Taking any damage ends stable → back to `dying` (counters reset, then damage
applies its failure per "damage while dying").
## Tool-specific initiative behavior
Death is a combat state, not an automatic removal action.
When a character or NPC becomes `dead`:
- Do **not** remove them from initiative.
- Do **not** automatically deactivate them.
- Do **not** remove them from the encounter participants list.
- Leave them in turn order. Other table/tool events may still matter on that
initiative count.
- DM may manually mark them inactive or remove them.
When a non-NPC monster becomes `dead`:
- Set `isActive = false` automatically.
- Keep it in the encounter participants list until DM removes it.
- Player display hides it because inactive participants are hidden.
## Invariants
- `status === "dying"` ⟺ hp === 0 AND no terminal flag.
- `status === "stable"` ⟹ hp === 0, unconscious, counters 0.
- `status === "dead"` ⟹ hp === 0, no further saves, counters 0.
- `status === "dead"` ⟹ participant remains in initiative and participants until DM removes them.
- `status === "dead"` does not itself mean inactive; monster death sets inactive as a separate rule.
- Normal +HP applies to `dying` or `stable`, not `dead`; dead requires revive, revival, or DM override.
- Successes and failures tracked **independently**. 2 fail + 3 success = stable.
Counters do not need to be consecutive.
- A roll of 10+ is a success. 9 or lower is a failure.
## Sources
- [PHB 5e: Death Saving Throws](https://roll20.net/compendium/dnd5e/Rules%3ADamage%20and%20Healing)
- [Reddit: What happens on 3 successful death saves?](https://www.reddit.com/r/DnD/comments/io5js7/what_happens_if_you_succeed_in_3_death_saving/)
- [Roll20: Conditions (Unconscious)](https://roll20.net/compendium/dnd5e/Conditions)