D&D 5e death-save state machine, undo/redo fixes, dev bulk delete #4

Merged
robert merged 11 commits from single-source into main 2026-07-06 22:14:05 -04:00
Collaborator

Follow-up to merged PR #3. Death-save feature complete + bugs fixed.

Death-save (D&D 5e)

  • Status enum source of truth: conscious/dying/stable/dead
  • Characters + NPCs use death saves; monsters auto-dead+inactive at 0 HP
  • Death-save actions: Success, Fail, Nat1, Nat20, Stabilize, Revive
  • Dead PC/NPC stays in initiative; DM removes manually
  • Stable shows regain hint (1 HP after 1d4 hours)
  • Crit Damage button: +2 failures at 0 HP (5e crit-at-0)
  • Player display hides inactive; DM shows all with state

Undo/redo (major bugs fixed)

  • Redo rebuilt from current state — wrong order/content/no-op. Root: writers stored oldValues only.
  • Writers now store forward arrays (newTurnOrderIds, newParticipants, newValues incl death state)
  • expandUndo redo uses stored forward state
  • Missing cases added: stabilize, revive, deactivate_dead_monster (were no-op)
  • Death ops restore full state (status, deathSave*, isActive, conditions incl unconscious)
  • Pause/resume excluded from undo (lifecycle, not player action)
  • Mock storage undo() was no-op — now real (applies patch + flips undone)
  • New harness: undoLast/redoLast exercise real mechanism

Test infra

  • Full undo+redo roundtrip tests (was undo-only, redo untested)
  • 11 death-path undo roundtrips
  • Skip-guard in run-tests.sh (pre-flight grep refuses .skip/xdescribe/xit)
  • Removed dead round-trip.test.js (5 skipped tests testing deleted scripts)
  • Mock firestore: where() export, getDocs applies constraints (pre-existing gap)

combat.js (replay/verify tool)

  • NPC now type=npc (was stale isNpc field)
  • Death-save eligibility keyed on type; outcomes rotate success/fail/nat1/nat20
  • Between-round revive fixed (heal no-op on dead -> reviveParticipant)
  • verify.js false positive fixed: round wrap via non-nextTurn event not detected
  • Campaign doc now has players array (mirrors app)

Dev tool: bulk delete all campaigns

  • Debug button (dev builds only) wipes all campaigns/encounters/logs
  • Was 30s+/campaign (fetched all logs, client-filtered). Now SQL bulk DELETE, no fetch.
  • deleteCollection: server SQL bulk, firebase fetch+batch
  • 3-layer gate: UI hidden prod, storage throws prod, HTTP 403 prod

Other

  • Drag reorder bug fixed (downward was no-op)
  • Paused combat can reorder across current-turn pointer
  • Auto-scroll current participant (player display, DM view)
  • Campaign collapse state persisted (localStorage)

307 tests, all green.

