Merge pull request 'Single source of truth: combat logic, storage parity, slot ordering' (#3) from single-source into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-05 00:07:56 -04:00
53 changed files with 2478 additions and 1385 deletions
+1
View File
@@ -13,3 +13,4 @@ server/data/*.sqlite
server/data/*.sqlite-* server/data/*.sqlite-*
/data /data
/scratch /scratch
tmp/
+11 -4
View File
@@ -247,15 +247,22 @@ docker compose -f docker/docker-compose.yml up --build
``` ```
This serves the app at `http://localhost:8080` (override with `PORT`), proxies `/api` and `/ws` to the backend, and persists the SQLite database in a named Docker volume. Override the Firestore-style app-id namespace with `TRACKER_APP_ID` if needed. This serves the app at `http://localhost:8080` (override with `PORT`), proxies `/api` and `/ws` to the backend, and persists the SQLite database in a named Docker volume. Override the Firestore-style app-id namespace with `TRACKER_APP_ID` if needed.
**Local dev (backend + frontend separately):** **Local dev (recommended):**
```bash ```bash
npm install # installs root, server/, and shared/ workspaces npm install # installs root, server/, and shared/ workspaces
npm run server:dev # starts backend on :4001 (SQLite at server/data/tracker.sqlite) ./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
./scripts/dev-stop.sh # stop both
```
**Manual (fallback):**
```bash
npm install
npm run server:dev # starts backend on :4001 (SQLite at data/tracker.sqlite)
# in another terminal: # in another terminal:
REACT_APP_STORAGE=ws \ REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \ REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
npm start npm start
``` ```
+136 -174
View File
@@ -1,204 +1,166 @@
# TODO # TODO
Backlog of bugs + long-term items, from user. Milestones live in Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
REWORK_PLAN.md.
## Feature backlog ## Open
### CRITICAL BUG - storage ### FEAT: clarify "Is NPC" in add-participant
- docker for sql is not using persistant storage... - Ambiguous label. May expand work based on what NPC means here (ally? monster?
display-only? skip in turn order?). Clarify intent before UX changes.
### feat - campaign section rollup ### FEAT: first-class undo/redo UI buttons (B --- do now)
- Toolbar buttons ↶/↷ in AdminView header, not buried in /logs.
- Undo = revert latest non-undone log. Redo = re-apply latest undone.
- Uses current 2-write undo (non-tx). Race safety = log refactor later.
- Disabled when stack empty. Keyboard shortcuts (cmd+z / cmd+shift+z).
### feat - add all characters to participants list ### FEAT-LOG: unified log refactor (was FEAT-2 + M6, batched)
- Single event schema, one source of truth:
`{ ts, type, payload, undo_payload, undone, encounterId,
snapshot:{ round, currentTurnParticipantId, turnOrderIds, activeIds } }`
- Common format consumed by: UI log view, download/copy export,
replay-combat, analyze-turns. One shape, four consumers.
- Transactional undo: server endpoint `POST /api/undo/:eventId`. Single
SQLite tx applies undo_payload + flips `undone`. Replaces fragile 2-write
(log update + encounter update as separate calls).
- Download/copy: exports event stream as JSON for offline analysis.
- replay-combat + analyze-turns rewritten to emit/consume same event shape.
- Migration: keep old log entries readable; new format for new writes.
### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
- Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional,
no stale clobber.
- Replaces fragile `/logs` snapshot-write undo.
- Migration: keep old undo working for existing entries until cleared; new
format for new entries.
- Related: BUG-7 (reorder no undo).
## Architecture: 1-list turn order model (DONE) ## FEAT - parallel campaigns
## FEAT - multi user
## FEAT - clarify what end encounter does and what initiatives reset means
## Done (history)
### FEAT: first-class undo/redo UI buttons (DONE)
- ↶/↷ pills in InitiativeControls, always visible when encounter open.
- Undo = latest non-undone log (per encounter). Redo = latest undone.
- encounterPath added to all 14 log contexts (filter key).
- redo:patch (forward) added to undoData. Real redo replays forward state.
- Disabled when stack empty. Tooltip shows target action.
- Uses current 2-write undo (non-tx). Race safety = FEAT-LOG refactor.
### Architecture: 1-list turn order model (slot, never sort)
- Single source: turnOrderIds === participants.map(id). No re-sort after - Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) overrides initiative --- cross-init allowed, DM choice. - Drag (reorder) = same-init tie-break only. Cross-init blocked.
- startEncounter sorts ALL participants by init once, then frozen. - startEncounter sorts ALL participants by init once, then frozen.
- addParticipant splices by init pos. remove/toggle/reorder sync list. - addParticipant/updateParticipant slot by init (slotIndexForInit), preserve
- Display renders participants[] directly (no sortParticipantsByInitiative). drag order. Display renders participants[] directly (no sort).
- BUG-6 (reorder divergence) fixed structurally. BUG-5 (rotation) held - Static guard test errs if `.sort(` reintroduced outside allowlist.
(500 rounds CLEAN). - Design doc: docs/INITIATIVE_ORDERING.md.
### FEAT-3: initiative first-class entry (add + edit) ### Single source of truth: combat logic
- Current: only initMod at char-build. No initiative field at add-participant - All 15 App.js handlers delegate to @ttrpg/shared. ~498 lines inline dupes deleted.
or edit. 3-step to set after other steps. - shared/turn.js = only place turn logic lives.
- Need: initiative field at add-char, add-monster, AND edit participant.
- Separate design + RED. Own work item.
- Related: tie-break = drag order (current, works). Expose clearly.
### FEAT-1: Dead participants stay in turn order --- DONE ### Storage parity (firebase + server adapters)
- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds` - Neutral queryConstraints ({__type:'orderBy'|'limit'}) honored by both adapters.
on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get - Shared contract test runs both identically. Memory adapter deleted; factory
death-save turn. `isActive` = DM toggle only. throws on unknown mode.
- Tests: `shared/tests/turn.dead-skip.test.js` (4 green). Char tests updated - ws storage mode renamed to server (env var + adapter name).
to new behavior.
### FEAT-2: upgrade app internal logs to be parseable ### Logging contract
- Goal: combat logs in Firestore store enough structured state to run - Every mutating op logs message + undo payload. No-op = null log.
skip/rotation analysis on ANY historic round --- not just replay stdout. - Structural enforcement: per-op contract test (turn.logging.test.js) +
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must static source-scan guard (static.no-unlogged.test.js).
guess roster from message strings. Brittle.
- Upgrade: add structured fields at turn-state mutation log sites in
App.js (startEncounter, toggleActive, addParticipant, removeParticipant,
applyHpChange death/revive, togglePause, nextTurn):
```
turnSnapshot: { round, currentTurnParticipantId, turnOrderIds, activeIds }
```
- Then `scripts/analyze-turns.js` ingests app logs directly (adapter fetch).
Works on real game sessions, any round, deterministic.
- Parser scaffold NOW ingests replay stdout only (stopgap until FEAT-2).
## Confirmed bugs (tests written, NOT fixed) ### Custom conditions per campaign (DONE)
- Freeform per-campaign conditions. Add applies to participant + persists to
campaign palette in one step. Badge render uses merged allConditions
(built-ins + custom). toggleCondition accepts any string. Dedup
case-insensitive. maxLength 40. Combat + replay tests prove arbitrary
string ids survive round-trip.
### BUG-1: addParticipant + pause/resume corrupts turn rotation ### Death saves: D&D 5e model (DONE)
- **RESOLVED** as side effect of BUG-2 fix (dup-id rejection broke chain). - Separate success/fail tracking. deathSave(enc, id, type, n),
- Audit: 0 violations / 100 rounds after BUG-2 fix. type='success'|'fail'. Fields: deathSaves, deathFails, isStable, isDying.
- Replay: 10 rounds clean, no skip/dupe. 3 success=stable, 3 fail=dead. Returns {patch, log, status, isDying}.
- Audit: 128 violations / 100 rounds, 4 symptom faces. Old single-counter broken (successes missing). Old data lost (feature
- Symptom chain (one bug family): never worked in prod). UI: green ✓ + red ✕ rows, Stabilized/Dying badges.
1. pause blocks nextTurn advance → totalTurns stays frozen (e.g. 120)
2. addParticipant re-adds same `r${totalTurns}` id (BUG-2: no dedup) ### FEAT-3: initiative first-class entry (DONE)
3. togglePause resume rebuilds turnOrderIds → dup id appears x2 - Initiative field at add-char, add-monster, edit participant.
4. nextTurn gets stuck on dup id → rotation breaks - Inline edit wired. Tie-break = drag order.
5. eventually nextTurn throws 'Encounter not running'
- Symptom counts (audit-state.js, 100 rounds): ### UI feedback: toast + info modal (DONE)
62x turnOrder-no-dup, 52x rotation-dupes, 14x nextTurn-throws - All 23 native alert() replaced. ToastStack (6s auto-dismiss + manual X)
- Repro in replay round 10+: current stuck on one participant forever, for transient failures. InfoModal (persistent OK) for validations.
nextTurn returns same id, round never advances. React context provider wraps all 3 App branches.
- Clean minimal repro (shared/tests/turn.pause-add.test.js) PASSES = combo - Fixed: native alert vanished instantly on browser focus loss.
needs more state than single add+pause. Audit authoritative repro.
- Clean subsystems (zero violations): HP bounds, isActive, deathSave ### Filter dup chars from add-participant dropdown (DONE)
range, conditions, removeParticipant orphans. - Character dropdown excludes chars already in encounter. Prevents
- Real repro = `node scripts/audit-state.js` (or audit-rotation.js). dup-add at source. No more dup alert path needed.
### Test timeouts (DONE)
- jest.setTimeout(10000) in setupTests.js (CRA blocks config-level timeout).
### Warning = failure in tests (DONE)
- console.error/warn throw in test env.
### BUG-1: addParticipant + pause/resume corrupts rotation
- RESOLVED as side effect of BUG-2 fix.
### BUG-2: addParticipant allows duplicate id ### BUG-2: addParticipant allows duplicate id
- **FIXED** (commit: addParticipant throws on dup id). - FIXED (addParticipant throws on dup id).
- Test: `shared/tests/turn.characterization.test.js` 'addParticipant rejects
duplicate id' --- GREEN.
### bug-3 was a halucination has been removed ### BUG-4: hide-player-HP breaks display view
- FIXED --- mock honors setDoc{merge}, all 5 activeDisplay sites use merge.
### BUG-4: hide-player-HP breaks display view (preexisting) --- PROD FIXED, TEST RED (mock bug) ### BUG-5: mid-round addParticipant/revive corrupts rotation
- **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls - FIXED --- slot-array + DRY advance core nextActiveAfter.
use `{merge:true}` which is IGNORED (setDoc = replace per contract).
Each write clobbers other fields on activeDisplay/status doc.
- line 1619: hide-HP toggle → clobbers campaignId+encounterId (display paused)
- line 1648: start combat → clobbers hidePlayerHp
- line 1779: end combat → clobbers hidePlayerHp
- line 1997: deactivate → clobbers hidePlayerHp
- line 2002: activate → clobbers hidePlayerHp
- Toggle "hide player HP" in admin → display view flips to "Game Session Paused".
- Toggling back does NOT recover. Must re-activate encounter in encounters
panel to restore display.
- Expected: hide-HP toggle updates one field on activeDisplay/status doc,
display stays live on current encounter.
- Likely cause: toggle writes to wrong path, or clobbers activeCampaignId/
activeEncounterId with null (setDoc replace vs updateDoc patch).
- Fix: use updateDoc (patch) not setDoc (replace); or include all existing
fields when writing.
- Status update (2026-07): all 5 sites now use `{merge:true}`. Real firebase
adapter honors merge → production works. BUT jsdom test still RED because
`src/__mocks__/firebase/firestore.js` setDoc records call, IGNORES opts
(no actual merge). Mock must simulate firebase merge semantics for test
to pass. Fix = mock setDoc: if opts.merge, MOCK_DB.merge(path,data) else
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, ws adapter
uses PATCH). Decide which.
- Test: render App + DisplayView, toggle hide-HP, assert display still shows
encounter (not paused).
### BUG-5: mid-round addParticipant/revive corrupts rotation --- FIXED ### BUG-6: reorderParticipants doesn't update turnOrderIds
- Fixed (commit `494327f`). Slot-array turn order + DRY advance core - FIXED structurally by 1-list model.
`nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate.
- 500-round replay: 0 skips, 0 double-acts.
### BUG-6: reorderParticipants doesn't update turnOrderIds --- FIXED ### BUG-7: reorderParticipants not logged
- Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds = - FIXED --- returns log:{message, undo}. Handler calls logAction. deathSave,
participants.map(id) always. reorder cross-init allowed (DM override). addParticipants, updateParticipant logging gaps also closed.
Display === rotation by construction.
- Test: `shared/tests/turn.reorder.test.js` 'reorder updates turnOrderIds' (RED).
- `reorderParticipants(enc, draggedId, targetId)` swaps two same-initiative
participants in `participants[]` array but leaves `turnOrderIds` unchanged.
- nextTurn rotates via `turnOrderIds` only → reorder has NO effect on combat
rotation. Mid-encounter drag-drop = pointless.
- replay-combat.js calls reorderParticipants with WRONG signature
`(enc, reorderedArray)` --- swallowed by try/catch, silent no-op. So
replay never exercised real path either.
- Fix: reorder must also update turnOrderIds to match new participant order
(within same-initiative tie).
### BUG-7: reorderParticipants has no undo ### BUG-8: server adapter has no reconnect
- Test: `shared/tests/turn.undo.test.js` 'reorderParticipants has no undo' (GREEN doc). - FIXED --- onclose reconnects + re-subscribes existing paths.
- `reorderParticipants` returns `log: null`. Other ops return `log.undo`.
- Cannot undo drag-drop. Candidate for undo system (M6).
### BUG-8: ws adapter has no reconnect
- Test: `server/tests/ws-reconnect.test.js` (RED).
- WS dies (idle/error/close) → `wsReady=null`, subscribers dead forever.
- Display frozen until full reload.
- Fix: `onclose` → reconnect + re-subscribe existing paths.
### BUG-10: deact+reactivate same round double-acts participant ### BUG-10: deact+reactivate same round double-acts participant
- Discovered in 500-round replay (3 occurrences). DISTINCT from BUG-5. - FIXED --- 1-list model keeps slot position on toggle. Reactivate does not
- Pattern: participant acts → DM deactivates them → DM reactivates them grant second turn. Test: turn.bug10.test.js.
same round → `computeTurnOrderAfterAddition` re-inserts by initiative
(front) → acts AGAIN before round ends.
- No "acted-this-round" guard. Slot-array model has no per-round-acted set.
- Edge case (DM deact+reactivate same participant same round).
- Fix candidate: track actedThisRound set, skip re-acted; OR insertion
places reactivate AFTER current position (not by initiative).
- Parser now discounts deact-current advances, so this surfaced real.
### BUG-11: FE Combat.scenario test crashes (pre-existing) ### BUG-11: FE Combat.scenario test crashes
- `src/tests/Combat.scenario.test.js:254` deathSave query helper throws - FIXED --- moved to shared/turn.combat.test.js, pure functions, 100 rounds.
(button not found).
- Baseline (my changes removed) also exit=1. Pre-existing, not regression.
- Crashes whole FE test run (process dies).
### BUG-13: reorderParticipants crossing current pointer = ambiguous acted-semantics ### BUG-12: campaign selection follows activeDisplay
- Discovered 7/1 replay. `reorderParticipants` (shared/turn.js:522) = pure - FIXED.
drag, no pointer logic. Swapping two actors across current pointer mid-round
= ambiguous who-acted-this-round. Earlier replay arbitrary swaps showed ### BUG-13: reorderParticipants crossing current pointer = ambiguous
skip/double (R9 Summon3 2x, R11 Goblin1 2x) before fix restricted swaps to - FIXED --- block cross-pointer reorder during active encounter (both dirs).
upcoming-only. Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double.
- Replay now avoids crossing (adjacent upcoming pair only, commit af165f4). Pre-combat: free reorder. Test: turn.bug13.test.js.
Real app untested: if DM drags actor past current pointer mid-round, skip/
double behavior undefined.
- Decide: block cross-pointer reorder, or define acted-semantics. RED needed.
### BUG-14: addParticipant init-insertion breaks after drag-reorder ### BUG-14: addParticipant init-insertion breaks after drag-reorder
- Discovered 7/1 replay. `computeTurnOrderAfterAddition` scans for first id - FIXED --- slotIndexForInit scans current list (post-drag aware).
with init < addedInit, assumes list init-sorted. After drag, list NOT sorted
→ scan hits wrong slot.
- Trace turn 30→31: list `[Goblin1:20,Goblin2:22,...]` (drag moved Goblin1
before Goblin2). Add Reinforce3 init 21 → scan hits Goblin1:20 (idx 0, <21)
first → insert at 0. Should slot after Goblin2:22. WRONG.
- Root conflict: 1-list model = drag source of truth (no re-sort); addParticipant
= init-based insertion (needs sorted list). After ANY drag, add-insertion
meaningless.
- Proposed fix: append to end always (option A). DM drags to position. Matches
drag = source of truth. Makes `computeTurnOrderAfterAddition` trivial.
- Related: FEAT-3 (initiative first-class field).
## Pipeline (bugs only --- milestones live in REWORK_PLAN.md) ### BUG-15: DisplayView re-sorts (drag order not preserved)
- [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites - FIXED --- display renders participants[] directly.
- [x] BUG-5: fixed (1-list model, 500 rounds clean)
- [x] BUG-6: fixed structurally (1-list model) ### BUG-16: subscribeCollection hook drops queryConstraints
- [x] BUG-12: fixed --- campaign selection follows activeDisplay - FIXED --- neutral builders, both adapters honor orderBy/limit.
- [x] BUG-15: fixed --- DisplayView no longer re-sorts (drag order preserved)
- [x] BUG-8: ws adapter reconnect (implemented + GREEN) ### BUG-17: dead SDK imports in App.js
- [ ] BUG-10: deact+reactivate double-act - FIXED --- trimmed (auth + getFirestore + getStorage remain).
- [ ] BUG-11: FE Combat.scenario crash
- [ ] BUG-13: reorder cross-pointer semantics (RED + decide block/allow) ### BUG-18: stale comments reference deleted memory adapter
- [ ] BUG-14: addParticipant init-insert breaks post-drag (append? + RED) - FIXED.
### FEAT-1: Dead participants stay in turn order
- DONE --- applyHpChange no longer flips isActive on death. Dead stay in
rotation, nextTurn visits them, PCs get death-save turn.
### combat.scenario 100 rounds not turns
- DONE --- loops by actual round-wrap count.
### feat: add all characters to participants list
- DONE --- addParticipants bulk add wired.
+2 -2
View File
@@ -18,9 +18,9 @@ COPY tailwind.config.js postcss.config.js ./
# better-sqlite3 native build (alpine musl) # better-sqlite3 native build (alpine musl)
RUN cd server && npm rebuild better-sqlite3 RUN cd server && npm rebuild better-sqlite3
# build frontend (ws storage, same-origin via caddy) # build frontend (server storage, same-origin /api + /ws via caddy)
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
ENV REACT_APP_STORAGE=ws ENV REACT_APP_STORAGE=server
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
+23 -10
View File
@@ -50,14 +50,19 @@ git config core.hooksPath .githooks # enable pre-push test gate
## Run ## Run
### Backend (dev) ### Local dev stack (one command)
```bash ```bash
npm run server:dev # :4001, db: server/data/tracker.sqlite ./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
# or direct: ./scripts/dev-stop.sh # stop both
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
``` ```
- backend: `npm run server:dev` (:4001, sqlite `data/tracker.sqlite`)
- frontend: `npm start` (:3999, `REACT_APP_STORAGE=server`)
- logs: `tmp/server.log`, `tmp/fe.log`
Idempotent: if port busy, leaves existing proc as-is.
Smoke check: Smoke check:
```bash ```bash
curl http://127.0.0.1:4001/health # -> {"ok":true} curl http://127.0.0.1:4001/health # -> {"ok":true}
@@ -65,12 +70,20 @@ curl http://127.0.0.1:4001/health # -> {"ok":true}
Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume. Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Frontend (dev server, ws mode) ### Manual (fallback)
Backend:
```bash ```bash
REACT_APP_STORAGE=ws \ npm run server:dev # :4001, db: server/data/tracker.sqlite
# or direct:
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
```
Frontend (server mode):
```bash
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \ REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \ BROWSER=none PORT=3999 \
npm start npm start
``` ```
@@ -225,12 +238,12 @@ App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/..
`getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`). `getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`).
- `firebase` → real SDK init - `firebase` → real SDK init
- `ws`/`memory` → stub auth + db sentinel, route via `storage.*` adapter - `server` → stub auth + db sentinel, route via `storage.*` adapter
## Test layers ## Test layers
- **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises ws adapter. - **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises server adapter.
- **Layer 2**: ws adapter vs live backend. Proves translation + path identity. - **Layer 2**: server adapter vs live backend. Proves translation + path identity.
Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers). Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers).
+2 -2
View File
@@ -52,8 +52,8 @@ Round 2: turn 1 (Fighter) → ... → turn 8 (Merchant) [round counter +=1 at
| Term | Meaning | | Term | Meaning |
|------|---------| |------|---------|
| **Adapter** | `src/storage/{firebase,ws,memory}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. | | **Adapter** | `src/storage/{firebase,server}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
| **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. | | **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. |
| **Generic KV doc store** | Backend stores opaque JSON at arbitrary path strings. No shape-specific endpoints. Backend = firebase mirror, not REST API for app entities. | | **Generic KV doc store** | Backend stores opaque JSON at arbitrary path strings. No shape-specific endpoints. Backend = firebase mirror, not REST API for app entities. |
| **Layer 1 test** | App vs firebase mock. Proves adapter call shape. | | **Layer 1 test** | App vs firebase mock. Proves adapter call shape. |
| **Layer 2 test** | ws adapter vs live backend. Proves translation + path identity. | | **Layer 2 test** | server adapter vs live backend. Proves translation + path identity. |
+59
View File
@@ -0,0 +1,59 @@
# Initiative Ordering — Design
## Core Principle
**Slot, never sort.**
Initiative order = a single list. Participants occupy slots determined by
initiative value. The list is mutated by insert/move operations — never
re-sorted wholesale.
## Single Source of Truth
One list: `participants[]`.
- Display order = `participants[]` order (both Player view + DM view).
- Turn order = `participants[]` order.
- No derived/re-sorted copy. `turnOrderIds`, if present, is always an exact
mirror (`participants.map(p => p.id)`) — never an independent ordering.
## When Ordering Changes
| Action | Effect on order |
|--------|-----------------|
| **Add** (roll or manual) | Insert participant into slot by initiative |
| **Edit initiative** | Move participant to new slot |
| **Drag** | Reorder within a tie (same initiative) only |
| **Remove** | Splice out; others keep position |
| **Start encounter** | Freeze current list. No re-sort. |
| **Round wrap** | No rebuild. Continue rotation through existing list. |
| **Damage/heal/death/save** | No order change. |
| **Toggle active** | No position change. Skip in rotation only. |
## Slotting Rules
- Insert/move positions participants so the list stays initiative-descending.
- **Tie-break = original add order.** Later additions slot *after* existing
same-init participants. Stable insertion.
- Tie order is only changed by explicit DM drag. Never auto-changed.
## Drag (DM Tie-Break Override)
- Only participants with the **same initiative** are draggable onto each other.
- Cross-initiative drag is **not allowed**. (Use edit-initiative field instead.)
- Drag persists in `participants[]`. Survives all subsequent operations.
- **Re-slotting on add/edit must preserve drag-established tie order.**
## Explicitly Forbidden
- `sort()` on every mutation. Overwrites drag order. Root cause of past drift.
- Separate display order vs turn order. Causes player/DM divergence.
- Re-sort on round wrap. Replays rounds, introduces skips.
- Cross-initiative drag. Contradicts initiative as the primary key.
- Auto-changing tie order on add/edit. Silently loses DM intent.
## Why
Sorting is destructive to manual ordering. A stable slot list with drag for
ties gives deterministic, DM-controllable order that never drifts between
display surfaces or combat rounds.
+10 -10
View File
@@ -61,7 +61,7 @@ The storage interface is the test seam and the upstream-compat layer.
| Impl | When used | Automated-tested? | | Impl | When used | Automated-tested? |
|---|---|---| |---|---|---|
| `firebase.js` | default (`STORAGE=firebase`) — upstream path | No — requires live Firebase project | | `firebase.js` | default (`STORAGE=firebase`) — upstream path | No — requires live Firebase project |
| `ws.js` | `STORAGE=ws` — our fork, talks to backend | Yes — against running backend | | `server.js` | `STORAGE=server` — our fork, talks to backend | Yes — against running backend |
| `memory.js` | test-only, in-process | Yes — fast, deterministic | | `memory.js` | test-only, in-process | Yes — fast, deterministic |
**Frontend interface contract** (all three implement): **Frontend interface contract** (all three implement):
@@ -82,7 +82,7 @@ Memory impl: in-memory Map + EventEmitter, for tests (M3).
storage/ storage/
index.js # factory: pick impl from STORAGE env index.js # factory: pick impl from STORAGE env
firebase.js # extracted from current App.js (verbatim) firebase.js # extracted from current App.js (verbatim)
ws.js # NEW — talks to backend server.js # talks to backend (was ws.js)
memory.js # NEW — test only memory.js # NEW — test only
contract.js # interface spec (runStorageContract) contract.js # interface spec (runStorageContract)
tests/ # frontend tests tests/ # frontend tests
@@ -116,7 +116,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
| M | Does | Tests? | | M | Does | Tests? |
|---|---|---| |---|---|---|
| 0 | repo, branch, remotes | no | | 0 | repo, branch, remotes | no |
| 1 | build backend (Node+Express+ws+better-sqlite3) | unit tests as built | | 1 | build backend (Node+Express+WebSocket+better-sqlite3) | unit tests as built |
| 2 | frontend WS adapter — app runs vs backend, cross-device works | yes | | 2 | frontend WS adapter — app runs vs backend, cross-device works | yes |
| 3 | characterization tests lock current behavior | yes | | 3 | characterization tests lock current behavior | yes |
| 4 | resolve initiative rotation corruption (BUG-5) | yes | | 4 | resolve initiative rotation corruption (BUG-5) | yes |
@@ -135,7 +135,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
- **Upstream-PRable:** n/a (fork infra) - **Upstream-PRable:** n/a (fork infra)
### Milestone 1 — Build backend ✅ ### Milestone 1 — Build backend ✅
- `server/`: Express + ws + better-sqlite3. - `server/`: Express + WebSocket + better-sqlite3.
- Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path. - Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path.
- Server holds authoritative state. No turn logic server-side (logic stays client-side in `shared/turn.js`). - Server holds authoritative state. No turn logic server-side (logic stays client-side in `shared/turn.js`).
- **Exit criteria:** backend boots, serves state over WS, persists to SQLite, unit tests green. ✅ DONE. - **Exit criteria:** backend boots, serves state over WS, persists to SQLite, unit tests green. ✅ DONE.
@@ -144,10 +144,10 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Milestone 2 — Frontend WS adapter ✅ ### Milestone 2 — Frontend WS adapter ✅
- Define `storage/contract.js` interface spec. - Define `storage/contract.js` interface spec.
- Move all Firestore call sites from `App.js` into `storage/firebase.js` behind interface (verbatim). - Move all Firestore call sites from `App.js` into `storage/firebase.js` behind interface (verbatim).
- Implement `storage/ws.js` per interface, talking to backend. Generic KV ops, subscribes to WS. - Implement `storage/server.js` per interface, talking to backend. Generic KV ops, subscribes via WebSocket.
- Implement `storage/memory.js` for frontend unit tests. - Implement `storage/memory.js` for frontend unit tests.
- `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged). - `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged).
- App runs against backend with `STORAGE=ws`. - App runs against backend with `STORAGE=server`.
- Cross-device verified manually: DM view + player display + tablet. - Cross-device verified manually: DM view + player display + tablet.
- **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE. - **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE.
- **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌. - **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌.
@@ -155,7 +155,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Milestone 3 — Characterization tests lock current behavior ✅ ### Milestone 3 — Characterization tests lock current behavior ✅
- Lock current behavior via tests. - Lock current behavior via tests.
- Cover: START, NEXT_TURN, PAUSE, RESUME, ADD_PARTICIPANT, REMOVE_PARTICIPANT, TOGGLE_ACTIVE, REORDER, APPLY_DAMAGE/HEAL, DEATH_SAVE, END. - Cover: START, NEXT_TURN, PAUSE, RESUME, ADD_PARTICIPANT, REMOVE_PARTICIPANT, TOGGLE_ACTIVE, REORDER, APPLY_DAMAGE/HEAL, DEATH_SAVE, END.
- Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (ws adapter vs live backend, proves translation). - Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (server adapter vs live backend, proves translation).
- Iterate until confident: baseline solid, regressions impossible to silently slip. - Iterate until confident: baseline solid, regressions impossible to silently slip.
- **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE. - **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE.
- **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape). - **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape).
@@ -173,7 +173,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
- Single container: caddy (front, static + proxy) + node backend (internal :4001). - Single container: caddy (front, static + proxy) + node backend (internal :4001).
- Files in `docker/` tree (kept separate from upstream root Dockerfile): - Files in `docker/` tree (kept separate from upstream root Dockerfile):
- `docker/Dockerfile` — build FE + BE, runtime caddy+node - `docker/Dockerfile` — build FE + BE, runtime caddy+node
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback - `docker/Caddyfile` — proxy /api + /ws (WebSocket path) to node, static SPA fallback
- `docker/entrypoint.sh` — node bg + caddy fg - `docker/entrypoint.sh` — node bg + caddy fg
- `docker/docker-compose.yml` — one `app` service, volume for sqlite - `docker/docker-compose.yml` — one `app` service, volume for sqlite
- Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080. - Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080.
@@ -214,7 +214,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Manual smoke via config flags ### Manual smoke via config flags
- `STORAGE=firebase` → current behavior (friend's path, upstream default). - `STORAGE=firebase` → current behavior (friend's path, upstream default).
- `STORAGE=ws` → our path, local backend. - `STORAGE=server` → our path, local backend.
- docker-compose profiles mirror the above. - docker-compose profiles mirror the above.
### Accepted test gap ### Accepted test gap
@@ -261,6 +261,6 @@ Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally
- M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅ - M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
- Backend live: port 4001, db `./data/tracker.sqlite` - Backend live: port 4001, db `./data/tracker.sqlite`
- Frontend: port 3999 with `REACT_APP_STORAGE=ws` - Frontend: port 3999 with `REACT_APP_STORAGE=server`
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`. - Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
- Next milestones: M5 docker-compose. Undo moved to TODO backlog. - Next milestones: M5 docker-compose. Undo moved to TODO backlog.
+5 -6
View File
@@ -194,11 +194,11 @@ curl http://127.0.0.1:4001/health # → {"ok":true}
Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume. Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Frontend (ws mode) ### Frontend (server mode)
```bash ```bash
REACT_APP_STORAGE=ws \ REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \ REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \ BROWSER=none PORT=3999 \
npm start npm start
``` ```
@@ -210,10 +210,9 @@ Firebase mode (default): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.e
`STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`: `STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`:
- `firebase` (default) → real SDK - `firebase` (default) → real SDK
- `ws` → backend (docker/prod) - `server` → backend (docker/prod)
- `memory` → in-process (test seed)
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs memory/ws/firebase. All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs server/firebase.
## Known RED / backlog ## Known RED / backlog
+8 -1
View File
@@ -6,4 +6,11 @@ REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET_HERE"
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE" REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE"
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE" REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE"
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default" REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default"
# --- Self-hosted backend mode (optional) ---
# Default storage = firebase (SDK). Set REACT_APP_STORAGE=server to use the
# bundled Express + WebSocket backend (server/) instead.
# REACT_APP_STORAGE="server"
# REACT_APP_BACKEND_URL="http://127.0.0.1:4001"
# REACT_APP_BACKEND_REALTIME_URL="ws://127.0.0.1:4001/ws"
+24 -10
View File
@@ -13,7 +13,6 @@
], ],
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"firebase": "^10.12.2", "firebase": "^10.12.2",
@@ -24,6 +23,9 @@
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
},
"devDependencies": {
"@testing-library/react": "^14.3.1"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@@ -5237,17 +5239,18 @@
} }
}, },
"node_modules/@testing-library/react": { "node_modules/@testing-library/react": {
"version": "13.4.0", "version": "14.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
"integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.5.0", "@testing-library/dom": "^9.0.0",
"@types/react-dom": "^18.0.0" "@types/react-dom": "^18.0.0"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=14"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0", "react": "^18.0.0",
@@ -5255,9 +5258,10 @@
} }
}, },
"node_modules/@testing-library/react/node_modules/@testing-library/dom": { "node_modules/@testing-library/react/node_modules/@testing-library/dom": {
"version": "8.20.1", "version": "9.3.4",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
"integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
@@ -5270,13 +5274,14 @@
"pretty-format": "^27.0.2" "pretty-format": "^27.0.2"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=14"
} }
}, },
"node_modules/@testing-library/react/node_modules/aria-query": { "node_modules/@testing-library/react/node_modules/aria-query": {
"version": "5.1.3", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"deep-equal": "^2.0.5" "deep-equal": "^2.0.5"
@@ -5286,6 +5291,7 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.1.0", "ansi-styles": "^4.1.0",
@@ -5635,6 +5641,7 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -5660,6 +5667,7 @@
"version": "18.3.27", "version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -5671,6 +5679,7 @@
"version": "18.3.7", "version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
@@ -9383,6 +9392,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -9505,6 +9515,7 @@
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"array-buffer-byte-length": "^1.0.0", "array-buffer-byte-length": "^1.0.0",
@@ -10118,6 +10129,7 @@
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.2", "call-bind": "^1.0.2",
@@ -12560,6 +12572,7 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -16816,6 +16829,7 @@
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.7", "call-bind": "^1.0.7",
+4 -2
View File
@@ -8,7 +8,6 @@
], ],
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"firebase": "^10.12.2", "firebase": "^10.12.2",
@@ -28,7 +27,7 @@
"server:dev": "npm run dev --workspace server", "server:dev": "npm run dev --workspace server",
"server:test": "npm test --workspace server", "server:test": "npm test --workspace server",
"shared:test": "npm test --workspace shared", "shared:test": "npm test --workspace shared",
"test:all": "npm run shared:test && npm run server:test" "test:all": "CI=true react-scripts test --watchAll=false && npm run shared:test && npm run server:test"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -47,5 +46,8 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@testing-library/react": "^14.3.1"
} }
} }
+12 -2
View File
@@ -1,10 +1,20 @@
# scripts/ # scripts/
Manual demo tool. NOT test. Dev orchestration + manual demo tool. NOT test.
## dev-start.sh / dev-stop.sh
Local dev stack: backend (:4001, sqlite) + frontend (:3999, server mode).
One command. Writes env vars for you. Logs to `tmp/server.log`, `tmp/fe.log`.
```bash
./scripts/dev-start.sh # start (idempotent: leaves running ports as-is)
./scripts/dev-stop.sh # stop both
```
## replay-combat.js ## replay-combat.js
Live backend demo. Drives full combat via ws adapter (same contract as App). Live backend demo. Drives full combat via server adapter (same contract as App).
Player display live-updates. Watch UI react to state changes. Player display live-updates. Watch UI react to state changes.
```bash ```bash
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Start local dev stack: node backend (sqlite) + react frontend, ws storage mode. # Start local dev stack: node backend (sqlite) + react frontend, server storage mode.
# Usage: ./scripts/dev-start.sh # Usage: ./scripts/dev-start.sh
# Stop: ./scripts/dev-stop.sh # Stop: ./scripts/dev-stop.sh
set -euo pipefail set -euo pipefail
@@ -25,12 +25,12 @@ else
echo "backend already on :4001" echo "backend already on :4001"
fi fi
# frontend: ws storage, :3999 # frontend: server storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then if ! lsof -ti :3999 >/dev/null 2>&1; then
echo "starting frontend :3999..." echo "starting frontend :3999..."
REACT_APP_STORAGE=ws \ REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \ REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \ BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 & nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid echo $! > tmp/fe.pid
+8 -5
View File
@@ -30,10 +30,10 @@ const {
toggleParticipantActive, applyHpChange, deathSave, toggleParticipantActive, applyHpChange, deathSave,
toggleCondition, reorderParticipants, endEncounter, toggleCondition, reorderParticipants, endEncounter,
} = shared; } = shared;
const { createWsStorage } = require('../src/storage/ws'); const { createServerStorage } = require('../src/storage/server');
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001'; const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws'; const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
const ROUNDS = parseInt(process.argv[2], 10) || 100; const ROUNDS = parseInt(process.argv[2], 10) || 100;
const DELAY = parseInt(process.argv[3], 10) || 200; const DELAY = parseInt(process.argv[3], 10) || 200;
@@ -51,7 +51,7 @@ const getPath = {
const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Use the ADAPTER as the contract boundary (same as App). No raw REST. // Use the ADAPTER as the contract boundary (same as App). No raw REST.
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL }); const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
// Mirror App.js CONDITIONS so we exercise all of them. // Mirror App.js CONDITIONS so we exercise all of them.
const CONDITIONS = [ const CONDITIONS = [
@@ -60,6 +60,9 @@ const CONDITIONS = [
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained', 'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed', 'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed',
]; ];
// Custom (freeform) condition ids — DM-added strings. toggleCondition must
// accept ANY string (UI custom-condition contract). Exercise both paths.
const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed'];
async function patch(encounterPath, enc, result, label) { async function patch(encounterPath, enc, result, label) {
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; } if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
@@ -151,7 +154,7 @@ async function main() {
await sleep(DELAY); await sleep(DELAY);
let totalTurns = 0; let totalTurns = 0;
const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5); const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS].sort(() => Math.random() - 0.5);
let reinforcementsAdded = 0; let reinforcementsAdded = 0;
let lastPaused = false; let lastPaused = false;
let lastReorder = 0; let lastReorder = 0;
@@ -240,7 +243,7 @@ async function main() {
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
if (living.length > 0) { if (living.length > 0) {
const tgt = pick(living); const tgt = pick(living);
const cond = pick(CONDITIONS); const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
try { try {
const c = toggleCondition(enc, tgt.id, cond); const c = toggleCondition(enc, tgt.id, cond);
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`); enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
+1 -1
View File
@@ -10,7 +10,7 @@
// logs/{id} doc // logs/{id} doc
// //
// No shape-specific tables. Data is opaque JSON. This is the firebase mirror: // No shape-specific tables. Data is opaque JSON. This is the firebase mirror:
// the adapter (src/storage/ws.js) is a thin passthrough, app logic unchanged. // the adapter (src/storage/server.js) is a thin passthrough, app logic unchanged.
'use strict'; 'use strict';
+1 -1
View File
@@ -1,6 +1,6 @@
// server/index.js — generic KV document store over HTTP + WebSocket. // server/index.js — generic KV document store over HTTP + WebSocket.
// firebase mirror: doc-tree model. Thin REST, path-based WS push. // firebase mirror: doc-tree model. Thin REST, path-based WS push.
// Adapter (src/storage/ws.js) = passthrough, no shape translation. // Adapter (src/storage/server.js) = passthrough, no shape translation.
'use strict'; 'use strict';
@@ -1,9 +1,9 @@
// Layer 2 test: exercise ws.js storage adapter against a LIVE backend. // Layer 2 test: exercise server.js storage adapter against a LIVE backend.
// Complements Layer 1 (App + firebase mock) which proves App call shape but // Complements Layer 1 (App + firebase mock) which proves App call shape but
// never touches ws.js. This catches translation bugs in the adapter. // never touches ws.js. This catches translation bugs in the adapter.
// //
// Runs the shared storage contract (same spec memory/firebase satisfy) against // Runs the shared storage contract (same spec memory/firebase satisfy) against
// createWsStorage pointed at an ephemeral backend instance. A FRESH backend is // createServerStorage pointed at an ephemeral backend instance. A FRESH backend is
// spun up per test to guarantee isolation (backend has no reset endpoint yet). // spun up per test to guarantee isolation (backend has no reset endpoint yet).
'use strict'; 'use strict';
@@ -11,7 +11,7 @@
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { createServer } = require('../index'); const { createServer } = require('../index');
const { createWsStorage } = require('../../src/storage/ws'); const { createServerStorage } = require('../../src/storage/server');
const { runStorageContract } = require('../../src/storage/contract'); const { runStorageContract } = require('../../src/storage/contract');
// Factory: fresh backend (unique sqlite file) + storage pointed at it. // Factory: fresh backend (unique sqlite file) + storage pointed at it.
@@ -26,9 +26,9 @@ async function makeStorage() {
const port = handle.server.address().port; const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`; const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createWsStorage({ baseUrl, wsUrl }); const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
storage.dispose = (done) => handle.close(done); storage.dispose = (done) => handle.close(done);
return storage; return storage;
} }
runStorageContract('ws (live backend)', makeStorage); runStorageContract('server (live backend)', makeStorage);
@@ -8,7 +8,7 @@
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { createServer } = require('../index'); const { createServer } = require('../index');
const { createWsStorage } = require('../../src/storage/ws'); const { createServerStorage } = require('../../src/storage/server');
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms)); const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
@@ -22,7 +22,7 @@ async function makeStorage() {
const port = handle.server.address().port; const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`; const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createWsStorage({ baseUrl, wsUrl }); const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
storage.dispose = (done) => handle.close(done); storage.dispose = (done) => handle.close(done);
return storage; return storage;
} }
@@ -48,7 +48,7 @@ describe('BUG-8: ws adapter reconnect after drop', () => {
await flush(1000); await flush(1000);
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last).toEqual({ name: 'V2' }); expect(last).toEqual({ id: 'a', name: 'V2' });
} finally { } finally {
await new Promise(r => storage.dispose(r)); await new Promise(r => storage.dispose(r));
} }
+1
View File
@@ -3,4 +3,5 @@ module.exports = {
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['<rootDir>/tests/**/*.test.js'], testMatch: ['<rootDir>/tests/**/*.test.js'],
collectCoverageFrom: ['turn.js'], collectCoverageFrom: ['turn.js'],
testTimeout: 10000,
}; };
+70
View File
@@ -0,0 +1,70 @@
// STATIC GUARD: no `.sort(` introduced in shared/turn.js outside the ONE
// allowed function (sortParticipantsByInitiative, used by startEncounter to
// freeze the list once). Slot-not-sort design (docs/INITIATIVE_ORDERING.md):
// mutations = insert/move, never wholesale re-sort. A stray `.sort(` after
// start destroys drag tie-break order.
//
// This test errs the moment someone adds `.sort(` anywhere but the allowlist.
// Maintenance: add a function to ALLOWED only if it runs at startEncounter
// (one-time freeze), NOT in add/update/reorder/nextTurn paths.
'use strict';
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
// Functions permitted to call .sort(. Listed so intent is explicit + reviewed.
const ALLOWED_SORT_FUNCTIONS = new Set([
'sortParticipantsByInitiative',
]);
// Collect top-level function bodies by brace counting.
// Matches `function NAME(` decls AND `const NAME = ... =>` arrow fns.
// Returns [{ name, body }].
function collectBody(src, fromIdx) {
let i = fromIdx;
while (i < src.length && src[i] !== '{') i++;
let depth = 0;
const start = i;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) break; }
}
return src.slice(start, i + 1);
}
function extractFunctions(src) {
const out = [];
const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g;
const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g;
let m;
while ((m = fnRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
while ((m = arrowRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
return out;
}
describe('STATIC: no .sort( outside allowlist (slot-not-sort design)', () => {
test('every .sort( call lives in an allowed function', () => {
const fns = extractFunctions(SRC);
const offenders = [];
for (const { name, body } of fns) {
if (!ALLOWED_SORT_FUNCTIONS.has(name) && /\.sort\(/.test(body)) {
offenders.push(name);
}
}
expect(offenders).toEqual([]);
});
test('allowed sort function is declared (no silent allowlist drift)', () => {
const declared = new Set(extractFunctions(SRC).map(f => f.name));
for (const name of ALLOWED_SORT_FUNCTIONS) {
expect(declared.has(name)).toBe(true);
}
});
});
+98
View File
@@ -0,0 +1,98 @@
// STATIC GUARD: every mutation logged. Scans shared/turn.js source.
// Invariant: any return with patch != null MUST also have log != null.
// return { patch: {...}, log: {...} } — OK (logged mutation)
// return { patch: null, log: null } — OK (no-op)
// return { patch: {...}, log: null } — FAIL (unlogged mutation = BUG-7 class)
//
// BUG-7 history: reorderParticipants + deathSave + addParticipants +
// updateParticipant all returned log:null with real patches. Per-op test
// missed them because gaps section asserted null as "expected." Static scan
// catches any future regression regardless of test enumeration.
'use strict';
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
// Extract function name + body via brace counting.
function collectBody(src, fromIdx) {
let i = fromIdx;
while (i < src.length && src[i] !== '{') i++;
let depth = 0;
const start = i;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) break; }
}
return src.slice(start, i + 1);
}
function extractFunctions(src) {
const out = [];
const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g;
const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g;
let m;
while ((m = fnRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
while ((m = arrowRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
return out;
}
// Split body into return statements at top level of the function.
// Returns array of return-expr strings.
function extractReturns(body) {
const returns = [];
// find `return ` at depth 1 (inside fn body)
let i = 0;
// body starts with `{`. Skip into depth 1.
while (i < body.length && body[i] !== '{') i++;
i++; // past {
let depth = 1;
let start = -1;
for (; i < body.length; i++) {
const ch = body[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
if (depth === 1 && body.slice(i, i + 7) === 'return ') {
start = i + 7;
}
if (depth === 1 && ch === ';' && start !== -1) {
returns.push(body.slice(start, i));
start = -1;
}
}
return returns;
}
// For a return expr, detect: has `log: null` AND has `patch:` that is NOT null.
// Returns true if suspicious (unlogged mutation).
function isUnloggedMutation(retExpr) {
if (!/log:\s*null/.test(retExpr)) return false;
// patch: null → no-op, OK
if (/patch:\s*null/.test(retExpr)) return false;
// patch: { ... } or patch: someVar → mutation with null log = BAD
if (/patch:/.test(retExpr)) return true;
return false;
}
describe('STATIC: every mutation logged (logging contract)', () => {
const fns = extractFunctions(SRC);
test('no function returns patch!=null with log:null', () => {
const offenders = [];
for (const { name, body } of fns) {
const rets = extractReturns(body);
for (const r of rets) {
if (isUnloggedMutation(r)) {
offenders.push(`${name}: return ${r.trim().slice(0, 80)}`);
}
}
}
expect(offenders).toEqual([]);
});
});
+82
View File
@@ -0,0 +1,82 @@
// BUG-10: deact+reactivate same round double-acts participant.
// OLD bug: reactivation re-inserted by initiative (front of pointer) →
// participant acted again before round end.
//
// 1-list model: toggleActive keeps slot position. Reactivation does NOT
// grant second turn. Each participant acts at most once per round.
//
// Test: walk rounds, count visits. Deactivate+reactivate mid-round must
// not cause any participant to act twice in same round.
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant,
startEncounter, nextTurn, toggleParticipantActive,
} = shared;
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
describe('BUG-10: deact+reactivate same round', () => {
test('no participant acts twice in a round after deact+reactivate', () => {
// [a(10), b(7), c(3)]
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // a current, r1
const r1 = [];
r1.push(e.currentTurnParticipantId); // a acts
e = apply(e, nextTurn(e)); r1.push(e.currentTurnParticipantId); // b acts
// b already acted. Deactivate b (NOT current now — b just became current
// via nextTurn, so b IS current). Toggle off advances pointer to c.
e = apply(e, toggleParticipantActive(e, 'b')); // b off
// current advanced to c (b was current)
r1.push(e.currentTurnParticipantId); // c "becomes" active turn
// reactivate b same round
e = apply(e, toggleParticipantActive(e, 'b')); // b on
// nextTurn from c → round 2 (a). b must NOT re-act in round 1.
e = apply(e, nextTurn(e));
expect(e.round).toBe(2);
expect(e.currentTurnParticipantId).toBe('a');
// b acted once in r1, must act once in r2
e = apply(e, nextTurn(e));
expect(e.currentTurnParticipantId).toBe('b');
e = apply(e, nextTurn(e));
expect(e.currentTurnParticipantId).toBe('c');
// per-round visit count
const bCountR1 = r1.filter(id => id === 'b').length;
expect(bCountR1).toBe(1);
});
test('deactivate+reactivate non-current who already acted: no re-act', () => {
// [a(10), b(7), c(3)]. a acts, b acts, c acts (round 1 done).
// Then deactivate a (already acted, not current since c is current).
// Reactivate a. a must not act again until round 2.
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // a
e = apply(e, nextTurn(e)); // b
e = apply(e, nextTurn(e)); // c (r1 full: a,b,c)
// c is current. deactivate a (not current, already acted).
e = apply(e, toggleParticipantActive(e, 'a')); // a off, pointer stays c
expect(e.currentTurnParticipantId).toBe('c');
e = apply(e, toggleParticipantActive(e, 'a')); // a on
expect(e.currentTurnParticipantId).toBe('c');
// nextTurn from c → a (round 2). a acts in r2, once.
e = apply(e, nextTurn(e));
expect(e.round).toBe(2);
expect(e.currentTurnParticipantId).toBe('a');
});
});
+72
View File
@@ -0,0 +1,72 @@
// BUG-13: cross-pointer reorder blocked (skip/double-act otherwise).
// nextTurn walks turnOrderIds forward from current pointer. Dragging across
// pointer = ambiguous who-acts-next:
// - upcoming dragged ahead of pointer → walk wraps past → SKIPPED
// - acted dragged behind pointer → acts again → DOUBLE-ACT
// Full fix needs actedThisRound tracking. Pragmatic now: block both dirs
// during active encounter. Pre-combat: free reorder (no pointer).
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant,
startEncounter, nextTurn, reorderParticipants,
} = shared;
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
describe('BUG-13: cross-pointer reorder blocked during active encounter', () => {
test('dragging not-yet-acted participant ahead of pointer = no-op', () => {
// [a(10), b(10), c(10)]. a acts -> b current. c not yet acted.
let e = enc([p('a',10),p('b',10),p('c',10)]);
e = apply(e, startEncounter(e)); // a (idx0, pointer)
e = apply(e, nextTurn(e)); // b (idx1, pointer)
expect(e.currentTurnParticipantId).toBe('b');
// Drag c (idx2, behind pointer) before b (idx1, pointer). Crosses pointer.
// Would let c act by initiative-reinsert but nextTurn forward-walk skips.
// Blocked instead.
const r = reorderParticipants(e, 'c', 'b');
expect(r.patch).toBeNull();
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
});
test('dragging already-acted participant behind pointer = no-op', () => {
// [a(10), b(10), c(10)]. a acts (idx0, ahead of pointer).
let e = enc([p('a',10),p('b',10),p('c',10)]);
e = apply(e, startEncounter(e)); // a (pointer idx0)
e = apply(e, nextTurn(e)); // b (pointer idx1)
// Drag a (already acted, ahead of pointer) after c (behind pointer).
// Crosses pointer. Would let a act again. Blocked.
const r = reorderParticipants(e, 'a', 'c');
expect(r.patch).toBeNull();
});
test('reorder within same side of pointer = allowed', () => {
// [a(10), b(10), c(10), d(10)]. a current (pointer idx0).
// b,c,d all behind pointer (upcoming). Reorder among them = safe.
let e = enc([p('a',10),p('b',10),p('c',10),p('d',10)]);
e = apply(e, startEncounter(e)); // a (idx0)
// swap b,c (both idx1,2 — behind pointer). No cross.
const r = reorderParticipants(e, 'c', 'b');
expect(r.patch).not.toBeNull();
expect(r.patch.participants.map(p => p.id)).toEqual(['a','c','b','d']);
});
test('pre-combat: free reorder (no pointer)', () => {
// isStarted=false. No pointer. Reorder allowed freely.
let e = enc([p('a',10),p('b',10),p('c',10)]);
const r = reorderParticipants(e, 'c', 'a');
expect(r.patch).not.toBeNull();
expect(r.patch.participants.map(p => p.id)).toEqual(['c','a','b']);
});
});
+54
View File
@@ -0,0 +1,54 @@
// BUG-7: reorderParticipants not logged.
// Every combat op returns { patch, log }. Handler calls logAction(log.message,
// ctx, { updates: log.undo }) → writes entry to logs collection.
// reorderParticipants returns log: null → handler skips logAction → drag
// invisible in combat log + no undo payload (siblings all have one).
//
// RED: prove log is null (bug). Fix: return { message, undo }.
'use strict';
const shared = require('@ttrpg/shared');
const { makeParticipant, reorderParticipants } = shared;
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('BUG-7: reorderParticipants logged', () => {
test('returns log object (not null) with message + undo', () => {
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
const r = reorderParticipants(e, 'c', 'b');
expect(r.patch).not.toBeNull();
expect(r.log).not.toBeNull();
expect(typeof r.log.message).toBe('string');
expect(r.log.message.length).toBeGreaterThan(0);
expect(r.log.undo).toBeDefined();
});
test('undo restores original participants order', () => {
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
const orig = e.participants.map(p => p.id);
const r = reorderParticipants(e, 'c', 'b');
const restored = { ...e, ...r.log.undo };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
test('no-op (same id) returns null log', () => {
const e = enc([p('a', 10), p('b', 10)]);
const r = reorderParticipants(e, 'a', 'a');
expect(r.log).toBeNull();
});
test('no-op (cross-init blocked) returns null log', () => {
const e = enc([p('a', 10), p('b', 5)]);
const r = reorderParticipants(e, 'a', 'b');
expect(r.patch).toBeNull();
expect(r.log).toBeNull();
});
});
+22 -44
View File
@@ -6,7 +6,6 @@ const shared = require('@ttrpg/shared');
const { const {
sortParticipantsByInitiative, sortParticipantsByInitiative,
computeTurnOrderAfterRemoval, computeTurnOrderAfterRemoval,
computeTurnOrderAfterAddition,
startEncounter, startEncounter,
nextTurn, nextTurn,
togglePause, togglePause,
@@ -213,22 +212,21 @@ describe('applyHpChange', () => {
expect(patch.participants[0].currentHp).toBe(10); expect(patch.participants[0].currentHp).toBe(10);
}); });
test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => { test('damage to 0 deactivates + keeps turn order (unified)', () => {
// FEAT-1: death no longer deactivates or removes from turn order. // Unified: death flips isActive=false (removed from active rotation).
// Dead stay in rotation, nextTurn still visits them, PCs get death-save turn. // turnOrderIds unchanged (no turn-order patch on death).
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)]; const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const { patch } = applyHpChange(e, 'a', 'damage', 5); const { patch } = applyHpChange(e, 'a', 'damage', 5);
expect(patch.participants[0].currentHp).toBe(0); expect(patch.participants[0].currentHp).toBe(0);
expect(patch.participants[0].isActive).toBe(true); expect(patch.participants[0].isActive).toBe(false);
expect(patch.turnOrderIds).toBeUndefined(); expect(patch.turnOrderIds).toBeUndefined();
expect(patch.currentTurnParticipantId).toBeUndefined(); expect(patch.currentTurnParticipantId).toBeUndefined();
}); });
test('heal above 0 resets death saves, keeps active (FEAT-1)', () => { test('heal above 0 reactivates + resets death saves (unified)', () => {
// FEAT-1: revive no longer flips isActive (was already active — death // Unified: revive from 0 flips isActive=true, deathSaves reset.
// doesn't deactivate). deathSaves still reset. const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5); const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
expect(patch.participants[0].currentHp).toBe(5); expect(patch.participants[0].currentHp).toBe(5);
expect(patch.participants[0].isActive).toBe(true); expect(patch.participants[0].isActive).toBe(true);
@@ -249,22 +247,22 @@ describe('applyHpChange', () => {
}); });
describe('deathSave', () => { describe('deathSave', () => {
test('increments saves', () => { test('increments fails', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 0 })]; const ps = [p('a', 10, { currentHp: 0, deathFails: 0 })];
const { patch } = deathSave(enc(ps), 'a', 1); const { patch } = deathSave(enc(ps), 'a', 'fail', 1);
expect(patch.participants[0].deathSaves).toBe(1); expect(patch.participants[0].deathFails).toBe(1);
}); });
test('clicking same save decrements (toggle)', () => { test('clicking same fail decrements (toggle)', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })]; const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
const { patch } = deathSave(enc(ps), 'a', 2); const { patch } = deathSave(enc(ps), 'a', 'fail', 2);
expect(patch.participants[0].deathSaves).toBe(1); expect(patch.participants[0].deathFails).toBe(1);
}); });
test('third save sets isDying', () => { test('third fail sets isDying', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })]; const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
const result = deathSave(enc(ps), 'a', 3); const result = deathSave(enc(ps), 'a', 'fail', 3);
expect(result.patch.participants[0].deathSaves).toBe(3); expect(result.patch.participants[0].deathFails).toBe(3);
expect(result.patch.participants[0].isDying).toBe(true); expect(result.patch.participants[0].isDying).toBe(true);
expect(result.isDying).toBe(true); expect(result.isDying).toBe(true);
}); });
@@ -285,17 +283,17 @@ describe('toggleCondition', () => {
}); });
describe('reorderParticipants', () => { describe('reorderParticipants', () => {
test('drag before target (1-list, cross-init allowed)', () => { test('drag before target (same-init tie)', () => {
const ps = [p('a', 10), p('b', 10), p('c', 10)]; const ps = [p('a', 10), p('b', 10), p('c', 10)];
const { patch } = reorderParticipants(enc(ps), 'a', 'c'); const { patch } = reorderParticipants(enc(ps), 'a', 'c');
// drag a before c: remove a → [b,c], insert before c → [b,a,c] // drag a before c: remove a → [b,c], insert before c → [b,a,c]
expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']); expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']);
}); });
test('cross-init drag allowed (1-list, DM override)', () => { test('cross-init drag blocked (no-op)', () => {
const ps = [p('a', 10), p('b', 5)]; const ps = [p('a', 10), p('b', 5)];
const { patch } = reorderParticipants(enc(ps), 'a', 'b'); const { patch } = reorderParticipants(enc(ps), 'a', 'b');
expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']); expect(patch).toBeNull();
}); });
}); });
@@ -327,26 +325,6 @@ describe('computeTurnOrderAfterRemoval', () => {
}); });
}); });
describe('computeTurnOrderAfterAddition', () => {
test('not started = empty', () => {
const out = computeTurnOrderAfterAddition(enc([]), 'a');
expect(out).toEqual({});
});
test('returns insertAt (1-list: caller splices + syncs)', () => {
const e = enc([p('a',3)], { isStarted: true, turnOrderIds: ['a'], participants: [p('a',3)] });
const out = computeTurnOrderAfterAddition(e, 'a');
// already present → no-op
expect(out).toEqual({});
});
test('no-op if already present', () => {
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'] });
const out = computeTurnOrderAfterAddition(e, 'a');
expect(out).toEqual({});
});
});
describe('addParticipant', () => { describe('addParticipant', () => {
test('appends participant', () => { test('appends participant', () => {
const np = p('z', 7); const np = p('z', 7);
+40 -3
View File
@@ -32,6 +32,9 @@ const CONDITIONS = [
'invisible','paralyzed','petrified','poisoned','prone','restrained', 'invisible','paralyzed','petrified','poisoned','prone','restrained',
'sapped','shield','slowed','stunned','unconscious','vexed', 'sapped','shield','slowed','stunned','unconscious','vexed',
]; ];
// Custom (non-built-in) condition ids: DM-added freeform strings.
// toggleCondition must accept ANY string (UI custom-condition contract).
const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed'];
function p(id, init, extra = {}) { function p(id, init, extra = {}) {
return makeParticipant({ return makeParticipant({
@@ -88,7 +91,8 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
let lastPaused = false; let lastPaused = false;
let lastReorder = 0; let lastReorder = 0;
let reinforcementsAdded = 0; let reinforcementsAdded = 0;
const condQueue = [...CONDITIONS]; // built-ins first, then custom (proves arbitrary string accepted)
const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS];
for (let roundN = 1; roundN <= ROUNDS; roundN++) { for (let roundN = 1; roundN <= ROUNDS; roundN++) {
const startRound = e.round; const startRound = e.round;
@@ -148,7 +152,7 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
if (living.length > 0) { if (living.length > 0) {
const tgt = pick(living); const tgt = pick(living);
const cond = pick(CONDITIONS); const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {} try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {}
} }
} }
@@ -162,7 +166,7 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
} }
// 5. deathSave // 5. deathSave
if (actor && actor.currentHp <= 0 && !actor.isNpc) { if (actor && actor.currentHp <= 0 && !actor.isNpc) {
try { e = apply(e, deathSave(e, actor.id, 1)); } catch (err) {} try { e = apply(e, deathSave(e, actor.id, 'fail', 1)); } catch (err) {}
} }
// 6. removeParticipant // 6. removeParticipant
if (totalTurns % 5 === 0) { if (totalTurns % 5 === 0) {
@@ -231,7 +235,16 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) { if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) {
violations.push({ round: roundN, type: 'hp-invalid', id: part.id, hp: part.currentHp, max: part.maxHp }); violations.push({ round: roundN, type: 'hp-invalid', id: part.id, hp: part.currentHp, max: part.maxHp });
} }
// custom conditions persisted as raw strings (not dropped)
for (const c of (part.conditions || [])) {
if (typeof c !== 'string' || !c) {
violations.push({ round: roundN, type: 'condition-not-string', id: part.id, c });
}
}
} }
// custom (freeform) conditions must survive toggle round-trip
// (presence verified via condition-not-string invariant above;
// queue drains them through toggleCondition which proves acceptance)
// revive dead between rounds // revive dead between rounds
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false); const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
@@ -254,4 +267,28 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
} }
expect(violations).toHaveLength(0); expect(violations).toHaveLength(0);
}); });
// Custom (freeform) condition strings: UI contract.
// toggleCondition must accept ANY string, not just built-ins.
test('custom condition strings survive add/remove round-trip', () => {
let e = setupEncounter();
e = apply(e, startEncounter(e));
const tgt = e.participants[0].id;
// add custom (not in built-ins)
e = apply(e, toggleCondition(e, tgt, 'hexed'));
let p = e.participants.find(x => x.id === tgt);
expect(p.conditions).toContain('hexed');
// emoji-bearing custom id
e = apply(e, toggleCondition(e, tgt, '🛡️blessed'));
p = e.participants.find(x => x.id === tgt);
expect(p.conditions).toContain('🛡️blessed');
// remove
e = apply(e, toggleCondition(e, tgt, 'hexed'));
p = e.participants.find(x => x.id === tgt);
expect(p.conditions).not.toContain('hexed');
expect(p.conditions).toContain('🛡️blessed');
});
}); });
+34 -17
View File
@@ -1,6 +1,9 @@
// M4 desired behavior: dead PC stays in turn order, turn still comes up, // Unified behavior (App main): death flips isActive=false, dead participant
// deathSave fires. Current code filters isActive (set false on death) so // removed from active rotation, skipped by nextTurn. deathSave is a manual
// dead participants are SKIPPED. Test asserts desired state = RED on current. // DM action (button), not tied to rotation. turnOrderIds unchanged on death
// (only isActive flag flips). Revive (heal from 0) reactivates.
//
// Previous "M4: dead stays in rotation" concept reversed by unification.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared; const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
@@ -24,8 +27,8 @@ function enc(ps) {
round:0, currentTurnParticipantId:null, turnOrderIds:[] }; round:0, currentTurnParticipantId:null, turnOrderIds:[] };
} }
describe('M4: dead participants stay in turn order', () => { describe('unified: death deactivates, dead skipped in rotation', () => {
test('dead PC not removed from turnOrderIds', () => { test('dead PC not removed from turnOrderIds (stays in list, inactive)', () => {
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch };
@@ -35,39 +38,53 @@ describe('M4: dead participants stay in turn order', () => {
expect(e.turnOrderIds).toEqual(orderBefore); expect(e.turnOrderIds).toEqual(orderBefore);
}); });
test('dead PC turn still comes up (nextTurn visits them)', () => { test('dead PC skipped by nextTurn (isActive=false)', () => {
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch };
// kill b // kill b
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
// advance: a→b→c. b's turn should come up. // advance: a→c (b skipped, inactive)
e = { ...e, ...nextTurn(e).patch }; e = { ...e, ...nextTurn(e).patch };
expect(e.currentTurnParticipantId).toBe('b'); expect(e.currentTurnParticipantId).toBe('c');
}); });
test('dead PC on their turn can deathSave', () => { test('dead PC deathSave fires on manual call (not via rotation)', () => {
const ps = [pc('a', 20), pc('b', 15)]; const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch };
// kill b (current = a) // kill b (current = a)
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
// advance to b's turn // b is dead: DM can still fire deathSave (manual action)
e = { ...e, ...nextTurn(e).patch }; const r = deathSave(e, 'b', 'fail', 1);
expect(e.currentTurnParticipantId).toBe('b');
// b is dead, on their turn: deathSave should not throw
const r = deathSave(e, 'b', 1);
expect(r.patch).toBeTruthy(); expect(r.patch).toBeTruthy();
const b = r.patch.participants.find(x => x.id === 'b'); const b = r.patch.participants.find(x => x.id === 'b');
expect(b.deathSaves).toBe(1); expect(b.deathFails).toBe(1);
}); });
test('dead PC not auto-set isActive=false by applyHpChange', () => { // D1 unification: shared now matches App main — death flips isActive=false.
// Dead participant removed from active rotation (skipped by nextTurn).
test('dead PC auto-set isActive=false by applyHpChange', () => {
const ps = [pc('a', 20), pc('b', 15)]; const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch };
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
const b = e.participants.find(x => x.id === 'b'); const b = e.participants.find(x => x.id === 'b');
expect(b.isActive).toBe(true); expect(b.isActive).toBe(false);
});
test('revive (heal from 0) reactivates participant', () => {
const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
const dead = e.participants.find(x => x.id === 'b');
expect(dead.isActive).toBe(false);
// heal b back up
e = { ...e, ...applyHpChange(e, 'b', 'heal', 50).patch };
const revived = e.participants.find(x => x.id === 'b');
expect(revived.isActive).toBe(true);
expect(revived.currentHp).toBe(50);
expect(revived.deathSaves).toBe(0);
}); });
}); });
+90
View File
@@ -0,0 +1,90 @@
// Death saves: broken model. Current = single counter, 3=dying. D&D 5e needs
// separate success/fail tracking:
// 3 successes = stable (0hp unconscious, safe until healed)
// 3 failures = dead
// Current tracks "saves" (really fails via red X UI), no successes at all.
// "Stable" state doesn't exist.
//
// RED: prove current can't model 3-success stability.
'use strict';
const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, applyHpChange, deathSave } = shared;
function p(id) {
return makeParticipant({ id, name: id, type: 'character',
initiative: 10, maxHp: 100, currentHp: 100 });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
describe('death saves: missing success/stable model', () => {
test('3 successes makes character stable (not dead, not dying)', () => {
// Character at 0hp. Roll 3 successful death saves.
let e = enc([p('a')]);
e = apply(e, startEncounter(e));
e = apply(e, applyHpChange(e, 'a', 'damage', 100)); // 0hp
expect(e.participants[0].currentHp).toBe(0);
// Roll 3 successful saves. Should mark stable.
e = apply(e, deathSave(e, 'a', 'success', 1));
e = apply(e, deathSave(e, 'a', 'success', 2));
const r3 = deathSave(e, 'a', 'success', 3);
e = apply(e, r3);
// RED: current deathSave signature = (enc, id, saveNumber). No type arg.
// Will throw or misbehave. Want: status field.
expect(r3.status).toBe('stable');
expect(e.participants[0].isDying).toBe(false);
expect(e.participants[0].isStable).toBe(true);
expect(e.participants[0].deathFails).toBe(0);
});
test('3 failures makes character dead (isDying for removal)', () => {
let e = enc([p('a')]);
e = apply(e, startEncounter(e));
e = apply(e, applyHpChange(e, 'a', 'damage', 100));
const r1 = deathSave(e, 'a', 'fail', 1);
e = apply(e, r1);
const r2 = deathSave(e, 'a', 'fail', 2);
e = apply(e, r2);
const r3 = deathSave(e, 'a', 'fail', 3);
e = apply(e, r3);
expect(r3.status).toBe('dead');
expect(e.participants[0].isDying).toBe(true);
expect(e.participants[0].deathFails).toBe(3);
});
test('successes and fails tracked independently', () => {
// Mixed: 1 success, 2 fails, then 2 more successes = stable.
let e = enc([p('a')]);
e = apply(e, startEncounter(e));
e = apply(e, applyHpChange(e, 'a', 'damage', 100));
e = apply(e, deathSave(e, 'a', 'success', 1));
expect(e.participants[0].deathSaves).toBe(1);
expect(e.participants[0].deathFails).toBe(0);
e = apply(e, deathSave(e, 'a', 'fail', 1));
e = apply(e, deathSave(e, 'a', 'fail', 2));
expect(e.participants[0].deathSaves).toBe(1);
expect(e.participants[0].deathFails).toBe(2);
// 2 more successes → total 3 successes → stable
e = apply(e, deathSave(e, 'a', 'success', 2));
const r = deathSave(e, 'a', 'success', 3);
expect(r.status).toBe('stable');
expect(e.participants[0].deathFails).toBe(2); // fails unchanged
});
test('deathSave signature: (enc, id, type, n)', () => {
// type = 'success' | 'fail'. n = 1|2|3.
expect(deathSave.length).toBe(4);
});
});
+13 -10
View File
@@ -82,20 +82,23 @@ describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () =
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
}); });
test('reorder cross-init: allowed, list + rotation reflect new order', () => { test('reorder cross-init: blocked (no-op)', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]); let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,b,c] e = apply(e, startEncounter(e)); // [a,b,c]
e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a const r = reorderParticipants(e, 'c', 'a'); // cross-init
expect(e.turnOrderIds).toEqual(['c','a','b']); expect(r.patch).toBeNull();
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
}); });
test('reorder: rotation follows new list order', () => { test('reorder same-init: within upcoming side of pointer follows new order', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]); let e = enc([p('a',10),p('b',10),p('c',10),p('d',3)]); // a,b,c tie
e = apply(e, startEncounter(e)); // [a,b,c], cur=a e = apply(e, startEncounter(e)); // [a,b,c,d], cur=a (idx0)
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a // Reorder c before b (both upcoming idx1,2). Within same side = allowed.
const rot = walkRotation(e); // start a, next c (wrap), next b, back a e = apply(e, reorderParticipants(e, 'c', 'b')); // [a,c,b,d], cur=a
expect(rot).toEqual(['a','c','b']); expect(e.turnOrderIds).toEqual(['a','c','b','d']);
// walk: a current, next c, next b, next d, wrap a
e = apply(e, nextTurn(e));
expect(e.currentTurnParticipantId).toBe('c');
}); });
test('toggle inactive: list unchanged (stays in rotation slot)', () => { test('toggle inactive: list unchanged (stays in rotation slot)', () => {
+203
View File
@@ -0,0 +1,203 @@
// Logging contract: every mutating combat op returns { patch, log } where
// log = { message: string, undo: object|null }. No-op (no state change)
// returns { patch: null, log: null }. Display lifecycle ops return { patch }
// only (no log field expected).
//
// logAction handler does:
// if (log) logAction(log.message, ctx, { updates: log.undo })
// → null log = skipped = gap in audit trail.
//
// Contract = every combat mutation MUST produce a log entry.
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
reorderParticipants, endEncounter,
} = shared;
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
// Helper: assert a result is a logged mutation (patch + valid log).
function expectLogged(r) {
expect(r.patch).not.toBeNull();
expect(r.log).not.toBeNull();
expect(typeof r.log).toBe('object');
expect(typeof r.log.message).toBe('string');
expect(r.log.message.length).toBeGreaterThan(0);
}
function expectNoOp(r) {
expect(r.patch).toBeNull();
expect(r.log).toBeNull();
}
describe('Logging contract: mutating ops', () => {
let e;
beforeEach(() => {
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
});
test('startEncounter logs', () => {
const r = startEncounter(e);
expectLogged(r);
});
test('nextTurn logs', () => {
const started = apply(e, startEncounter(e));
const r = nextTurn(started);
expectLogged(r);
});
test('togglePause logs', () => {
const r = togglePause(apply(e, startEncounter(e)));
expectLogged(r);
});
test('addParticipant logs', () => {
const r = addParticipant(e, p('d', 5));
expectLogged(r);
});
test('removeParticipant logs', () => {
const r = removeParticipant(e, 'b');
expectLogged(r);
});
test('toggleParticipantActive logs', () => {
const r = toggleParticipantActive(e, 'b');
expectLogged(r);
});
test('applyHpChange (damage) logs', () => {
const r = applyHpChange(e, 'b', 'damage', 10);
expectLogged(r);
});
test('applyHpChange (heal) logs', () => {
const r = applyHpChange(e, 'b', 'heal', 5);
expectLogged(r);
});
test('deathSave logs', () => {
const started = apply(e, startEncounter(e));
const dying = apply(started, applyHpChange(started, 'b', 'damage', 100));
const r = deathSave(dying, 'b', 'fail', 1);
expectLogged(r);
});
test('toggleCondition logs', () => {
const r = toggleCondition(e, 'b', 'poisoned');
expectLogged(r);
});
test('reorderParticipants logs (BUG-7)', () => {
// same-init tie (both 10) for valid reorder
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
const r = reorderParticipants(e2, 'x', 'a');
expectLogged(r);
});
test('endEncounter logs', () => {
const started = apply(e, startEncounter(e));
const r = endEncounter(started);
expectLogged(r);
});
});
describe('Logging contract: no-ops', () => {
let e;
beforeEach(() => {
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
});
test('reorder same-id = no-op', () => {
expectNoOp(reorderParticipants(e, 'a', 'a'));
});
test('reorder cross-init = no-op', () => {
expectNoOp(reorderParticipants(e, 'a', 'b'));
});
});
describe('Logging undo payloads', () => {
let e;
beforeEach(() => {
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
});
test('startEncounter undo restores pre-combat state', () => {
const r = startEncounter(e);
expect(r.log.undo).toBeDefined();
expect(r.log.undo.isStarted).toBe(false);
});
test('endEncounter undo restores combat state', () => {
const started = apply(e, startEncounter(e));
const r = endEncounter(started);
expect(r.log.undo).toBeDefined();
expect(r.log.undo.isStarted).toBe(true);
});
test('applyHpChange undo restores prior hp', () => {
const r = applyHpChange(e, 'b', 'damage', 10);
expect(r.log.undo).toBeDefined();
expect(r.log.undo.participants).toBeDefined();
});
test('reorder undo restores prior order (BUG-7)', () => {
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
const orig = e2.participants.map(p => p.id);
const r = reorderParticipants(e2, 'x', 'a');
expect(r.log.undo).toBeDefined();
const restored = { ...e2, ...r.log.undo };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
});
describe('Logging: addParticipants + updateParticipant', () => {
let e;
beforeEach(() => {
e = enc([p('a', 10), p('b', 7)]);
});
test('addParticipants logs', () => {
const r = addParticipants(e, [p('c', 3)]);
expectLogged(r);
});
test('updateParticipant (same slot) logs', () => {
const r = updateParticipant(e, 'b', { name: 'B' });
expectLogged(r);
});
test('updateParticipant (init change) logs', () => {
const r = updateParticipant(e, 'b', { initiative: 5 });
expectLogged(r);
});
test('addParticipants undo restores prior list', () => {
const orig = e.participants.map(p => p.id);
const r = addParticipants(e, [p('c', 3)]);
const restored = { ...e, ...r.log.undo };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
test('updateParticipant undo restores prior participant', () => {
const orig = e.participants.map(p => p.id);
const r = updateParticipant(e, 'b', { name: 'B' });
const restored = { ...e, ...r.log.undo };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
});
+16 -18
View File
@@ -18,23 +18,20 @@ function enc(ps) {
} }
describe('reorderParticipants', () => { describe('reorderParticipants', () => {
test('drag before target (1-list model)', () => { test('drag before target (1-list model, pre-combat)', () => {
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie
let e = enc(ps); let e = enc(ps); // pre-combat, no pointer
e = { ...e, ...startEncounter(e).patch };
// initial order: b,c,a (init 20,20,10)
expect(e.turnOrderIds).toEqual(['b', 'c', 'a']);
const r = reorderParticipants(e, 'c', 'b'); const r = reorderParticipants(e, 'c', 'b');
// drag c before b: remove c → [b,a], insert before b → [c,b,a] // drag c before b: remove c → [a,b], insert before b → [a,c,b]
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']); expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'c', 'b']);
}); });
test('cross-init drag allowed (1-list, DM override)', () => { test('cross-init drag blocked (no-op)', () => {
const ps = [p('a', 10), p('b', 20)]; const ps = [p('a', 10), p('b', 20)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; // [b,a] e = { ...e, ...startEncounter(e).patch }; // [b,a]
const r = reorderParticipants(e, 'a', 'b'); const r = reorderParticipants(e, 'a', 'b');
expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'b']); expect(r.patch).toBeNull();
}); });
test('throws if id not found', () => { test('throws if id not found', () => {
@@ -47,21 +44,22 @@ describe('reorderParticipants', () => {
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => { test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => {
const ps = [p('a', 10), p('b', 20), p('c', 20)]; const ps = [p('a', 10), p('b', 20), p('c', 20)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch }; // started, but reorder pre-pointer advance
const r = reorderParticipants(e, 'c', 'b'); // startEncounter sets current=b (idx0). reorder c before b crosses pointer.
expect(r.patch.turnOrderIds).toEqual(['c', 'b', 'a']); // Use pre-combat to test syncTurnOrder.
expect(r.patch.turnOrderIds).toEqual(r.patch.participants.map(p => p.id)); let pre = enc(ps);
pre = { ...pre, ...reorderParticipants(pre, 'c', 'b').patch };
expect(pre.turnOrderIds).toEqual(['a','c','b']);
expect(pre.turnOrderIds).toEqual(pre.participants.map(p => p.id));
}); });
// BUG-6 candidate: reorder should affect turnOrderIds so mid-combat // BUG-6 candidate: reorder should affect turnOrderIds so mid-combat
// drag-drop changes who goes next within same-initiative tie. // drag-drop changes who goes next within same-initiative tie.
// Currently RED (turnOrderIds not in patch). // Currently RED (turnOrderIds not in patch).
test('reorder updates turnOrderIds to reflect new participant order', () => { test('reorder updates turnOrderIds to reflect new participant order (pre-combat)', () => {
const ps = [p('a', 10), p('b', 20), p('c', 20)]; const ps = [p('a', 10), p('b', 20), p('c', 20)];
let e = enc(ps); let e = enc(ps); // pre-combat, no pointer
e = { ...e, ...startEncounter(e).patch };
// order: b,c,a
e = { ...e, ...reorderParticipants(e, 'c', 'b').patch }; e = { ...e, ...reorderParticipants(e, 'c', 'b').patch };
expect(e.turnOrderIds).toEqual(['c', 'b', 'a']); expect(e.turnOrderIds).toEqual(['a', 'c', 'b']);
}); });
}); });
+77
View File
@@ -0,0 +1,77 @@
// SLOT-NOT-SORT invariant: addParticipant + updateParticipant must PRESERVE
// manually-dragged tie order (same-initiative participants).
//
// Design doc (docs/INITIATIVE_ORDERING.md):
// "Re-slotting on add/edit must preserve drag-established tie order."
// "Tie-break = original add order. Later additions slot AFTER existing
// same-init participants. Stable insertion."
//
// Current code uses sortParticipantsByInitiative (stable sort, tie-break =
// original array index). That is NOT stable insertion: it re-sorts the whole
// list, destroying drag order when a drag moved a same-init pair.
//
// RED now: drag tie order survives neither add nor edit.
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant,
startEncounter, addParticipant, updateParticipant, reorderParticipants,
} = shared;
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
describe('SLOT-NOT-SORT: drag tie order survives add/edit', () => {
test('addParticipant preserves existing drag tie order', () => {
// Two same-init participants. DM drags b BEFORE a (tie override).
let e = enc([p('a', 10), p('b', 10)]);
e = apply(e, reorderParticipants(e, 'b', 'a')); // drag b ahead of a → [b,a]
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
// Add unrelated participant (different init). Tie order [b,a] MUST survive.
e = apply(e, addParticipant(e, p('c', 5)));
const ids = e.participants.map(x => x.id);
// c slots last (init 5 < 10). b,a tie order preserved.
expect(ids).toEqual(['b', 'a', 'c']);
});
test('addParticipant with same init as dragged pair slots AFTER tie group', () => {
// DM drags b before a (both init 10). Then add d also init 10.
// d must slot AFTER existing same-init pair (stable insertion), not re-sort.
let e = enc([p('a', 10), p('b', 10)]);
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a]
e = apply(e, addParticipant(e, p('d', 10))); // init 10, after tie group
const ids = e.participants.map(x => x.id);
expect(ids).toEqual(['b', 'a', 'd']);
});
test('updateParticipant preserves drag tie order on unrelated field edit', () => {
let e = enc([p('a', 10), p('b', 10)]);
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a]
// Edit a's name (not initiative). Tie order MUST survive.
e = apply(e, updateParticipant(e, 'a', { name: 'A2' }));
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
});
test('updateParticipant init change slots, preserves OTHER tie group order', () => {
// Two tie groups: [a,b] init 10, [c,d] init 5. DM drags b before a.
let e = enc([p('a', 10), p('b', 10), p('c', 5), p('d', 5)]);
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c,d]
// Edit c's initiative to 10 — slots into top group. [c] order within its
// own old group irrelevant (it leaves that group). But [b,a] tie MUST stay.
e = apply(e, updateParticipant(e, 'c', { initiative: 10 }));
const topTwo = e.participants.filter(x => x.initiative === 10).map(x => x.id);
// b before a preserved. c joins the 10-group (exact slot less critical
// than b,a order surviving).
expect(topTwo.indexOf('b')).toBeLessThan(topTwo.indexOf('a'));
});
});
+192 -79
View File
@@ -31,9 +31,18 @@ const formatInitMod = (mod) => {
return mod >= 0 ? `+${mod}` : `${mod}`; return mod >= 0 ? `+${mod}` : `${mod}`;
}; };
// Sort used ONLY at insert points (startEncounter, addParticipant) to position // SLOT, NEVER SORT. 1-list model (docs/INITIATIVE_ORDERING.md).
// participants by initiative. Once positioned, turnOrderIds = participants.map(id) //
// (1-list model). No re-sort after start — drag/edit are manual overrides. // `sortParticipantsByInitiative` exists for ONE call site: startEncounter
// (freeze list once by init). After that, mutations = insert/move ops that
// PRESERVE existing array order. No wholesale re-sort ever — destroys drag
// tie-break order.
//
// slotIndexForInit: pure insert position. Scans existing list, returns index
// of first participant with init STRICTLY LESS than target. New participant
// splices there. Same-init participants stay ahead (stable). Existing tie
// order (incl. drag-established) untouched because scan reads current array,
// not original add-order.
const sortParticipantsByInitiative = (participants, originalOrder) => { const sortParticipantsByInitiative = (participants, originalOrder) => {
return [...participants].sort((a, b) => { return [...participants].sort((a, b) => {
if (a.initiative === b.initiative) { if (a.initiative === b.initiative) {
@@ -45,6 +54,17 @@ const sortParticipantsByInitiative = (participants, originalOrder) => {
}); });
}; };
// Find splice index for a single participant by initiative. List assumed
// already initiative-descending (startEncounter freezes it; add/edit maintain).
// Returns position to insert so list stays descending, ties go AFTER existing
// same-init (stable insertion). Current array order = source of truth.
function slotIndexForInit(list, init) {
for (let i = 0; i < list.length; i++) {
if (init > list[i].initiative) return i;
}
return list.length;
}
// 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id). // 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id).
// Call after any participants[] mutation. Returns turnOrderIds patch. // Call after any participants[] mutation. Returns turnOrderIds patch.
const syncTurnOrder = (participants) => ({ const syncTurnOrder = (participants) => ({
@@ -94,26 +114,6 @@ const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants)
// current pointer — no re-sort anywhere except startEncounter. // current pointer — no re-sort anywhere except startEncounter.
// Tie rule: insert AFTER existing same-init (preserves creation order). // Tie rule: insert AFTER existing same-init (preserves creation order).
// NOTE: 1-list model — caller syncs participants[] in same pos as insert target. // NOTE: 1-list model — caller syncs participants[] in same pos as insert target.
const computeTurnOrderAfterAddition = (encounter, addedId) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
if (currentIds.includes(addedId)) return {};
const added = (encounter.participants || []).find(p => p.id === addedId);
if (!added) return {};
// find first id with strictly lower initiative; insert before it (== after all >= )
const initOf = id => {
const p = (encounter.participants || []).find(x => x.id === id);
return p ? (p.initiative || 0) : 0;
};
const addedInit = added.initiative || 0;
let insertAt = currentIds.length;
for (let i = 0; i < currentIds.length; i++) {
if (initOf(currentIds[i]) < addedInit) { insertAt = i; break; }
}
return { insertAt }; // caller splices participants[] at this pos, then syncs
};
// ----------------------------------------------------------------------------
// Participant factory (mirrors ParticipantManager.handleAddParticipant shape) // Participant factory (mirrors ParticipantManager.handleAddParticipant shape)
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -129,7 +129,9 @@ function makeParticipant(opts) {
isNpc: opts.isNpc || false, isNpc: opts.isNpc || false,
conditions: opts.conditions || [], conditions: opts.conditions || [],
isActive: opts.isActive !== undefined ? opts.isActive : true, isActive: opts.isActive !== undefined ? opts.isActive : true,
deathSaves: opts.deathSaves || 0, deathSaves: opts.deathSaves || 0, // successes (0-3)
deathFails: opts.deathFails || 0, // failures (0-3)
isStable: opts.isStable || false,
isDying: opts.isDying || false, isDying: opts.isDying || false,
}; };
} }
@@ -306,31 +308,19 @@ function togglePause(encounter) {
// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.) // ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.)
// If encounter already started, also slot participant into turnOrderIds by // If encounter already started, also slot participant into turnOrderIds by
// initiative (via computeTurnOrderAfterAddition). // initiative (slotIndexForInit). 1-list model.
function addParticipant(encounter, participant) { function addParticipant(encounter, participant) {
if ((encounter.participants || []).some(p => p.id === participant.id)) { if ((encounter.participants || []).some(p => p.id === participant.id)) {
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`); throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
} }
// 1-list: splice participant into participants[] by initiative position, // SLOT (not sort): insert by initiative into current list. Preserves
// then sync turnOrderIds = participants.map(id). // existing array order incl. drag-established tie order.
let updatedParticipants; const base = [...(encounter.participants || [])];
let insertAt; const idx = slotIndexForInit(base, participant.initiative);
if (!encounter.isStarted) { base.splice(idx, 0, participant);
updatedParticipants = [...(encounter.participants || []), participant]; const updatedParticipants = base;
} else {
const { insertAt: at } = computeTurnOrderAfterAddition(
{ ...encounter, participants: [...(encounter.participants || []), participant] },
participant.id);
insertAt = at !== undefined ? at : (encounter.participants || []).length;
updatedParticipants = [
...(encounter.participants || []).slice(0, insertAt),
participant,
...(encounter.participants || []).slice(insertAt),
];
}
const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {};
return { return {
patch: { participants: updatedParticipants, ...turnUpdates }, patch: { participants: updatedParticipants, ...syncTurnOrder(updatedParticipants) },
log: { log: {
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`, message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
undo: { undo: {
@@ -345,16 +335,49 @@ function addParticipant(encounter, participant) {
// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters"). // ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
function addParticipants(encounter, newParticipants) { function addParticipants(encounter, newParticipants) {
const undo = { participants: encounter.participants || [] };
const updatedParticipants = [...(encounter.participants || []), ...newParticipants]; const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
return { patch: { participants: updatedParticipants }, log: null }; const names = newParticipants.map(p => p.name).join(', ');
return {
patch: { participants: updatedParticipants },
log: {
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
undo,
},
};
} }
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc). // UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
function updateParticipant(encounter, participantId, updatedData) { function updateParticipant(encounter, participantId, updatedData) {
const updatedParticipants = (encounter.participants || []).map(p => const existing = encounter.participants || [];
p.id === participantId ? { ...p, ...updatedData } : p const target = existing.find(p => p.id === participantId);
); if (!target) throw new Error(`Participant "${participantId}" not found.`);
return { patch: { participants: updatedParticipants }, log: null }; const merged = { ...target, ...updatedData };
// SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no
// position change (design doc). Re-slots on unrelated edits shuffle the
// list mid-round → rotation dupes.
const undo = { participants: encounter.participants || [] };
if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) {
const sameSlot = existing.map(p => p.id === participantId ? merged : p);
const fields = Object.keys(updatedData).join(', ');
return {
patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) },
log: {
message: `${target.name} updated (${fields})`,
undo,
},
};
}
const without = existing.filter(p => p.id !== participantId);
const idx = slotIndexForInit(without, merged.initiative);
without.splice(idx, 0, merged);
return {
patch: { participants: without, ...syncTurnOrder(without) },
log: {
message: `${target.name} updated (initiative: ${merged.initiative})`,
undo,
},
};
} }
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant // REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
@@ -427,24 +450,24 @@ function applyHpChange(encounter, participantId, changeType, amount) {
const isDead = newHp === 0; const isDead = newHp === 0;
const wasResurrected = wasDead && newHp > 0; const wasResurrected = wasDead && newHp > 0;
// FEAT-1: death no longer flips isActive or touches turnOrderIds. // Unified (App main): death flips isActive=false (removed from active
// Dead participants stay in turn order, nextTurn still visits them, PCs // rotation, skipped by nextTurn). Revive flips true. No turnOrderIds change.
// get their death-save turn. isActive = DM-controlled combatant toggle only.
const updatedParticipants = (encounter.participants || []).map(p => { const updatedParticipants = (encounter.participants || []).map(p => {
if (p.id !== participantId) return p; if (p.id !== participantId) return p;
const updates = { ...p, currentHp: newHp }; const updates = { ...p, currentHp: newHp };
if (isDead && !wasDead) { if (isDead && !wasDead) {
updates.isActive = false;
updates.deathSaves = p.deathSaves || 0; updates.deathSaves = p.deathSaves || 0;
updates.isDying = false; updates.isDying = false;
} }
if (wasResurrected) { if (wasResurrected) {
updates.isActive = true;
updates.deathSaves = 0; updates.deathSaves = 0;
updates.isDying = false; updates.isDying = false;
} }
return updates; return updates;
}); });
// No turn-order updates on death/revive (FEAT-1).
const turnUpdates = {}; const turnUpdates = {};
const hpLine = `${participant.currentHp}${newHp} HP`; const hpLine = `${participant.currentHp}${newHp} HP`;
@@ -471,30 +494,64 @@ function applyHpChange(encounter, participantId, changeType, amount) {
}; };
} }
// DEATH_SAVE — verbatim from ParticipantManager.handleDeathSaveChange // DEATH_SAVE — D&D 5e model. Separate success/fail tracking.
// saveNumber: 1 | 2 | 3. Returns isDying flag if 3rd save hit (client triggers removal animation). // type: 'success' | 'fail'
function deathSave(encounter, participantId, saveNumber) { // n: 1 | 2 | 3 (which pip clicked)
// 3 successes = stable (0hp unconscious, safe). 3 fails = dead (isDying,
// caller animates removal). Toggling same pip = undo that pip.
// Returns { patch, log, status } where status: 'stable'|'dead'|'pending'.
function deathSave(encounter, participantId, type, n) {
const participant = (encounter.participants || []).find(p => p.id === participantId); const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.'); if (!participant) throw new Error('Participant not found.');
const currentSaves = participant.deathSaves || 0; if (type !== 'success' && type !== 'fail') {
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber; throw new Error(`deathSave type must be 'success' or 'fail', got "${type}".`);
}
const name = participant.name;
const undo = { participants: encounter.participants || [] };
if (newSaves === 3) { // Toggle: clicking current-value pip decrements (undo that pip).
// Mark dying — caller waits for animation, then calls removeParticipant. const current = type === 'success'
const updatedParticipants = (encounter.participants || []).map(p => ? (participant.deathSaves || 0)
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p : (participant.deathFails || 0);
); const next = current === n ? n - 1 : n;
return {
patch: { participants: updatedParticipants }, const field = type === 'success' ? 'deathSaves' : 'deathFails';
log: null, const updates = { [field]: next };
isDying: true,
}; // Clear terminal flags on any change (re-eval below).
let status = 'pending';
const newSuccesses = type === 'success' ? next : (participant.deathSaves || 0);
const newFails = type === 'fail' ? next : (participant.deathFails || 0);
if (newSuccesses >= 3) {
updates.isStable = true;
updates.isDying = false;
status = 'stable';
} else if (newFails >= 3) {
updates.isStable = false;
updates.isDying = true;
status = 'dead';
} else {
updates.isStable = false;
updates.isDying = false;
} }
const updatedParticipants = (encounter.participants || []).map(p => const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves } : p p.id === participantId ? { ...p, ...updates } : p
); );
return { patch: { participants: updatedParticipants }, log: null, isDying: false };
const message = status === 'stable'
? `${name} stabilized (3 death saves)`
: status === 'dead'
? `${name} failed 3 death saves — dead`
: `${name} death ${type}: ${next}/3`;
return {
patch: { participants: updatedParticipants },
log: { message, undo },
status,
isDying: status === 'dead', // back-compat for App.js handler
};
} }
// TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition // TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition
@@ -517,21 +574,54 @@ function toggleCondition(encounter, participantId, conditionId) {
}; };
} }
// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative // REORDER_PARTICIPANTS — drag. Same-initiative ONLY (tie-break override).
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds. // Cross-init drag = no-op (use edit-initiative field instead). Splice move.
// Cross-pointer drag = no-op once encounter started (BUG-13). nextTurn walks
// turnOrderIds forward from current pointer. Dragging a not-yet-acted
// participant to a position behind the pointer would skip them (wrap past).
// Dragging an already-acted participant ahead of pointer would re-act them.
// Blocking cross-pointer keeps rotation deterministic. Pre-combat: free.
function reorderParticipants(encounter, draggedId, targetId) { function reorderParticipants(encounter, draggedId, targetId) {
const participants = [...(encounter.participants || [])]; const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId);
const target = participants.find(p => p.id === targetId);
if (!dragged || !target) throw new Error('Dragged or target item not found.');
if (draggedId === targetId) return { patch: null, log: null };
if (dragged.initiative !== target.initiative) {
return { patch: null, log: null }; // cross-init blocked
}
const draggedIndex = participants.findIndex(p => p.id === draggedId); const draggedIndex = participants.findIndex(p => p.id === draggedId);
const targetIndex = participants.findIndex(p => p.id === targetId); // BUG-13: block cross-pointer reorder while encounter running. nextTurn
if (draggedIndex === -1 || targetIndex === -1) { // walks turnOrderIds forward from current pointer. Dragging across the
throw new Error('Dragged or target item not found.'); // pointer makes who-acts-next ambiguous — either skip (upcoming dragged
// ahead of pointer, walk wraps past) or double-act (acted dragged behind).
// Full fix needs actedThisRound tracking. Pragmatic now: block both dirs.
// Pre-combat: free reorder (no pointer yet).
if (encounter.isStarted && encounter.currentTurnParticipantId) {
const pointerIdx = participants.findIndex(p => p.id === encounter.currentTurnParticipantId);
const targetIdx0 = participants.findIndex(p => p.id === targetId);
if (pointerIdx >= 0) {
const crosses = (a, b) => (a <= pointerIdx && b > pointerIdx) || (a > pointerIdx && b <= pointerIdx);
if (crosses(draggedIndex, targetIdx0)) {
return { patch: null, log: null }; // cross-pointer blocked
}
}
} }
const [removedItem] = participants.splice(draggedIndex, 1); const [removedItem] = participants.splice(draggedIndex, 1);
// recompute targetIndex after removal (shift if dragged was before target)
const newTargetIndex = participants.findIndex(p => p.id === targetId); const newTargetIndex = participants.findIndex(p => p.id === targetId);
participants.splice(newTargetIndex, 0, removedItem); participants.splice(newTargetIndex, 0, removedItem);
const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {}; const turnUpdates = syncTurnOrder(participants); // 1-list: always mirror
return { patch: { participants, ...turnUpdates }, log: null }; return {
patch: { participants, ...turnUpdates },
log: {
message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`,
undo: {
participants: encounter.participants || [],
turnOrderIds: encounter.turnOrderIds || [],
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
},
},
};
} }
// END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter // END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter
@@ -557,6 +647,27 @@ function endEncounter(encounter) {
}; };
} }
// ----------------------------------------------------------------------------
// Display lifecycle (activeDisplay/status doc). Pure patches, same shape as
// App's inline storage.updateDoc(getPath.activeDisplay(), ...). Single source.
// Callers persist patch to the activeDisplay/status doc.
// ----------------------------------------------------------------------------
// ACTIVATE_DISPLAY — start combat: point player display at this encounter.
function activateDisplay({ campaignId, encounterId }) {
return { patch: { activeCampaignId: campaignId, activeEncounterId: encounterId } };
}
// CLEAR_DISPLAY — end combat: player display shows nothing.
function clearDisplay() {
return { patch: { activeCampaignId: null, activeEncounterId: null } };
}
// TOGGLE_HIDE_PLAYER_HP — flip player-HP visibility on display.
function toggleHidePlayerHp(currentValue) {
return { patch: { hidePlayerHp: !currentValue } };
}
module.exports = { module.exports = {
DEFAULT_MAX_HP, DEFAULT_MAX_HP,
DEFAULT_INIT_MOD, DEFAULT_INIT_MOD,
@@ -567,7 +678,6 @@ module.exports = {
sortParticipantsByInitiative, sortParticipantsByInitiative,
syncTurnOrder, syncTurnOrder,
computeTurnOrderAfterRemoval, computeTurnOrderAfterRemoval,
computeTurnOrderAfterAddition,
makeParticipant, makeParticipant,
buildCharacterParticipant, buildCharacterParticipant,
buildMonsterParticipant, buildMonsterParticipant,
@@ -584,4 +694,7 @@ module.exports = {
toggleCondition, toggleCondition,
reorderParticipants, reorderParticipants,
endEncounter, endEncounter,
activateDisplay,
clearDisplay,
toggleHidePlayerHp,
}; };
+439 -456
View File
File diff suppressed because it is too large Load Diff
+25 -3
View File
@@ -38,11 +38,33 @@ export const MOCK_DB = {
return () => state.subscribers.get(path)?.delete(cb); return () => state.subscribers.get(path)?.delete(cb);
}, },
_notify(path) { _notify(path) {
// notify exact doc path subscribers // notify exact doc path subscribers (wrapped in act for test isolation).
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => cb()); // Set flag true around cb: testing-library asyncWrapper (waitFor) flips it
// false during poll, which makes raw react act() warn 'not configured'.
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => {
try {
const { act } = require('react');
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
act(() => cb());
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
} catch {
cb();
}
});
// notify parent collection subscribers // notify parent collection subscribers
const parent = path.split('/').slice(0, -1).join('/'); const parent = path.split('/').slice(0, -1).join('/');
if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => cb()); if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => {
try {
const { act } = require('react');
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
act(() => cb());
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
} catch {
cb();
}
});
}, },
nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); }, nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); },
_state: state, _state: state,
+29 -2
View File
@@ -19,7 +19,11 @@ export function limit(n) { return { __type: 'limit', n }; }
// writes // writes
export async function setDoc(docRef, data, opts) { export async function setDoc(docRef, data, opts) {
recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null }); recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null });
MOCK_DB.set(docRef.path, clone(data)); if (opts && opts.merge) {
MOCK_DB.merge(docRef.path, clone(data));
} else {
MOCK_DB.set(docRef.path, clone(data));
}
return undefined; return undefined;
} }
export async function updateDoc(docRef, patch) { export async function updateDoc(docRef, patch) {
@@ -70,6 +74,7 @@ export async function getDocs(collRefOrQuery) {
// realtime — emit from mock DB, capture unsub // realtime — emit from mock DB, capture unsub
export function onSnapshot(refOrQuery, onSuccess, onError) { export function onSnapshot(refOrQuery, onSuccess, onError) {
const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path); const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
const constraints = refOrQuery.constraints || [];
// fire immediately with current state // fire immediately with current state
const emit = () => { const emit = () => {
if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) { if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) {
@@ -80,7 +85,8 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
data: () => data, data: () => data,
}); });
} else { } else {
const docs = MOCK_DB.collection(path); let docs = MOCK_DB.collection(path);
docs = applyConstraints(docs, constraints);
onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) }); onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) });
} }
}; };
@@ -90,6 +96,27 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
return unsub; return unsub;
} }
// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs.
// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit
// supported (App's LOG_QUERY uses exactly these).
function applyConstraints(docs, constraints) {
let out = [...docs];
for (const c of constraints) {
if (c.__type === 'orderBy') {
out.sort((a, b) => {
const av = a.data[c.field];
const bv = b.data[c.field];
if (av === bv) return 0;
const cmp = av > bv ? 1 : -1;
return c.dir === 'desc' ? -cmp : cmp;
});
} else if (c.__type === 'limit') {
out = out.slice(0, c.n);
}
}
return out;
}
function clone(v) { function clone(v) {
if (v === null || v === undefined) return v; if (v === null || v === undefined) return v;
return JSON.parse(JSON.stringify(v)); return JSON.parse(JSON.stringify(v));
+25
View File
@@ -2,6 +2,31 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { resetMockDb } from './__mocks__/firebase/_mock-db'; import { resetMockDb } from './__mocks__/firebase/_mock-db';
// RTL v14: tell React this is an act environment. Without it every
// async update warns "current testing environment is not configured to
// support act(...)". RTL reads getGlobalThis(), so set on globalThis.
// Must be set before any component renders.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
// Test timeout guard. CRA blocks `testTimeout` in package.json jest config
// (not in allowlist). Set via jest global here instead. Fails fast on hang.
jest.setTimeout(10000);
// WARNING = FAILURE. Fail test on any console.error/warn (React act warnings,
// deprecations, prop errors). App code's own error logs allowed only inside
// try/catch failure paths — those tests should assert the error was handled,
// not silently log it. Override the spies to throw.
const originalError = console.error;
const originalWarn = console.warn;
console.error = (...args) => {
originalError(...args);
throw new Error(`console.error in test: ${args.map(String).join(' ').slice(0, 500)}`);
};
console.warn = (...args) => {
originalWarn(...args);
throw new Error(`console.warn in test: ${args.map(String).join(' ').slice(0, 500)}`);
};
// polyfill crypto.randomUUID for jsdom (used by generateId in App.js). // polyfill crypto.randomUUID for jsdom (used by generateId in App.js).
if (!global.crypto) global.crypto = {}; if (!global.crypto) global.crypto = {};
if (!global.crypto.randomUUID) { if (!global.crypto.randomUUID) {
+83 -32
View File
@@ -27,10 +27,10 @@ function runStorageContract(name, factory) {
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); }); afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
describe('getDoc / setDoc', () => { describe('getDoc / setDoc', () => {
test('setDoc then getDoc returns the doc', async () => { test('setDoc then getDoc returns the doc (with id)', async () => {
await storage.setDoc('campaigns/a', { name: 'Alpha' }); await storage.setDoc('campaigns/a', { name: 'Alpha' });
const doc = await storage.getDoc('campaigns/a'); const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ name: 'Alpha' }); expect(doc).toEqual({ id: 'a', name: 'Alpha' });
}); });
test('getDoc on missing path returns null', async () => { test('getDoc on missing path returns null', async () => {
@@ -42,7 +42,15 @@ function runStorageContract(name, factory) {
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] }); await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
await storage.setDoc('campaigns/a', { name: 'Beta' }); await storage.setDoc('campaigns/a', { name: 'Beta' });
const doc = await storage.getDoc('campaigns/a'); const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ name: 'Beta' }); expect(doc).toEqual({ id: 'a', name: 'Beta' });
});
// main L1624: setDoc(path, data, {merge:true}) — merge flag.
test('setDoc with {merge:true} merges into existing doc', async () => {
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
await storage.setDoc('campaigns/a', { players: [1] }, { merge: true });
const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1] });
}); });
}); });
@@ -51,13 +59,13 @@ function runStorageContract(name, factory) {
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] }); await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] });
await storage.updateDoc('campaigns/a', { players: [1, 2] }); await storage.updateDoc('campaigns/a', { players: [1, 2] });
const doc = await storage.getDoc('campaigns/a'); const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ name: 'Alpha', players: [1, 2] }); expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1, 2] });
}); });
test('updateDoc on missing doc creates it', async () => { test('updateDoc on missing doc creates it', async () => {
await storage.updateDoc('campaigns/a', { name: 'Alpha' }); await storage.updateDoc('campaigns/a', { name: 'Alpha' });
const doc = await storage.getDoc('campaigns/a'); const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ name: 'Alpha' }); expect(doc).toEqual({ id: 'a', name: 'Alpha' });
}); });
}); });
@@ -79,7 +87,7 @@ function runStorageContract(name, factory) {
expect(id).toBeTruthy(); expect(id).toBeTruthy();
expect(path).toBe(`campaigns/a/encounters/${id}`); expect(path).toBe(`campaigns/a/encounters/${id}`);
const doc = await storage.getDoc(path); const doc = await storage.getDoc(path);
expect(doc).toEqual({ name: 'E1' }); expect(doc).toEqual({ id, name: 'E1' });
}); });
test('two addDocs produce distinct ids', async () => { test('two addDocs produce distinct ids', async () => {
@@ -91,33 +99,15 @@ function runStorageContract(name, factory) {
describe('firebase-prefixed path identity', () => { describe('firebase-prefixed path identity', () => {
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...). // App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
// Adapter must normalize internally so write+read at prefixed path round-trips // main prod truth: ALWAYS prefixed. Write+read same prefixed round-trips.
// AND collection queries at bare canonical path find prefixed-written docs. // Cross bare<->prefixed lookup is NOT required by main (App never does it).
// Catches replay-script bug (wrote prefixed, adapter reads bare, missed). // Kept as same-prefix roundtrip only — matches prod.
const PREFIX = 'artifacts/test-app/public/data'; const PREFIX = 'artifacts/test-app/public/data';
test('setDoc prefixed then getCollection bare finds it', async () => { test('setDoc prefixed then getDoc same prefixed path returns it (id)', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c1`, { name: 'P1' });
const docs = await storage.getCollection('campaigns');
expect(docs.some(d => d.name === 'P1')).toBe(true);
});
test('setDoc prefixed then getDoc same prefixed path returns it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' }); await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`); const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
expect(doc).toEqual({ name: 'P2' }); expect(doc).toEqual({ id: 'c2', name: 'P2' });
});
test('setDoc prefixed then getDoc bare path returns it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c3`, { name: 'P3' });
const doc = await storage.getDoc('campaigns/c3');
expect(doc).toEqual({ name: 'P3' });
});
test('setDoc bare then getCollection prefixed finds it', async () => {
await storage.setDoc('campaigns/c4', { name: 'P4' });
const docs = await storage.getCollection(`${PREFIX}/campaigns`);
expect(docs.some(d => d.name === 'P4')).toBe(true);
}); });
}); });
@@ -165,7 +155,29 @@ function runStorageContract(name, factory) {
{ type: 'delete', path: 'campaigns/a' }, { type: 'delete', path: 'campaigns/a' },
]); ]);
expect(await storage.getDoc('campaigns/a')).toBeNull(); expect(await storage.getDoc('campaigns/a')).toBeNull();
expect(await storage.getDoc('campaigns/b')).toEqual({ name: 'B' }); expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
});
// set-only batch (not used in main currently, but interface allows).
test('applies set-only batch', async () => {
await storage.batchWrite([
{ type: 'set', path: 'campaigns/a', data: { name: 'A' } },
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
]);
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A' });
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
});
// update-only batch (interface allows).
test('applies update-only batch', async () => {
await storage.setDoc('campaigns/a', { name: 'A', hp: 10 });
await storage.setDoc('campaigns/b', { name: 'B', hp: 5 });
await storage.batchWrite([
{ type: 'update', path: 'campaigns/a', data: { hp: 8 } },
{ type: 'update', path: 'campaigns/b', data: { hp: 2 } },
]);
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A', hp: 8 });
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B', hp: 2 });
}); });
}); });
@@ -176,7 +188,7 @@ function runStorageContract(name, factory) {
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc)); storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
await flush(); await flush();
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({ name: 'Alpha' }); expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' });
}); });
test('fires cb on subsequent change', async () => { test('fires cb on subsequent change', async () => {
@@ -186,7 +198,7 @@ function runStorageContract(name, factory) {
await storage.setDoc('campaigns/a', { name: 'Alpha' }); await storage.setDoc('campaigns/a', { name: 'Alpha' });
await flush(); await flush();
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last).toEqual({ name: 'Alpha' }); expect(last).toEqual({ id: 'a', name: 'Alpha' });
}); });
test('unsubscribe stops callbacks', async () => { test('unsubscribe stops callbacks', async () => {
@@ -220,6 +232,45 @@ function runStorageContract(name, factory) {
expect(last).toHaveLength(1); expect(last).toHaveLength(1);
}); });
}); });
// queryConstraints: orderBy + limit. App's combat log uses
// [orderBy('timestamp','desc'), limit(500)] — newest 500 entries.
// Adapter MUST honor these. Constraint shape = neutral {__type} objects
// (what firebase mock produces; what App passes via shared builders).
describe('subscribeCollection queryConstraints', () => {
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
const limitC = (n) => ({ __type: 'limit', n });
beforeEach(async () => {
// seed 5 log docs, timestamps out of order
await storage.setDoc('logs/l1', { msg: 'one', timestamp: 1 });
await storage.setDoc('logs/l2', { msg: 'two', timestamp: 3 });
await storage.setDoc('logs/l3', { msg: 'three', timestamp: 5 });
await storage.setDoc('logs/l4', { msg: 'four', timestamp: 2 });
await storage.setDoc('logs/l5', { msg: 'five', timestamp: 4 });
});
test('orderBy desc sorts newest-first', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc')]);
});
expect(result.map(d => d.msg)).toEqual(['three', 'five', 'two', 'four', 'one']);
});
test('limit returns only first N after ordering', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2)]);
});
expect(result.map(d => d.msg)).toEqual(['three', 'five']);
});
test('no constraints returns all (insertion/id order)', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, []);
});
expect(result).toHaveLength(5);
});
});
}); });
} }
+23 -11
View File
@@ -1,10 +1,8 @@
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged). // firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
// Matches interface of memory.js / ws.js so App.js calls stay identical. // Matches interface of memory.js / ws.js so App.js calls stay identical.
// //
// NOTE: App.js currently imports SDK directly. This adapter extracted verbatim. // App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
// Two-phase refactor: // the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
// Phase A (now): adapter exists, wraps SDK. Hooks/writes can switch incrementally.
// Phase B (later): App.js imports storage factory, drops direct SDK imports.
'use strict'; 'use strict';
@@ -120,21 +118,34 @@ export function createFirebaseStorage() {
}, },
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe. // Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
subscribeDoc(path, cb) { subscribeDoc(path, cb, errCb) {
recordAdapterCall({ fn: 'subscribeDoc', path }); recordAdapterCall({ fn: 'subscribeDoc', path });
return onSnapshot(doc(db, path), (snap) => { return onSnapshot(doc(db, path), (snap) => {
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null); cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
}, (err) => console.error(`subscribeDoc ${path}:`, err)); }, (err) => {
console.error(`subscribeDoc ${path}:`, err);
if (typeof errCb === 'function') errCb(err);
});
}, },
subscribeCollection(collectionPath, cb, queryConstraints = []) { subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath }); recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
const q = queryConstraints.length > 0 // queryConstraints = neutral {__type} builders (from index.js).
? query(collection(db, collectionPath), ...queryConstraints) // Translate to SDK orderBy/limit.
const sdkConstraints = queryConstraints.map(c => {
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
if (c.__type === 'limit') return limit(c.n);
return c; // pass-through (forward compat)
});
const q = sdkConstraints.length > 0
? query(collection(db, collectionPath), ...sdkConstraints)
: collection(db, collectionPath); : collection(db, collectionPath);
return onSnapshot(q, (snap) => { return onSnapshot(q, (snap) => {
cb(snap.docs.map(d => ({ id: d.id, ...d.data() }))); cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
}, (err) => console.error(`subscribeCollection ${collectionPath}:`, err)); }, (err) => {
console.error(`subscribeCollection ${collectionPath}:`, err);
if (typeof errCb === 'function') errCb(err);
});
}, },
dispose() { /* SDK managed; no-op */ }, dispose() { /* SDK managed; no-op */ },
@@ -142,7 +153,8 @@ export function createFirebaseStorage() {
} }
// Re-export SDK pieces App.js uses directly (until full refactor). // Re-export SDK pieces App.js uses directly (until full refactor).
// orderBy/limit NOT re-exported: App uses neutral builders from index.js.
export { export {
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot, doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
query, orderBy, limit, writeBatch, query, writeBatch,
}; };
+16 -9
View File
@@ -1,5 +1,6 @@
// src/storage/index.js — storage factory + SDK re-exports. // src/storage/index.js — storage factory + SDK re-exports.
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=ws: backend. // STORAGE=firebase (default): adapter wraps SDK. STORAGE=server: backend.
// orderBy/limit below = NEUTRAL builders (not SDK passthrough).
// App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending). // App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending).
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
@@ -8,11 +9,10 @@ import {
} from 'firebase/auth'; } from 'firebase/auth';
import { import {
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, onSnapshot, updateDoc, deleteDoc, query, writeBatch,
} from 'firebase/firestore'; } from 'firebase/firestore';
import { initFirebase, createFirebaseStorage } from './firebase'; import { initFirebase, createFirebaseStorage } from './firebase';
import { createWsStorage } from './ws'; import { createServerStorage } from './server';
import { createMemoryStorage } from './memory';
let storageInstance = null; let storageInstance = null;
@@ -24,13 +24,13 @@ export function getStorage() {
const ok = initFirebase(); const ok = initFirebase();
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.'); if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
storageInstance = createFirebaseStorage(); storageInstance = createFirebaseStorage();
} else if (mode === 'ws') { } else if (mode === 'server') {
storageInstance = createWsStorage({ storageInstance = createServerStorage({
baseUrl: process.env.REACT_APP_BACKEND_URL || '', baseUrl: process.env.REACT_APP_BACKEND_URL || '',
wsUrl: process.env.REACT_APP_BACKEND_WS || '', realtimeUrl: process.env.REACT_APP_BACKEND_REALTIME_URL || '',
}); });
} else { } else {
storageInstance = createMemoryStorage(); throw new Error(`Unknown REACT_APP_STORAGE mode: ${mode}. Use 'firebase' or 'server'.`);
} }
return storageInstance; return storageInstance;
} }
@@ -39,9 +39,16 @@ export function getStorageMode() {
return process.env.REACT_APP_STORAGE || 'firebase'; return process.env.REACT_APP_STORAGE || 'firebase';
} }
// Neutral query-constraint builders. App builds constraints with these (not
// raw SDK), so both adapters read the same shape. firebase adapter translates
// neutral -> SDK; server adapter applies client-side. Combat log uses these:
// [orderBy('timestamp','desc'), limit(500)]
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
export function limit(n) { return { __type: 'limit', n }; }
export { export {
initializeApp, initializeApp,
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, onSnapshot, updateDoc, deleteDoc, query, writeBatch,
}; };
-140
View File
@@ -1,140 +0,0 @@
// memory.js — in-process storage impl. Test seed.
// Map<docPath, data>. EventEmitter for subscribe.
// Mirrors firebase semantics: setDoc=replace, updateDoc=shallow merge, addDoc=auto-id.
'use strict';
import { EventEmitter } from 'events';
function createMemoryStorage() {
const docs = new Map(); // path -> data obj
const bus = new EventEmitter();
bus.setMaxListeners(1000);
// Firebase-prefixed paths (artifacts/{APP_ID}/public/data/...) normalized to
// bare canonical. Matches ws.js norm() so all impls share path identity.
function norm(p) {
if (!p) return p;
return p.replace(/^[\s\S]*\/public\/data\//, '');
}
// ---- path helpers ----
// collection path = path with even number of segments OR known collection.
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
// getCollection(path) returns all docs whose path === path/id for any single id segment.
function isCollectionPath(p) {
return p.split('/').length % 2 === 1;
}
function emitDoc(path, data) { bus.emit('doc:' + path, data); }
function emitCollection(collPath) {
const children = collectionDocs(collPath);
bus.emit('coll:' + collPath, children);
}
function collectionDocs(collPath) {
const out = [];
const segLen = collPath.split('/').length + 1;
for (const [p, data] of docs) {
const segs = p.split('/');
if (segs.length !== segLen) continue;
const parent = segs.slice(0, -1).join('/');
if (parent === collPath) out.push(data);
}
return out;
}
function genId() {
return (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
const storage = {
async getDoc(rawPath) {
const path = norm(rawPath);
return docs.has(path) ? deepClone(docs.get(path)) : null;
},
async setDoc(rawPath, data) {
const path = norm(rawPath);
docs.set(path, deepClone(data));
emitDoc(path, deepClone(data));
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async updateDoc(rawPath, patch) {
const path = norm(rawPath);
const existing = docs.has(path) ? docs.get(path) : {};
const merged = { ...existing, ...patch };
docs.set(path, merged);
emitDoc(path, deepClone(merged));
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async deleteDoc(rawPath) {
const path = norm(rawPath);
docs.delete(path);
emitDoc(path, null);
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async addDoc(rawCollectionPath, data) {
const collectionPath = norm(rawCollectionPath);
const id = genId();
const path = `${collectionPath}/${id}`;
docs.set(path, deepClone(data));
emitDoc(path, deepClone(data));
emitCollection(collectionPath);
return { id, path };
},
async getCollection(rawCollPath) {
const collPath = norm(rawCollPath);
return collectionDocs(collPath).map(deepClone);
},
async batchWrite(ops) {
for (const op of ops) {
const mop = { ...op, path: norm(op.path) };
if (mop.type === 'set') await storage.setDoc(mop.path, mop.data);
else if (mop.type === 'delete') await storage.deleteDoc(mop.path);
else if (mop.type === 'update') await storage.updateDoc(mop.path, mop.data);
}
},
subscribeDoc(rawPath, cb) {
const path = norm(rawPath);
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
Promise.resolve().then(() => cb(cur));
const handler = (data) => cb(data);
bus.on('doc:' + path, handler);
return () => bus.off('doc:' + path, handler);
},
subscribeCollection(rawCollPath, cb) {
const collPath = norm(rawCollPath);
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
const handler = (docs) => cb(docs);
bus.on('coll:' + collPath, handler);
return () => bus.off('coll:' + collPath, handler);
},
dispose() { bus.removeAllListeners(); docs.clear(); },
// test/debug
_docs: docs,
};
return storage;
}
function deepClone(v) {
if (v === null || v === undefined) return v;
return JSON.parse(JSON.stringify(v));
}
export { createMemoryStorage };
+68 -16
View File
@@ -11,13 +11,13 @@ if (typeof WebSocket !== 'undefined') {
WebSocketImpl = WebSocket; WebSocketImpl = WebSocket;
} }
function createWsStorage({ baseUrl, wsUrl } = {}) { function createServerStorage({ baseUrl, realtimeUrl } = {}) {
// Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy). // Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy).
// Fallback to localhost for bare `npm start` dev without proxy. // Fallback to localhost for bare `npm start` dev without proxy.
const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, ''); const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, '');
let WS; let WS;
if (wsUrl) { if (realtimeUrl) {
WS = wsUrl; WS = realtimeUrl;
} else if (typeof window !== 'undefined' && window.location) { } else if (typeof window !== 'undefined' && window.location) {
// derive from current origin (http→ws, https→wss), same host/port. // derive from current origin (http→ws, https→wss), same host/port.
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -33,8 +33,39 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return p.replace(/^[\s\S]*\/public\/data\//, ''); return p.replace(/^[\s\S]*\/public\/data\//, '');
} }
// Inject id (last path segment) into doc — matches main firebase truth:
// { id: docSnap.id, ...snap.data() }
// Backend stores docs WITHOUT id; client adds it so all adapters share shape.
function withId(rawPath, data) {
if (data === null || data === undefined) return data;
const id = norm(rawPath).split('/').pop();
return { id, ...data };
}
// Apply neutral {__type} query constraints client-side. Backend returns all
// docs; adapter sorts/limits. Mirrors firebase mock applyConstraints.
function applyConstraints(docs, constraints) {
let out = [...docs];
for (const c of constraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'orderBy') {
out.sort((a, b) => {
const av = a[c.field];
const bv = b[c.field];
if (av === bv) return 0;
const cmp = av > bv ? 1 : -1;
return c.dir === 'desc' ? -cmp : cmp;
});
} else if (c.__type === 'limit') {
out = out.slice(0, c.n);
}
}
return out;
}
const docSubs = new Map(); // path -> Set<cb> const docSubs = new Map(); // path -> Set<cb>
const collSubs = new Map(); // collPath -> Set<cb> const collSubs = new Map(); // collPath -> Set<cb>
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
let ws = null; let ws = null;
let wsReady = null; let wsReady = null;
@@ -121,11 +152,12 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
const doc = await storage.getDoc(c.path); const doc = await storage.getDoc(c.path);
docCbs.forEach(cb => cb(doc)); docCbs.forEach(cb => cb(doc));
} }
// collection subscribers at parent path (doc belongs to this collection) // collection subscribers at parent path (apply their stored constraints)
if (c.parent) { if (c.parent) {
const collCbs = collSubs.get(c.parent); const collCbs = collSubs.get(c.parent);
if (collCbs) { if (collCbs) {
const docs = await storage.getCollection(c.parent); const constraints = collConstraints.get(c.parent) || [];
const docs = applyConstraints(await storage.getCollection(c.parent), constraints);
collCbs.forEach(cb => cb(docs)); collCbs.forEach(cb => cb(docs));
} }
} }
@@ -154,12 +186,19 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
async getDoc(rawPath) { async getDoc(rawPath) {
const p = norm(rawPath); const p = norm(rawPath);
const res = await api('GET', '/api/doc', { path: p }); const res = await api('GET', '/api/doc', { path: p });
return res && res.data !== undefined ? res.data : null; const data = res && res.data !== undefined ? res.data : null;
return withId(rawPath, data);
}, },
async setDoc(rawPath, data) { async setDoc(rawPath, data, opts = {}) {
const p = norm(rawPath); const p = norm(rawPath);
await api('PUT', '/api/doc', null, { path: p, data }); // merge flag -> PATCH (shallow merge, create-on-miss). Matches firebase
// setDoc(path, data, {merge:true}) semantics.
if (opts.merge) {
await api('PATCH', '/api/doc', null, { path: p, patch: data });
} else {
await api('PUT', '/api/doc', null, { path: p, data });
}
}, },
async updateDoc(rawPath, patch) { async updateDoc(rawPath, patch) {
@@ -180,7 +219,16 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
async getCollection(rawCollPath) { async getCollection(rawCollPath) {
const p = norm(rawCollPath); const p = norm(rawCollPath);
return await api('GET', '/api/collection', { path: p }); const docs = await api('GET', '/api/collection', { path: p });
// Backend returns array of { id, data } OR bare data[]; normalize to
// { id, ...data } (firebase truth).
if (!Array.isArray(docs)) return [];
return docs.map(d => {
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
return { id: d.id, ...d.data };
}
return { ...d };
});
}, },
async batchWrite(ops) { async batchWrite(ops) {
@@ -188,9 +236,9 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
await api('POST', '/api/batch', null, { ops: normOps }); await api('POST', '/api/batch', null, { ops: normOps });
}, },
subscribeDoc(rawPath, cb) { subscribeDoc(rawPath, cb, errCb) {
const p = norm(rawPath); const p = norm(rawPath);
// Initial value via REST (independent of WS connect). // Initial value via REST (independent of WS connect). getDoc injects id.
storage.getDoc(p).then(cb).catch(() => {}); storage.getDoc(p).then(cb).catch(() => {});
// WS only for subsequent change notifications. // WS only for subsequent change notifications.
ensureWs().then(() => { ensureWs().then(() => {
@@ -201,10 +249,14 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return () => { docSubs.get(p)?.delete(cb); }; return () => { docSubs.get(p)?.delete(cb); };
}, },
subscribeCollection(rawCollPath, cb) { subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
const p = norm(rawCollPath); const p = norm(rawCollPath);
// Initial value via REST (independent of WS connect). collConstraints.set(p, queryConstraints || []);
storage.getCollection(p).then(cb).catch(() => {}); // Initial value via REST (independent of WS connect). Apply constraints
// client-side — backend returns all docs, adapter sorts/limits.
storage.getCollection(p)
.then(docs => cb(applyConstraints(docs, queryConstraints || [])))
.catch(() => {});
// WS only for subsequent change notifications. // WS only for subsequent change notifications.
ensureWs().then(() => { ensureWs().then(() => {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p })); ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
@@ -218,7 +270,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
disposed = true; disposed = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) ws.close(); if (ws) ws.close();
docSubs.clear(); collSubs.clear(); docSubs.clear(); collSubs.clear(); collConstraints.clear();
if (typeof cb === 'function') cb(); if (typeof cb === 'function') cb();
}, },
@@ -234,4 +286,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return storage; return storage;
} }
export { createWsStorage }; export { createServerStorage };
+9 -7
View File
@@ -41,7 +41,7 @@ describe('Combat -> Firebase', () => {
test('startEncounter: also sets activeDisplay to this encounter', async () => { test('startEncounter: also sets activeDisplay to this encounter', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data.activeCampaignId).toBeTruthy(); expect(last.data.activeCampaignId).toBeTruthy();
expect(last.data.activeEncounterId).toBeTruthy(); expect(last.data.activeEncounterId).toBeTruthy();
@@ -111,26 +111,28 @@ describe('Combat -> Firebase', () => {
fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && last.data.activeCampaignId === null; return last && last.data.activeCampaignId === null;
}); });
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => { test('toggleHidePlayerHp: setDoc{merge} patch on activeDisplay/status', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const switchBtn = screen.getByRole('switch'); // Two switches now (Hide player HP + Hide NPC/monster HP). Scope to player one.
const playerHpLabel = screen.getByText('Hide player HP');
const switchBtn = playerHpLabel.parentElement.querySelector('[role="switch"]');
fireEvent.click(switchBtn); fireEvent.click(switchBtn);
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && 'hidePlayerHp' in last.data; return last && 'hidePlayerHp' in last.data;
}); });
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toHaveProperty('hidePlayerHp'); expect(last.data).toHaveProperty('hidePlayerHp');
}); });
+256 -262
View File
@@ -1,23 +1,84 @@
// Combat.scenario.test.js // Combat.scenario.test.js
// Full combat scenario: campaign -> encounter -> participants -> 100 rounds of // Full combat scenario driven through shared/turn.js directly --- NO React.
// damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove.
// Drives the SAME UI buttons a DM clicks. Failing assertions do NOT abort the run:
// each phase wraps in try/catch, failures collected, final expect reports all.
// //
// Purpose: exercise as much of the supported feature surface as possible in one // Does 100 ROUNDS (not turns). A round = one full pass through initiative
// long combat, surfacing behavioral bugs characterization tests miss. // (every active participant acts once); round counter increments at wrap.
// See docs/GLOSSARY.md. Previous version lied: called nextTurn 100× (~100
// turns, ~12 rounds). Now loops by actual round-wrap count.
//
// Exercises ALL combat paths deterministically (less random than replay):
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
// updateParticipant, removeParticipant, toggleParticipantActive,
// applyHpChange (damage/heal), deathSave (incl. 3-success → isDying),
// toggleCondition, reorderParticipants (drag same-init tie), endEncounter,
// activateDisplay, clearDisplay, toggleHidePlayerHp.
//
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
// failures collected, final expect reports all.
import React from 'react'; const {
import { screen, fireEvent, waitFor, within } from '@testing-library/react'; makeParticipant,
import '@testing-library/jest-dom'; buildCharacterParticipant,
import App from '../App'; buildMonsterParticipant,
import { startEncounter,
renderApp, createCampaignViaUI, selectCampaignByName, nextTurn,
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady, togglePause,
} from './testHelpers'; addParticipant,
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db'; addParticipants,
updateParticipant,
removeParticipant,
toggleParticipantActive,
applyHpChange,
deathSave,
toggleCondition,
reorderParticipants,
endEncounter,
activateDisplay,
clearDisplay,
toggleHidePlayerHp,
} = require('../../shared');
// ---------- scenario helpers (UI only, same buttons as human) ---------- // aliases for combat funcs that clash with local helper names
const {
applyHpChange: combatApplyHpChange,
toggleParticipantActive: combatToggleActive,
toggleCondition: combatToggleCondition,
deathSave: combatDeathSave,
} = require('../../shared');
// ---------- scenario state (mirrors two firestore docs) ----------
const ROUNDS = 100;
// encounter doc
let enc = {
name: 'BigBoss',
participants: [],
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
};
// activeDisplay/status doc
let display = {
activeCampaignId: null,
activeEncounterId: null,
hidePlayerHp: true,
hideNpcHp: false,
};
const CAMPAIGN_ID = 'camp-1';
const ENCOUNTER_ID = 'enc-1';
const roster = [];
function applyEnc(patch) {
if (!patch) return;
for (const [k, v] of Object.entries(patch)) enc[k] = v;
}
function applyDisplay(patch) {
if (!patch) return;
for (const [k, v] of Object.entries(patch)) display[k] = v;
}
const RESULTS = []; const RESULTS = [];
function record(phase, fn) { function record(phase, fn) {
@@ -29,205 +90,93 @@ async function recordAsync(phase, fn) {
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); } catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
} }
function getParticipantForm() { // ---------- helpers (shared, no React) ----------
const heading = screen.getByText('Add Participants');
let node = heading; const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
for (let i = 0; i < 6; i++) { const any = (name) => enc.participants.find(p => p.name === name);
node = node.parentElement; const idOf = (name) => { const p = any(name); return p ? p.id : null; };
if (!node) break;
if (node.querySelector('form')) return within(node); function addCharacterViaUI(name, maxHp, initMod) {
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
}
function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
applyEnc(addParticipant(enc, participant).patch);
}
function addCharacterParticipant(charName) {
const character = roster.find(c => c.name === charName);
if (!character) throw new Error(`char not found: ${charName}`);
if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
throw new Error(`${charName} already in encounter`);
} }
return within(heading.parentElement); const { participant } = buildCharacterParticipant(character);
applyEnc(addParticipant(enc, participant).patch);
} }
function addAllCharacters() {
// Find a participant's encounter <li> row by name. Scoped to the encounter const toAdd = roster
// participant list (NOT the CharacterManager roster, which also shows names). .filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
// Encounter participant rows render an 'Init:' label; roster rows do not. .map(c => buildCharacterParticipant(c).participant);
function getParticipantRow(name) { applyEnc(addParticipants(enc, toAdd).patch);
const lis = document.querySelectorAll('li');
for (const li of lis) {
const txt = li.textContent || '';
if (txt.includes('Init:') && txt.includes(name)) {
return within(li);
}
}
throw new Error(`encounter participant row not found: ${name}`);
} }
function startCombat() {
// Character roster (CharacterManager). Assumes campaign selected. applyEnc(startEncounter(enc).patch);
async function addCharacterViaUI(name, maxHp, initMod) { applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch);
fireEvent.change(document.getElementById('characterName'), { target: { value: name } });
fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } });
fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } });
fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i }));
await waitFor(() => {
const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') &&
Array.isArray(c.data.players) && c.data.players.some(p => p.name === name));
if (!call) throw new Error('char not persisted');
});
} }
function endCombat() {
function setParticipantType(type) { applyEnc(endEncounter(enc).patch);
// The Type select is inside the Add Participants form. applyDisplay(clearDisplay().patch);
const form = getParticipantForm();
const selects = form.getAllByRole('combobox');
// first combobox in the participant form is Type
fireEvent.change(selects[0], { target: { value: type } });
}
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
const form = getParticipantForm();
setParticipantType('monster');
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
if (isNpc) {
const npcCheck = form.getByRole('checkbox', { name: /NPC/i });
if (!npcCheck.checked) fireEvent.click(npcCheck);
}
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added');
});
}
async function addCharacterParticipant(charName) {
const form = getParticipantForm();
setParticipantType('character');
// character select is the 2nd combobox in the form after Type
const charSelect = form.getAllByRole('combobox')[1];
// find option whose text includes the char name
const opt = [...charSelect.querySelectorAll('option')].find(o => o.textContent.includes(charName));
if (!opt) throw new Error(`char option not found: ${charName}`);
fireEvent.change(charSelect, { target: { value: opt.value } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last || !last.data.participants?.some(p => p.name === charName)) throw new Error('char not added');
});
}
async function addAllCharacters() {
fireEvent.click(screen.getByRole('button', { name: /Add All/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('add all no-op');
});
} }
function nextTurnAction() { applyEnc(nextTurn(enc).patch); }
function pauseCombat() { applyEnc(togglePause(enc).patch); if (!enc.isPaused) throw new Error('not paused'); }
function resumeCombat() { applyEnc(togglePause(enc).patch); if (enc.isPaused) throw new Error('not resumed'); }
function applyDamage(name, amount) { function applyDamage(name, amount) {
const row = getParticipantRow(name); const p = any(name);
const dmgBtn = row.queryByTitle('Damage'); if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI)
if (!dmgBtn) { applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch);
// participant dead (Damage button hidden when currentHp===0). Expected game
// state over a long fight; not a bug. Skip silently.
return;
}
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
fireEvent.click(dmgBtn);
} }
function applyHeal(name, amount) { function applyHeal(name, amount) {
const row = getParticipantRow(name); const p = any(name);
const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal'); if (!p) return; // removed (death) = skip (no button in UI)
if (!healBtn) throw new Error(`${name} has no Heal button`); applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch);
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
fireEvent.click(healBtn);
} }
function toggleActive(name) { function toggleActive(name) {
const row = getParticipantRow(name); const p = any(name);
const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive'); if (!p) throw new Error(`no active target: ${name}`);
if (!btn) throw new Error(`${name} has no active toggle`); applyEnc(combatToggleActive(enc, p.id).patch);
fireEvent.click(btn);
} }
function openConditions(name) { function toggleConditionAction(name, label) {
const row = getParticipantRow(name); const p = any(name);
const btn = row.getByTitle('Conditions'); if (!p) throw new Error(`no condition target: ${name}`);
// idempotent: ensure panel open. Click toggles; if another participant's panel applyEnc(combatToggleCondition(enc, p.id, label).patch);
// was open it's already closed by this participant's row focus, so just click.
fireEvent.click(btn);
}
function toggleCondition(name, label) {
openConditions(name);
// panel render is async (React state). Wait for button by title.
return waitFor(() => {
const condButtons = document.querySelectorAll('button[title]');
const target = [...condButtons].find(b => b.getAttribute('title') === label);
if (!target) throw new Error(`condition button not found: ${label}`);
fireEvent.click(target);
});
} }
function editParticipant(name, patch) { function editParticipant(name, patch) {
const row = getParticipantRow(name); const p = any(name);
fireEvent.click(row.getByTitle('Edit')); if (!p) throw new Error(`no edit target: ${name}`);
// EditParticipantModal. Scope to the modal via its form inputs. applyEnc(updateParticipant(enc, p.id, patch).patch);
const modal = document.querySelector('.fixed.inset-0') || document.body;
const inputs = modal.querySelectorAll('input');
if (patch.name !== undefined) {
fireEvent.change(inputs[0], { target: { value: patch.name } });
}
if (patch.initiative !== undefined && inputs[1]) {
fireEvent.change(inputs[1], { target: { value: String(patch.initiative) } });
}
const saveBtn = modal.querySelector('button[type="submit"]') ||
[...modal.querySelectorAll('button')].find(b => /^Save$/i.test(b.textContent.trim()));
fireEvent.click(saveBtn);
} }
function removeParticipant(name) { function removeParticipantByName(name) {
fireEvent.click(getParticipantRow(name).getByTitle('Remove')); const p = any(name);
if (!p) throw new Error(`no remove target: ${name}`);
applyEnc(removeParticipant(enc, p.id).patch);
} }
async function deathSave(name, saveNum) { function deathSaveAction(name, type, saveNum) {
const row = getParticipantRow(name); const p = any(name);
const btn = row.getByTitle(`Death save ${saveNum}`); if (!p) throw new Error(`no deathsave target: ${name}`);
fireEvent.click(btn); applyEnc(combatDeathSave(enc, p.id, type, saveNum).patch);
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('deathSave no write');
});
} }
async function nextTurn() { function dragTie(draggedName, targetName) {
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); const d = any(draggedName), t = any(targetName);
await waitFor(() => { if (!d || !t) throw new Error('drag target missing');
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; applyEnc(reorderParticipants(enc, d.id, t.id).patch);
if (!last) throw new Error('nextTurn no write');
});
} }
async function pauseCombat() { function toggleHidePlayerHpAction() {
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i })); applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last?.data?.isPaused) throw new Error('not paused');
});
}
async function resumeCombat() {
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (last?.data?.isPaused) throw new Error('not resumed');
});
}
async function startCombat() {
fireEvent.click(screen.getByRole('button', { name: /Start Combat/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last?.data?.isStarted) throw new Error('not started');
});
}
function toggleHidePlayerHp() {
fireEvent.click(screen.getByRole('switch'));
}
function currentEncDoc() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
return calls[calls.length - 1]?.data;
} }
// ---------- scenario ---------- // ---------- scenario ----------
const ROUNDS = 100; test('full 100-round combat scenario (shared, no React)', async () => {
test('full 100-round combat scenario', async () => {
await setupReady('ScenarioCamp', 'BigBoss');
// roster // roster
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2)); await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1)); await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
@@ -244,97 +193,142 @@ test('full 100-round combat scenario', async () => {
await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter')); await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric')); await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue')); await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
await recordAsync('addAllChars', () => addAllCharacters()); await recordAsync('addAllChars', () => addAllCharacters()); // bulk add path
// hidden hp toggle // hidden hp toggle x2 (back to default)
record('toggleHidePlayerHp', () => toggleHidePlayerHp()); record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
record('toggleHidePlayerHp back', () => toggleHidePlayerHp()); record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
await recordAsync('startCombat', () => startCombat()); await recordAsync('startCombat', () => startCombat());
// 100 rounds of mixed actions // ---------- 100 ROUNDS (loop by actual round-wrap count) ----------
for (let r = 1; r <= ROUNDS; r++) { let roundsDone = 0;
await recordAsync(`round ${r} nextTurn`, () => nextTurn()); let prevRound = enc.round;
let turnInRound = 0;
let turnTotal = 0;
// rotation integrity: turnOrderIds no dup, currentTurn valid while (roundsDone < ROUNDS) {
if (r % 10 === 0) { turnTotal++;
record(`round ${r} rotation-check`, () => { const actor = anyById(enc.currentTurnParticipantId);
const enc = currentEncDoc(); const r = enc.round;
if (!enc) throw new Error('no encounter doc');
// per-turn deterministic actions keyed by round + actor type
// damage OrcBoss every even round
if (r % 2 === 0 && turnInRound === 0) {
await recordAsync(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
}
// heal Cleric every 3 rounds
if (r % 3 === 0 && turnInRound === 1) {
record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
}
// condition on Fighter every 5 rounds
if (r % 5 === 0 && turnInRound === 2) {
record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
}
// toggleActive Goblin2 every 7 rounds (toggle off, next 7 toggle on)
if (r % 7 === 0 && turnInRound === 3) {
record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
}
// edit Wolf initiative every 13 rounds
if (r % 13 === 0 && turnInRound === 4) {
record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
}
// pause/resume + add reinforcement + edit every 10 rounds
if (r % 10 === 0 && turnInRound === 0) {
await recordAsync(`r${r} pause`, () => pauseCombat());
await recordAsync(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
await recordAsync(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
await recordAsync(`r${r} resume`, () => resumeCombat());
}
// create a same-init tie + drag every 15 rounds (exercises reorderParticipants)
if (r % 15 === 0 && turnInRound === 5) {
const g1 = any('Goblin1');
if (g1 && alive('Goblin2')) {
record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
}
}
// death scenarios: drop a PC to 0, death saves, revive
if (r === 25 && turnInRound === 0) {
await recordAsync(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
// round 50: full 3-success death-save path (isDying) on Cleric, then remove
if (r === 50 && turnInRound === 0) {
await recordAsync(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
await recordAsync(`r${r} deathSave Cleric x3 (isDying)`, async () => {
deathSaveAction('Cleric', 'fail', 1);
deathSaveAction('Cleric', 'fail', 2);
deathSaveAction('Cleric', 'fail', 3); // → dead
});
const cl = any('Cleric');
if (cl && cl.isDying) record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
}
// remove a reinforcement every 30 rounds
if (r % 30 === 0 && turnInRound === 1) {
const rein = any(`Reinforce${r - 10}`);
if (rein) record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
}
// toggle hide hp every 20 rounds
if (r % 20 === 0 && turnInRound === 0) {
record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
}
// rotation integrity check every 10 rounds at round start
if (turnInRound === 0 && r % 10 === 0) {
record(`r${r} rotation-check`, () => {
const order = enc.turnOrderIds || []; const order = enc.turnOrderIds || [];
const uniq = new Set(order); const uniq = new Set(order);
if (uniq.size !== order.length) { if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
}
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) { if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`); throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
} }
const ids = enc.participants.map(p => p.id);
if (JSON.stringify(order) !== JSON.stringify(ids)) {
throw new Error(`turnOrderIds drift: order=${JSON.stringify(order)} ids=${JSON.stringify(ids)}`);
}
}); });
} }
// damage front monster every other round // advance turn
if (r % 2 === 0) record(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3)); await recordAsync(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2)); turnInRound++;
if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleCondition('Fighter', 'Stunned'));
if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
// pause/resume every 10 rounds, add a participant, resume // round wrap detected
if (r % 10 === 0) { if (enc.round > prevRound) {
await recordAsync(`round ${r} pause`, () => pauseCombat()); roundsDone++;
await recordAsync(`round ${r} addReinforcement`, () => prevRound = enc.round;
addMonsterParticipant(`Reinforce${r}`, 10, 1)); turnInRound = 0;
await recordAsync(`round ${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
await recordAsync(`round ${r} resume`, () => resumeCombat());
}
// edit initiative on Wolf every 13
if (r % 13 === 0) record(`round ${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
// damage-to-0 + death save on Rogue around round 25 and 50
if (r === 25) {
record(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1));
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
if (r === 50) {
record(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
record(`round ${r} deathSave Cleric x3`, async () => {
await deathSave('Cleric', 1);
await deathSave('Cleric', 2);
await deathSave('Cleric', 3);
});
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
}
// remove a reinforcement late
if (r === 30) {
await recordAsync(`round ${r} pause`, () => pauseCombat());
record(`round ${r} remove Reinforce20`, () => removeParticipant('Reinforce20'));
await recordAsync(`round ${r} resume`, () => resumeCombat());
} }
} }
await recordAsync('endCombat', async () => { await recordAsync('endCombat', () => endCombat());
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
// End-combat ConfirmationModal has title 'End Encounter?'. Scope Confirm to it.
const endConfirm = await screen.findByRole('heading', { name: /End Encounter/i });
const modal = endConfirm.closest('.fixed.inset-0') || document.body;
const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim()));
fireEvent.click(confirmBtn);
await waitFor(() => {
const last = currentEncDoc();
if (last?.isStarted !== false) throw new Error('not ended');
});
});
// ---------- report ---------- // ---------- report ----------
const failed = RESULTS.filter(r => !r.ok); const failed = RESULTS.filter(x => !x.ok);
if (failed.length > 0) { if (failed.length > 0) {
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n'); const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`); console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
} }
// eslint-disable-next-line no-console
console.log(`\n=== SCENARIO: ${RESULTS.length - failed.length}/${RESULTS.length} phases ok ===\n`);
expect(failed).toEqual([]); expect(failed).toEqual([]);
}, 240000); // long timeout: 100 rounds expect(roundsDone).toBe(ROUNDS);
// lifecycle assertions (activeDisplay mirrors combat state)
expect(enc.isStarted).toBe(false);
expect(enc.round).toBe(0);
expect(enc.currentTurnParticipantId).toBeNull();
expect(display.activeCampaignId).toBeNull();
expect(display.activeEncounterId).toBeNull();
});
function anyById(id) {
return enc.participants.find(p => p.id === id);
}
+8 -8
View File
@@ -42,7 +42,7 @@ describe('Encounter -> Firebase', () => {
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//); expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
}); });
test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => { test('togglePlayerDisplay: setDoc{merge} patch on activeDisplay/status', async () => {
await setupCampaignAndEncounter('Camp D', 'Enc D'); await setupCampaignAndEncounter('Camp D', 'Enc D');
await selectEncounterByName('Enc D'); await selectEncounterByName('Enc D');
@@ -50,33 +50,33 @@ describe('Encounter -> Firebase', () => {
const eyeBtn = await screen.findByTitle('Activate for Player Display'); const eyeBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(eyeBtn); fireEvent.click(eyeBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status')); await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
const call = findCall('updateDoc', 'activeDisplay/status'); const call = findCall('setDoc', 'activeDisplay/status');
// BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields) // main truth: setDoc{merge} patch, not replace (would clobber fields)
expect(call.data).toMatchObject({ expect(call.data).toMatchObject({
activeCampaignId: expect.any(String), activeCampaignId: expect.any(String),
activeEncounterId: expect.any(String), activeEncounterId: expect.any(String),
}); });
}); });
test('togglePlayerDisplay off: updateDoc nulls active ids', async () => { test('togglePlayerDisplay off: setDoc{merge} nulls active ids', async () => {
await setupCampaignAndEncounter('Camp O', 'Enc O'); await setupCampaignAndEncounter('Camp O', 'Enc O');
await selectEncounterByName('Enc O'); await selectEncounterByName('Enc O');
// turn ON // turn ON
const onBtn = await screen.findByTitle('Activate for Player Display'); const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn); fireEvent.click(onBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status')); await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
// turn OFF // turn OFF
const offBtn = await screen.findByTitle('Deactivate for Player Display'); const offBtn = await screen.findByTitle('Deactivate for Player Display');
fireEvent.click(offBtn); fireEvent.click(offBtn);
await waitFor(() => { await waitFor(() => {
const calls = findCalls('updateDoc', 'activeDisplay/status'); const calls = findCalls('setDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
return last.data.activeCampaignId === null; return last.data.activeCampaignId === null;
}); });
const calls = findCalls('updateDoc', 'activeDisplay/status'); const calls = findCalls('setDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
+9 -7
View File
@@ -1,8 +1,8 @@
// BUG-4 repro: toggling hidePlayerHp must not clobber activeDisplay doc. // BUG-4 repro: toggling hidePlayerHp must not clobber activeDisplay doc.
// setDoc = replace (contract). {merge:true} arg ignored. // setDoc = replace would clobber. setDoc({merge:true}) patches only — matches main.
// Toggling hide-HP writes {hidePlayerHp:X} alone → activeCampaignId + activeEncounterId → null. // Toggling hide-HP writes {hidePlayerHp:X} via setDoc{merge} → activeCampaignId + activeEncounterId preserved.
// Display reads null → "Game Session Paused". Recover requires re-activating encounter. // Display keeps reading them → stays active.
// Fix: use updateDoc (patch), not setDoc. // Regression: if caller swaps to plain setDoc (no merge) or updateDoc-throws-on-missing, re-breaks.
import React from 'react'; import React from 'react';
import { render, waitFor, screen, fireEvent } from '@testing-library/react'; import { render, waitFor, screen, fireEvent } from '@testing-library/react';
@@ -50,14 +50,16 @@ describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
await waitFor(() => { await waitFor(() => {
const writes = getAdapterCalls().filter( const writes = getAdapterCalls().filter(
c => c.fn === 'updateDoc' && c.path.includes('activeDisplay/status') c => c.fn === 'setDoc' && c.path.includes('activeDisplay/status')
); );
expect(writes.length).toBeGreaterThan(0); expect(writes.length).toBeGreaterThan(0);
const last = writes[writes.length - 1]; const last = writes[writes.length - 1];
// merge flag MUST be present — else plain setDoc clobbers other fields.
expect(last.opts && last.opts.merge).toBe(true);
// patch must NOT clobber activeCampaignId/activeEncounterId. // patch must NOT clobber activeCampaignId/activeEncounterId.
// BUG: setDoc replace writes only {hidePlayerHp:true} → clobbers. // BUG: setDoc replace writes only {hidePlayerHp:true} → clobbers.
// Fix: updateDoc patch — other fields untouched. // Fix: setDoc{merge} patch — other fields untouched.
expect(last.patch.hidePlayerHp).toBe(true); expect(last.data.hidePlayerHp).toBe(true);
}, { timeout: 3000 }); }, { timeout: 3000 });
}); });
}); });
+9 -9
View File
@@ -82,10 +82,10 @@ describe('Logs -> Firebase', () => {
fireEvent.click(undoBtns[0]); fireEvent.click(undoBtns[0]);
await waitFor(() => { await waitFor(() => {
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`) && c.data.undone === true); const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
return und; return und;
}); });
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`)); const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
expect(markUndone.data.undone).toBe(true); expect(markUndone.data.undone).toBe(true);
// encounter path updated with undo payload (any encounter update after undo click) // encounter path updated with undo payload (any encounter update after undo click)
const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
@@ -125,7 +125,7 @@ describe('DeathSave -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
// death save buttons appear // death save buttons appear
const save1 = screen.getByTitle('Death save 1'); const save1 = screen.getByTitle('Success 1');
fireEvent.click(save1); fireEvent.click(save1);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
expect(lastEncCall().data.participants[0].deathSaves).toBe(1); expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
@@ -159,13 +159,13 @@ describe('DeathSave -> Firebase', () => {
fireEvent.click(screen.getByTitle('Damage')); fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.click(screen.getByTitle('Death save 1')); fireEvent.click(screen.getByTitle('Fail 1'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 1);
fireEvent.click(screen.getByTitle('Death save 2')); fireEvent.click(screen.getByTitle('Fail 2'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 2); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 2);
fireEvent.click(screen.getByTitle('Death save 3')); fireEvent.click(screen.getByTitle('Fail 3'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
expect(lastEncCall().data.participants[0].isDying).toBe(true); expect(lastEncCall().data.participants[0].isDying).toBe(true);
expect(lastEncCall().data.participants[0].deathSaves).toBe(3); expect(lastEncCall().data.participants[0].deathFails).toBe(3);
}); });
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
// Lock: storage adapters must use ESM exports (no module.exports). // Lock: storage adapters must use ESM exports (no module.exports).
// Regression guard: CJS in src/ crashes CRA prod build (ESM strict). // Regression guard: CJS in src/ crashes CRA prod build (ESM strict).
// Bug history: ws.js + memory.js used module.exports. Dev lenient (masked), // Bug history: server.js + firebase.js used module.exports. Dev lenient (masked),
// prod bundle crashed blank page. firebase.js always ESM. // prod bundle crashed blank page. firebase.js always ESM.
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -8,7 +8,7 @@ import path from 'path';
const ADAPTER_DIR = path.join(__dirname, '..', 'storage'); const ADAPTER_DIR = path.join(__dirname, '..', 'storage');
describe('storage adapters use ESM (no CJS)', () => { describe('storage adapters use ESM (no CJS)', () => {
const adapters = ['ws.js', 'memory.js', 'firebase.js', 'index.js']; const adapters = ['server.js', 'firebase.js', 'index.js'];
test.each(adapters)('%s has no module.exports', (file) => { test.each(adapters)('%s has no module.exports', (file) => {
const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8'); const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8');
// strip line comments so words like 'require' in explanatory comments don't trip the guard // strip line comments so words like 'require' in explanatory comments don't trip the guard
+21
View File
@@ -0,0 +1,21 @@
// Firebase adapter contract test.
// Runs the SAME storage contract as server (src/storage/contract.js)
// against createFirebaseStorage. The SDK is mocked via src/__mocks__/firebase/*,
// so this proves the adapter translates contract calls -> SDK calls correctly
// (path handling, merge semantics, subscribe, queryConstraints) without network.
//
// queryConstraints (orderBy + limit) now tested in shared contract for BOTH
// adapters — no separate firebase-only block needed here.
import { initFirebase, createFirebaseStorage } from '../storage/firebase';
import { runStorageContract } from '../storage/contract';
import { resetMockDb } from '../__mocks__/firebase/_mock-db';
// Firebase mock DB is shared global state. Reset before each factory so
// contract tests are isolated.
runStorageContract('firebase', () => {
resetMockDb();
const ok = initFirebase();
if (!ok) throw new Error('initFirebase failed under mocked env');
return createFirebaseStorage();
});
+70
View File
@@ -0,0 +1,70 @@
// Storage factory (index.js) test.
// Verifies getStorage() returns the right adapter per REACT_APP_STORAGE env,
// caches as singleton, and getStorageMode() reports the active mode.
// Adapters are mocked (no network/init) — factory routing is the unit.
import { getStorage, getStorageMode } from '../storage/index';
// reset module-level singleton between tests
let originalStorage;
beforeEach(() => {
originalStorage = process.env.REACT_APP_STORAGE;
// bust the module singleton by re-importing fresh
jest.resetModules();
});
afterEach(() => {
if (originalStorage === undefined) delete process.env.REACT_APP_STORAGE;
else process.env.REACT_APP_STORAGE = originalStorage;
jest.resetModules();
});
describe('getStorageMode', () => {
test('defaults to firebase when env unset', () => {
delete process.env.REACT_APP_STORAGE;
const { getStorageMode } = require('../storage/index');
expect(getStorageMode()).toBe('firebase');
});
test('returns server when REACT_APP_STORAGE=server', () => {
process.env.REACT_APP_STORAGE = 'server';
const { getStorageMode } = require('../storage/index');
expect(getStorageMode()).toBe('server');
});
test('throws on unknown mode', () => {
process.env.REACT_APP_STORAGE = 'garbage';
const { getStorage } = require('../storage/index');
expect(() => getStorage()).toThrow(/Unknown REACT_APP_STORAGE/);
});
});
describe('getStorage factory routing', () => {
test('server mode returns server adapter (init may fail without backend — catch)', () => {
process.env.REACT_APP_STORAGE = 'server';
const { getStorage } = require('../storage/index');
try {
const s = getStorage();
expect(s).toBeTruthy();
expect(typeof s.getDoc).toBe('function');
} catch (e) {
// ws adapter without backend URL/config is allowed to throw at factory;
// routing reached ws branch (not firebase) which is the contract.
expect(e).toBeTruthy();
}
});
test('returns singleton on repeat call (same instance)', () => {
process.env.REACT_APP_STORAGE = 'server';
const { getStorage } = require('../storage/index');
let a;
try { a = getStorage(); } catch (e) { return; } // no backend ok
if (a) {
const b = getStorage();
expect(a).toBe(b);
}
});
});
-8
View File
@@ -1,8 +0,0 @@
// Runner: executes storage contract against each impl.
// TDD: contract = spec. Run against memory first. RED until memory.js built.
'use strict';
const { runStorageContract } = require('../storage/contract');
const { createMemoryStorage } = require('../storage/memory');
runStorageContract('memory', () => createMemoryStorage());
+3
View File
@@ -4,6 +4,9 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
import App from '../App'; import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db'; import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
// fast waitFor: 5ms poll vs default 50ms. Cuts scenario ~8x.
export const fastWaitFor = (cb, opts) => waitFor(cb, { interval: 5, timeout: 1000, ...opts });
// Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager). // Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager).
export function getParticipantForm() { export function getParticipantForm() {
const heading = screen.getByText('Add Participants'); const heading = screen.getByText('Add Participants');