Commit Graph
24 Commits
Author SHA1 Message Date
robertandClaude Fable 5 995347d255 Fix white /logs page in firebase mode: emulate offset in adapter
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>
2026-07-06 22:50:29 -04:00
david raistrick 055895875a Dev-only bulk delete all campaigns; deleteCollection SQL bulk + gates
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)
2026-07-06 19:06:52 -04:00
david raistrick 2569cc4497 Builds replayable combat logs with first-class undo/redo, unified verification tooling, fast indexed log queries, and stricter CI.
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.
2026-07-06 10:33:28 -04:00
david raistrick 27b2272ced fix(storage): neutral queryConstraints honored by both adapters
Bug: server adapter accepted queryConstraints (orderBy/limit) but ignored them.
Combat log [orderBy('timestamp','desc'), limit(500)] returned ALL logs unordered
in server mode — unbounded growth. No test caught it: shared contract tested
subscribeCollection with 0 constraints; the firebase-only queryConstraint test
never touched the server adapter. Parity gap.

Fix (Plan A — neutral shape):
- index.js: orderBy()/limit() now NEUTRAL builders returning {__type}. Removed
  SDK orderBy/limit re-export.
- firebase.js: subscribeCollection translates neutral -> SDK query constraints.
- server.js: applyConstraints() helper sorts/limits client-side (backend returns
  all). Constraints stored per collection, applied on initial fetch + WS change.
- contract.js: added 3 queryConstraint tests (orderBy desc, limit, no-constraints)
  to SHARED contract — both adapters now run identical assertions.
- firebase.contract.test.js: removed now-redundant firebase-only constraint
  block (covered by shared contract).

Data impact: ZERO. Constraints are query-time only, never stored. Combat log
docs keep timestamp field. No Firebase data migration needed.

208 tests green.
2026-07-04 16:06:28 -04:00
david raistrick e94e1959ff refactor: rename ws adapter to server across codebase
'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.
2026-07-04 15:47:13 -04:00
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 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 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 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 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 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 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 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 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 84dd17e174 test: Firebase mock harness + createCampaign characterization
- src/__mocks__/firebase/*: jest manual mocks (app/auth/firestore)
- src/__mocks__/firebase/_mock-db.js: in-memory DB + call recorder
- src/setupTests.js: jest-dom, env stubs, crypto polyfill, DB reset
- src/App.characterization.test.js: createCampaign -> setDoc path/payload locked
- src/storage/contract.js (renamed from .test.js, helper not suite)

21 tests green (memory 19 + createCampaign 2).
2026-06-28 17:59:50 -04:00
david raistrick 12b24eb707 M2 (C): storage barrel re-export, App.js imports swapped
- src/storage/index.js: re-exports Firebase SDK
- App.js: imports from ./storage (was firebase/* direct)
- STORAGE=firebase = identical behavior
- dev server compiles clean

Safe refactor proof. Next: per-call-site path-based rewrite for ws adapter.
2026-06-28 17:51:39 -04:00
david raistrick 2ee2bba93b M2 (TDD): storage contract test + memory impl
- src/storage/contract.test.js: storage interface spec (19 assertions)
- src/storage/memory.js: in-process impl (Map + EventEmitter)
- src/storage/storage.test.js: runner, memory first

TDD: contract RED first, memory built to satisfy, 19/19 green.
Next impls (ws, firebase) run same contract.
2026-06-28 17:18:14 -04:00