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:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user