The logs page white-screened when deployed against Firestore. LogsView
paginates with the neutral offset() constraint, which the server/sqlite
adapter pushes into SQL — but the firebase adapter's subscribeCollection
had no case for it and passed the raw {__type:'offset'} object through
to the SDK's query(), which throws on non-QueryConstraint values. The
throw fired inside useFirestoreCollection's effect on first render,
unmounting the whole tree. Dev never hit it because dev runs the server
adapter.
Fix: shared toSdkConstraints() translator in the firebase adapter,
used by getCollection and subscribeCollection. The client SDK has no
offset, so emulate it — widen limit to offset+limit and slice the
leading docs off the snapshot (same read cost as the admin SDK's
native offset, which also bills skipped docs). Unknown constraint
types are now dropped instead of passed through, so a future neutral
builder can't reintroduce the crash. This also fixes getCollection
silently skipping offset, which returned page 1 for every page.
Server/sqlite adapter unchanged; offset still runs in SQL. Two new
shared contract tests (offset pagination + offset past end) run
against both adapters and pass: firebase mock 33, live sqlite 40.
Also bump better-sqlite3 ^11.3.0 -> ^12.0.0: v11 fails to compile
against Node 26's V8 (GetPrototype removed), so server tests could
not run at all on Node 26. v12.11.1 builds clean; full server suite
passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Player display now tracks displayParticipants separately from raw encounter
participants so active->inactive can animate out before removal, and inactive->
active can animate in. This preserves 1-list order, keeps DM display behavior
unchanged, and avoids fading dying/stable participants.
PlayerParticipantCard handles transitions:
- active->inactive: scale/slide/fade out, then parent removes from display list
- inactive->active: scale/slide/fade in
- alive->dead: keep visible with dim/desaturate/red-rim death cue + skull pulse
- dying/stable: full visible, no fade/removal
DisplayView inactive characterization updated to wait for exit animation before
asserting removal. TODO item removed.
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
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).
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.
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).
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.
- 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
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.
Character dropdown in add-participant picker excluded characters already
in the encounter. Prevents dup-add at the source — user can only select
chars not yet participating.
Custom conditions (per-campaign freeform) previously only persisted to the
campaign palette — Add button did NOT apply them to the targeted participant,
and badges rendered via CONDITIONS (missing custom ids) so applied custom
conditions did not display. Now:
- addCustomCondition(label, participantId) persists to palette AND toggles
onto the participant in one step. Enter + Add button both pass p.id.
- Admin badge lookup uses allConditions (built-ins + custom), not CONDITIONS.
Shared contract: toggleCondition accepts ANY string. Prove it in tests:
- turn.combat.test.js adds CUSTOM_CONDITIONS ('hexed','rager',
'marked_for_death','🛡️blessed') to the seeded queue + random pool, asserts
condition-not-string invariant, and a dedicated add/remove round-trip test.
- replay-combat.js mirrors the same CUSTOM_CONDITIONS through the live backend.
Server-mode docs/env: REACT_APP_BACKEND_WS (stale pre-rename) -> correct
REACT_APP_BACKEND_REALTIME_URL=ws://.../ws in README, env.example,
docker/Dockerfile (STORAGE=ws -> server). DEVELOPMENT.md + scripts/README.md
now point to ./scripts/dev-start.sh as the primary dev entrypoint.
133 shared tests green.
Old model broken: single deathSaves counter, 3=revive. No fails tracked,
successes conflated with fails. DM couldn't model real death save outcomes.
New model (D&D 5e):
- deathSave(enc, id, type, n) — type='success'|'fail', n=1|2|3
- Participant fields: deathSaves (successes), deathFails, isStable, isDying
- 3 successes = stable (0hp unconscious, safe until healed)
- 3 failures = dead (isDying, caller animates removal)
- Toggling same pip = undo that pip
- Returns { patch, log, status } — status: 'stable'|'dead'|'pending'
isDying back-compat flag kept for App.js removal animation
App.js:
- UI split into green ✓ saves row + red ✕ fails row
- Status badges: Stabilized (emerald) / Dying (red pulse)
- handleDeathSaveChange(participantId, type, saveNumber) — new sig
- makeParticipant init: deathFails + isStable defaults
Data loss: old single-counter participants lose saves (feature didn't work,
no real state to preserve). User confirmed acceptable.
Tests:
- turn.deathsave.test.js: RED-then-GREEN, 3-success stable, 3-fail dead,
independent tracking, new signature
- Updated 6 callers across characterization/combat/dead-skip/logging/scenario
- Logs UI tests: new title selectors (Success N / Fail N)
243 tests green.
Per-op logging test encoded gaps as expected (asserted log:null = pass).
Missed 4 real bugs (BUG-7 + deathSave + addParticipants + updateParticipant).
Bunk. Static scan catches regressions regardless of test enumeration.
static.no-unlogged.test.js: scans turn.js source, extracts function bodies
via brace counting, finds any return with patch!=null AND log:null.
Verified: injected fake unlogged mutation in endEncounter → guard fails
with offender name + return expr. Restored → 128 green.
Logging contract now enforced structurally, not by enumeration.
addParticipants + updateParticipant returned log:null → invisible in
combat log. Contract test caught them. Now both return
log:{ message, undo:{ participants }}.
App.js handlers wired (handleAddAllCharacters, handleUpdateParticipant,
handleInlineInitiative) — call logAction on logged mutations.
Logging contract now complete: every mutating combat op produces a log
entry. No gaps. turn.logging.test.js proves it.
238 tests green.
Zero call sites. Old turn-array addition logic, superseded by 1-list
slotIndexForInit model. Removed func, export, stale comment, and 3
characterization tests that only locked the dead func's behavior.
1-list model keeps slot position on toggle. Reactivate does not re-insert
by initiative, so no second turn. computeTurnOrderAfterAddition is dead code
(no call sites). Tests prove no double-act in both scenarios.
Hangs/regressions fail fast instead of stalling CI.
- shared/jest.config.js: testTimeout 10000
- src/setupTests.js: jest.setTimeout(10000) (CRA blocks testTimeout in
package.json — not in allowlist)
- server/jest.config.js: already had 10000
Verified: injected hanging test -> 'Exceeded timeout of 10000 ms'.
'ws' conflated storage mode with transport protocol (WebSocket). Renamed for
clarity: the adapter talks the self-hosted server (src/storage/server.js), which
happens to use WebSocket for realtime — but the name should describe what it
connects to, not how.
Renames:
- src/storage/ws.js -> server.js
- createWsStorage() -> createServerStorage()
- storage mode 'ws' -> 'server'
- REACT_APP_BACKEND_WS -> REACT_APP_BACKEND_REALTIME_URL
- factory param wsUrl -> realtimeUrl
- server/tests/ws-*.test.js -> server-*.test.js
Kept literal: 'ws' npm package, ws:// protocol URLs, /ws WebSocket endpoint.
Transport is accurate there; only storage-naming changed.
Updated all docs (DEVELOPMENT, TESTING, REWORK_PLAN, GLOSSARY, TODO), scripts
(dev-start.sh, replay-combat.js), factory + ESM tests. 204 tests green.
addParticipant + updateParticipant used sortParticipantsByInitiative (stable
sort, tie-break = original array index). That destroyed manually-dragged tie
order when a drag moved a same-init pair — violated the slot-not-sort design
(docs/INITIATIVE_ORDERING.md: 'Re-slotting on add/edit must preserve
drag-established tie order').
Fix: new slotIndexForInit(list, init) returns splice index into the CURRENT
list (initiative-descending). addParticipant inserts there; updateParticipant
re-inserts only when initiative changed (unrelated edits keep slot — avoids
mid-round rotation dupes surfaced by the 100-round combat test).
Tests:
- turn.slot-not-sort.test.js: drag tie order survives add/edit (4 cases).
- static.no-sort.test.js: errs if .sort( added outside allowlist
(sortParticipantsByInitiative only). Verified by injecting a stray sort —
guard caught it.
- getDoc/getCollection/subscribe inject {id,...data} (main firebase shape)
- getCollection normalizes backend {id,data}[] OR bare data[] → {id,...data}
- setDoc(opts.merge) → PATCH (create-on-miss); else PUT (replace)
- ws-reconnect test asserts id now present
24/24 server green. All suites green: App 83, shared 91, server 24.
- firebase.contract.test.js: run storage contract against createFirebaseStorage
via SDK mock. Currently 12 fail (firebase diverges from contract: no norm(),
injects id). Failures real — firebase copied from main, memory/ws invented
new requirements. Contract under review vs main prod truth.
- storage.factory.test.js: getStorage() routing per REACT_APP_STORAGE env,
singleton cache, getStorageMode() reporting.
- Combat.scenario.test.js: remove console.log (test noise).
- package.json: test:all now runs App + shared + server suites (was missing App).
Move activeDisplay lifecycle to shared (one path, not duplicated in App):
- activateDisplay({campaignId, encounterId})
- clearDisplay()
- toggleHidePlayerHp(current)
All 6 App sites wired (start/end/2×delete-encounter/delete-campaign/hp-toggle).
Existing {patch,log} return shape preserved. No interface changes.
Combat.scenario.test.js rewritten to call shared directly, no React:
- Same actions as old UI version (roster chars, monsters, addAll, hp-toggle×2,
start, 100 rounds of damage/heal/conditions/toggle/edit/deathsave/pause/
resume/add/remove, end). Same funcs, same order, same data.
- Models two firestore docs (encounter + activeDisplay) via shared patches.
- Run time: 72s -> 4ms.
Both suites green: shared 91/91, App 77/77.
Single list = display = turn order. Slot by initiative, tie-break = add
order. Drag = same-init only (DM tie override). No re-sort on mutation.
Round wrap no rebuild. No cross-init drag.