Commit Graph
146 Commits
Author SHA1 Message Date
david raistrick 6baecd374e ws adapter: match main truth (id injection + setDoc merge)
- 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.
2026-07-04 12:35:28 -04:00
david raistrick 79af61cb8b match main firebase behavior; fix contract + mismatches
Contract rewritten to match main prod truth:
- require id injection in all doc/collection results
- honor setDoc({merge:true}) (main L1624 + 4 activeDisplay sites)
- drop invented bare<->prefixed cross-lookup tests (main never does this)
- add setDoc{merge}, batch set-only/update-only contract coverage

Fix mismatches vs main:
- subscribeCollection hook now forwards queryConstraints (was dropped;
  LOG_QUERY sort+limit honored again). Mock onSnapshot honors orderBy+limit.
  2 new contract tests prove the chain.
- subscribeDoc/subscribeCollection adapters now forward errCb. App hooks +
  DisplayView propagate subscribe errors to UI (match main onSnapshot 3rd arg).
- ws adapter signatures accept queryConstraints + errCb (interface match).

activeDisplay writes match main:
- 5 unguarded sites -> setDoc({merge:true}) (create-if-missing, not updateDoc)
- 3 guarded sites stay updateDoc

Delete memory adapter: third storage system added complexity, zero value.
firebase-mock covers fast tests, ws covers server path. Factory throws on
unknown mode now.

Delete phantom 'Phase A/B' comment in firebase.js header.

Tests updated to assert setDoc{merge} (not updateDoc) for activeDisplay writes.
83/83 green.
2026-07-04 12:22:19 -04:00
david raistrick b12ac904a6 test: add storage coverage (firebase contract, factory), silence scenario log
- 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).
2026-07-03 18:53:58 -04:00
david raistrick 7f60ec140e fix(scenario): run 100 rounds not turns, exercise all combat paths
Previous test lied: called nextTurn 100x = ~100 turns = ~12 rounds (see
docs/GLOSSARY.md turn vs round). Now loops by actual round-wrap count.

100 rounds = 975 turns verified via console log + expect(roundsDone).toBe(100).

Exercises ALL shared combat paths deterministically (vs replay random):
startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange
(damage+heal), deathSave (1/2/3-success → isDying path), toggleCondition,
reorderParticipants (drag same-init tie), endEncounter, activateDisplay,
clearDisplay, toggleHidePlayerHp.

Rotation integrity checked every 10 rounds: no dup turnOrderIds, currentTurn
in order, turnOrderIds === participants.map(id).

Run time: 19ms (was 72s React, lieing).
2026-07-03 18:29:35 -04:00
david raistrick e650cd2710 refactor: single-source display lifecycle + scenario test no React
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.
2026-07-03 18:26:02 -04:00
david raistrick d83d1383c0 refactor: single-source combat logic (App handlers call shared, slot model)
App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared:
startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s),
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange,
deathSave, toggleCondition, reorderParticipants. ONE code path for UI.

shared/turn.js aligned to slot model (docs/INITIATIVE_ORDERING.md):
- addParticipant: splice into slot by initiative (no sort)
- updateParticipant: re-slot on initiative change (no sort)
- reorderParticipants: same-init drag only (cross-init blocked)
- applyHpChange: death flips isActive=false (matches App), revive reactivates

No renames, no API changes. Shared func signatures unchanged.

Tests updated to match unified behavior:
- dead-skip: death deactivates (was FEAT-1 stays active)
- characterization: death deactivates + revive reactivates
- reorder/invariant: cross-init drag blocked, same-init allowed

Both suites green: shared 91/91, App 77/77, 0 warnings.
2026-07-03 17:59:41 -04:00
david raistrick d1cc3a8b9a test: warning=failure guard + fix ambiguous switch selector
- 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.
2026-07-03 16:05:04 -04:00
david raistrick 31d19c5912 test: kill act env warnings + remove debug console noise
Root cause: mock _notify cb fires during waitFor poll, but testing-library
asyncWrapper flips IS_REACT_ACT_ENVIRONMENT=false. Raw react act() then
warns 'not configured to support act' (146 warnings).

Fix: _notify sets globalThis.IS_REACT_ACT_ENVIRONMENT=true around act(),
restores after. setupTests sets flag globally. Both together = 0 warnings.

Also:
- Remove 4 debug console.log from App.js (encounter start/end, drag, add chars)
- Remove always-fires scenario summary console.log
- Add fastWaitFor helper (5ms poll) to testHelpers
- Swap scenario waitFor -> fastWaitFor