Follow-up to merged PR #3. Death-save feature complete + bugs fixed. ## Death-save (D&D 5e) - Status enum source of truth: conscious/dying/stable/dead - Characters + NPCs use death saves; monsters auto-dead+inactive at 0 HP - Death-save actions: Success, Fail, Nat1, Nat20, Stabilize, Revive - Dead PC/NPC stays in initiative; DM removes manually - Stable shows regain hint (1 HP after 1d4 hours) - Crit Damage button: +2 failures at 0 HP (5e crit-at-0) - Player display hides inactive; DM shows all with state ## Undo/redo (major bugs fixed) - Redo rebuilt from current state — wrong order/content/no-op. Root: writers stored oldValues only. - Writers now store forward arrays (newTurnOrderIds, newParticipants, newValues incl death state) - expandUndo redo uses stored forward state - Missing cases added: stabilize, revive, deactivate_dead_monster (were no-op) - Death ops restore full state (status, deathSave*, isActive, conditions incl unconscious) - Pause/resume excluded from undo (lifecycle, not player action) - Mock storage undo() was no-op — now real (applies patch + flips undone) - New harness: undoLast/redoLast exercise real mechanism ## Test infra - Full undo+redo roundtrip tests (was undo-only, redo untested) - 11 death-path undo roundtrips - Skip-guard in run-tests.sh (pre-flight grep refuses .skip/xdescribe/xit) - Removed dead round-trip.test.js (5 skipped tests testing deleted scripts) - Mock firestore: where() export, getDocs applies constraints (pre-existing gap) ## combat.js (replay/verify tool) - NPC now type=npc (was stale isNpc field) - Death-save eligibility keyed on type; outcomes rotate success/fail/nat1/nat20 - Between-round revive fixed (heal no-op on dead -> reviveParticipant) - verify.js false positive fixed: round wrap via non-nextTurn event not detected - Campaign doc now has players array (mirrors app) ## Dev tool: bulk delete all campaigns - Debug button (dev builds only) wipes all campaigns/encounters/logs - Was 30s+/campaign (fetched all logs, client-filtered). Now SQL bulk DELETE, no fetch. - deleteCollection: server SQL bulk, firebase fetch+batch - 3-layer gate: UI hidden prod, storage throws prod, HTTP 403 prod ## Other - Drag reorder bug fixed (downward was no-op) - Paused combat can reorder across current-turn pointer - Auto-scroll current participant (player display, DM view) - Campaign collapse state persisted (localStorage) 307 tests, all green.
keen added 9 commits 2026-07-06 20:04:43 -04:00
feat(combat): add first-class undo and redo controls

Add undo and redo controls to the combat UI so the DM can recover from recent
actions without leaving the encounter view.

Undo and redo operate on the current encounter's combat history. Empty stacks
produce clear feedback instead of failing silently. Redo order follows normal
stack behavior after multiple undos.

This makes combat history actionable during play, not just visible in the log.

feat(logs): make combat logs replayable

Replace plain combat log messages with structured combat events that can be
used by the UI, exported as JSON, replayed, and verified.

Each new log entry records the action type, encounter identity, participant
identity, a small action delta, undo intent, and a turn snapshot. Download and
copy now export the event stream as JSON so a saved combat log is useful for
offline analysis and debugging.

Legacy logs remain viewable, but new logs use the structured event format.

feat(logs): make undo and redo transactional

Apply undo and redo as single storage operations so the encounter state and log
state cannot drift apart.

Server storage applies the encounter update and the log undone flag inside one
SQLite transaction. Firebase storage uses a batch write for the same behavior.
The storage contract now includes undo/redo semantics.

This replaces fragile multi-write undo behavior where a failure could update
the encounter without marking the log, or mark the log without updating the
encounter.

feat(combat): add unified replay and verification tool

Add one combat CLI for replaying live combat and verifying combat logs.

Replay drives the live backend through the same shared combat logic used by the
app, writes a JSON event log to an explicit output path, and automatically
verifies the result. Verification checks for DM-visible combat problems such as
skipped turns, double actions, bad round changes, and unexpected turn order
changes.

The tool uses the same JSON event stream produced by log downloads, supports
verbose turn output, and handles Ctrl-C by ending the encounter, writing the
partial log, and verifying what was captured.

fix(perf): keep long combat logging fast

Remove the combat-time log query bottleneck that made long replays slow as log
volume grew.

Combat controls no longer subscribe to the log collection just to keep undo and
redo state warm. Undo and redo now query the latest matching encounter log only
when clicked. Server collection queries support exact filters, ordering,
limits, and offsets, and SQLite indexes keep latest-log and per-encounter log
lookups fast.

Also fix duplicate WebSocket handler registration so realtime updates do not
double-fire under write load.

fix(turns): make toggle active a status change

Make toggle active a roster/status edit instead of a turn advance.

Deactivating the current participant no longer passes the turn or increments
the round. The current turn stays where it is until the DM explicitly clicks
Next Turn, and Next Turn skips inactive participants during normal rotation.

This matches the initiative design: slot order is stable, toggle active does
not move participants, and round changes only come from explicit turn advance.

chore(ci): make warnings and hangs fail fast

Tighten test and build checks so failures are visible instead of noisy or
silent.

