Files
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

5.1 KiB

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

{
  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