Tests: 77 pass / 0 fail / 0 warnings / 0 console noise.
2026-07-03 15:59:14 -04:00
david raistrick 530aaadf90 test: kill act env warnings (globalThis flag + _notify act wrapper)
- setupTests.js: set globalThis.IS_REACT_ACT_ENVIRONMENT = true (RTL reads
  getGlobalThis, not global). Kills 'environment not configured for act'.
- _mock-db.js _notify: set flag true around subscriber cb, restore prev
  after. RTL waitFor flips flag false mid-poll -> act() warned. Now clean.
- 146 act warnings -> 0.
2026-07-03 15:58:50 -04:00
david raistrick 1a744e511a test: upgrade @testing-library/react v14, kill act warnings
@testing-library/react v13 -> v14. v13 used deprecated
ReactDOMTestUtils.act on every render -> 708 deprecation warnings.
v14 imports act from react directly. Drops to 0.

Mock _notify (src/__mocks__/firebase/_mock-db.js): wrap subscriber
callbacks in act(() => cb()). Sync setState from subscribe callbacks
fired outside test act boundary -> 14 warnings. Now 0. try/catch
fallback if react/act unavailable (defensive).

Full suite: 166 pass / 1 fail (Combat.scenario BUG-11 pre-existing
crash). Warnings: 1416 -> 0.

Note: NOT complete kill-shared. App.js still inlines 8 logic fns
(startEncounter, nextTurn, togglePause, endEncounter, addParticipant,
updateParticipant, reorder/drag). turn.js versions dead in app, used
by replay + turn tests only. Next: make handlers call turn.js.
2026-07-03 15:46:27 -04:00
robert 8cf3dad5b6 Merge pull request 'Rework backend' (#2) from rework-backend into main
Reviewed-on: #2
2026-07-03 08:35:32 -04:00
david raistrick 3d0dc79206 feat(UI): hide NPC/monster HP toggle (player display)
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).
2026-07-02 11:29:02 -04:00
david raistrick 4b8b8bccfb feat(UI): collapsible Campaigns section
Campaigns heading -> clickable button. ChevronDown expanded,
ChevronRight collapsed. Toggles grid visibility. Shows count
(e.g. 'Campaigns (61)'). Preserves Create Campaign button.

State: campaignsCollapsed in AdminView. aria-expanded/controls for a11y.
2026-07-02 11:17:04 -04:00
david raistrick c0998da0a7 fix(BUG-4): updateDoc patch on activeDisplay (not setDoc replace)
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.
2026-07-01 23:32:23 -04:00
david raistrick d00cc104c9 feat(FEAT-3): reslot on all participant mutation paths
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.
2026-07-01 23:00:40 -04:00
david raistrick 08c27c1ca5 feat(FEAT-3): reslot on inline init change + gate field
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.
2026-07-01 22:29:38 -04:00
david raistrick 0514939c51 feat(FEAT-3): initiative first-class field (add + inline edit)
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.
2026-07-01 22:25:52 -04:00
robertandClaude Sonnet 5 ec578eeef5 Bump to v0.4, document self-hosted backend in README
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>
2026-07-01 19:59:46 -04:00
david raistrick da25f46e3e fix(docker): single container (caddy+node), ESM adapters fix blank page
Docker: moved all docker files to docker/ tree (was conflated with
upstream Dockerfile at root + server/Dockerfile). Single container now:
caddy (front, serves static + proxies /api /ws) + node backend (internal
:4001). Node never exposed. entrypoint.sh runs both. Compose: one service.

Blank page root cause: storage adapters had inconsistent module systems.
firebase.js = ESM (export). ws.js + memory.js = CJS (module.exports).
CRA prod build = ESM strict -> CJS runtime crash, blank root. Dev mode
lenient, masked bug. First ws prod build (docker) = first exposure.
Never dev/prod split intended; just inconsistency from M2 era.

Fix: all adapters ESM. ws.js lazy-loads 'ws' pkg via dynamic import()
(Node/jest only; browser uses global WebSocket). index.js static
imports. server jest: added babel.config.js (preset-env, node target)
to transform ESM for jest.

Test: src/tests/StorageEsm.test.js — 4 tests grep all adapters for
module.exports / require(). Regression guard catches CJS leak.