Builds run with CI enabled so warnings fail production builds. The full test
command runs app, shared, and server suites with hard timeouts so hangs fail
quickly. Static eslint coverage fails on warnings as well as errors.

Tests were updated around the new async combat logging flow, structured log
events, transactional undo, replay verification, and toggle-active semantics.
- 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
Replay (scripts/combat/replay.js):
- Merchant now type=npc via asNpc (was stale isNpc)
- Death-save eligibility keyed on type (character|npc), not removed isNpc
- Rotate outcomes success/fail/nat1/nat20 (was success-only)
- Import stabilizeParticipant, reviveParticipant
- Between-round revive: dead -> reviveParticipant, stable -> heal,
  inactive -> reactivate (heal was no-op on dead, leaving active-dead monsters)
- Fix deathSave return unwrap: it returns {enc, status}; callStep runner
  must extract .enc or state corrupts (round undefined, combat auto-ends)
- describe() fix: death-save arg is outcome, not type

Scenario (src/tests/Combat.scenario.test.js):
- Import stabilizeParticipant, reviveParticipant
- applyDamage accepts options (crit at 0 HP)
- Add stabilizeAction, reviveAction helpers
- addAllCharacters now actually exercises batch add (was no-op)
- New deterministic edge-case test: monster death (dead+inactive),
  NPC nat20, NPC nat1+damage+revive+heal, character stable->damage->crit,
  massive damage
Root cause: mutation writers stored oldValues only. Redo rebuilt from
current state via derived logic — wrong order, wrong content, or no-op.
Undo patched fields in place but ignored roster/array order changes.

Writers now store forward arrays (shared/turn.js):
- addParticipant: newTurnOrderIds, newCurrentTurnParticipantId
- removeParticipant: newTurnOrderIds, newCurrentTurnParticipantId
- updateParticipant: oldTurnOrderIds + newTurnOrderIds (initiative re-slot)
- reorderParticipants: newParticipants + newTurnOrderIds
- damage/heal/deathSave/stabilize/revive/deactivate_dead_monster:
  oldValues + newValues both capture full death state incl conditions

expandUndo redo uses stored forward state instead of deriving:
- add/remove: order via newTurnOrderIds
- update: initiative change re-orders both directions
- reorder: re-applies newParticipants
- death ops: restore via newValues (was HP-only, dropped status/conditions)

Missing expandUndo cases added: stabilize, revive, deactivate_dead_monster
(were default:null -> undo no-op).

Test infra (shared/tests/_helpers.js):
- Mock storage undo() real now: applies patch + flips undone flag (was no-op)
- addDoc persists log entries, getCollection returns them
- undoLast/redoLast harness helpers exercise real mechanism
- Undo tests assert against actual persisted doc, not expandUndo output

turn.undo.test.js rewritten: every op tested as undo->deepEqual before,
redo->deepEqual after-op (12 cases). Was undo-only, redo untested.
turn.deathsave.undo.test.js added: 11 death-path roundtrips.

Dead code removed:
- round-trip.test.js: skipped suite testing deleted replay-from-logs.js
  (5 skipped tests rotting). Coverage gap acknowledged in TODO.

Skip-guard added (scripts/run-tests.sh): pre-flight grep refuses to run
if any .skip/xdescribe/xit found in test dirs. No CI; this is the gate.
togglePause no longer writes log entry (shared/turn.js). Lifecycle op, not
player action. Undo stack now targets last real action, not flag flip.
Removed dead pause/resume cases from expandUndo.

verify.js false positive: round wrap via non-nextTurn event (removeParticipant)
not detected — cycleActed never reset, actors flagged as acted_twice. Fix:
detect round change on ANY event, drain removed/inactive before finalize
(avoids false skipped when actor removed mid-round).

Tests updated: togglePause logging test asserts no log; undo test asserts
excluded. Repro confirmed via minimal verify case (removeParticipant r1->r2).
D&D 5e: stable creature regains 1 HP after 1d4 hours. Static display-only
reminder, no storage/time-tracking. Fires only for participants with
status=stable (dying->stable or dead->revive), shown where Revive button
would sit. Derived from status, survives refresh.
5e crit-at-0-HP rule: any crit damage while downed = +2 death-save failures.
Shared logic supported via applyHpChange(..., { isCriticalHit: true }) but UI
had no trigger. Normal Damage at 0 only added 1 fail.

