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
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
I had claude take a look and align the buttons a bit better.
Before:
After:
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">
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follow-up to merged PR #3. Death-save feature complete + bugs fixed.
Death-save (D&D 5e)
Undo/redo (major bugs fixed)
Test infra
combat.js (replay/verify tool)
Dev tool: bulk delete all campaigns
Other
307 tests, all green.
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 damage5e 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).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)I had claude take a look and align the buttons a bit better.
Before:

After:

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.