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:
@@ -13,3 +13,4 @@ server/data/*.sqlite
|
|||||||
server/data/*.sqlite-*
|
server/data/*.sqlite-*
|
||||||
/data
|
/data
|
||||||
/scratch
|
/scratch
|
||||||
|
tmp/
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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. |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
Generated
+24
-10
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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());
|
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user