Verified: docker page renders (root 4534 chars, UI visible).
server 24 green, shared 90 green, FE ESM 4 green.
2026-07-01 19:03:59 -04:00
david raistrick c1d982b4a4 fix(BUG-8): ws adapter auto-reconnect after drop
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.
2026-07-01 18:26:42 -04:00
david raistrick 58ae04b400 fix(BUG-15): DisplayView no longer re-sorts participants by initiative
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.
2026-07-01 17:31:40 -04:00
david raistrick dbd0c75792 fix(BUG-12): campaign selection follows activeDisplay
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.
2026-07-01 17:05:00 -04:00
david raistrick 750ee99080 feat: display campaign createdAt in UI card
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).
2026-07-01 16:41:17 -04:00
david raistrick 3ea67019d2 refactor: App.js 3 sites to shared 1-list contract
delete/toggle/hp sites used OLD computeTurnOrderAfterRemoval/Addition
contract (return turnOrderIds). New 1-list contract: helpers return
advance-only + insertAt; list sync via syncTurnOrder at call site.

- delete: syncTurnOrder(updated) + advance-only removal
- toggle: stay-in-slot, flip isActive, sync, advance only if deact==current
- hp: FEAT-1 unchanged (death/revive no turn changes)

shared exports syncTurnOrder. Build green.
2026-07-01 16:05:18 -04:00
david raistrick 7c3ec105d5 refactor: App.js 1-list display + start/resume (no re-sort)
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.
2026-07-01 16:03:38 -04:00
david raistrick d1cbe7091a refactor: App.js imports shared turn funcs (DRY), delete duplicates
Delete duplicate consts (DEFAULT_MAX_HP/INIT_MOD/MONSTER_DEFAULT_INIT_MOD,
generateId, rollD20, formatInitMod) + funcs (sortParticipantsByInitiative,
computeTurnOrderAfterRemoval, computeTurnOrderAfterAddition) from App.js.
Import from @ttrpg/shared (1-list model). Kills second drift source.

CRA resolves @ttrpg/shared via npm workspaces symlink. Build green.
2026-07-01 16:02:35 -04:00
david raistrick 7467a8d30f feat(M5): docker-compose full stack (caddy + node backend + sqlite)
docker-compose.yml: two profiles.
  - backend: backend (node+ws+better-sqlite3, /data volume) + frontend
    (Caddy static build, STORAGE=ws, same-origin proxy)
  - firebase: existing Dockerfile + nginx (upstream path, untouched)
  Run: docker compose --profile backend up --build. OrbStack local now,
  remote docker context later.

server/Dockerfile: node:18-alpine, workspaces (shared dep), rebuild
better-sqlite3 for musl, DB at /data/tracker.sqlite.

Dockerfile.ws: CRA build STORAGE=ws → caddy:2-alpine serves /srv.
No backend URL baked (same-origin).

