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)
+1 -1
View File
@@ -174,7 +174,7 @@ Runs pure turn.js combat, audits 9 invariant classes per round:
1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN)
3. isActive consistency (dead = inactive)
3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
4. turnOrder no dup ids
5. turnOrder ids all active
6. currentTurn valid + active
+28 -18
View File
@@ -53,16 +53,16 @@ Object in `encounter.participants[]`:
|---|---|---|
| `id` | string | `generateId()` |
| `name` | string | |
| `type` | `'character'` \| `'monster'` | character = PC (death saves), monster = hostile/NPC |
| `type` | `'character'` \| `'npc'` \| `'monster'` | character/NPC = death saves, monster = dead at 0 HP |
| `originalCharacterId` | string\|null | links back to campaign character if type=character |
| `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. |
| `maxHp` | int | |
| `currentHp` | int | 0 = dead/dying |
| `isNpc` | bool | monster flagged NPC (display color, no death saves) |
| `currentHp` | int | 0 = dead/dying/stable by `status` |
| `conditions` | array | condition ids from `CONDITIONS` list |
| `isActive` | bool | in turn rotation? false = skipped by nextTurn |
| `deathSaves` | int | PC only, 0-3 fails |
| `isDying` | bool | death animation flag (player display) |
| `status` | `'conscious'` \| `'dying'` \| `'stable'` \| `'dead'` | death-save source of truth |
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
## Build flow (UI)
@@ -99,7 +99,7 @@ ParticipantManager section. Two paths:
- **Monster Name** (`placeholder: "e.g., Dire Wolf"`)
- **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2)
- **Max HP** (`DEFAULT_MAX_HP` = 10)
- **Is NPC?** checkbox (flag, changes display color)
- **Is NPC?** checkbox (sets `type: 'npc'`, keeps monster/DM-control color, enables death saves)
- Click **Add to Encounter**
- Initiative auto-rolled: `rollD20() + mod`
@@ -113,13 +113,13 @@ ParticipantManager section. Two paths:
Participant object added:
```js
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp,
isNpc, conditions:[], isActive:true, deathSaves:0, isDying:false }
conditions:[], isActive:true, status:'conscious', deathSaveSuccesses:0, deathSaveFailures:0 }
```
### 6. Reorder before start (tie-break)
Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`.
Post-start drag: see BUG-13/14 in `TODO.md` (cross-init + pointer semantics untested).
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
## Combat flow (UI)
@@ -135,8 +135,9 @@ InitiativeControls panel (sticky, right side).
### Next Turn
- **Next Turn** button (disabled if paused)
- Advances to next active participant in `turnOrderIds`
- Wraps at end → `round += 1`, re-sorts active by initiative at round start
- Dead (`isActive:false`) skipped, stay in rotation
- Wraps at end → `round += 1`
- No re-sort after start; 1-list order remains source of truth
- Inactive (`isActive:false`) participants are skipped, but stay in slot
### Pause / Resume
- **Pause Combat** → `isPaused=true`, Next Turn disabled
@@ -147,11 +148,19 @@ InitiativeControls panel (sticky, right side).
Per-participant input + buttons:
- Number input
- **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)`
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)`
- Death: hp→0 sets `isActive:false`, PC gets `deathSaves` tracking
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` for living/dying/stable participants; normal heal does not affect dead participants
- Character/NPC at 0 HP → `status:'dying'`, death-save controls, unconscious condition
- Monster at 0 HP → `status:'dead'`, `isActive:false`
- Massive damage (`damage >= currentHp + maxHp`) → dead
### Death saves (PC only, at 0 HP)
3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal.
### Death saves (character/NPC only, at 0 HP)
Action buttons: Success, Fail, Nat1, Nat20, Stabilize.
- Success: +1 success; 3 successes → stable/unconscious at 0 HP
- Fail: +1 failure; 3 failures → dead
- Nat1: +2 failures
- Nat20: 1 HP, conscious, counters reset
- Stabilize: stable/unconscious at 0 HP, counters reset
- Revive: dead → 0 HP, stable/unconscious, active
### Conditions
- Click participant → expand conditions picker (all 22 from `CONDITIONS`)
@@ -170,7 +179,7 @@ What it shows:
- Round + current turn participant
- All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix)
- HP bars, conditions, death saves
- Inactive monsters hidden (pre-staged reserves)
- Inactive participants hidden (including auto-inactive dead monsters and DM-hidden reserves)
Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets).
@@ -181,11 +190,11 @@ Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single
- **Display** = `participants[]` order (AdminView + DisplayView, no re-sort)
- **Turn rotation** = `turnOrderIds` (mirrors participants[])
- **Drag** = source of truth, overrides initiative
- **Add mid-combat** = append to participants[] + sync (BUG-14: init-insert broken post-drag)
- **Add mid-combat** = slot by initiative into participants[] + sync
- **Toggle active** = flip `isActive` only, stay in slot
- **Remove** = drop from participants[] + sync, advance current if needed
No re-sort after `startEncounter` except round-wrap (re-sorts active by init at top of round).
No re-sort after `startEncounter`.
## Storage paths quick reference
@@ -202,7 +211,8 @@ logs/{logId} action log entry
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
- Pause before big roster changes (adds/removes). Resume re-syncs cleanly.
- Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added).
- Dead monsters stay in rotation, skipped. Remove via trash icon to clean list.
- Dead monsters become inactive automatically and are skipped. Remove via trash icon to clean list.
- Dead characters/NPCs stay active until DM marks inactive, removes, or revives them.
- Player display auto-follows Start Combat. Manual control via Open Player Window.
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs.
+133
View File
@@ -0,0 +1,133 @@
# Log System Redesign Plan
**Status:** DRAFT — not started
**Branch:** `feat-turn-writes-logs`
**Goal:** Lean mutation deltas. Kill bloated payload/undo duplication.
## Problem
Current events = 10-16KB each. `add_participant` row = 16KB to say "Fighter added, init 22." Root cause: full participant roster stored 3x (payload + undo_payload.updates + undo_payload.redo).
DB: 2486 docs, most bloated. Replay trace 1.6-5.5MB for ~250 steps.
Old log (pre-refactor) = message string + small undo object. Proven worked for undo/redo + viewer. Just wanted MORE STRUCTURE, not MORE DATA. Overbuilt instead.
## Intention
Each event = mutation delta + lean snapshot. Structured for filter/group/analyzer. No roster arrays.
### Canonical lean event shape
```js
{
id, ts, type, // identity + filter key
message, // human text (viewer displays)
encounterId, encounterName, // which encounter
participantId, participantName, // who (nullable: pause/nextTurn have none)
// type-specific scalar delta (one of these clusters per type):
// add: { init, maxHp, type: 'character'|'monster' }
// remove: { dead: bool }
// damage/heal:{ amount }
// condition: { condition }
// toggleActive:{ revive: bool }
// reorder: { draggedId, targetId }
// update: { changedFields: ['notes', ...] }
// nextTurn: (none — snapshot carries new round/pointer)
// pause/resume/start/end: (none)
snapshot: { // lean, ALREADY GOOD — keep as-is
round, currentTurnParticipantId, turnOrderIds, activeIds
},
undo: <type-specific inverse, id-based> // minimal
}
```
### Undo inverses (id-based, minimal)
| type | undo |
|------|------|
| add_participant | `{ participantId }` → removeParticipant by id |
| remove_participant | full participant obj (must restore — only place full obj needed) |
| damage | `{ amount: -amount }` or heal back |
| heal | `{ amount: -amount }` or damage back |
| condition | `{ condition }` (toggle is its own inverse) |
| toggleActive | `{ revive: !revive }` |
| reorder | `{ draggedId, targetId }` (swap back) |
| update | `{ changedFields: [...], oldValues: {...} }` |
| nextTurn | `{ currentTurnParticipantId, round }` from snapshot delta |
| pause/resume | toggle |
| start/end | full prior scalar state |
**No `redo` field.** Redo = re-apply forward delta from event itself.
**No `undo_payload` wrapper.** Just `undo` object.
## 4 Consumers
1. **Viewer** — shows `message` + `type` + `participantName` + `undone` flag. Legacy logs (no type) display message only.
2. **Undo/redo** — apply `undo` inverse by id. Id-based, minimal.
3. **Replay** — turn.js funcs write own lean events. Trust func return. NO read-back. JSONL trace = lean snapshots + types.
4. **Analyzer** — reads `snapshot` sequence + `type`. Reconstructs roster from event stream (add/remove by id). NO payload arrays needed.
## Legacy Support
- **Viewer:** yes — show old message-only logs, no type parse
- **Undo:** no — old logs unundoable (acceptable, sit inert)
- **Analyzer:** best-effort — snapshot may be absent on legacy → skip invariant check for that run
## Why Lean
- `add_participant` 16KB → ~150 bytes
- Replay trace 5.5MB → ~250KB
- Undo still works (proven old log did same)
- Analyzer unaffected (uses snapshot+type, not payload)
## Migration Plan
### Phase 1: Redesign event writers (shared/turn.js)
- [ ] Rewrite `buildEntry()` to produce lean shape (delta fields per type)
- [ ] Rewrite `commit()` — no patch storage, write lean event
- [ ] Per-func: compute minimal delta + inverse from enc before/after
- [ ] Drop `payload`, `undo_payload`, `undo_payload.redo`
### Phase 2: Update undo executor
- [ ] Find undo consumer (App.js `applyUndo` or similar)
- [ ] Switch to id-based inverse application
- [ ] Handle each type's undo shape
- [ ] Drop `transactionalUndo` full-patch path if exists
### Phase 3: Update replay trace
- [ ] `replay-combat.js` callStep: trust return, snapshot only (no read-back)
- [ ] Trace = `{step, ts, type, pre:leanSnap, post:leanSnap}` — no participant arrays
### Phase 4: Update analyzer
- [ ] `analyze-turns.js` already uses snapshot+type only — verify no payload dep
- [ ] Reconstruct roster from event stream for skip/double-act checks (id-level)
### Phase 5: Legacy compat
- [ ] `logEvent.js normalizeEvent` — lift legacy into lean shape (type='legacy', message only)
- [ ] Viewer handles legacy gracefully
- [ ] Download = lean events only (legacy excluded or message-only)
### Phase 6: Cleanup
- [ ] Delete old bloated docs from DB? Or leave (inert, viewer reads message)?
- [ ] Verify build CI=true passes
- [ ] Verify analyzer clean on fresh replay
- [ ] Verify undo works on new events
## NOT in scope
- Fixing 5 pre-existing initiative bugs (logged in TODO.md, separate)
- Changing snapshot shape (already lean)
- Changing viewer UI (just data source)
## Open Questions
- Undo for `remove_participant`: must restore full obj (only place full obj needed). Acceptable?
- Should legacy logs be deleted from DB or left inert?
- Download format: lean JSON array (no legacy) — confirm
+1 -1
View File
@@ -118,7 +118,7 @@ node tests/audit/audit-rotation.js
Runs pure turn.js combat. Audits 9 invariant classes per round:
1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN)
3. isActive consistency (dead = inactive)
3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
4. turnOrder no dup ids
5. turnOrder ids all active
6. currentTurn valid + active