handleCritDamage handler calls applyHpChange with isCriticalHit: true.
Button renders in HP block (right-aligned) for character/NPC participants
when dying or stable and combat started. Dead excluded (no-op).
Both combat.js replay and Combat.scenario.test.js built character participants
inline via buildCharacterParticipant without persisting to campaign doc. Real
app stores chars in campaign doc players array; encounter participants built
from that. Replay/scenario diverged from real flow — campaign had zero chars.

Fix:
- replay.js: roster chars now have id, persisted to campaign doc players array
  at setup_campaign step
- Combat.scenario.test.js: addCharacterViaUI writes char to campaign doc
  players array (mirrors app), CAMPAIGN_PATH const added
Feature: debug button to wipe all campaigns/encounters/logs in dev builds.
Previously bulk delete fetched all logs per campaign, client-filtered,
batchWrite — 30s+/campaign. Now SQL bulk DELETE, no fetch.

Server (server/db.js, server/index.js):
- deleteCollection(collPath, {where}) — SQL DELETE FROM docs WHERE parent=?,
  optional where-filter. Broadcasts deletions to WS subscribers.
- DELETE /api/collection endpoint
- Gate: ALLOW_DEV_ENDPOINTS=1 env OR createServer({allowDevEndpoints:true})
- createServer accepts allowDevEndpoints param (tests bypass env)

Storage (src/storage/server.js, src/storage/firebase.js):
- deleteCollection(path, whereField, whereValue) both adapters
- Firebase: fetch matching + batch-delete (firestore no bulk), 500-chunk
- Gate: throws if NODE_ENV not development/test
- Contract-tested both backends

App (src/App.js):
- deleteCampaignCascade refactored (reusable, no try/catch split)
- handleDeleteAllCampaigns: Promise.all per campaign, deleteCollection for
  encounters (no fetch), deleteCollection logs once globally, parallel
- Button dev-gated (NODE_ENV), confirm modal, hidden when no campaigns

Mock fixes (surfaced by new tests):
- firebase firestore mock: added where() export, getDocs applies constraints
  (was returning all docs ignoring query constraints — pre-existing gap)

Tests:
- contract: deleteCollection (bulk, where-filter, empty) both backends
- server-contract: live deleteCollection (bulk, where, 403 gate)
- runStorageContract via makeStorage({allowDevEndpoints:true})

Safety (3 layers):
- UI button hidden in prod (NODE_ENV gate)
- storage method throws in prod (NODE_ENV gate)
- HTTP endpoint 403 in prod (env/param gate)
robert added 1 commit 2026-07-06 22:01:26 -04:00
Owner

I had claude take a look and align the buttons a bit better.

Before:
image.png

After:
image.png

I had claude take a look and align the buttons a bit better. Before: <img width="1034" alt="image.png" src="attachments/b4c8bd75-8183-49d8-9384-8fe888c1a35f"> After: <img width="1034" alt="image.png" src="attachments/ff20d24a-0366-4bbf-a8e2-7a851ad5c362">
robert added 1 commit 2026-07-06 22:13:33 -04:00
robert merged commit 3646a9cf8e into main 2026-07-06 22:14:05 -04:00
Author
Collaborator

yeah it needed work. it was enough just getting it working. (and the success/fail buttons were a side effect - too damn complex to get it to do the 1 vs 2 saves/fails etc etc etc.......thanks!

oh, and, overflow damage works - both before and at zero. or should. take 11 points to your 1point left wiht 10 max, and you dead. take 10 when at zero? you dead.

yeah it needed work. it was enough just getting it _working_. (and the success/fail buttons were a side effect - too damn complex to get it to do the 1 vs 2 saves/fails etc etc etc.......thanks! oh, and, overflow damage works - both before and at zero. or should. take 11 points to your 1point left wiht 10 max, and you dead. take 10 when at zero? you dead.
Sign in to join this conversation.