Caddyfile: handle /api/* + handle /ws → backend:4001 (path preserved,
mutually-exclusive handles so try_files SPA fallback never shadows proxy).
handle { static try_files } last. HTTP basic auth block optional.

src/storage/ws.js: same-origin defaults. Empty baseUrl = relative fetch
(Caddy proxy). wsUrl derives from window.location (http→ws/https→wss).
Fallback localhost for bare npm start dev.

.dockerignore: add data/ scratch/ tmp/ (never bake into image). Keep
Caddyfile in context (frontend build COPYs it).

Smoke verified via OrbStack:
  - GET / → 200 (static SPA)
  - PUT/GET /api/doc roundtrip → JSON persists
  - WS /ws subscribe + change push → both work through proxy

Firebase profile: pre-existing Dockerfile requires .env.local (hardcoded
COPY on main, not changed here). User must create file. Not a regression.
2026-07-01 14:39:47 -04:00
david raistrick b024fa08bb tests: scenario rotation integrity check (every 10 rounds)
Combat.scenario.test.js: per-10-round assertion - turnOrderIds no dup,
currentTurnParticipantId in turnOrderIds. 299/299 phases pass.

NOTE: scenario runs against firebase MOCK. Mock updateDoc merges correctly
(real ws adapter would clobber per BUG-4 class). So check validates mock
shape, not adapter translation. Layer 2 (ws-contract) covers adapter.
2026-06-30 14:04:27 -04:00
david raistrick e514a48d6e tests: BUG-8 ws reconnect RED, BUG-7 reorder no-undo doc, ws _test accessor
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.
2026-06-30 13:59:58 -04:00
david raistrick d979b03f2e tests: BUG-4 RED locked (hide-HP clobbers activeDisplay), add write recorder
src/tests/HideHpToggle.test.js: renders App, selects campaign, toggles
hide-player-HP switch, asserts setDoc data includes activeCampaignId +
activeEncounterId. RED: data only {hidePlayerHp:true}, both clobbered.

Root cause proven with evidence (recorder):
  setDoc(activeDisplay/status, {hidePlayerHp:true}, {merge:true})
  data written = {hidePlayerHp:true} ONLY
  activeCampaignId = undefined
  activeEncounterId = undefined

setDoc = replace per contract. {merge:true} arg ignored. Toggle wipes
encounter pointer → DisplayView reads null → 'Game Session Paused'.
Fix: use updateDoc (patch), not setDoc.

src/storage/firebase.js: adapter recorder now captures setDoc + updateDoc
(data + opts). Was subscribe-only. Enables write-path assertions.
2026-06-30 13:55:14 -04:00
david raistrick f81308a0df tests: consolidate into tests/ dirs, fix import paths
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).
2026-06-29 16:02:22 -04:00
david raistrick 9fd0f3ec38 M3: fix path-shape drift via adapter contract + identity tests
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).
2026-06-29 15:13:03 -04:00
david raistrick 1d4c561c09 M3: add full 100-round combat scenario test
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.
2026-06-29 13:51:32 -04:00
david raistrick 84a8b78acd M2: refactor DisplayView to storage adapter (red test first)
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.
2026-06-29 13:13:46 -04:00
david raistrick 52866784b2 M2: replace shape-specific backend with generic KV doc store (firebase mirror)
Backend was endpoint-mapper (POST /api/campaigns ignores path id, etc). Adapter
translation layer brittle, untested, lost doc identity. Generic contract (Layer 2)
test caught 15 bugs immediately.

Rewrite to firebase-mirror KV model:
- server/db.js: docs table (path PK, parent, data JSON). getDoc/setDoc/updateDoc/
  deleteDoc/getCollection/batchWrite. parent = path prefix for collection queries.
- server/index.js: generic REST (GET/PUT/PATCH/DELETE /api/doc, GET /api/collection,
  POST /api/collection addDoc, POST /api/batch). WS subscribe by path (doc|collection),
  broadcast to doc subs at changed path + collection subs at parent path.
- src/storage/ws.js: thin passthrough adapter. norm() strips firebase prefix.
  initial value via REST (independent of WS connect), subsequent changes via WS.
- shared/turn.js kept (M4 use). server/handlers.js + server.test.js removed (logic
  now in App, backend is dumb KV).
- src/storage/contract.js: flush() bumped to 50ms (WS roundtrip > setTimeout(0)).

Layer 2 test (server/ws-contract.test.js): spins fresh backend per test, runs same
storage contract spec against createWsStorage. Catches adapter translation bugs
that firebase-mock Layer 1 tests cannot.

nanoid v5 ESM breaks jest CJS -> replaced with crypto.randomUUID (Node builtin).

Tests: 114 green (39 shared + 19 ws-contract + 56 frontend).
2026-06-29 13:00:24 -04:00
david raistrick 35cd1581e3 Reapply "M3: stub db sentinel in ws/memory mode so legacy guards pass"
This reverts commit d1ee69a70a.
2026-06-29 12:36:16 -04:00
david raistrick ed67535b1f Reapply "M2: fix ws adapter for browser WebSocket + firebase path prefix"
This reverts commit 74b4c2c42d.
2026-06-29 12:36:16 -04:00
david raistrick 17245dfa1b Reapply "M2: gate storage init on STORAGE mode (firebase vs ws/memory)"
This reverts commit e843acdf8a.
2026-06-29 12:36:16 -04:00
david raistrick e843acdf8a Revert "M2: gate storage init on STORAGE mode (firebase vs ws/memory)"
This reverts commit 3e84f28325.
2026-06-29 12:03:57 -04:00
david raistrick 74b4c2c42d Revert "M2: fix ws adapter for browser WebSocket + firebase path prefix"
This reverts commit e743d40e8d.
2026-06-29 12:03:57 -04:00
david raistrick d1ee69a70a Revert "M3: stub db sentinel in ws/memory mode so legacy guards pass"
This reverts commit a5a4df78f0.
2026-06-29 12:03:57 -04:00
david raistrick a5a4df78f0 M3: stub db sentinel in ws/memory mode so legacy guards pass
ws/memory storage mode never set the module-level `db` variable. 24 handlers
guarded with `if (!db) return` early-exited, silently dropping all writes
(create campaign, add encounter, participant CRUD, combat, logs).

db stays a truthy sentinel object { __localStub: true } in non-firebase mode.
All real reads/writes route through storage.*; db only used by guards.

56 frontend tests green. Verified via headed browser: create campaign flow
works end-to-end (modal closes, campaign appears via WS realtime push).
2026-06-29 11:30:08 -04:00
david raistrick e743d40e8d M2: fix ws adapter for browser WebSocket + firebase path prefix
- WebSocketImpl: native browser WebSocket if present, else ws pkg (node/jest)
- ensureWs: use onopen/onmessage/onerror/onclose property handlers (browser API)
  instead of ws.on('open') EventEmitter (node-only) — was silent no-op in browser
- norm(): strip 'artifacts/{APP_ID}/public/data/' prefix from all paths
  App passes firebase-prefixed paths; backend uses canonical campaigns/...
- apply norm() to getDoc/getCollection/setDoc/updateDoc/deleteDoc/addDoc/
  subscribeDoc/subscribeCollection/changeTypesForDocPath/changeTypesForCollPath

Verified: STORAGE=ws boots, WS subscribe fires, backend broadcast -> browser
live update (curl POST campaign -> appears without reload). Cross-device sync
confirmed end-to-end.
2026-06-28 22:43:30 -04:00
david raistrick 3e84f28325 M2: gate storage init on STORAGE mode (firebase vs ws/memory)
- STORAGE_MODE = getStorageMode()
- initializeStorage(): firebase = real SDK init; ws/memory = stub auth
- App auth flow: ws/memory skip signInAnonymously, unblock UI
- error screen mode-aware message

56 tests green. STORAGE=ws now boots past config error.
2026-06-28 21:11:56 -04:00
david raistrick 812298fa73 M2: refactor all firebase write sites to storage adapter
- 37 call sites: setDoc/updateDoc/deleteDoc/addDoc/getDocs/writeBatch -> storage.*
- adapter wraps SDK, path-string interface
- storage instance app-wide (getStorage)
- firebase.js: static imports (getDoc/getDocs alias), no dynamic import

56 frontend tests green. STORAGE=firebase = identical behavior.
2026-06-28 21:05:39 -04:00
david raistrick 5bb9e5fc19 M2: refactor hooks to storage adapter (subscribe)
- src/storage/index.js: getStorage() factory + SDK re-exports
- App.js: useFirestoreDocument/Collection call storage.subscribeDoc/Collection
- getStorage import added

56 frontend tests green. Hooks now impl-agnostic (firebase vs ws).
2026-06-28 19:03:44 -04:00
david raistrick 35b5a1d238 test: logs + deathSave characterization (6 tests)
- Logs.characterization.test.js: logAction (write + undo payload), clearLogs batch delete, undo (updateDoc encounter + mark undone), deathSave increment + isDying
- mock firestore getDocs: return .ref.path on docs (batch.delete support)
- mock addDoc: record full doc path not collection path

All write sites characterized. 56 frontend tests green.
2026-06-28 19:00:08 -04:00
david raistrick d581e60ba3 test: combat characterization (9 tests)
- Combat.characterization.test.js: startEncounter (state + activeDisplay), nextTurn, round wrap, pause, resume, endEncounter (reset + clear activeDisplay), toggleHidePlayerHp

Locks combat control write paths.
2026-06-28 18:52:49 -04:00
david raistrick 4158a1634d test: participant characterization (9 tests)
- Participant.characterization.test.js: addMonster (shape, initiative range, NPC), deleteParticipant, toggleActive, applyDamage, damage-to-0, heal revive, toggleCondition
- testHelpers.js: getParticipantForm (scoped), addMonsterViaUI, setupReady, startCombatViaUI

Locks participant write paths + payload shapes. Refactor guard.
2026-06-28 18:50:42 -04:00
david raistrick 0c1196aee1 test: encounter characterization (6 tests)
- Encounter.characterization.test.js: createEncounter, path nesting, togglePlayerDisplay on/off, deleteEncounter + clears activeDisplay
- testHelpers.js: createEncounterViaUI, selectEncounterByName

Locks encounter write paths + payload shapes.
2026-06-28 18:30:57 -04:00