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.
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.
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.
- 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.
- setupTests.js: console.error/warn now throw. Warning = failure.
- Combat.characterization: two role=switch (player HP + NPC HP), scope
to player-HP label. getByRole('switch') was ambiguous.
Full suite: 77/77 pass, 0 warnings.
Mirrors hidePlayerHp. New hideNpcHp flag on activeDisplay doc.
- AdminView: 'Hide NPC/monster HP' switch in Player Display settings.
- DisplayView: HP bar gated by !(hideNpcHp && p.type !== 'character').
Covers monsters + NPCs (all non-player).
- Default false (show NPC HP) — opposite of player HP default true.
updateDoc patch (BUG-4 pattern). No clobber.
Test: HideHpToggle selector scoped to 'hide player hp' (now 2 switches).
All 5 storage.setDoc(activeDisplay, {...}, {merge:true}) →
storage.updateDoc(activeDisplay, {...}).
setDoc merge:true worked in prod (firebase honors merge) but ws adapter
+ mock ignore opts arg entirely → clobbers doc. updateDoc uses PATCH
across all adapters (firebase real updateDoc, ws PATCH endpoint, mock
merge). Consistent, no clobber.
Sites fixed:
- hidePlayerHp toggle
- startEncounter (set active ids)
- endEncounter (null active ids)
- deactivate active display
- activate new display
TDD: HideHpToggle.test RED first (assert updateDoc patch, impl still
setDoc → 0 calls found). GREEN after switch.
Char tests updated: Encounter.characterization (2) + Combat.characterization
(2) assert updateDoc on activeDisplay, not setDoc.
BUG-4: prod was already fixed (merge:true), test was RED due to mock
ignoring opts. Now all 3 adapters consistent via updateDoc.
Reslot (stable sort by init desc, tie-break = original array index) now
fires on all 4 paths that can change order:
1. Add participant — sortParticipantsByInitiative([...parts, new], parts)
2. Edit modal save (handleUpdateParticipant) — reslot + syncTurnOrder
3. Drag reorder — splice move (already correct, untouched)
4. Inline init field — reslot (already committed 08c27c1)
Before: add appended (ignored init), edit modal overwrote value without
moving slot. Both caused list order to drift from init order until
startEncounter (sorts once). Now any init change immediately reslots
into correct position. Display + AdminView reflect order.
Stable sort preserves drag order within ties (tie-break = original index
= reflects prior drag). Move-one semantics: only changed element moves.
EditParticipantModal: added htmlFor/id link on Initiative label (was
missing — a11y + testable).
Tests: ReslotAllPaths.test.js (2). RED first (add appended, edit modal
no reslot), green after impl.
Reslot: handleInlineInitiative now sorts participants[] by init desc
(stable, tie-break original index) via sortParticipantsByInitiative.
Display + AdminView reflect new order after init edit. Not a blind
re-sort — only moved element changes position.
Gate: inline init field disabled when combat active + not paused.
Matches drag gating. DM must pause to edit initiative mid-combat.
Tests: InitiativeReslot.test.js (2). RED first (no reslot, Goblin stayed
at idx 1), green after impl (reslots to idx 0). Field gate test.
Add form: optional initiative field (monster + character). Empty = roll
d20+mod (current behavior). Filled = use value, skip roll. 'blank=roll'
hint + 'auto' placeholder for clarity.
Inline edit: ALL participants. Number input in participant row. Blur or
Enter commits. Capped 2 digits (max 99). Auto-select on focus for quick
overwrite. Styled to match other fields (border-stone-700, rounded-md,
shadow-sm, w-10).
handleAddParticipant: manualInit detects set value. lastRollDetails
adapts display (manual flag shows 'Set initiative' vs 'Rolled d20').
Campaign card date: Clock icon + 'Created:' prefix, own row, muted
stone-300 opacity-70. Was crammed inline with character/encounter counts.
Tests: InitiativeField.test.js (2 tests - set value + empty=roll).
RED first (field missing), then green after impl. App + Participant
characterization still green (18 total).
Note: inline init edit does NOT re-sort. Known followup — displays + list
order must reflect changed initiative. Tracked separately.
The rework-backend merge added an optional self-hosted Express/ws/SQLite
backend (npm workspaces, single-container Docker deployment) alongside
the existing Firebase default. Bump APP_VERSION and refresh README to
cover both storage modes and the new repo layout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WS adapter had no reconnect. WS dies (idle/error/close) → wsReady=null,
subscribers dead forever, display frozen until full reload.
Changes (src/storage/ws.js):
- onClose: schedule reconnect via setTimeout(500ms), ensureWs re-arms.
Guard: disposed flag stops reconnect after dispose.
- onOpen: resubscribe all existing doc/coll subscribers (backend state
may have changed). Re-fetch current values on RECONNECT only (skip
first connect — initial REST fetch in subscribe* already did). Added
everConnected flag to distinguish first vs reconnect.
- reconnectTimer unref'd (Node) to avoid hanging event loop.
- dispose(cb): set disposed, clear timer, close ws, then cb.
Also fixed test teardown leaks:
- server/index.js close(): terminate all wss.clients before wss.close().
Reconnect test spawned new ws to server; old close hung on live conn.
- both ws test factories: port 0 (OS picks free) instead of module-local
nextPort counter. Parallel jest workers collided on EADDRINUSE.
Tests: ws-reconnect GREEN (1.7s), ws-contract 23 GREEN. No regression.
server suite 24/24. shared 90/90.
DisplayView called sortParticipantsByInitiative() on visibleParticipants,
ignoring DM drag order. 1-list model = participants[] IS display source.
After cross-init drag, player view diverged from AdminView/turnOrderIds.
Repro: round 4 replay. [reorder Summon1(10)→before Merchant(11)] made
turnOrderIds = [...,Summon2,Summon1,Merchant,OrcBoss]. AdminView correct.
DisplayView re-sorted = Summon2,Merchant,Summon1 (init order) = visually
Merchant appeared between Summon2 and Summon1, NOT at end. DM confused.
Fix: removed sort. DisplayView now renders participants[] order directly
(filter inactive monsters only), matching AdminView line 1222.
Test: RED → GREEN (src/tests/DisplayView.drag-order.test.js). Seeds 3
monsters in drag order [High:20, Low:10, Mid:11]. Asserts DOM order =
participants[] order, not init-sorted. No DisplayView regressions.
Selection effect had `!selectedCampaignId` guard — once any campaign
selected, new activeDisplay.activeCampaignId writes ignored. Replay tool
writes activeDisplay to new campaign each run; UI stayed on old selection
=> displayed wrong campaign data.
Removed guard. Selection now syncs when activeCampaignId differs from
current selection. Manual deselect (null) does not force-select (RED test
locks this).
Also fixed test helper bug: createCampaignViaUI/createEncounterViaUI
returned FIRST setDoc match (idA for all creates). Now filters by name +
.pop() for latest. This masked the real bug for several debug cycles.
Tests: 2 new (SelectionFollowsActiveDisplay), both green. No regressions
in full FE suite (App, Combat, DisplayView, Encounter, HideHpToggle,
Logs, Participant, storage all pass). Combat.scenario = pre-existing
BUG-11 crash, not regression.
Campaign card now shows created date/time next to char/encounter counts.
Lets DM tell newest campaign apart (replay tool creates many).
createdAt already set at campaign create (line 2174). Display renders
formatted: 'Jul 1, 2026, 16:32'.
replay-combat.js: campaign + encounter names now include timestamp
(new Date().toLocaleString) for easy identification.
WS collection push verified live (injected test campaigns appeared
without reload).
3 sites fixed to match shared 1-list model:
- line 1216 display: sortedParticipants = participants[] (no re-sort). GM
list renders participants[] directly = turnOrderIds.
- startCombat inline: sort ALL participants by init (active+inactive),
first active = current, persist participants[] reordered + turnOrderIds.
- resume inline: no re-sort on resume. turnOrderIds unchanged.
Display === rotation === turnOrderIds by construction (1-list invariant).
Build green.
server/tests/ws-reconnect.test.js: subscribe, write (fires), force-drop WS,
write again (must still fire). RED on current. wsReady=null after drop,
no reconnect, subscribers dead forever. Display frozen.
src/storage/ws.js: added _test accessor (getWs, forceDrop, getReady,
docSubs, collSubs) for reconnect test. Test-only, no behavior change.
TODO: BUG-7 (reorder no undo), BUG-8 (ws reconnect) added.
Move all test files out of source dirs into per-workspace tests/:
- shared/tests/ (3 unit test files)
- server/tests/ (1 integration test)
- src/tests/ (8 characterization + scenario tests + testHelpers)
Fix all relative import paths (App, storage, __mocks__, testHelpers).
Fix jest.config testMatch globs in shared/ and server/ (rootDir +
<rootDir>/tests pattern).
Delete scripts/repro-pause-bug.js (debug scratch, superseded by
turn.pause-add.test.js).
Keep scripts/replay-combat.js + scripts/audit-rotation.js as manual
demo/exploratory tools (NOT unit tests, not deterministic).
No logic changes. All green: shared 49 + 1 validated RED, server 23,
FE 62. Scenario test unchanged (240s timeout, pre-existing slow).
Root cause (HAR-diagnosed): replay script wrote firebase-prefixed paths via
raw REST, bypassing adapter norm(). Two path roots coexisted in db:
bare 'campaigns/X' (adapter writes, from App)
prefixed 'artifacts/.../campaigns/X' (replay raw writes)
Adapter read bare, missed prefixed. UI showed stale test1 (legit manual UI
write, not wiped) but replay campaigns invisible.
A. replay-combat.js: use createWsStorage adapter instead of raw fetch. Same
contract boundary as App. norm() runs on all paths. Can't drift.
Mirror App.js getPath locally for path construction.
B. contract.js: 4 new identity tests (setDoc prefixed -> getCollection bare,
setDoc prefixed -> getDoc bare, setDoc prefixed -> getDoc prefixed,
setDoc bare -> getCollection prefixed). Run against every impl (memory,
ws). memory.js lacked norm() -> RED first, now GREEN after adding norm.
C. db moved out of /tmp to ./data/tracker.sqlite (gitignored). Never tmp.
Tests: 124 green (39 shared + 23 ws-contract + 62 FE).
Drives same UI buttons a DM clicks. Exercises:
- campaign + character roster (3 PCs)
- monsters, NPC, add-all, hidden hp toggle
- start combat, 100 rounds of nextTurn
- damage/heal, conditions (stunned), toggle-active
- edit initiative, edit name, remove participant
- pause/resume with reinforcement mid-combat
- damage-to-0 + death saves (x3) + revive
- end combat + confirm
Harness: each phase wrapped in recordAsync try/catch. Failures collected,
reported at end, do NOT abort run. Found no app bugs; selector bugs in harness
fixed (char form ids vs placeholders, encounter row scope via Init: marker,
conditions panel async render, dead-participant damage skip as expected game
state, end-encounter modal title 'End Encounter?' not 'End Combat').
289/289 phases ok. 58 other frontend + 39 shared + 19 ws-contract = 116 total green.
DisplayView missed in original M2 refactor — raw onSnapshot(doc(db,path))
survived. In ws/memory mode db is a stub sentinel, so raw SDK calls crash
('Expected first argument to collection() to be a CollectionReference...').
Reported by human testing player display after start combat.
TDD:
1. RED: DisplayView.characterization.test.js asserts adapter.subscribeDoc
called for campaign + encounter + activeDisplay paths. Adapter recorder
(getAdapterCalls/resetAdapterCalls in firebase.js) instruments subscribe
calls — catches raw-SDK bypass that firebase mock alone cannot (mock db
satisfies raw onSnapshot, hiding the bug).
2. Fix: 2 raw onSnapshot sites in DisplayView -> storage.subscribeDoc.
3. GREEN: 2 new tests pass, 116 total green.
Audit confirmed DisplayView was the ONLY remaining raw SDK site in App.js.