Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e31fe15382 | |||
| a2c63cc77f | |||
| e22f412c52 | |||
| 4406fd2045 | |||
| 81c0b26b71 | |||
| da25f46e3e | |||
| c1d982b4a4 | |||
| afdd72e829 | |||
| 58ae04b400 | |||
| d73405753a | |||
| 3b07fc27b0 | |||
| af165f4491 | |||
| dbd0c75792 | |||
| 750ee99080 | |||
| 313a897e4b | |||
| 3ea67019d2 | |||
| 7c3ec105d5 | |||
| d1cbe7091a | |||
| 5d3a0607ef | |||
| 94b62dc5ab | |||
| fcddb58b8b | |||
| 7467a8d30f | |||
| 5521a2f6c6 | |||
| 494327ff17 | |||
| c72b88f8bb | |||
| 0473eacc1d | |||
| c6d3b7e1a6 | |||
| e0f75cfb6c | |||
| b62996dcbf | |||
| 260fb314bc | |||
| fb7fbb2263 | |||
| 435e109070 | |||
| b024fa08bb | |||
| 49ea39ea93 | |||
| b2fd06ed17 | |||
| e514a48d6e | |||
| c90fc6ffb0 | |||
| d979b03f2e | |||
| be481767f0 | |||
| bac94d85ff | |||
| 08c6146cf7 | |||
| d48ecf1460 | |||
| c314d1975e | |||
| a8e88cf0f0 | |||
| d35a730e12 | |||
| 912c493974 | |||
| 40fc4e596b | |||
| 80b454d087 | |||
| 2756b7b3eb | |||
| f81308a0df | |||
| 33e0e52789 | |||
| 13490fe3de | |||
| 7866dec83b | |||
| 891fc696d9 | |||
| 9fd0f3ec38 | |||
| 6630fd9158 | |||
| 1d4c561c09 | |||
| 84a8b78acd | |||
| 52866784b2 | |||
| 35cd1581e3 | |||
| ed67535b1f | |||
| 17245dfa1b | |||
| e843acdf8a | |||
| 74b4c2c42d | |||
| d1ee69a70a | |||
| a5a4df78f0 | |||
| b095e37bfe | |||
| 54e8df9ffa | |||
| e743d40e8d | |||
| 3e84f28325 | |||
| 812298fa73 | |||
| 5bb9e5fc19 | |||
| 35b5a1d238 | |||
| d581e60ba3 | |||
| 4158a1634d | |||
| 0c1196aee1 | |||
| 672f042b60 | |||
| b6555648ee | |||
| 84dd17e174 | |||
| 12b24eb707 | |||
| 2ee2bba93b | |||
| 9457f48b23 | |||
| fa19913e23 | |||
| 0e76fb2fc7 | |||
| e06adaa081 | |||
| d679c9d1e9 | |||
| ad7979d8fd | |||
| 33b27775b4 |
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pre-push: run test suites before push. Skip with --no-verify.
|
||||||
|
echo "[pre-push] running tests..."
|
||||||
|
npm run test:all || { echo "[pre-push] tests failed. push aborted. (skip: git push --no-verify)"; exit 1; }
|
||||||
|
exit 0
|
||||||
+7
-1
@@ -1,4 +1,3 @@
|
|||||||
# .gitignore
|
|
||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
@@ -7,3 +6,10 @@ dist
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
*.log
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.sqlite-*
|
||||||
|
server/data/*.sqlite
|
||||||
|
server/data/*.sqlite-*
|
||||||
|
/data
|
||||||
|
/scratch
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
Backlog of bugs + long-term items, from user. Milestones live in
|
||||||
|
REWORK_PLAN.md.
|
||||||
|
|
||||||
|
## Feature backlog
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
- Single source: turnOrderIds === participants.map(id). No re-sort after
|
||||||
|
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
|
||||||
|
- Drag (reorder) overrides initiative — cross-init allowed, DM choice.
|
||||||
|
- startEncounter sorts ALL participants by init once, then frozen.
|
||||||
|
- addParticipant splices by init pos. remove/toggle/reorder sync list.
|
||||||
|
- Display renders participants[] directly (no sortParticipantsByInitiative).
|
||||||
|
- BUG-6 (reorder divergence) fixed structurally. BUG-5 (rotation) held
|
||||||
|
(500 rounds CLEAN).
|
||||||
|
|
||||||
|
### FEAT-3: initiative first-class entry (add + edit)
|
||||||
|
- Current: only initMod at char-build. No initiative field at add-participant
|
||||||
|
or edit. 3-step to set after other steps.
|
||||||
|
- 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
|
||||||
|
- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds`
|
||||||
|
on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get
|
||||||
|
death-save turn. `isActive` = DM toggle only.
|
||||||
|
- Tests: `shared/tests/turn.dead-skip.test.js` (4 green). Char tests updated
|
||||||
|
to new behavior.
|
||||||
|
|
||||||
|
### FEAT-2: upgrade app internal logs to be parseable
|
||||||
|
- Goal: combat logs in Firestore store enough structured state to run
|
||||||
|
skip/rotation analysis on ANY historic round — not just replay stdout.
|
||||||
|
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must
|
||||||
|
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)
|
||||||
|
|
||||||
|
### BUG-1: addParticipant + pause/resume corrupts turn rotation
|
||||||
|
- **RESOLVED** as side effect of BUG-2 fix (dup-id rejection broke chain).
|
||||||
|
- Audit: 0 violations / 100 rounds after BUG-2 fix.
|
||||||
|
- Replay: 10 rounds clean, no skip/dupe.
|
||||||
|
- Audit: 128 violations / 100 rounds, 4 symptom faces.
|
||||||
|
- Symptom chain (one bug family):
|
||||||
|
1. pause blocks nextTurn advance → totalTurns stays frozen (e.g. 120)
|
||||||
|
2. addParticipant re-adds same `r${totalTurns}` id (BUG-2: no dedup)
|
||||||
|
3. togglePause resume rebuilds turnOrderIds → dup id appears x2
|
||||||
|
4. nextTurn gets stuck on dup id → rotation breaks
|
||||||
|
5. eventually nextTurn throws 'Encounter not running'
|
||||||
|
- Symptom counts (audit-state.js, 100 rounds):
|
||||||
|
62x turnOrder-no-dup, 52x rotation-dupes, 14x nextTurn-throws
|
||||||
|
- Repro in replay round 10+: current stuck on one participant forever,
|
||||||
|
nextTurn returns same id, round never advances.
|
||||||
|
- Clean minimal repro (shared/tests/turn.pause-add.test.js) PASSES = combo
|
||||||
|
needs more state than single add+pause. Audit authoritative repro.
|
||||||
|
- Clean subsystems (zero violations): HP bounds, isActive, deathSave
|
||||||
|
range, conditions, removeParticipant orphans.
|
||||||
|
- Real repro = `node scripts/audit-state.js` (or audit-rotation.js).
|
||||||
|
|
||||||
|
### BUG-2: addParticipant allows duplicate id
|
||||||
|
- **FIXED** (commit: 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 (preexisting)
|
||||||
|
- **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls
|
||||||
|
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.
|
||||||
|
- Test: render App + DisplayView, toggle hide-HP, assert display still shows
|
||||||
|
encounter (not paused).
|
||||||
|
|
||||||
|
### BUG-5: mid-round addParticipant/revive corrupts rotation — FIXED
|
||||||
|
- Fixed (commit `494327f`). Slot-array turn order + DRY advance core
|
||||||
|
`nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate.
|
||||||
|
- 500-round replay: 0 skips, 0 double-acts.
|
||||||
|
|
||||||
|
### BUG-6: reorderParticipants doesn't update turnOrderIds — FIXED
|
||||||
|
- Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds =
|
||||||
|
participants.map(id) always. reorder cross-init allowed (DM override).
|
||||||
|
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
|
||||||
|
- Test: `shared/tests/turn.undo.test.js` 'reorderParticipants has no undo' (GREEN doc).
|
||||||
|
- `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
|
||||||
|
- Discovered in 500-round replay (3 occurrences). DISTINCT from BUG-5.
|
||||||
|
- Pattern: participant acts → DM deactivates them → DM reactivates them
|
||||||
|
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)
|
||||||
|
- `src/tests/Combat.scenario.test.js:254` deathSave query helper throws
|
||||||
|
(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
|
||||||
|
- Discovered 7/1 replay. `reorderParticipants` (shared/turn.js:522) = pure
|
||||||
|
drag, no pointer logic. Swapping two actors across current pointer mid-round
|
||||||
|
= ambiguous who-acted-this-round. Earlier replay arbitrary swaps showed
|
||||||
|
skip/double (R9 Summon3 2x, R11 Goblin1 2x) before fix restricted swaps to
|
||||||
|
upcoming-only.
|
||||||
|
- Replay now avoids crossing (adjacent upcoming pair only, commit af165f4).
|
||||||
|
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
|
||||||
|
- Discovered 7/1 replay. `computeTurnOrderAfterAddition` scans for first id
|
||||||
|
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-4: fix setDoc→updateDoc for all 5 activeDisplay sites
|
||||||
|
- [x] BUG-5: fixed (1-list model, 500 rounds clean)
|
||||||
|
- [x] BUG-6: fixed structurally (1-list model)
|
||||||
|
- [x] BUG-12: fixed — campaign selection follows activeDisplay
|
||||||
|
- [x] BUG-15: fixed — DisplayView no longer re-sorts (drag order preserved)
|
||||||
|
- [x] BUG-8: ws adapter reconnect (implemented + GREEN)
|
||||||
|
- [ ] BUG-10: deact+reactivate double-act
|
||||||
|
- [ ] BUG-11: FE Combat.scenario crash
|
||||||
|
- [ ] BUG-13: reorder cross-pointer semantics (RED + decide block/allow)
|
||||||
|
- [ ] BUG-14: addParticipant init-insert breaks post-drag (append? + RED)
|
||||||
@@ -4,28 +4,36 @@
|
|||||||
|
|
||||||
# Ignore Node.js modules (they will be installed in the Docker image)
|
# Ignore Node.js modules (they will be installed in the Docker image)
|
||||||
node_modules
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
# Ignore build output (it will be generated in the Docker image)
|
# Ignore build output (it will be generated in the Docker image)
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
|
|
||||||
# Ignore Docker files themselves
|
# Ignore Docker files themselves (Caddyfile MUST stay in context for frontend build)
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
Dockerfile.ws
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
# Ignore any local environment files if you have them
|
# Ignore any local environment files if you have them
|
||||||
.env
|
.env
|
||||||
# .env.local
|
.env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
# Ignore IDE and OS-specific files
|
# Ignore IDE and OS-specific files
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea
|
||||||
*.suo
|
*.suo
|
||||||
*.user
|
*.user
|
||||||
*.userosscache
|
*.userosscache
|
||||||
*.sln.docstates
|
*.sln.docstates
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Ignore local sqlite data + scratch diagnostics (never bake into image)
|
||||||
|
data/
|
||||||
|
scratch/
|
||||||
|
tmp/
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Caddyfile — single-container (caddy + node)
|
||||||
|
# Caddy serves built frontend, proxies /api + /ws to node backend on :4001.
|
||||||
|
# Node never exposed directly; only caddy on :80.
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy 127.0.0.1:4001
|
||||||
|
}
|
||||||
|
handle /ws {
|
||||||
|
reverse_proxy 127.0.0.1:4001
|
||||||
|
}
|
||||||
|
# catch-all: static frontend (SPA fallback)
|
||||||
|
handle {
|
||||||
|
root * /srv
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# docker/Dockerfile — single container: caddy (front) + node (back).
|
||||||
|
# Build context = repo root.
|
||||||
|
# ---- build stage: frontend + install backend deps ----
|
||||||
|
FROM node:18-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN npm install --include-workspace-root
|
||||||
|
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
COPY server/ ./server/
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY public/ ./public/
|
||||||
|
COPY tailwind.config.js postcss.config.js ./
|
||||||
|
|
||||||
|
# better-sqlite3 native build (alpine musl)
|
||||||
|
RUN cd server && npm rebuild better-sqlite3
|
||||||
|
|
||||||
|
# build frontend (ws storage, same-origin via caddy)
|
||||||
|
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
|
||||||
|
ENV REACT_APP_STORAGE=ws
|
||||||
|
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
|
||||||
|
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
|
||||||
|
|
||||||
|
# prune backend dev deps for runtime
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
# ---- runtime stage: caddy + node ----
|
||||||
|
FROM node:18-alpine
|
||||||
|
RUN apk add --no-cache caddy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/shared/node_modules ./shared/node_modules
|
||||||
|
COPY --from=build /app/server/node_modules ./server/node_modules
|
||||||
|
COPY --from=build /app/package*.json ./
|
||||||
|
COPY --from=build /app/shared/package.json ./shared/
|
||||||
|
COPY --from=build /app/server/package.json ./server/
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
COPY server/ ./server/
|
||||||
|
# built frontend served by caddy
|
||||||
|
COPY --from=build /app/build /srv
|
||||||
|
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=4001
|
||||||
|
ENV DB_PATH=/data/tracker.sqlite
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# docker/docker-compose.yml — single container: caddy (front) + node (back).
|
||||||
|
# Usage (from repo root):
|
||||||
|
# docker compose -f docker/docker-compose.yml up --build
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# no image: field => compose auto-names (docker-app), never pulls,
|
||||||
|
# always builds local. Service image private, never published.
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
args:
|
||||||
|
- REACT_APP_TRACKER_APP_ID=${TRACKER_APP_ID:-ttrpg-initiative-tracker-default}
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:80"
|
||||||
|
volumes:
|
||||||
|
- app-data:/data
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/tracker.sqlite
|
||||||
|
- PORT=4001
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# docker/entrypoint.sh — run node backend + caddy proxy in one container.
|
||||||
|
# Caddy foreground (PID 1, handles signals). Node background.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# node backend (internal :4001)
|
||||||
|
cd /app/server
|
||||||
|
node index.js &
|
||||||
|
NODE_PID=$!
|
||||||
|
|
||||||
|
# caddy proxy (foreground, :80)
|
||||||
|
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm workspaces.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
package.json # workspaces root
|
||||||
|
src/ # React frontend (CRA)
|
||||||
|
App.js # main app (~2900 lines)
|
||||||
|
storage/ # adapter layer (firebase/ws/memory + contract)
|
||||||
|
__mocks__/firebase/ # firebase SDK mock (Layer 1 tests)
|
||||||
|
tests/ # frontend tests
|
||||||
|
server/ # Backend: generic KV doc store (firebase mirror)
|
||||||
|
index.js # REST (doc/coll/batch) + WS bootstrap
|
||||||
|
db.js # SQLite docs table, KV ops, broadcast
|
||||||
|
tests/ # backend + adapter-vs-live tests
|
||||||
|
shared/ # Pure logic, no I/O (client + server + tests import)
|
||||||
|
turn.js # turn-order state machine
|
||||||
|
tests/ # turn logic tests
|
||||||
|
scripts/ # manual demo tool (NOT test)
|
||||||
|
replay-combat.js # live backend demo
|
||||||
|
tests/
|
||||||
|
audit/ # exploratory bug-finders (manual, Math.random)
|
||||||
|
audit-rotation.js # rotation invariant
|
||||||
|
audit-state.js # 9 invariant classes
|
||||||
|
scratch/ # gitignored: throwaway repro/exploration
|
||||||
|
docs/
|
||||||
|
REWORK_PLAN.md
|
||||||
|
DEVELOPMENT.md # this file
|
||||||
|
GLOSSARY.md # domain terms (turn vs round, etc)
|
||||||
|
ENCOUNTER_BUILDER.md # DM interface guide
|
||||||
|
TESTING.md # test + automation ops
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:keen99/ttrpg-initiative-tracker.git
|
||||||
|
cd ttrpg-initiative-tracker
|
||||||
|
npm install
|
||||||
|
git config core.hooksPath .githooks # enable pre-push test gate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
### Backend (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server:dev # :4001, db: server/data/tracker.sqlite
|
||||||
|
# or direct:
|
||||||
|
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke check:
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:4001/health # -> {"ok":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
|
||||||
|
|
||||||
|
### Frontend (dev server, ws mode)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REACT_APP_STORAGE=ws \
|
||||||
|
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||||
|
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
|
||||||
|
BROWSER=none PORT=3999 \
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens http://127.0.0.1:3999/. Admin view `/`, player view `/display`.
|
||||||
|
|
||||||
|
Firebase mode (default, upstream): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.example`). `STORAGE_MODE=firebase` falls through to real SDK.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:all # shared + server (fast, no frontend)
|
||||||
|
npm run shared:test # pure turn logic
|
||||||
|
npm run server:test # adapter vs live backend
|
||||||
|
npm test # CRA frontend (src/tests/, slow with scenario)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Suites
|
||||||
|
|
||||||
|
| Suite | Location | What | Count |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Unit (turn logic) | `shared/tests/` | pure nextTurn, rotation, pause-add | 50 (1 skip) |
|
||||||
|
| Integration (adapter vs backend) | `server/tests/` | ws adapter through live REST/WS | 23 |
|
||||||
|
| Characterization (UI) | `src/tests/` | locks current App.js behavior | 62 |
|
||||||
|
| Scenario | `src/tests/Combat.scenario.test.js` | 100-round full combat (240s) | 289 phases |
|
||||||
|
|
||||||
|
Total: 134 green + 1 validated RED (skipped).
|
||||||
|
|
||||||
|
### Test types
|
||||||
|
|
||||||
|
- **Unit** = pure logic, fast, no I/O. Locks behavior of single functions.
|
||||||
|
- **Integration** = real backend per test, adapter translation verified.
|
||||||
|
- **Characterization** = render App via mock, assert current (buggy or not) UI behavior. Not desired-state.
|
||||||
|
- **Scenario** = end-to-end flow through rendered App, asserts full sequence completes.
|
||||||
|
- **Contract** = same spec run against every storage impl (memory, ws, firebase). Catches adapter drift.
|
||||||
|
|
||||||
|
### Running one file / pattern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test --workspace shared -- --testPathPattern=round-rotation
|
||||||
|
CI=true npx react-scripts test --watchAll=false src/tests/App.characterization.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario test is slow
|
||||||
|
|
||||||
|
`Combat.scenario.test.js` runs 100 combat rounds through rendered App — 240s timeout by design. Skip when iterating:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CI=true npx react-scripts test --watchAll=false --testPathIgnorePatterns="Combat.scenario"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demo tool (NOT test)
|
||||||
|
|
||||||
|
`scripts/replay-combat.js` = live backend demo. Watch UI react to state changes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start backend + frontend first
|
||||||
|
node scripts/replay-combat.js [rounds] [delayMs]
|
||||||
|
# defaults: 100 rounds, 200ms/step
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage per round: damage, heal, all 22 conditions, toggleActive,
|
||||||
|
removeParticipant, addParticipant (reinforcements), updateParticipant,
|
||||||
|
pause/resume, reorderParticipants, endEncounter. Revives dead each round
|
||||||
|
to sustain full round count.
|
||||||
|
|
||||||
|
## Audit tools (NOT unit tests)
|
||||||
|
|
||||||
|
`tests/audit/` = exploratory, Math.random, non-deterministic. Manual run.
|
||||||
|
Unit tests (`{shared,server,src}/tests/`) lock known bugs deterministically.
|
||||||
|
|
||||||
|
### audit-rotation.js
|
||||||
|
|
||||||
|
Pure turn.js simulation of replay op sequence. Detects rotation violations
|
||||||
|
(skip/dupe per round). Found BUG-1 (addParticipant + pause corrupts rotation).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tests/audit/audit-rotation.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Bisect: comment/uncomment op blocks to isolate triggering combo.
|
||||||
|
|
||||||
|
### audit-state.js
|
||||||
|
|
||||||
|
Runs pure turn.js combat, audits 9 invariant classes per round:
|
||||||
|
|
||||||
|
1. rotation integrity (skip/dupe)
|
||||||
|
2. HP bounds (0 ≤ hp ≤ max, no NaN)
|
||||||
|
3. isActive consistency (dead = inactive)
|
||||||
|
4. turnOrder no dup ids
|
||||||
|
5. turnOrder ids all active
|
||||||
|
6. currentTurn valid + active
|
||||||
|
7. deathSave range (0 ≤ saves ≤ 3, reset on revive)
|
||||||
|
8. removeParticipant orphans
|
||||||
|
9. undo support
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tests/audit/audit-state.js [rounds] # default 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Current state (post BUG-1/2 fix): 0 violations / 100 rounds.
|
||||||
|
|
||||||
|
See `TODO.md` for known bugs.
|
||||||
|
|
||||||
|
## Scratch
|
||||||
|
|
||||||
|
`scratch/` = gitignored throwaway. Repro scripts, exploration, debug.
|
||||||
|
Not committed. Use freely, delete anytime.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # CRA production build -> build/
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker build (existing, frontend-only):
|
||||||
|
```bash
|
||||||
|
docker build -t ttrpg-initiative-tracker .
|
||||||
|
docker run -p 8080:80 --rm ttrpg-initiative-tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
Full-stack docker-compose arrives in M5.
|
||||||
|
|
||||||
|
## Storage architecture
|
||||||
|
|
||||||
|
### Generic KV doc store
|
||||||
|
|
||||||
|
Backend = firebase mirror. Single `docs` table: `path` (PK), `parent`, `data` (JSON), `updated_at`. Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.
|
||||||
|
|
||||||
|
```
|
||||||
|
Client (browser) Server
|
||||||
|
| |
|
||||||
|
|-- storage.setDoc(path,data) -->| REST PUT /api/doc
|
||||||
|
|<---- 200 ----------------------|
|
||||||
|
| |
|
||||||
|
|-- storage.subscribeDoc(path) -->| WS subscribe
|
||||||
|
|<---- WS {initial} --------------| immediate value
|
||||||
|
| ... |
|
||||||
|
|<---- WS {change} --------------| on any write to path
|
||||||
|
| |
|
||||||
|
Display / tablet |
|
||||||
|
|<---- WS {change} --------------| same push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path normalization
|
||||||
|
|
||||||
|
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Adapter `norm()` strips prefix → bare canonical (`campaigns/...`). All impls share identity (contract test).
|
||||||
|
|
||||||
|
### STORAGE_MODE flow
|
||||||
|
|
||||||
|
`getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`).
|
||||||
|
- `firebase` → real SDK init
|
||||||
|
- `ws`/`memory` → stub auth + db sentinel, route via `storage.*` adapter
|
||||||
|
|
||||||
|
## Test layers
|
||||||
|
|
||||||
|
- **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises ws adapter.
|
||||||
|
- **Layer 2**: ws 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).
|
||||||
|
|
||||||
|
## Local pipeline (pre-push hook)
|
||||||
|
|
||||||
|
Private repo = no free GitHub Actions. Tests run locally via git hook.
|
||||||
|
|
||||||
|
`.githooks/pre-push` runs `npm run test:all` (shared + server, fast). Frontend tests not gated (slow).
|
||||||
|
|
||||||
|
Skip:
|
||||||
|
```bash
|
||||||
|
git push --no-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Already configured on this checkout after `git config core.hooksPath .githooks`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Milestone | State |
|
||||||
|
|---|---|
|
||||||
|
| 0 repo/branch | ✅ done |
|
||||||
|
| 1 backend + tests | ✅ done |
|
||||||
|
| 2 frontend WS adapter | ✅ done |
|
||||||
|
| 3 characterization tests | ✅ done (134 green) |
|
||||||
|
| 4 skip fix + manual override | ⬜ next |
|
||||||
|
| 5 docker compose | ⬜ |
|
||||||
|
| 6 undo rework | ⬜ |
|
||||||
|
| 7 playwright e2e | ⬜ deferred |
|
||||||
|
|
||||||
|
See `docs/REWORK_PLAN.md` for full plan, `TODO.md` for known bugs.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- `origin` = `github.com:keen99/ttrpg-initiative-tracker` (this fork)
|
||||||
|
- `upstream` = `code.draft13.com/robert/ttrpg-initiative-tracker` (friend's Gitea, read-only)
|
||||||
|
- work branch: `rework-backend` (off `main`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream # pull friend's changes
|
||||||
|
git merge upstream/main # rebase our branch onto his
|
||||||
|
```
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Encounter Builder — DM Interface Guide
|
||||||
|
|
||||||
|
How a DM (or LLM automating the DM role) builds and runs encounters via the UI and storage layer. Covers entity model, build flow, combat controls, and the storage paths backing each action.
|
||||||
|
|
||||||
|
## Entity model
|
||||||
|
|
||||||
|
Three nested entities. All stored as opaque JSON docs in the KV store (generic doc store — see `docs/DEVELOPMENT.md`).
|
||||||
|
|
||||||
|
```
|
||||||
|
Campaign
|
||||||
|
└─ Encounter(s)
|
||||||
|
└─ Participant(s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus two global docs:
|
||||||
|
- `activeDisplay/status` — controls player view (which campaign+encounter, hide-HP flag)
|
||||||
|
- `logs/{id}` — append-only action log entries
|
||||||
|
|
||||||
|
### Campaign
|
||||||
|
|
||||||
|
Path: `artifacts/{APP_ID}/public/data/campaigns/{campaignId}`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | string | |
|
||||||
|
| `playerDisplayBackgroundUrl` | string | optional, image URL for player display bg |
|
||||||
|
| `ownerId` | string | user id |
|
||||||
|
| `createdAt` | ISO string | |
|
||||||
|
| `players` | array | campaign-level character roster (templates, NOT combatants) |
|
||||||
|
|
||||||
|
Campaign characters = reusable templates. Default HP + init mod. Added to any encounter via ParticipantManager. Not combatants themselves.
|
||||||
|
|
||||||
|
### Encounter
|
||||||
|
|
||||||
|
Path: `artifacts/{APP_ID}/public/data/campaigns/{campaignId}/encounters/{encounterId}`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | string | |
|
||||||
|
| `createdAt` | ISO string | |
|
||||||
|
| `participants` | array | the combatants (see below) |
|
||||||
|
| `round` | int | 0 = not started |
|
||||||
|
| `currentTurnParticipantId` | string\|null | who acts now |
|
||||||
|
| `isStarted` | bool | combat active |
|
||||||
|
| `isPaused` | bool | frozen turn order (add/remove/edit allowed) |
|
||||||
|
| `turnOrderIds` | array | participant ids in turn order = participants[] order (1-list model) |
|
||||||
|
|
||||||
|
### Participant
|
||||||
|
|
||||||
|
Object in `encounter.participants[]`:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | string | `generateId()` |
|
||||||
|
| `name` | string | |
|
||||||
|
| `type` | `'character'` \| `'monster'` | character = PC (death saves), monster = hostile/NPC |
|
||||||
|
| `originalCharacterId` | string\|null | links back to campaign character if type=character |
|
||||||
|
| `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. |
|
||||||
|
| `maxHp` | int | |
|
||||||
|
| `currentHp` | int | 0 = dead/dying |
|
||||||
|
| `isNpc` | bool | monster flagged NPC (display color, no death saves) |
|
||||||
|
| `conditions` | array | condition ids from `CONDITIONS` list |
|
||||||
|
| `isActive` | bool | in turn rotation? false = skipped by nextTurn |
|
||||||
|
| `deathSaves` | int | PC only, 0-3 fails |
|
||||||
|
| `isDying` | bool | death animation flag (player display) |
|
||||||
|
|
||||||
|
## Build flow (UI)
|
||||||
|
|
||||||
|
Admin view at `/`. Steps:
|
||||||
|
|
||||||
|
### 1. Create campaign
|
||||||
|
- Click **Create Campaign** button
|
||||||
|
- Enter name + optional background URL
|
||||||
|
- Submits → `setDoc(campaigns/{id}, { name, playerDisplayBackgroundUrl, ownerId, createdAt, players:[] })`
|
||||||
|
|
||||||
|
### 2. Select campaign
|
||||||
|
- Click campaign card → `setSelectedCampaignId(campaign.id)`
|
||||||
|
- Now managing: CharacterManager + EncounterManager visible
|
||||||
|
|
||||||
|
### 3. Add campaign characters (optional templates)
|
||||||
|
CharacterManager section. Per character:
|
||||||
|
- **Name**
|
||||||
|
- **Default HP** (`DEFAULT_MAX_HP` = 10)
|
||||||
|
- **Init Mod** (`DEFAULT_INIT_MOD` = 0)
|
||||||
|
|
||||||
|
→ `updateDoc(campaign, { players:[...existing, newChar] })`
|
||||||
|
|
||||||
|
These are reusable across encounters. Add to encounter later (auto-rolls initiative).
|
||||||
|
|
||||||
|
### 4. Create encounter
|
||||||
|
- Click **Create Encounter**
|
||||||
|
- Enter name
|
||||||
|
→ `setDoc(campaigns/{cid}/encounters/{eid}, { name, createdAt, participants:[], round:0, currentTurnParticipantId:null, isStarted:false, isPaused:false })`
|
||||||
|
|
||||||
|
### 5. Add participants
|
||||||
|
ParticipantManager section. Two paths:
|
||||||
|
|
||||||
|
**Monster/NPC:**
|
||||||
|
- **Monster Name** (`placeholder: "e.g., Dire Wolf"`)
|
||||||
|
- **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2)
|
||||||
|
- **Max HP** (`DEFAULT_MAX_HP` = 10)
|
||||||
|
- **Is NPC?** checkbox (flag, changes display color)
|
||||||
|
- Click **Add to Encounter**
|
||||||
|
- Initiative auto-rolled: `rollD20() + mod`
|
||||||
|
|
||||||
|
**Character (from campaign roster):**
|
||||||
|
- Select character from dropdown
|
||||||
|
- Click **Add to Encounter**
|
||||||
|
- OR **Add All (Roll Init)** — bulk-adds all campaign chars, each rolls own initiative
|
||||||
|
|
||||||
|
**Duplicate guard:** same `originalCharacterId` blocked (alerts "already in this encounter"). Monsters no dedup.
|
||||||
|
|
||||||
|
Participant object added:
|
||||||
|
```js
|
||||||
|
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp,
|
||||||
|
isNpc, conditions:[], isActive:true, deathSaves:0, isDying:false }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Reorder before start (tie-break)
|
||||||
|
Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`.
|
||||||
|
|
||||||
|
Post-start drag: see BUG-13/14 in `TODO.md` (cross-init + pointer semantics untested).
|
||||||
|
|
||||||
|
## Combat flow (UI)
|
||||||
|
|
||||||
|
InitiativeControls panel (sticky, right side).
|
||||||
|
|
||||||
|
### Start
|
||||||
|
- **Start Combat** button (disabled if no active participants)
|
||||||
|
- Sorts ALL participants by initiative (1-list: `participants[]` = display + turn order)
|
||||||
|
- `round=1`, `currentTurnParticipantId` = first active, `isStarted=true`, `isPaused=false`
|
||||||
|
- Sets `activeDisplay` → this campaign+encounter (player display syncs)
|
||||||
|
- Initiative fixed at start. NOT re-derived from mod after.
|
||||||
|
|
||||||
|
### Next Turn
|
||||||
|
- **Next Turn** button (disabled if paused)
|
||||||
|
- Advances to next active participant in `turnOrderIds`
|
||||||
|
- Wraps at end → `round += 1`, re-sorts active by initiative at round start
|
||||||
|
- Dead (`isActive:false`) skipped, stay in rotation
|
||||||
|
|
||||||
|
### Pause / Resume
|
||||||
|
- **Pause Combat** → `isPaused=true`, Next Turn disabled
|
||||||
|
- While paused: add/remove participants, adjust HP, edit initiative, reorder ties
|
||||||
|
- **Resume Combat** → `isPaused=false`, no re-sort (1-list: turnOrderIds already current)
|
||||||
|
|
||||||
|
### HP adjustments (combat only)
|
||||||
|
Per-participant input + buttons:
|
||||||
|
- Number input
|
||||||
|
- **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)`
|
||||||
|
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)`
|
||||||
|
- Death: hp→0 sets `isActive:false`, PC gets `deathSaves` tracking
|
||||||
|
|
||||||
|
### Death saves (PC only, at 0 HP)
|
||||||
|
3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal.
|
||||||
|
|
||||||
|
### Conditions
|
||||||
|
- Click participant → expand conditions picker (all 22 from `CONDITIONS`)
|
||||||
|
- Active conditions show as badges, click to remove
|
||||||
|
|
||||||
|
### End combat
|
||||||
|
- **End Combat** button → resets `isStarted:false`, `round:0`, `currentTurn:null`, `turnOrderIds:[]`
|
||||||
|
- Clears `activeDisplay` (player view goes blank)
|
||||||
|
|
||||||
|
## Player display
|
||||||
|
|
||||||
|
Separate view at `/display` or `?playerView=true`. Read-only second screen.
|
||||||
|
|
||||||
|
What it shows:
|
||||||
|
- Current encounter name
|
||||||
|
- Round + current turn participant
|
||||||
|
- All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix)
|
||||||
|
- HP bars, conditions, death saves
|
||||||
|
- Inactive monsters hidden (pre-staged reserves)
|
||||||
|
|
||||||
|
Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets).
|
||||||
|
|
||||||
|
## 1-list turn order model
|
||||||
|
|
||||||
|
Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single source of truth.
|
||||||
|
|
||||||
|
- **Display** = `participants[]` order (AdminView + DisplayView, no re-sort)
|
||||||
|
- **Turn rotation** = `turnOrderIds` (mirrors participants[])
|
||||||
|
- **Drag** = source of truth, overrides initiative
|
||||||
|
- **Add mid-combat** = append to participants[] + sync (BUG-14: init-insert broken post-drag)
|
||||||
|
- **Toggle active** = flip `isActive` only, stay in slot
|
||||||
|
- **Remove** = drop from participants[] + sync, advance current if needed
|
||||||
|
|
||||||
|
No re-sort after `startEncounter` except round-wrap (re-sorts active by init at top of round).
|
||||||
|
|
||||||
|
## Storage paths quick reference
|
||||||
|
|
||||||
|
```
|
||||||
|
campaigns/{cid} campaign doc
|
||||||
|
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
|
||||||
|
campaigns/{cid}/encounters/{eid}/participants ❌ NOT a path — participants inline
|
||||||
|
activeDisplay/status player display control
|
||||||
|
logs/{logId} action log entry
|
||||||
|
```
|
||||||
|
|
||||||
|
## DM tips
|
||||||
|
|
||||||
|
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
|
||||||
|
- Pause before big roster changes (adds/removes). Resume re-syncs cleanly.
|
||||||
|
- Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added).
|
||||||
|
- Dead monsters stay in rotation, skipped. Remove via trash icon to clean list.
|
||||||
|
- Player display auto-follows Start Combat. Manual control via Open Player Window.
|
||||||
|
|
||||||
|
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Glossary — TTRPG Initiative Tracker
|
||||||
|
|
||||||
|
Domain terms used throughout the app, shared turn logic, tests, and docs. Keep
|
||||||
|
these definitions stable so logs, UI labels, and code agree.
|
||||||
|
|
||||||
|
## Combat structure
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Initiative** | The ordered sequence determining who acts next. Rolled once at the start of an encounter; re-rolled only on a new encounter or explicit DM re-roll. |
|
||||||
|
| **Round** | One full pass through initiative — every active participant gets exactly one turn. N participants (PCs + NPCs + monsters + features) = N turns per round. Round counter increments when initiative wraps back to the first participant. |
|
||||||
|
| **Turn** | A single participant's initiative slot within a round. One participant acts. As many turns per round as there are participants. |
|
||||||
|
| **Initiative slot** | Synonym for turn's position in the ordered list. |
|
||||||
|
| **Top of round** | The first turn of a round (round counter increments here). |
|
||||||
|
|
||||||
|
Example: encounter with 8 participants (3 PCs + 4 monsters + 1 NPC).
|
||||||
|
|
||||||
|
```
|
||||||
|
Round 1: turn 1 (Fighter) → turn 2 (Goblin1) → ... → turn 8 (Merchant)
|
||||||
|
Round 2: turn 1 (Fighter) → ... → turn 8 (Merchant) [round counter +=1 at top]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Participants
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Participant** | Any combatant tracked in initiative. Has HP, initiative roll, conditions, and an `isActive` flag. |
|
||||||
|
| **PC** (Player Character) | Controlled by a player. On death → death saves (not removed from initiative). |
|
||||||
|
| **NPC** (Non-Player Character) | DM-controlled ally/neutral (e.g. merchant, quest-giver). May or may not roll initiative. |
|
||||||
|
| **Monster** | Hostile DM-controlled combatant. On death → typically removed from active initiative or marked dead. |
|
||||||
|
| **Feature / Lair** | Environmental or legendary effect that occupies an initiative slot (e.g. lair action at initiative 20). |
|
||||||
|
|
||||||
|
## Participant state
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **HP** (Hit Points) | Current health. `currentHp` / `maxHp`. At 0 → dying or dead (rules differ by type). |
|
||||||
|
| **Initiative mod** | Bonus added to d20 initiative roll. `defaultInitMod`. |
|
||||||
|
| **Conditions** | Temporary status effects (stunned, prone, poisoned, etc.) applied/toggled during play. Array on participant. |
|
||||||
|
| **isActive** | Whether the participant is in the active initiative rotation. Set false on death (CURRENT behavior — see M4 skip-bug fix). |
|
||||||
|
| **Death save** | PC-only mechanic. Successes/failures tracked at 0 HP. 3 fails → dead; 3 successes → stable. |
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Admin view** (`/`) | DM interface. Full create/edit/combat control. |
|
||||||
|
| **Player view** (`/display` or `?playerView=true`) | Read-only second-screen display for players. Shows current turn, HP bars, conditions, round. No DM controls. |
|
||||||
|
| **Active display** | The single `activeDisplay/status` doc controlling what the player view shows (which campaign/encounter, hide-player-HP flag). |
|
||||||
|
|
||||||
|
## Backend / storage
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Adapter** | `src/storage/{firebase,ws,memory}.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. |
|
||||||
|
| **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 2 test** | ws adapter vs live backend. Proves translation + path identity. |
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# Initiative Tracker — Rework Plan
|
||||||
|
|
||||||
|
Status: **APPROVED — executing**
|
||||||
|
Owner: draistrick (fork → `keen99/ttrpg-initiative-tracker`, private)
|
||||||
|
Upstream: `code.draft13.com/robert/ttrpg-initiative-tracker` (friend's Gitea)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Replace Firebase with self-hosted backend.** Browser cannot own a DB file (sandbox). Cross-device (DM + tablet + player view) requires a real backend. Backend is the foundation, built first.
|
||||||
|
2. **Automated test ecosystem as the baseline.** Lock current behavior before changing it.
|
||||||
|
3. **Remain mergeable upstream.** Default behavior (Firebase) preserved behind flag. Upstream `main` stays clean. Friend keeps Firebase path.
|
||||||
|
4. **Self-hostable in local Docker** (in-house network). Public exposure = future, only after auth + multiuser safety.
|
||||||
|
|
||||||
|
## Non-Goals (this plan)
|
||||||
|
|
||||||
|
- Ripping Firebase. Kept as default adapter upstream.
|
||||||
|
- Public/multiuser deployment. Deferred.
|
||||||
|
- Rewriting the entire 2935-line `App.js`. Only extract what testability demands.
|
||||||
|
- Feature/bug work. That lives in `TODO.md`. This plan = infra + backend + test harness only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
### Why Firebase is wrong here (for this fork)
|
||||||
|
- Requires Google account + network for a single-user tabletop tool.
|
||||||
|
- Realtime value (DM view ↔ player display) is real but solvable locally.
|
||||||
|
- API key baked into client bundle (CRA `REACT_APP_*` at build); security depends entirely on console rules not in repo.
|
||||||
|
- Vendor lock + quota; `onSnapshot` on collections burns reads.
|
||||||
|
- Friend keeps it; we fork off it.
|
||||||
|
|
||||||
|
### Why a backend is mandatory
|
||||||
|
Browser sandbox cannot write the filesystem. No sqlite file, no `/data/db.sqlite`, nothing. Browser JS is blocked from disk by design. Therefore cross-device storage (DM ↔ tablet ↔ player view) requires a separate Node process owning the DB file and serving the browser over HTTP/WebSocket. There is no browser-only path. **The backend is step one, not deferred.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Stack (locked)
|
||||||
|
- **Node.js** runtime
|
||||||
|
- **Express** web framework
|
||||||
|
- **ws** WebSocket lib (realtime push, replaces `onSnapshot`)
|
||||||
|
- **better-sqlite3** SQLite driver (synchronous, simple, fast)
|
||||||
|
- **SQLite** DB (single file, docker volume, trivial backup)
|
||||||
|
- **Jest** test runner (already in CRA deps)
|
||||||
|
|
||||||
|
Postgres deferred until public multiuser exposure is real. SQLite schema ports easily if that day comes.
|
||||||
|
|
||||||
|
### Backend design
|
||||||
|
- Owns SQLite file. Only writer.
|
||||||
|
- Holds authoritative state.
|
||||||
|
- Generic KV doc store (firebase mirror): single `docs` table (path PK, parent, data JSON, updated_at). Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.
|
||||||
|
- WS broadcast on every state change → all connected clients (DM view, player display, tablet) update instantly.
|
||||||
|
|
||||||
|
### Three storage impls, one interface (frontend)
|
||||||
|
|
||||||
|
The storage interface is the test seam and the upstream-compat layer.
|
||||||
|
|
||||||
|
| Impl | When used | Automated-tested? |
|
||||||
|
|---|---|---|
|
||||||
|
| `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 |
|
||||||
|
| `memory.js` | test-only, in-process | Yes — fast, deterministic |
|
||||||
|
|
||||||
|
**Frontend interface contract** (all three implement):
|
||||||
|
- `getDoc(path)`, `setDoc(path, data, opts)`, `updateDoc(path, patch)`
|
||||||
|
- `deleteDoc(path)`, `batch(ops)`
|
||||||
|
- `subscribeDoc(path, cb)` / `subscribeCollection(path, cb)` → real-time push
|
||||||
|
|
||||||
|
Firebase impl: existing `onSnapshot` + SDK calls, moved verbatim behind interface (M2).
|
||||||
|
WS impl: thin adapter; generic KV ops, receives **state updates** via WS subscribe (M2).
|
||||||
|
Memory impl: in-memory Map + EventEmitter, for tests (M3).
|
||||||
|
|
||||||
|
### Repo layout (npm workspaces)
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
package.json # workspaces root
|
||||||
|
src/ # React frontend (existing, refactored behind storage interface)
|
||||||
|
storage/
|
||||||
|
index.js # factory: pick impl from STORAGE env
|
||||||
|
firebase.js # extracted from current App.js (verbatim)
|
||||||
|
ws.js # NEW — talks to backend
|
||||||
|
memory.js # NEW — test only
|
||||||
|
contract.js # interface spec (runStorageContract)
|
||||||
|
tests/ # frontend tests
|
||||||
|
server/ # NEW
|
||||||
|
index.js # Express + ws bootstrap, generic KV REST
|
||||||
|
db.js # better-sqlite3, docs table (KV), broadcast
|
||||||
|
handlers.js # REST handlers
|
||||||
|
tests/ # adapter vs live backend (Layer 2 test)
|
||||||
|
shared/ # pure logic, no I/O, importable by client + server + tests
|
||||||
|
turn.js # turn logic (single source; tests import)
|
||||||
|
tests/ # turn logic unit tests (characterization + desired)
|
||||||
|
data/ # gitignored sqlite DB
|
||||||
|
docker-compose.yml # NEW — M5
|
||||||
|
docs/
|
||||||
|
REWORK_PLAN.md # this file
|
||||||
|
DEVELOPMENT.md
|
||||||
|
GLOSSARY.md
|
||||||
|
TODO.md # bugs + features (separate from this plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- **Now:** `AUTH_MODE=none`. App gated by nginx HTTP basic auth (reuse friend's existing pattern). In-house only. Risk acceptable: someone sees your initiative counter.
|
||||||
|
- **Future:** `AUTH_MODE=token` — real login, real users. Only if/when publicly exposed. Not built this plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
Each milestone = independently mergeable PR upstream (unless marked ❌).
|
||||||
|
|
||||||
|
| M | Does | Tests? |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | repo, branch, remotes | no |
|
||||||
|
| 1 | build backend (Node+Express+ws+better-sqlite3) | unit tests as built |
|
||||||
|
| 2 | frontend WS adapter — app runs vs backend, cross-device works | yes |
|
||||||
|
| 3 | characterization tests lock current behavior | yes |
|
||||||
|
| 4 | resolve initiative rotation corruption (BUG-5) | yes |
|
||||||
|
| 5 | docker single container (caddy+node) | smoke ✅ |
|
||||||
|
| 6 | _moved to TODO backlog (feature work)_ | - |
|
||||||
|
| 7 | playwright multi-window e2e (deferred) | e2e |
|
||||||
|
| 8 | (future) public exposure | - |
|
||||||
|
|
||||||
|
### Milestone 0 — Repo + branch setup ✅
|
||||||
|
- Fresh branch off `main` (not `dsr-rework`). Name: `rework-backend`.
|
||||||
|
- `upstream` remote = friend's Gitea (read-only fetch).
|
||||||
|
- Push origin = `keen99/ttrpg-initiative-tracker` (private).
|
||||||
|
- npm workspaces root config.
|
||||||
|
- Commit this plan.
|
||||||
|
- **Exit criteria:** clean branch, plan committed, remotes set. ✅ DONE.
|
||||||
|
- **Upstream-PRable:** n/a (fork infra)
|
||||||
|
|
||||||
|
### Milestone 1 — Build backend ✅
|
||||||
|
- `server/`: Express + ws + 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.
|
||||||
|
- 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.
|
||||||
|
- **Upstream-PRable:** ❌ divergence (friend stays Firebase).
|
||||||
|
|
||||||
|
### Milestone 2 — Frontend WS adapter ✅
|
||||||
|
- Define `storage/contract.js` interface spec.
|
||||||
|
- 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/memory.js` for frontend unit tests.
|
||||||
|
- `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged).
|
||||||
|
- App runs against backend with `STORAGE=ws`.
|
||||||
|
- Cross-device verified manually: DM view + player display + tablet.
|
||||||
|
- **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE.
|
||||||
|
- **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌.
|
||||||
|
|
||||||
|
### Milestone 3 — Characterization tests lock current behavior ✅
|
||||||
|
- Lock current behavior via tests.
|
||||||
|
- 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).
|
||||||
|
- Iterate until confident: baseline solid, regressions impossible to silently slip.
|
||||||
|
- **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE.
|
||||||
|
- **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape).
|
||||||
|
|
||||||
|
### Milestone 4 — Resolve initiative rotation corruption (BUG-5) ✅
|
||||||
|
- **Fixed** (commit `494327f`).
|
||||||
|
- Slot-array turn order model + DRY advance core (`nextActiveAfter`).
|
||||||
|
Both `nextTurn` + `computeTurnOrderAfterRemoval` delegate → one advance
|
||||||
|
path, no drift.
|
||||||
|
- 500-round replay: 0 skips, 0 double-acts.
|
||||||
|
- Tests: `turn.skip.test.js`, `turn.dry.test.js` (advance parity lock).
|
||||||
|
- **Upstream-PRable:** ✅ bug fix.
|
||||||
|
|
||||||
|
### Milestone 5 — Docker compose ✅
|
||||||
|
- Single container: caddy (front, static + proxy) + node backend (internal :4001).
|
||||||
|
- Files in `docker/` tree (kept separate from upstream root Dockerfile):
|
||||||
|
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
|
||||||
|
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback
|
||||||
|
- `docker/entrypoint.sh` — node bg + caddy fg
|
||||||
|
- `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.
|
||||||
|
- No `image:` field => compose auto-names, never pulls service image (private).
|
||||||
|
- **Exit criteria:** `docker compose up` runs full stack in-house. ✅ DONE.
|
||||||
|
- Verified: REST roundtrip, WS subscribe+push, replay 20 rounds CLEAN (0 skips/doubles/shifts), UI styled (Tailwind compiles).
|
||||||
|
- **Upstream-PRable:** ✅ separate docker/ tree, root Dockerfile untouched, firebase default preserved.
|
||||||
|
|
||||||
|
### Milestone 6 — Undo rework — _MOVED to TODO backlog_
|
||||||
|
- Moved: feature work (transactional undo), not infra. Lives in `TODO.md` now.
|
||||||
|
- Scope: events table `(type, payload, undo_payload, undone, ts)`; undo = apply undo_payload in tx.
|
||||||
|
|
||||||
|
### Milestone 7 — Playwright E2E (deferred)
|
||||||
|
- Multi-window E2E: DM view + display + player view in separate browser contexts against running backend.
|
||||||
|
- Verify realtime sync end-to-end.
|
||||||
|
- **Only build if sync regresses or we deviate significantly.** Turn-logic unit + backend integration tests cover most regression risk cheaper.
|
||||||
|
- **Exit criteria:** e2e green for core combat flow across 3 windows.
|
||||||
|
- **Upstream-PRable:** ✅ if test infra shared.
|
||||||
|
|
||||||
|
### Milestone 8 — (Future) Public exposure
|
||||||
|
- Real auth (`AUTH_MODE=token`).
|
||||||
|
- Rate limiting, CSRF, hardening.
|
||||||
|
- Postgres migration if load warrants.
|
||||||
|
- Only if we decide to expose publicly + multiuser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Layers
|
||||||
|
1. **Turn logic unit tests** (Jest, pure functions, `shared/tests/`). Characterization + desired. Cheap, essential.
|
||||||
|
2. **Backend integration tests** (Jest, `server/tests/`) — spin server on random port, assert WS pushes + SQLite persists + transactional correctness.
|
||||||
|
3. **Frontend adapter contract tests** (Jest, `src/tests/`) — impl parity against interface (memory). Firebase mock harness for Layer 1 App tests.
|
||||||
|
|
||||||
|
### Characterization → desired
|
||||||
|
1. **Characterization** — capture current behavior exactly (bugs included). Locks extraction/port as provably identical. Lets later fix be provable.
|
||||||
|
2. **Desired-behavior (red)** — write what *should* happen. Fail today. Fix → green. Bug stays dead. (Bug fixes live in TODO.md, tracked separately.)
|
||||||
|
|
||||||
|
### Manual smoke via config flags
|
||||||
|
- `STORAGE=firebase` → current behavior (friend's path, upstream default).
|
||||||
|
- `STORAGE=ws` → our path, local backend.
|
||||||
|
- docker-compose profiles mirror the above.
|
||||||
|
|
||||||
|
### Accepted test gap
|
||||||
|
- Firebase adapter untested (requires live project). Accepted cost.
|
||||||
|
- Mitigated by: interface contract; if firebase impl drifts, integration smoke only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mergeability upstream
|
||||||
|
|
||||||
|
| Milestone | Upstream-PRable? | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 repo setup | n/a | fork infra |
|
||||||
|
| 1 backend | ❌ | divergence (friend stays Firebase) |
|
||||||
|
| 2 WS adapter | ⚠️ partial | interface + firebase extract ✅, WS ❌ |
|
||||||
|
| 3 characterization tests | ✅ | if storage-agnostic |
|
||||||
|
| 4 BUG-5 rotation fix | ✅ | bug fix |
|
||||||
|
| 5 docker | ✅ | separate docker/ tree, root Dockerfile untouched, firebase preserved |
|
||||||
|
| 6 undo (moved to TODO) | - | - |
|
||||||
|
| 7 playwright | ✅ | if test infra shared |
|
||||||
|
|
||||||
|
Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally zero change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **CRA + workspaces friction.** Create React App may resist monorepo layout. Mitigation: keep `src/` as CRA root, `server/` + `shared/` as separate workspaces imported via alias. Eject/craco only if forced.
|
||||||
|
- **Firebase drift untested.** Mitigation: interface contract; friend's path his to maintain.
|
||||||
|
- **Undo history migration.** Existing log entries use old snapshot format. Mitigation: keep old undo working until cleared, new format for new entries.
|
||||||
|
- **WS reconnect/state-sync edge cases.** Transient drop mid-combat. Mitigation: client requests full state resync on (re)connect; server is source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
1. **Branch:** `rework-backend` off `main`.
|
||||||
|
2. **npm workspaces** for `server/` + `shared/` alongside CRA `src/`. Fallback alias if CRA fights.
|
||||||
|
3. **Backend = generic KV doc store** (firebase mirror), not shape-specific endpoints. Thin adapter passthrough. Opaque JSON at arbitrary path strings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
- M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
|
||||||
|
- Backend live: port 4001, db `./data/tracker.sqlite`
|
||||||
|
- Frontend: port 3999 with `REACT_APP_STORAGE=ws`
|
||||||
|
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
|
||||||
|
- Next milestones: M5 docker-compose. Undo moved to TODO backlog.
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
# Testing & Automation — Operating Guide
|
||||||
|
|
||||||
|
How to run tests, demos, audits, docker stack, and understand the test layers. For any LLM session picking up this repo.
|
||||||
|
|
||||||
|
## Test commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:all # shared + server (fast, ~2s) — pre-push gate
|
||||||
|
npm run shared:test # pure turn logic (shared/turn.js)
|
||||||
|
npm run server:test # ws adapter vs live backend
|
||||||
|
npm test # CRA frontend (src/tests/, slow w/ scenario)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-push hook (`.githooks/pre-push`) runs `npm run test:all`. Frontend not gated (slow). Skip: `git push --no-verify`.
|
||||||
|
|
||||||
|
Setup hook once per clone:
|
||||||
|
```bash
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test suites
|
||||||
|
|
||||||
|
| Suite | Location | What | Count |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Unit (turn logic) | `shared/tests/` | pure nextTurn, rotation, pause-add, dead-skip, reorder, round, invariant, dry | 90 |
|
||||||
|
| Integration (adapter vs backend) | `server/tests/` | ws adapter through live REST/WS | 24 |
|
||||||
|
| Characterization (UI) | `src/tests/` | locks current App.js behavior | 66 |
|
||||||
|
| ESM guard | `src/tests/StorageEsm.test.js` | no CJS in adapters | 4 |
|
||||||
|
|
||||||
|
Total: ~184. 1 known RED (BUG-4 HideHpToggle, backlog).
|
||||||
|
|
||||||
|
### Run one file / pattern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test --workspace shared -- --testPathPattern=round-rotation
|
||||||
|
npm run server:test -- tests/ws-reconnect
|
||||||
|
CI=true npx react-scripts test --watchAll=false --testPathPattern="DisplayView.drag-order"
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend uses `react-scripts test` (CRA). Always set `CI=true` + `--watchAll=false` for single runs.
|
||||||
|
|
||||||
|
## Test layers (important)
|
||||||
|
|
||||||
|
Two layers, both required:
|
||||||
|
|
||||||
|
- **Layer 1**: App vs firebase mock (`src/__mocks__/firebase/`). Proves adapter call shape. Never exercises ws adapter.
|
||||||
|
- **Layer 2**: ws adapter vs live backend (`server/tests/`). Proves translation + path identity.
|
||||||
|
|
||||||
|
Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws event handler bugs). Layer 2 catches those.
|
||||||
|
|
||||||
|
## Test types
|
||||||
|
|
||||||
|
| Type | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| **Unit** | pure logic, fast, no I/O. Locks single function behavior. |
|
||||||
|
| **Integration** | real backend per test (port 0 = OS picks free). Adapter translation verified. |
|
||||||
|
| **Characterization** | render App via mock, assert current UI behavior (buggy or not). NOT desired-state. |
|
||||||
|
| **Contract** | same spec run against every storage impl (memory, ws, firebase). Catches adapter drift. |
|
||||||
|
| **Scenario** | end-to-end flow through rendered App. `Combat.scenario.test.js` = 100 rounds, ~240s. Pre-existing crash (BUG-11). |
|
||||||
|
|
||||||
|
## TDD discipline
|
||||||
|
|
||||||
|
RED first → fix → GREEN. Never change functional code to pass tests for existing state without test driving it.
|
||||||
|
|
||||||
|
- Find bug → write failing test (RED)
|
||||||
|
- Fix code → test passes (GREEN)
|
||||||
|
- Log confirmed bug in `TODO.md`
|
||||||
|
- One bug at a time, commit with evidence
|
||||||
|
|
||||||
|
## Replay tool (demo, NOT unit test)
|
||||||
|
|
||||||
|
`scripts/replay-combat.js` — drives full combat via ws adapter (same contract as App) against live backend. UI updates in real-time if frontend running.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start backend + frontend first
|
||||||
|
node scripts/replay-combat.js [rounds] [delayMs]
|
||||||
|
# defaults: 100 rounds, 200ms/step
|
||||||
|
# faster: 20 400 = 20 rounds, 400ms each
|
||||||
|
|
||||||
|
# against docker stack:
|
||||||
|
BACKEND_URL=http://127.0.0.1:8080 node scripts/replay-combat.js 20 400
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage per round: damage, heal, all 22 conditions, toggleActive, removeParticipant, addParticipant (reinforcements), updateParticipant, pause/resume, reorderParticipants, endEncounter. Revives dead each round to sustain count.
|
||||||
|
|
||||||
|
Output → log file, then analyze:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/replay-combat.js 20 400 > tmp/run.log 2>&1
|
||||||
|
node scripts/analyze-turns.js tmp/run.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit 0 = clean. Reports skips, double-acts, order shifts.
|
||||||
|
|
||||||
|
### analyze-turns.js
|
||||||
|
|
||||||
|
Parses replay log. Detects:
|
||||||
|
- **real skips**: active participant not acted in a round
|
||||||
|
- **double-acts**: same participant twice in a round
|
||||||
|
- **order shifts**: turnOrderIds changed unexpectedly
|
||||||
|
|
||||||
|
Handles `[pointer X→Y wrap]` events (mutation-driven advance) and `[reorder A→before B]`. Logs `order=[Name:init,...]` + `parts=[Name:init,...]` per turn. Parser blind to DisplayView render (separate concern — FE test covers that).
|
||||||
|
|
||||||
|
Round marker: `--- round N starting ---` (top of loop, post-fix).
|
||||||
|
|
||||||
|
## Audit tools (NOT unit tests)
|
||||||
|
|
||||||
|
`tests/audit/` — exploratory, `Math.random`, non-deterministic. Manual run. NOT jest.
|
||||||
|
|
||||||
|
### audit-rotation.js
|
||||||
|
Pure turn.js simulation of replay op sequence. Detects rotation violations. Found BUG-1.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tests/audit/audit-rotation.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### audit-state.js
|
||||||
|
Runs pure turn.js combat. Audits 9 invariant classes per round:
|
||||||
|
1. rotation integrity (skip/dupe)
|
||||||
|
2. HP bounds (0 ≤ hp ≤ max, no NaN)
|
||||||
|
3. isActive consistency (dead = inactive)
|
||||||
|
4. turnOrder no dup ids
|
||||||
|
5. turnOrder ids all active
|
||||||
|
6. currentTurn valid + active
|
||||||
|
7. deathSave range (0-3, reset on revive)
|
||||||
|
8. removeParticipant orphans
|
||||||
|
9. undo support
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tests/audit/audit-state.js [rounds] # default 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Current state: 0 violations / 100 rounds (post BUG-1/2 fix).
|
||||||
|
|
||||||
|
## Docker stack
|
||||||
|
|
||||||
|
Single container: caddy (front, static + proxy) + node backend (internal :4001).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build + run (from repo root)
|
||||||
|
docker compose -f docker/docker-compose.yml up --build -d
|
||||||
|
# → http://127.0.0.1:8080
|
||||||
|
|
||||||
|
# logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs app --tail 20
|
||||||
|
|
||||||
|
# stop
|
||||||
|
docker compose -f docker/docker-compose.yml down
|
||||||
|
|
||||||
|
# rebuild after code change
|
||||||
|
docker compose -f docker/docker-compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
|
||||||
|
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback
|
||||||
|
- `docker/entrypoint.sh` — runs node bg + caddy fg
|
||||||
|
- `docker/docker-compose.yml` — one `app` service, volume for sqlite
|
||||||
|
|
||||||
|
### Verify docker stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# REST roundtrip
|
||||||
|
curl -s -X PUT http://127.0.0.1:8080/api/doc -H 'Content-Type: application/json' \
|
||||||
|
-d '{"path":"campaigns/test","data":{"name":"X"}}' >/dev/null
|
||||||
|
curl -s "http://127.0.0.1:8080/api/doc?path=campaigns/test"
|
||||||
|
|
||||||
|
# WS subscribe + push (node one-liner, see scripts)
|
||||||
|
# Full combat: replay against docker
|
||||||
|
BACKEND_URL=http://127.0.0.1:8080 node scripts/replay-combat.js 20 400 > tmp/docker.log 2>&1
|
||||||
|
node scripts/analyze-turns.js tmp/docker.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inspect docker sqlite
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec docker-app-1 sh -c 'node -e "
|
||||||
|
const db=require(\"better-sqlite3\")(\"/data/tracker.sqlite\");
|
||||||
|
const rows=db.prepare(\"SELECT path, substr(data,1,50) as d FROM docs\").all();
|
||||||
|
console.log(\"count=\"+rows.length);
|
||||||
|
rows.forEach(r=>console.log(r.path+\" => \"+r.d));
|
||||||
|
"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev servers (non-docker)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
npm run server:dev # :4001, db: ./data/tracker.sqlite
|
||||||
|
# or:
|
||||||
|
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
|
||||||
|
curl http://127.0.0.1:4001/health # → {"ok":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
|
||||||
|
|
||||||
|
### Frontend (ws mode)
|
||||||
|
```bash
|
||||||
|
REACT_APP_STORAGE=ws \
|
||||||
|
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||||
|
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
|
||||||
|
BROWSER=none PORT=3999 \
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
→ http://127.0.0.1:3999/. Admin `/`, player `/display`.
|
||||||
|
|
||||||
|
Firebase mode (default): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.example`).
|
||||||
|
|
||||||
|
## Storage modes
|
||||||
|
|
||||||
|
`STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`:
|
||||||
|
- `firebase` (default) → real SDK
|
||||||
|
- `ws` → backend (docker/prod)
|
||||||
|
- `memory` → in-process (test seed)
|
||||||
|
|
||||||
|
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs memory/ws/firebase.
|
||||||
|
|
||||||
|
## Known RED / backlog
|
||||||
|
|
||||||
|
- BUG-4: HideHpToggle RED (setDoc→updateDoc, clobbers activeDisplay)
|
||||||
|
- BUG-10: deact+reactivate double-act
|
||||||
|
- BUG-11: Combat.scenario test crash
|
||||||
|
- BUG-13: reorder cross-pointer semantics
|
||||||
|
- BUG-14: addParticipant init-insert post-drag
|
||||||
|
|
||||||
|
See `TODO.md` for full list + status.
|
||||||
|
|
||||||
|
## Scratch
|
||||||
|
|
||||||
|
`scratch/` — gitignored throwaway. Repro scripts, exploration, debug. Not committed. Use freely, delete anytime.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
See `docs/REWORK_PLAN.md` for milestones, `TODO.md` for bugs, `docs/DEVELOPMENT.md` for setup, `docs/GLOSSARY.md` for terms, `docs/ENCOUNTER_BUILDER.md` for DM interface.
|
||||||
Generated
+4288
-16
File diff suppressed because it is too large
Load Diff
+13
-5
@@ -2,25 +2,33 @@
|
|||||||
"name": "ttrpg-initiative-tracker",
|
"name": "ttrpg-initiative-tracker",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.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",
|
||||||
"firebase": "^10.12.2",
|
"firebase": "^10.12.2",
|
||||||
"lucide-react": "^0.395.0",
|
"lucide-react": "^0.395.0",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4",
|
"tailwindcss": "^3.4.3",
|
||||||
"autoprefixer": "^10.4.19",
|
"web-vitals": "^2.1.4"
|
||||||
"postcss": "^8.4.38",
|
|
||||||
"tailwindcss": "^3.4.3"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject",
|
||||||
|
"server:dev": "npm run dev --workspace server",
|
||||||
|
"server:test": "npm test --workspace server",
|
||||||
|
"shared:test": "npm test --workspace shared",
|
||||||
|
"test:all": "npm run shared:test && npm run server:test"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# scripts/
|
||||||
|
|
||||||
|
Manual demo tool. NOT test.
|
||||||
|
|
||||||
|
## replay-combat.js
|
||||||
|
|
||||||
|
Live backend demo. Drives full combat via ws adapter (same contract as App).
|
||||||
|
Player display live-updates. Watch UI react to state changes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start backend + frontend first (see docs/DEVELOPMENT.md)
|
||||||
|
node scripts/replay-combat.js [rounds] [delayMs]
|
||||||
|
# defaults: 100 rounds, 200ms/step
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage per round: damage, heal, all 22 conditions, toggleActive,
|
||||||
|
removeParticipant, addParticipant (reinforcements), updateParticipant,
|
||||||
|
pause/resume, reorderParticipants, endEncounter. Revives dead each round
|
||||||
|
to sustain full round count.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `tests/audit/` — exploratory bug-finders (manual run, non-deterministic)
|
||||||
|
- `{shared,server,src}/tests/` — jest unit/integration/characterization
|
||||||
|
- `scratch/` — gitignored throwaway
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
// scripts/analyze-turns.js
|
||||||
|
// Ingest replay-combat.js stdout (or any text matching its format), reconstruct
|
||||||
|
// rounds, report real skips + double-acts. Deterministic — no eyeballing.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/analyze-turns.js [path] # analyze a saved log file
|
||||||
|
// node scripts/replay-combat.js 100 100 | node scripts/analyze-turns.js
|
||||||
|
// cat /tmp/replay.log | node scripts/analyze-turns.js
|
||||||
|
//
|
||||||
|
// Skip = participant active for WHOLE round (never deactivated/removed mid-round
|
||||||
|
// before their slot, never added mid-round) but never appeared as a turn actor.
|
||||||
|
// Double-act = same participant takes 2+ turns in one round.
|
||||||
|
//
|
||||||
|
// FEAT-2 (structured turn snapshot in app logs) will let this ingest live app
|
||||||
|
// logs too, not just replay stdout. Format-agnostic core lives in parseReplay().
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// ---------- parsing ----------
|
||||||
|
|
||||||
|
const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)(?:\s*\|\s*order=\[(.*)\](?:\s*cur=.*)?)?\s*$/;
|
||||||
|
const DEACTIVATE_RE = /^\s*\[(?:deactivate)\s+(.+?)\]\s*$/;
|
||||||
|
const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/;
|
||||||
|
const ADD_RE = /^\s*\[(?:add)\s+(.+?)\]\s*$/;
|
||||||
|
const REMOVE_RE = /^\s*\[(?:remove dead|remove)\s+(.+?)\]\s*$/;
|
||||||
|
const PAUSE_RE = /^\s*\[pause\]\s*$/;
|
||||||
|
const RESUME_RE = /^\s*\[resume\]\s*$/;
|
||||||
|
const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+(?:complete|starting)/;
|
||||||
|
const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/;
|
||||||
|
const REORDER_RE = /^\s*\[reorder\s+(.+?)→before\s+(.+?)\]\s*$/;
|
||||||
|
const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/;
|
||||||
|
|
||||||
|
function parseLine(line) {
|
||||||
|
if (TURN_RE.test(line)) {
|
||||||
|
const m = line.match(TURN_RE);
|
||||||
|
const orderStr = m[4] || '';
|
||||||
|
// parse Name:init pairs
|
||||||
|
const order = orderStr.split(',').map(s => s.trim()).filter(Boolean).map(pair => {
|
||||||
|
const [name, init] = pair.split(':');
|
||||||
|
return { name: name.trim(), init: init !== undefined ? +init : null };
|
||||||
|
});
|
||||||
|
return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim(), order };
|
||||||
|
}
|
||||||
|
if (FIRST_RE.test(line)) {
|
||||||
|
const m = line.match(FIRST_RE);
|
||||||
|
return { kind: 'turn', turn: 0, round: 1, actor: m[1].trim() };
|
||||||
|
}
|
||||||
|
if (DEACTIVATE_RE.test(line)) return { kind: 'deactivate', name: line.match(DEACTIVATE_RE)[1].trim() };
|
||||||
|
if (REACTIVATE_RE.test(line)) return { kind: 'reactivate', name: line.match(REACTIVATE_RE)[1].trim() };
|
||||||
|
if (ADD_RE.test(line)) return { kind: 'add', name: line.match(ADD_RE)[1].trim() };
|
||||||
|
if (REMOVE_RE.test(line)) return { kind: 'remove', name: line.match(REMOVE_RE)[1].trim() };
|
||||||
|
if (PAUSE_RE.test(line)) return { kind: 'pause' };
|
||||||
|
if (RESUME_RE.test(line)) return { kind: 'resume' };
|
||||||
|
if (POINTER_RE.test(line)) {
|
||||||
|
const m = line.match(POINTER_RE);
|
||||||
|
return { kind: 'pointer', from: m[1].trim(), to: m[2].trim(), wrap: m[3] === ' wrap' };
|
||||||
|
}
|
||||||
|
if (REORDER_RE.test(line)) {
|
||||||
|
const m = line.match(REORDER_RE);
|
||||||
|
return { kind: 'reorder', dragged: m[1].trim(), target: m[2].trim() };
|
||||||
|
}
|
||||||
|
if (ROUND_COMPLETE_RE.test(line)) return { kind: 'round-complete', round: +line.match(ROUND_COMPLETE_RE)[1] };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- reconstruction ----------
|
||||||
|
|
||||||
|
// Build per-round timeline: round -> { turns: [actor], mutations: [{stepIdx,...}] }
|
||||||
|
// Then compute skips + double-acts.
|
||||||
|
function reconstruct(events) {
|
||||||
|
// global state: active set by name. Start populated lazily from first turn.
|
||||||
|
const active = new Set();
|
||||||
|
const rounds = new Map(); // round -> { turns: [name], events: [{...}] }
|
||||||
|
let curRound = 1;
|
||||||
|
let sawFirstTurn = false;
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.kind === 'turn') {
|
||||||
|
sawFirstTurn = true;
|
||||||
|
curRound = ev.round;
|
||||||
|
if (!rounds.has(curRound)) rounds.set(curRound, { turns: [], events: [], complete: false });
|
||||||
|
const r = rounds.get(curRound);
|
||||||
|
r.turns.push(ev.actor);
|
||||||
|
r.events.push({ ...ev, idx: r.events.length });
|
||||||
|
if (!active.has(ev.actor)) active.add(ev.actor); // first sighting = active
|
||||||
|
} else if (ev.kind === 'deactivate') {
|
||||||
|
active.delete(ev.name);
|
||||||
|
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||||
|
r.events.push({ ...ev, idx: r.events.length });
|
||||||
|
} else if (ev.kind === 'reactivate' || ev.kind === 'add') {
|
||||||
|
active.add(ev.name);
|
||||||
|
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||||
|
r.events.push({ ...ev, idx: r.events.length });
|
||||||
|
} else if (ev.kind === 'remove') {
|
||||||
|
active.delete(ev.name);
|
||||||
|
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||||
|
r.events.push({ ...ev, idx: r.events.length });
|
||||||
|
} else if (ev.kind === 'pointer') {
|
||||||
|
// wrap pointer advances to next round — credit there.
|
||||||
|
if (ev.wrap) curRound += 1;
|
||||||
|
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||||
|
r.events.push({ ...ev, idx: r.events.length });
|
||||||
|
} else if (ev.kind === 'reorder') {
|
||||||
|
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||||
|
r.events.push({ ...ev, idx: r.events.length });
|
||||||
|
} else if (ev.kind === 'round-complete') {
|
||||||
|
if (rounds.has(ev.round)) rounds.get(ev.round).complete = true;
|
||||||
|
}
|
||||||
|
// pause/resume: rotation-affecting but no roster change; tracked in events
|
||||||
|
else if (ev.kind === 'pause' || ev.kind === 'resume') {
|
||||||
|
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||||
|
r.events.push({ ...ev, idx: r.events.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each round, recompute active-at-start and acted, then find real skips.
|
||||||
|
function analyze(rounds) {
|
||||||
|
const report = [];
|
||||||
|
for (const [roundN, r] of [...rounds.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
// Replay stdout doesn't dump roster, so infer "active at round start":
|
||||||
|
// walk events IN ORDER, snapshot active set at first turn of this round.
|
||||||
|
// We replay from a clean per-round pass using a carry-over active set.
|
||||||
|
report.push(analyzeRound(roundN, r));
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run per-round with active-set carry-over across rounds (module scope).
|
||||||
|
function analyzeRounds(rounds) {
|
||||||
|
// Carry active set + current-name forward round to round.
|
||||||
|
let activeCarry = new Set();
|
||||||
|
let currentCarry = null;
|
||||||
|
const reports = [];
|
||||||
|
const sortedRounds = [...rounds.entries()].sort((a, b) => a[0] - b[0]);
|
||||||
|
for (const [roundN, r] of sortedRounds) {
|
||||||
|
if (!r.complete) continue; // incomplete final round — can't judge skips
|
||||||
|
if (roundN === 1) { activeCarry = new Set(); currentCarry = null; }
|
||||||
|
const result = analyzeRoundWithCarry(roundN, r, activeCarry, currentCarry);
|
||||||
|
reports.push(result.report);
|
||||||
|
activeCarry = result.activeAfter;
|
||||||
|
currentCarry = result.currentAfter;
|
||||||
|
}
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When current participant is deactivated/removed, code advances current to
|
||||||
|
// next active. That target gets the turn pointer = acts. Parser can't see
|
||||||
|
// roster/order from stdout, so on deact-current the NEXT turn actor is the
|
||||||
|
// advance target and is credited an extra "pointer turn" (not a logged turn).
|
||||||
|
function analyzeRoundWithCarry(roundN, r, activeAtStart, currentAtStart) {
|
||||||
|
// activeAtStart: Set copy. Mutations during round adjust a working copy.
|
||||||
|
const active = new Set(activeAtStart);
|
||||||
|
const activeWholeRound = new Set(activeAtStart); // participants never toggled off/removed
|
||||||
|
const addedThisRound = new Set();
|
||||||
|
const turns = []; // ordered actor names (logged)
|
||||||
|
const pointerTurns = new Set(); // names that got the turn pointer this round
|
||||||
|
let current = currentAtStart; // current participant name (carry)
|
||||||
|
|
||||||
|
for (const ev of r.events) {
|
||||||
|
if (ev.kind === 'turn') {
|
||||||
|
turns.push(ev.actor);
|
||||||
|
pointerTurns.add(ev.actor);
|
||||||
|
if (!active.has(ev.actor)) active.add(ev.actor); // first-ever sighting
|
||||||
|
current = ev.actor;
|
||||||
|
} else if (ev.kind === 'pointer') {
|
||||||
|
// mutation advanced current pointer: ev.to now holds it = got the turn.
|
||||||
|
// Credit ev.to. Update tracking.
|
||||||
|
pointerTurns.add(ev.to);
|
||||||
|
current = ev.to;
|
||||||
|
} else if (ev.kind === 'deactivate' || ev.kind === 'remove') {
|
||||||
|
// deact/REMOVE of current → code auto-advances (emitted as pointer line).
|
||||||
|
// Disqualify from whole-round (roster mutation = not "whole round").
|
||||||
|
activeWholeRound.delete(ev.name);
|
||||||
|
active.delete(ev.name);
|
||||||
|
} else if (ev.kind === 'reactivate' || ev.kind === 'add') {
|
||||||
|
activeWholeRound.delete(ev.name);
|
||||||
|
active.add(ev.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// acted = names that took a turn OR got pointer via mutation-advance
|
||||||
|
// (deact/remove of current advances to target — that target acts).
|
||||||
|
// Pointer lines from replay tell us the target explicitly.
|
||||||
|
const acted = new Set([...turns, ...pointerTurns]);
|
||||||
|
|
||||||
|
// double-acts: logged turns with count > 1 (pointer-credits excluded —
|
||||||
|
// a deact-advance target acting once via pointer then once via nextTurn
|
||||||
|
// is correct, not a bug).
|
||||||
|
const counts = {};
|
||||||
|
for (const n of turns) counts[n] = (counts[n] || 0) + 1;
|
||||||
|
const doubleActs = Object.entries(counts).filter(([_, c]) => c > 1).map(([n, c]) => ({ name: n, count: c }));
|
||||||
|
|
||||||
|
// real skip: active for WHOLE round (no roster mutation) AND never got
|
||||||
|
// turn/pointer. Mutations disqualify from whole-round already.
|
||||||
|
const realSkips = [...activeWholeRound].filter(n => !acted.has(n));
|
||||||
|
|
||||||
|
return {
|
||||||
|
report: {
|
||||||
|
round: roundN,
|
||||||
|
turnCount: turns.length,
|
||||||
|
uniqueActors: acted.size,
|
||||||
|
realSkips,
|
||||||
|
doubleActs,
|
||||||
|
turns,
|
||||||
|
},
|
||||||
|
activeAfter: active,
|
||||||
|
currentAfter: current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- order-shift detection ----------
|
||||||
|
// Compare order+init between consecutive turn lines. Flag shifts NOT explained
|
||||||
|
// by: logged reorder, add/remove (roster change), or initiative change.
|
||||||
|
// DM drag-reorder = legit (logged reorder line). Phantom shifts = display/rotation
|
||||||
|
// divergence bug (invariant: display === turnOrderIds === nextTurn).
|
||||||
|
function detectOrderShifts(events) {
|
||||||
|
const shifts = [];
|
||||||
|
let prev = null;
|
||||||
|
let prevTurnNo = null;
|
||||||
|
// mutations since last turn (reorder/add/remove/reactivate/pointer)
|
||||||
|
let pending = [];
|
||||||
|
let initMap = {}; // name -> last known initiative
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.kind === 'turn' && ev.order && ev.order.length) {
|
||||||
|
const curNames = ev.order.map(o => o.name);
|
||||||
|
const curInits = {};
|
||||||
|
ev.order.forEach(o => { curInits[o.name] = o.init; });
|
||||||
|
|
||||||
|
if (prev) {
|
||||||
|
const sameRoster = prev.length === curNames.length &&
|
||||||
|
prev.every((n, i) => n === curNames[i]);
|
||||||
|
if (!sameRoster) {
|
||||||
|
// roster change (add/remove) — skip, expected order shift
|
||||||
|
} else {
|
||||||
|
// same roster, different order → explainable by reorder OR init change?
|
||||||
|
const orderChanged = JSON.stringify(prev) !== JSON.stringify(curNames);
|
||||||
|
const initChanged = ev.order.some(o => initMap[o.name] !== null && initMap[o.name] !== undefined && initMap[o.name] !== o.init);
|
||||||
|
const hasReorder = pending.some(p => p.kind === 'reorder');
|
||||||
|
if (orderChanged && !hasReorder && !initChanged) {
|
||||||
|
shifts.push({ turn: ev.turn, from: prev, to: curNames, reason: 'no logged reorder/init change' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = curNames;
|
||||||
|
curInits && Object.keys(curInits).forEach(k => { initMap[k] = curInits[k]; });
|
||||||
|
pending = [];
|
||||||
|
prevTurnNo = ev.turn;
|
||||||
|
} else if (ev.kind === 'reorder' || ev.kind === 'add' || ev.kind === 'remove' ||
|
||||||
|
ev.kind === 'reactivate' || ev.kind === 'pointer') {
|
||||||
|
pending.push(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shifts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CLI ----------
|
||||||
|
|
||||||
|
function readInput() {
|
||||||
|
const arg = process.argv[2];
|
||||||
|
if (arg) return fs.readFileSync(arg, 'utf8');
|
||||||
|
// stdin
|
||||||
|
return fs.readFileSync(0, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const text = readInput();
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const events = lines.map(parseLine).filter(Boolean);
|
||||||
|
const rounds = reconstruct(events);
|
||||||
|
const reports = analyzeRounds(rounds);
|
||||||
|
|
||||||
|
let totalSkips = 0;
|
||||||
|
let totalDoubles = 0;
|
||||||
|
const problemRounds = [];
|
||||||
|
|
||||||
|
for (const rep of reports) {
|
||||||
|
const hasIssue = rep.realSkips.length > 0 || rep.doubleActs.length > 0;
|
||||||
|
if (hasIssue) problemRounds.push(rep);
|
||||||
|
totalSkips += rep.realSkips.length;
|
||||||
|
totalDoubles += rep.doubleActs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rep of problemRounds) {
|
||||||
|
console.log(`R${rep.round}: turns=${rep.turnCount} unique=${rep.uniqueActors}`);
|
||||||
|
if (rep.realSkips.length) console.log(` REAL SKIPS: ${rep.realSkips.join(', ')}`);
|
||||||
|
if (rep.doubleActs.length) console.log(` DOUBLE-ACTS: ${rep.doubleActs.map(d => `${d.name}(${d.count}x)`).join(', ')}`);
|
||||||
|
console.log(` sequence: ${rep.turns.join(' -> ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// order-shift detection: flag unexplained display/rotation divergence
|
||||||
|
const shifts = detectOrderShifts(events);
|
||||||
|
if (shifts.length) {
|
||||||
|
console.log(`\n--- order shifts (${shifts.length}) ---`);
|
||||||
|
for (const s of shifts.slice(0, 10)) {
|
||||||
|
console.log(` turn ${s.turn}: [${s.from.join(',')}] → [${s.to.join(',')}] (${s.reason})`);
|
||||||
|
}
|
||||||
|
if (shifts.length > 10) console.log(` ... +${shifts.length - 10} more`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== ${reports.length} rounds analyzed ===`);
|
||||||
|
console.log(`real skips: ${totalSkips}`);
|
||||||
|
console.log(`double-acts: ${totalDoubles}`);
|
||||||
|
console.log(`order shifts: ${shifts.length}`);
|
||||||
|
const clean = totalSkips === 0 && totalDoubles === 0 && shifts.length === 0;
|
||||||
|
console.log(clean ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND');
|
||||||
|
|
||||||
|
process.exit(clean ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// scripts/replay-combat.js
|
||||||
|
// Drive a full combat through the LIVE backend via the ws storage adapter
|
||||||
|
// (same contract boundary as the App), so the player display window
|
||||||
|
// (subscribed via WS) live-updates as combat progresses.
|
||||||
|
// Uses shared/turn.js for all turn logic (same model as the UI).
|
||||||
|
//
|
||||||
|
// Coverage goals (rotate across rounds):
|
||||||
|
// - nextTurn (every turn)
|
||||||
|
// - applyHpChange damage + heal (varying magnitude)
|
||||||
|
// - toggleCondition (all CONDITIONS at least once)
|
||||||
|
// - toggleParticipantActive (mark inactive, later reactivate)
|
||||||
|
// - deathSave (when a PC reaches 0 HP)
|
||||||
|
// - addParticipant (reinforcements drop in)
|
||||||
|
// - removeParticipant (dead monsters hauled off)
|
||||||
|
// - updateParticipant (edit fields mid-combat)
|
||||||
|
// - togglePause / resume
|
||||||
|
// - reorderParticipants (initiative reorder)
|
||||||
|
// - endEncounter (cleanup)
|
||||||
|
//
|
||||||
|
// Run: node scripts/replay-combat.js [rounds] [delayMs]
|
||||||
|
// rounds default 100, delayMs default 200
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('../shared');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause,
|
||||||
|
addParticipant, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, applyHpChange, deathSave,
|
||||||
|
toggleCondition, reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
const { createWsStorage } = require('../src/storage/ws');
|
||||||
|
|
||||||
|
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 ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||||
|
const DELAY = parseInt(process.argv[3], 10) || 200;
|
||||||
|
|
||||||
|
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||||
|
const PUB = `artifacts/${APP_ID}/public/data`;
|
||||||
|
// Mirror App.js getPath. Adapter takes these; norm() strips prefix.
|
||||||
|
const getPath = {
|
||||||
|
campaigns: () => `${PUB}/campaigns`,
|
||||||
|
campaign: (id) => `${PUB}/campaigns/${id}`,
|
||||||
|
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
|
||||||
|
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
|
||||||
|
activeDisplay: () => `${PUB}/activeDisplay/status`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Use the ADAPTER as the contract boundary (same as App). No raw REST.
|
||||||
|
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL });
|
||||||
|
|
||||||
|
// Mirror App.js CONDITIONS so we exercise all of them.
|
||||||
|
const CONDITIONS = [
|
||||||
|
'alchemist_fire', 'bardic_inspiration', 'blinded', 'charmed', 'deafened',
|
||||||
|
'exhaustion', 'frightened', 'grappled', 'grazed', 'incapacitated',
|
||||||
|
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
|
||||||
|
'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function patch(encounterPath, enc, result, label) {
|
||||||
|
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
|
||||||
|
await storage.updateDoc(encounterPath, result.patch);
|
||||||
|
if (label) console.log(` [${label}]`);
|
||||||
|
// emit pointer-advance line when a MUTATION changes currentTurnParticipantId.
|
||||||
|
// nextTurn passes label=null — it's a normal advance, already logged via
|
||||||
|
// the turn line. Emitting pointer for it double-counts.
|
||||||
|
const oldCur = enc.currentTurnParticipantId;
|
||||||
|
const oldRound = enc.round;
|
||||||
|
const newEnc = { ...enc, ...result.patch };
|
||||||
|
const newCur = newEnc.currentTurnParticipantId;
|
||||||
|
const newRound = newEnc.round;
|
||||||
|
if (label && oldCur && newCur && oldCur !== newCur) {
|
||||||
|
const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur;
|
||||||
|
const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur;
|
||||||
|
const wrap = oldRound !== newRound ? ' wrap' : '';
|
||||||
|
console.log(` [pointer ${oldName}→${newName}${wrap}]`);
|
||||||
|
}
|
||||||
|
return newEnc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
|
||||||
|
|
||||||
|
const campaignId = crypto.randomUUID();
|
||||||
|
const encounterId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await storage.setDoc(getPath.campaign(campaignId), {
|
||||||
|
name: `Replay Campaign (${new Date().toLocaleString('en-US', { hour12: false })})`,
|
||||||
|
playerDisplayBackgroundUrl: '',
|
||||||
|
ownerId: 'replay',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
players: [
|
||||||
|
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||||
|
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||||
|
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const charSpecs = [
|
||||||
|
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||||
|
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||||
|
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||||
|
];
|
||||||
|
const monsterSpecs = [
|
||||||
|
{ name: 'Goblin1', maxHp: 100, initMod: 2 },
|
||||||
|
{ name: 'Goblin2', maxHp: 100, initMod: 2 },
|
||||||
|
{ name: 'OrcBoss', maxHp: 500, initMod: 1 },
|
||||||
|
{ name: 'Wolf', maxHp: 120, initMod: 3 },
|
||||||
|
{ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const participants = [
|
||||||
|
...charSpecs.map(c => buildCharacterParticipant(c).participant),
|
||||||
|
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
|
||||||
|
];
|
||||||
|
|
||||||
|
await storage.setDoc(getPath.encounter(campaignId, encounterId), {
|
||||||
|
name: `Big Boss Replay (${new Date().toLocaleString('en-US', { hour12: false })})`,
|
||||||
|
campaignId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
participants,
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
turnOrderIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
|
||||||
|
|
||||||
|
await storage.setDoc(getPath.activeDisplay(), {
|
||||||
|
activeCampaignId: campaignId,
|
||||||
|
activeEncounterId: encounterId,
|
||||||
|
hidePlayerHp: false,
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
|
||||||
|
const encounterPath = getPath.encounter(campaignId, encounterId);
|
||||||
|
const activeDisplayPath = getPath.activeDisplay();
|
||||||
|
|
||||||
|
// start
|
||||||
|
let enc = await storage.getDoc(encounterPath);
|
||||||
|
enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter');
|
||||||
|
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
|
||||||
|
await sleep(DELAY);
|
||||||
|
|
||||||
|
let totalTurns = 0;
|
||||||
|
const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5);
|
||||||
|
let reinforcementsAdded = 0;
|
||||||
|
let lastPaused = false;
|
||||||
|
let lastReorder = 0;
|
||||||
|
|
||||||
|
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||||
|
console.log(`--- round ${roundN} starting ---`);
|
||||||
|
// advance initiative until round counter ticks (full cycle done).
|
||||||
|
const cap = (enc.participants.length + 2) * 2;
|
||||||
|
let guard = 0;
|
||||||
|
while (enc.round < roundN + 1 && guard < cap) {
|
||||||
|
// NOTE: do NOT getDoc here — async re-fetch can return stale state and
|
||||||
|
// cause nextTurn to compute off pre-mutation data (double-acts/skips).
|
||||||
|
// Trust the local enc returned by patch (sync spread of updateDoc).
|
||||||
|
|
||||||
|
// 9. resume if paused: must happen BEFORE nextTurn or it throws.
|
||||||
|
if (lastPaused) {
|
||||||
|
enc = await patch(encounterPath, enc, togglePause(enc), 'resume');
|
||||||
|
lastPaused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t;
|
||||||
|
try { t = nextTurn(enc); } catch (e) { console.log(` nextTurn err: ${e.message}`); break; }
|
||||||
|
enc = await patch(encounterPath, enc, t, null);
|
||||||
|
totalTurns++;
|
||||||
|
const actorName = firstActiveName(enc);
|
||||||
|
const actor = currentParticipant(enc);
|
||||||
|
|
||||||
|
// Dump turn line with order AND initiative (DM drag may reorder without
|
||||||
|
// changing init — log both so parser can flag unexplained shifts).
|
||||||
|
const ordStr = enc.turnOrderIds.map(id => {
|
||||||
|
const p = enc.participants.find(x => x.id === id);
|
||||||
|
return p ? `${p.name}:${p.initiative}` : id;
|
||||||
|
}).join(',');
|
||||||
|
// Also dump participants[] order (display source). Diverge from order = sync bug.
|
||||||
|
const pStr = enc.participants.map(p => `${p.name}:${p.initiative}`).join(',');
|
||||||
|
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] parts=[${pStr}] cur=${enc.currentTurnParticipantId}`);
|
||||||
|
|
||||||
|
// 1. damage: actor hits a random living, active target.
|
||||||
|
if (actor) {
|
||||||
|
const foes = enc.participants.filter(
|
||||||
|
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false && !p.name.startsWith('Dead')
|
||||||
|
);
|
||||||
|
if (foes.length > 0) {
|
||||||
|
const tgt = pick(foes);
|
||||||
|
const dmg = 1 + Math.floor(Math.random() * 5); // 1-5
|
||||||
|
const h = applyHpChange(enc, tgt.id, 'damage', dmg);
|
||||||
|
if (h.patch) {
|
||||||
|
await storage.updateDoc(encounterPath, h.patch);
|
||||||
|
enc = { ...enc, ...h.patch };
|
||||||
|
console.log(` ${actorName} → ${tgt.name} (-${dmg}, hp=${tgt.currentHp - dmg})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. heal: Cleric (when active) heals lowest-HP ally every other turn.
|
||||||
|
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||||
|
const wounded = enc.participants
|
||||||
|
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false)
|
||||||
|
.sort((a, b) => (a.currentHp / a.maxHp) - (b.currentHp / b.maxHp));
|
||||||
|
if (wounded.length > 0) {
|
||||||
|
const tgt = wounded[0];
|
||||||
|
const amt = 2 + Math.floor(Math.random() * 5); // 2-6
|
||||||
|
const h = applyHpChange(enc, tgt.id, 'heal', amt);
|
||||||
|
if (h.patch) {
|
||||||
|
await storage.updateDoc(encounterPath, h.patch);
|
||||||
|
enc = { ...enc, ...h.patch };
|
||||||
|
console.log(` Cleric heal → ${tgt.name} (+${amt}, hp=${tgt.currentHp + amt})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. conditions: toggle a queued condition off some participant each turn.
|
||||||
|
if (condQueue.length > 0) {
|
||||||
|
const cond = condQueue[0];
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try {
|
||||||
|
const c = toggleCondition(enc, tgt.id, cond);
|
||||||
|
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
||||||
|
condQueue.shift();
|
||||||
|
} catch (e) { console.log(` condition ${cond} err: ${e.message}`); condQueue.shift(); }
|
||||||
|
}
|
||||||
|
} else if (totalTurns % 6 === 0) {
|
||||||
|
// second pass: toggle a random condition on random participant (add/remove).
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
const cond = pick(CONDITIONS);
|
||||||
|
try {
|
||||||
|
const c = toggleCondition(enc, tgt.id, cond);
|
||||||
|
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. toggleParticipantActive: randomly mark someone inactive, or reactivate.
|
||||||
|
if (totalTurns % 9 === 0) {
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try {
|
||||||
|
const r = toggleParticipantActive(enc, tgt.id);
|
||||||
|
enc = await patch(encounterPath, enc, r, `${tgt.isActive === false ? 'reactivate' : 'deactivate'} ${tgt.name}`);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. deathSave: when a PC is at 0 HP on their turn, attempt a save.
|
||||||
|
if (actor && actor.currentHp <= 0 && !actor.isNpc && actor.name !== actor.name.startsWith('Monster')) {
|
||||||
|
try {
|
||||||
|
const ds = deathSave(enc, actor.id, 1);
|
||||||
|
enc = await patch(encounterPath, enc, ds, `deathSave ${actor.name} (+1 success)`);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. removeParticipant: dead monsters hauled off (every ~5 turns).
|
||||||
|
if (totalTurns % 5 === 0) {
|
||||||
|
const dead = enc.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf'));
|
||||||
|
if (dead) {
|
||||||
|
try {
|
||||||
|
const r = removeParticipant(enc, dead.id);
|
||||||
|
enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. addParticipant (reinforcements): every 10 turns a new monster joins.
|
||||||
|
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
|
||||||
|
const spec = pick([
|
||||||
|
{ name: `Reinforce${reinforcementsAdded + 1}`, maxHp: 120, initMod: 1 },
|
||||||
|
{ name: `Summon${reinforcementsAdded + 1}`, maxHp: 80, initMod: 4 },
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
const built = buildMonsterParticipant(spec).participant;
|
||||||
|
const r = addParticipant(enc, built);
|
||||||
|
enc = await patch(encounterPath, enc, r, `add ${spec.name}`);
|
||||||
|
reinforcementsAdded++;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. updateParticipant: every 7 turns, edit a field on someone (e.g. temp AC).
|
||||||
|
if (totalTurns % 7 === 0) {
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try {
|
||||||
|
const r = updateParticipant(enc, tgt.id, { notes: `edited@turn${totalTurns}` });
|
||||||
|
enc = await patch(encounterPath, enc, r, `edit ${tgt.name} notes`);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. togglePause: every 12 turns, pause (resumes next iteration via above).
|
||||||
|
if (totalTurns % 12 === 0 && !lastPaused) {
|
||||||
|
enc = await patch(encounterPath, enc, togglePause(enc), 'pause');
|
||||||
|
lastPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. reorderParticipants: every 8 turns, drag one past another (DM reorder).
|
||||||
|
// Pick two ADJACENT UPCOMING actors (both strictly after current pointer)
|
||||||
|
// and swap them. Avoids crossing current pointer — crossing it creates
|
||||||
|
// ambiguous "who acted this round" semantics (skip/double). Swapping two
|
||||||
|
// upcoming actors is always safe and still exercises reorder.
|
||||||
|
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
||||||
|
const curIdx = enc.turnOrderIds.indexOf(enc.currentTurnParticipantId);
|
||||||
|
// upcoming = everyone after current in turn order (rest of this round)
|
||||||
|
const upcomingIds = enc.turnOrderIds.slice(curIdx + 1)
|
||||||
|
.filter(id => { const p = enc.participants.find(x => x.id === id); return p && p.currentHp > 0 && p.isActive !== false; });
|
||||||
|
// swap first adjacent upcoming pair (drag index1 before index0)
|
||||||
|
if (upcomingIds.length >= 2) {
|
||||||
|
const target = enc.participants.find(p => p.id === upcomingIds[0]);
|
||||||
|
const dragged = enc.participants.find(p => p.id === upcomingIds[1]);
|
||||||
|
try {
|
||||||
|
const r = reorderParticipants(enc, dragged.id, target.id);
|
||||||
|
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
|
||||||
|
lastReorder = totalTurns;
|
||||||
|
} catch (e) { /* swap not allowed — skip this round */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(DELAY);
|
||||||
|
guard++;
|
||||||
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||||
|
}
|
||||||
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||||
|
const alive = enc.participants.filter(p => p.currentHp > 0).length;
|
||||||
|
// revive dead: heal to full + reactivate. Sustains combat for 100 rounds
|
||||||
|
// and exercises toggleActive reactivate + heal-from-zero path.
|
||||||
|
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||||
|
for (const d of dead) {
|
||||||
|
try {
|
||||||
|
if (d.isActive === false) {
|
||||||
|
enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
|
||||||
|
}
|
||||||
|
const h = applyHpChange(enc, d.id, 'heal', d.maxHp);
|
||||||
|
enc = await patch(encounterPath, enc, h, `revive-heal ${d.name} →${d.maxHp}`);
|
||||||
|
} catch (e) { console.log(` revive ${d.name} err: ${e.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`replay: ${totalTurns} total turns across ${ROUNDS} rounds`);
|
||||||
|
|
||||||
|
// end
|
||||||
|
enc = await storage.getDoc(encounterPath);
|
||||||
|
if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter');
|
||||||
|
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||||
|
console.log('replay done');
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstActiveName(enc) {
|
||||||
|
if (!enc.currentTurnParticipantId) return '(none)';
|
||||||
|
const p = currentParticipant(enc);
|
||||||
|
return p ? p.name : '(missing)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentParticipant(enc) {
|
||||||
|
if (!enc.currentTurnParticipantId) return null;
|
||||||
|
return (enc.participants || []).find(x => x.id === enc.currentTurnParticipantId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error('replay failed:', err); process.exit(1); });
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
|
||||||
|
};
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
// server/db.js — generic KV document store on SQLite.
|
||||||
|
// Mirrors Firestore doc-tree model: every doc lives at a string path.
|
||||||
|
// Collections are implicit = all docs whose parent path equals the collection path.
|
||||||
|
//
|
||||||
|
// Path examples (canonical, prefix already stripped by adapter):
|
||||||
|
// campaigns/{id} doc
|
||||||
|
// campaigns/{cid}/encounters/{eid} doc
|
||||||
|
// campaigns/{cid}/encounters collection (parent of encounter docs)
|
||||||
|
// activeDisplay/status doc
|
||||||
|
// logs/{id} doc
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS docs (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
parent TEXT,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function openDb(dbPath) {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.exec(SCHEMA);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentOf('campaigns/abc/encounters/xyz') => 'campaigns/abc/encounters'
|
||||||
|
// parentOf('campaigns') => null (root-level doc, no parent collection tracked)
|
||||||
|
function parentOf(p) {
|
||||||
|
const i = p.lastIndexOf('/');
|
||||||
|
return i === -1 ? null : p.slice(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStore(db, broadcast) {
|
||||||
|
const stmtGet = db.prepare('SELECT data FROM docs WHERE path = ?');
|
||||||
|
const stmtUpsert = db.prepare(`
|
||||||
|
INSERT INTO docs (path, parent, data, updated_at) VALUES (@path, @parent, @data, @ts)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts
|
||||||
|
`);
|
||||||
|
const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?');
|
||||||
|
const stmtColl = db.prepare('SELECT path, data FROM docs WHERE parent = ? ORDER BY path ASC');
|
||||||
|
|
||||||
|
function getDoc(p) {
|
||||||
|
const row = stmtGet.get(p);
|
||||||
|
return row ? JSON.parse(row.data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDoc(p, data) {
|
||||||
|
const ts = Date.now();
|
||||||
|
stmtUpsert.run({ path: p, parent: parentOf(p), data: JSON.stringify(data), ts });
|
||||||
|
if (broadcast) broadcast({ path: p, parent: parentOf(p) });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// shallow merge; if doc missing, patch becomes the doc (matches firebase updateDoc create-on-miss)
|
||||||
|
function updateDoc(p, patch) {
|
||||||
|
const existing = getDoc(p) || {};
|
||||||
|
const merged = { ...existing, ...patch };
|
||||||
|
setDoc(p, merged);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDoc(p) {
|
||||||
|
stmtDelete.run(p);
|
||||||
|
if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCollection(collPath) {
|
||||||
|
return stmtColl.all(collPath).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchWrite(ops) {
|
||||||
|
const run = db.transaction((items) => {
|
||||||
|
const changed = [];
|
||||||
|
for (const op of items) {
|
||||||
|
if (op.type === 'set') {
|
||||||
|
setDoc(op.path, op.data);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path) });
|
||||||
|
} else if (op.type === 'delete') {
|
||||||
|
deleteDoc(op.path);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path), deleted: true });
|
||||||
|
} else if (op.type === 'update') {
|
||||||
|
updateDoc(op.path, op.data);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
});
|
||||||
|
const changed = run(ops);
|
||||||
|
if (broadcast) changed.forEach(c => broadcast(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, batchWrite };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { openDb, parentOf, makeStore };
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
// server/index.js — generic KV document store over HTTP + WebSocket.
|
||||||
|
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
|
||||||
|
// Adapter (src/storage/ws.js) = passthrough, no shape translation.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const { openDb, makeStore } = require('./db');
|
||||||
|
|
||||||
|
function createServer({ dbPath, port, corsOrigin } = {}) {
|
||||||
|
const db = openDb(dbPath || './data/tracker.sqlite');
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: corsOrigin || '*' }));
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
// WS subscribers: path -> Set<ws>.
|
||||||
|
// Subscribers register a path (doc or collection). On change, notify:
|
||||||
|
// - doc subscribers at the changed path
|
||||||
|
// - collection subscribers at the changed doc's parent path
|
||||||
|
const docSubscribers = new Map(); // path -> Set<ws>
|
||||||
|
const collSubscribers = new Map(); // collPath -> Set<ws>
|
||||||
|
|
||||||
|
function addSub(map, key, ws) {
|
||||||
|
if (!map.has(key)) map.set(key, new Set());
|
||||||
|
map.get(key).add(ws);
|
||||||
|
}
|
||||||
|
function removeSub(map, key, ws) {
|
||||||
|
const set = map.get(key);
|
||||||
|
if (set) { set.delete(ws); if (set.size === 0) map.delete(key); }
|
||||||
|
}
|
||||||
|
function dropWs(ws) {
|
||||||
|
for (const [key, set] of docSubscribers) { set.delete(ws); if (set.size === 0) docSubscribers.delete(key); }
|
||||||
|
for (const [key, set] of collSubscribers) { set.delete(ws); if (set.size === 0) collSubscribers.delete(key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(change) {
|
||||||
|
const payload = JSON.stringify({ type: 'change', change });
|
||||||
|
// doc subscriber at exact path
|
||||||
|
const docSet = docSubscribers.get(change.path);
|
||||||
|
if (docSet) [...docSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
|
||||||
|
// collection subscribers at parent path (collection contains this doc)
|
||||||
|
if (change.parent) {
|
||||||
|
const collSet = collSubscribers.get(change.parent);
|
||||||
|
if (collSet) [...collSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = makeStore(db, broadcast);
|
||||||
|
|
||||||
|
// --- generic REST ---
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
// GET /api/doc?path=campaigns/abc/encounters/xyz
|
||||||
|
app.get('/api/doc', (req, res) => {
|
||||||
|
const { path: p } = req.query;
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
res.json({ path: p, data: store.getDoc(p) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/collection?path=campaigns/abc/encounters
|
||||||
|
app.get('/api/collection', (req, res) => {
|
||||||
|
const { path: p } = req.query;
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
res.json(store.getCollection(p));
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/doc body: { path, data } (replace)
|
||||||
|
app.put('/api/doc', (req, res) => {
|
||||||
|
const { path: p, data } = req.body || {};
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
res.json({ path: p, data: store.setDoc(p, data) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/doc body: { path, patch } (shallow merge, create-on-miss)
|
||||||
|
app.patch('/api/doc', (req, res) => {
|
||||||
|
const { path: p, patch } = req.body || {};
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
res.json({ path: p, data: store.updateDoc(p, patch) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/doc?path=...
|
||||||
|
app.delete('/api/doc', (req, res) => {
|
||||||
|
const { path: p } = req.query;
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
store.deleteDoc(p);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/collection body: { path, data } (addDoc: auto-id under collection)
|
||||||
|
app.post('/api/collection', (req, res) => {
|
||||||
|
const { path: collPath, data } = req.body || {};
|
||||||
|
if (!collPath) return res.status(400).json({ error: 'path required' });
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const docPath = `${collPath}/${id}`;
|
||||||
|
res.json({ id, path: docPath, data: store.setDoc(docPath, data) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/batch body: { ops: [{type:'set'|'update'|'delete', path, data?}] }
|
||||||
|
app.post('/api/batch', (req, res) => {
|
||||||
|
const { ops } = req.body || {};
|
||||||
|
if (!Array.isArray(ops)) return res.status(400).json({ error: 'ops array required' });
|
||||||
|
store.batchWrite(ops);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- WebSocket: subscribe by path ---
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; }
|
||||||
|
if (msg.type === 'subscribe' && msg.path) {
|
||||||
|
if (msg.kind === 'collection') addSub(collSubscribers, msg.path, ws);
|
||||||
|
else addSub(docSubscribers, msg.path, ws);
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribed', path: msg.path, kind: msg.kind || 'doc' }));
|
||||||
|
} else if (msg.type === 'unsubscribe' && msg.path) {
|
||||||
|
if (msg.kind === 'collection') removeSub(collSubscribers, msg.path, ws);
|
||||||
|
else removeSub(docSubscribers, msg.path, ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on('close', () => dropWs(ws));
|
||||||
|
ws.on('error', () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
app, server, wss, store, db,
|
||||||
|
close(done) {
|
||||||
|
wss.clients.forEach(c => { try { c.terminate(); } catch {} });
|
||||||
|
wss.close();
|
||||||
|
server.close(() => { db.close(); if (done) done(); });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot standalone if run directly.
|
||||||
|
if (require.main === module) {
|
||||||
|
const port = parseInt(process.env.PORT, 10) || 4001;
|
||||||
|
const dbPath = process.env.DB_PATH || './data/tracker.sqlite';
|
||||||
|
const { server } = createServer({ dbPath, port });
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`ttrpg backend listening on :${port} (db: ${dbPath})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createServer };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
rootDir: '.',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['<rootDir>/tests/**/*.test.js'],
|
||||||
|
testTimeout: 10000,
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@ttrpg/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Self-hosted backend: Express + ws + better-sqlite3. Server-authoritative turn logic.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch index.js",
|
||||||
|
"start": "node index.js",
|
||||||
|
"test": "jest --forceExit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ttrpg/shared": "*",
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Layer 2 test: exercise ws.js storage adapter against a LIVE backend.
|
||||||
|
// Complements Layer 1 (App + firebase mock) which proves App call shape but
|
||||||
|
// never touches ws.js. This catches translation bugs in the adapter.
|
||||||
|
//
|
||||||
|
// Runs the shared storage contract (same spec memory/firebase satisfy) against
|
||||||
|
// createWsStorage pointed at an ephemeral backend instance. A FRESH backend is
|
||||||
|
// spun up per test to guarantee isolation (backend has no reset endpoint yet).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { createServer } = require('../index');
|
||||||
|
const { createWsStorage } = require('../../src/storage/ws');
|
||||||
|
const { runStorageContract } = require('../../src/storage/contract');
|
||||||
|
|
||||||
|
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
|
||||||
|
// Disposing the storage closes the backend so each test is fully isolated.
|
||||||
|
async function makeStorage() {
|
||||||
|
const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
|
||||||
|
const handle = createServer({ dbPath, port: 0 });
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
handle.server.on('error', reject);
|
||||||
|
handle.server.listen(0, resolve);
|
||||||
|
});
|
||||||
|
const port = handle.server.address().port;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const wsUrl = `ws://127.0.0.1:${port}/ws`;
|
||||||
|
const storage = createWsStorage({ baseUrl, wsUrl });
|
||||||
|
storage.dispose = (done) => handle.close(done);
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
runStorageContract('ws (live backend)', makeStorage);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// BUG-8: ws adapter has NO reconnect. WS dies (idle/error/close) → wsReady=null,
|
||||||
|
// subscribers dead forever, no re-subscribe. Display frozen until full reload.
|
||||||
|
// Test: subscribe, write (cb fires), force-drop WS, write again (must still fire).
|
||||||
|
// RED on current.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { createServer } = require('../index');
|
||||||
|
const { createWsStorage } = require('../../src/storage/ws');
|
||||||
|
|
||||||
|
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function makeStorage() {
|
||||||
|
const dbPath = path.join(os.tmpdir(), `ws-recon-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
|
||||||
|
const handle = createServer({ dbPath, port: 0 });
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
handle.server.on('error', reject);
|
||||||
|
handle.server.listen(0, resolve);
|
||||||
|
});
|
||||||
|
const port = handle.server.address().port;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const wsUrl = `ws://127.0.0.1:${port}/ws`;
|
||||||
|
const storage = createWsStorage({ baseUrl, wsUrl });
|
||||||
|
storage.dispose = (done) => handle.close(done);
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BUG-8: ws adapter reconnect after drop', () => {
|
||||||
|
test('subscribe fires cb after WS dropped + restored', async () => {
|
||||||
|
const storage = await makeStorage();
|
||||||
|
try {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'V1' });
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// force-drop WS (simulates idle timeout / network blip)
|
||||||
|
storage._test.forceDrop();
|
||||||
|
await flush(300);
|
||||||
|
// wsReady should be null now
|
||||||
|
expect(storage._test.getReady()).toBeNull();
|
||||||
|
|
||||||
|
// write again — subscriber must re-fire after reconnect
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'V2' });
|
||||||
|
await flush(1000);
|
||||||
|
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last).toEqual({ name: 'V2' });
|
||||||
|
} finally {
|
||||||
|
await new Promise(r => storage.dispose(r));
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @ttrpg/shared — barrel export.
|
||||||
|
module.exports = require('./turn.js');
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
rootDir: '.',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['<rootDir>/tests/**/*.test.js'],
|
||||||
|
collectCoverageFrom: ['turn.js'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@ttrpg/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Pure logic shared by client + server + tests. No I/O.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
// Characterization tests for shared/turn.js.
|
||||||
|
// Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix.
|
||||||
|
// These tests assert what the code does NOW, not what it SHOULD do.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const {
|
||||||
|
sortParticipantsByInitiative,
|
||||||
|
computeTurnOrderAfterRemoval,
|
||||||
|
computeTurnOrderAfterAddition,
|
||||||
|
startEncounter,
|
||||||
|
nextTurn,
|
||||||
|
togglePause,
|
||||||
|
addParticipant,
|
||||||
|
removeParticipant,
|
||||||
|
toggleParticipantActive,
|
||||||
|
applyHpChange,
|
||||||
|
deathSave,
|
||||||
|
toggleCondition,
|
||||||
|
reorderParticipants,
|
||||||
|
endEncounter,
|
||||||
|
makeParticipant,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
// Helper: minimal encounter with given participants.
|
||||||
|
function enc(participants = [], extra = {}) {
|
||||||
|
return {
|
||||||
|
name: 'Test Encounter',
|
||||||
|
participants,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
turnOrderIds: [],
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function p(id, initiative, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative, maxHp: 20, currentHp: 20,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sortParticipantsByInitiative', () => {
|
||||||
|
test('higher initiative first', () => {
|
||||||
|
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||||
|
const sorted = sortParticipantsByInitiative(ps, ps);
|
||||||
|
expect(sorted.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ties broken by original order', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
||||||
|
const sorted = sortParticipantsByInitiative(ps, ps);
|
||||||
|
expect(sorted.map(x => x.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startEncounter', () => {
|
||||||
|
test('throws if no participants', () => {
|
||||||
|
expect(() => startEncounter(enc([]))).toThrow('participants');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws if no active participants', () => {
|
||||||
|
const e = enc([p('a', 10, { isActive: false })]);
|
||||||
|
expect(() => startEncounter(e)).toThrow('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets round 1, turn order sorted, current = highest init', () => {
|
||||||
|
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||||
|
const e = enc(ps);
|
||||||
|
const { patch } = startEncounter(e);
|
||||||
|
expect(patch.isStarted).toBe(true);
|
||||||
|
expect(patch.round).toBe(1);
|
||||||
|
expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||||
|
expect(patch.currentTurnParticipantId).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inactive stays in turn order slot (1-list model)', () => {
|
||||||
|
const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)];
|
||||||
|
const { patch } = startEncounter(enc(ps));
|
||||||
|
// 1-list: all participants sorted by init (active+inactive), inactive stays in slot
|
||||||
|
expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||||
|
expect(patch.currentTurnParticipantId).toBe('c'); // b inactive, skipped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextTurn', () => {
|
||||||
|
test('throws if not started', () => {
|
||||||
|
expect(() => nextTurn(enc([p('a', 10)], { isStarted: false }))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws if paused', () => {
|
||||||
|
expect(() => nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('advances to next in order, no round bump', () => {
|
||||||
|
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||||
|
const e = enc(ps, {
|
||||||
|
isStarted: true,
|
||||||
|
round: 1,
|
||||||
|
currentTurnParticipantId: 'b',
|
||||||
|
turnOrderIds: ['b', 'c', 'a'],
|
||||||
|
});
|
||||||
|
const { patch } = nextTurn(e);
|
||||||
|
expect(patch.currentTurnParticipantId).toBe('c');
|
||||||
|
expect(patch.round).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wraps round when last in order', () => {
|
||||||
|
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||||
|
const e = enc(ps, {
|
||||||
|
isStarted: true,
|
||||||
|
round: 1,
|
||||||
|
currentTurnParticipantId: 'a',
|
||||||
|
turnOrderIds: ['b', 'c', 'a'],
|
||||||
|
});
|
||||||
|
const { patch } = nextTurn(e);
|
||||||
|
expect(patch.currentTurnParticipantId).toBe('b');
|
||||||
|
expect(patch.round).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ends encounter if no active participants', () => {
|
||||||
|
const ps = [p('a', 10, { isActive: false })];
|
||||||
|
const e = enc(ps, {
|
||||||
|
isStarted: true,
|
||||||
|
round: 1,
|
||||||
|
currentTurnParticipantId: 'a',
|
||||||
|
turnOrderIds: ['a'],
|
||||||
|
});
|
||||||
|
const { patch } = nextTurn(e);
|
||||||
|
expect(patch.isStarted).toBe(false);
|
||||||
|
expect(patch.currentTurnParticipantId).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('togglePause', () => {
|
||||||
|
test('pauses started encounter', () => {
|
||||||
|
const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
|
||||||
|
const { patch } = togglePause(e);
|
||||||
|
expect(patch.isPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume preserves turn order (no re-sort)', () => {
|
||||||
|
// BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer
|
||||||
|
// and caused skips. Order frozen at startEncounter, patched incrementally.
|
||||||
|
const ps = [p('a', 5), p('b', 15)];
|
||||||
|
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
|
||||||
|
const { patch } = togglePause(e);
|
||||||
|
expect(patch.isPaused).toBe(false);
|
||||||
|
expect(patch.turnOrderIds).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeParticipant', () => {
|
||||||
|
test('removes from participants array', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
const { patch } = removeParticipant(enc(ps), 'a');
|
||||||
|
expect(patch.participants.map(x => x.id)).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not started: no turn order mutation', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
const { patch } = removeParticipant(enc(ps), 'a');
|
||||||
|
expect(patch.turnOrderIds).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('started: removes from turnOrderIds', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
|
||||||
|
const { patch } = removeParticipant(e, 'a');
|
||||||
|
expect(patch.turnOrderIds).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('started: removing current picks next active', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 5), p('c', 3)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' });
|
||||||
|
const { patch } = removeParticipant(e, 'a');
|
||||||
|
expect(patch.currentTurnParticipantId).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleParticipantActive', () => {
|
||||||
|
test('deactivates participant', () => {
|
||||||
|
const ps = [p('a', 10, { isActive: true })];
|
||||||
|
const { patch } = toggleParticipantActive(enc(ps), 'a');
|
||||||
|
expect(patch.participants[0].isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('started: deactivating current advances turn', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||||
|
const { patch } = toggleParticipantActive(e, 'a');
|
||||||
|
expect(patch.currentTurnParticipantId).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('started: reactivating inserts by initiative', () => {
|
||||||
|
// BUG-5 fix: reactivated participant slots by initiative (not appended
|
||||||
|
// to end). Preserves correct rotation order.
|
||||||
|
const ps = [p('a', 10, { isActive: false }), p('b', 5)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
|
||||||
|
const { patch } = toggleParticipantActive(e, 'a');
|
||||||
|
// a init=10 > b init=5 → a slots before b
|
||||||
|
expect(patch.turnOrderIds).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyHpChange', () => {
|
||||||
|
test('damage reduces hp, clamps 0', () => {
|
||||||
|
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
|
||||||
|
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 5);
|
||||||
|
expect(patch.participants[0].currentHp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => {
|
||||||
|
// FEAT-1: death no longer deactivates or removes from turn order.
|
||||||
|
// Dead stay in rotation, nextTurn still visits them, PCs get death-save turn.
|
||||||
|
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||||
|
const { patch } = applyHpChange(e, 'a', 'damage', 5);
|
||||||
|
expect(patch.participants[0].currentHp).toBe(0);
|
||||||
|
expect(patch.participants[0].isActive).toBe(true);
|
||||||
|
expect(patch.turnOrderIds).toBeUndefined();
|
||||||
|
expect(patch.currentTurnParticipantId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal above 0 resets death saves, keeps active (FEAT-1)', () => {
|
||||||
|
// FEAT-1: revive no longer flips isActive (was already active — death
|
||||||
|
// doesn't deactivate). deathSaves still reset.
|
||||||
|
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
|
||||||
|
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
|
||||||
|
expect(patch.participants[0].currentHp).toBe(5);
|
||||||
|
expect(patch.participants[0].isActive).toBe(true);
|
||||||
|
expect(patch.participants[0].deathSaves).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal clamps to maxHp', () => {
|
||||||
|
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
|
||||||
|
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 10);
|
||||||
|
expect(patch.participants[0].currentHp).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero amount = no-op', () => {
|
||||||
|
const ps = [p('a', 10, { currentHp: 10 })];
|
||||||
|
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 0);
|
||||||
|
expect(patch).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deathSave', () => {
|
||||||
|
test('increments saves', () => {
|
||||||
|
const ps = [p('a', 10, { currentHp: 0, deathSaves: 0 })];
|
||||||
|
const { patch } = deathSave(enc(ps), 'a', 1);
|
||||||
|
expect(patch.participants[0].deathSaves).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking same save decrements (toggle)', () => {
|
||||||
|
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
|
||||||
|
const { patch } = deathSave(enc(ps), 'a', 2);
|
||||||
|
expect(patch.participants[0].deathSaves).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('third save sets isDying', () => {
|
||||||
|
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
|
||||||
|
const result = deathSave(enc(ps), 'a', 3);
|
||||||
|
expect(result.patch.participants[0].deathSaves).toBe(3);
|
||||||
|
expect(result.patch.participants[0].isDying).toBe(true);
|
||||||
|
expect(result.isDying).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleCondition', () => {
|
||||||
|
test('adds condition', () => {
|
||||||
|
const ps = [p('a', 10, { conditions: [] })];
|
||||||
|
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
|
||||||
|
expect(patch.participants[0].conditions).toEqual(['poisoned']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes condition', () => {
|
||||||
|
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
|
||||||
|
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
|
||||||
|
expect(patch.participants[0].conditions).toEqual(['blinded']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reorderParticipants', () => {
|
||||||
|
test('drag before target (1-list, cross-init allowed)', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
||||||
|
const { patch } = reorderParticipants(enc(ps), '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']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-init drag allowed (1-list, DM override)', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
const { patch } = reorderParticipants(enc(ps), 'a', 'b');
|
||||||
|
expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('endEncounter', () => {
|
||||||
|
test('resets all combat state', () => {
|
||||||
|
const e = enc([p('a', 10)], {
|
||||||
|
isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'],
|
||||||
|
});
|
||||||
|
const { patch } = endEncounter(e);
|
||||||
|
expect(patch.isStarted).toBe(false);
|
||||||
|
expect(patch.round).toBe(0);
|
||||||
|
expect(patch.currentTurnParticipantId).toBe(null);
|
||||||
|
expect(patch.turnOrderIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeTurnOrderAfterRemoval', () => {
|
||||||
|
test('not started = empty', () => {
|
||||||
|
const out = computeTurnOrderAfterRemoval(enc([]), 'a', []);
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing non-current: no turnOrderIds patch (1-list syncs at call site)', () => {
|
||||||
|
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
|
||||||
|
const out = computeTurnOrderAfterRemoval(e, 'a', []);
|
||||||
|
// 1-list: removal syncs turnOrderIds via participants[] at call site.
|
||||||
|
// Helper only handles current-advance. Non-current = empty patch.
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
test('appends participant', () => {
|
||||||
|
const np = p('z', 7);
|
||||||
|
const { patch } = addParticipant(enc([p('a', 10)]), np);
|
||||||
|
expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects duplicate id (skip-bug root cause)', () => {
|
||||||
|
// Two participants with same id → togglePause resume rebuilds order with
|
||||||
|
// dup id twice → nextTurn gets stuck repeating that id forever.
|
||||||
|
// Audit found this in 100-round replay (addParticipant fired while paused
|
||||||
|
// because nextTurn threw, loop spun, same totalTurns %10 → re-added).
|
||||||
|
const existing = p('x', 5);
|
||||||
|
const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 });
|
||||||
|
expect(() => addParticipant(enc([p('a', 10), existing]), dup)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Combat integrity test: replay exact op sequence through pure turn.js,
|
||||||
|
// assert rotation + state invariants per round. This IS the test the audit
|
||||||
|
// was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5.
|
||||||
|
//
|
||||||
|
// Mirrors scripts/replay-combat.js op order:
|
||||||
|
// damage, heal (cleric), conditions, toggleActive, deathSave,
|
||||||
|
// removeParticipant, addParticipant, updateParticipant, pause/resume,
|
||||||
|
// reorderParticipants, revive-between-rounds.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const {
|
||||||
|
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause,
|
||||||
|
addParticipant, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, applyHpChange, deathSave,
|
||||||
|
toggleCondition, reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
// ---- seeded RNG (deterministic, reproducible) ----
|
||||||
|
let _seed = 12345;
|
||||||
|
function rand() {
|
||||||
|
// LCG
|
||||||
|
_seed = (_seed * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return _seed / 0x7fffffff;
|
||||||
|
}
|
||||||
|
const rnd = (n) => Math.floor(rand() * n);
|
||||||
|
const pick = (arr) => arr[rnd(arr.length)];
|
||||||
|
|
||||||
|
const CONDITIONS = [
|
||||||
|
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||||
|
'exhaustion','frightened','grappled','grazed','incapacitated',
|
||||||
|
'invisible','paralyzed','petrified','poisoned','prone','restrained',
|
||||||
|
'sapped','shield','slowed','stunned','unconscious','vexed',
|
||||||
|
];
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 200, currentHp: 200,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEncounter() {
|
||||||
|
const ps = [
|
||||||
|
buildCharacterParticipant({ id:'c1', name:'Fighter', defaultMaxHp:200, defaultInitMod:2 }).participant,
|
||||||
|
buildCharacterParticipant({ id:'c2', name:'Cleric', defaultMaxHp:180, defaultInitMod:1 }).participant,
|
||||||
|
buildCharacterParticipant({ id:'c3', name:'Rogue', defaultMaxHp:160, defaultInitMod:3 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'Goblin1', maxHp:100, initMod:2 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'Goblin2', maxHp:100, initMod:2 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, isNpc:true }).participant,
|
||||||
|
];
|
||||||
|
// give deterministic ids to monsters for assertions
|
||||||
|
const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' };
|
||||||
|
ps.forEach((part) => { if (idMap[part.name]) part.id = idMap[part.name]; });
|
||||||
|
return {
|
||||||
|
name: 'combat-test', participants: ps,
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentParticipant(e) {
|
||||||
|
if (!e.currentTurnParticipantId) return null;
|
||||||
|
return (e.participants || []).find(x => x.id === e.currentTurnParticipantId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a result patch if present.
|
||||||
|
function apply(e, result) {
|
||||||
|
if (!result || !result.patch) return e;
|
||||||
|
return { ...e, ...result.patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||||
|
jest.setTimeout(30000);
|
||||||
|
|
||||||
|
const ROUNDS = 100;
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
test('every round visits each active participant exactly once', () => {
|
||||||
|
_seed = 12345; // reset for reproducibility
|
||||||
|
let e = setupEncounter();
|
||||||
|
e = apply(e, startEncounter(e));
|
||||||
|
|
||||||
|
let totalTurns = 0;
|
||||||
|
let lastPaused = false;
|
||||||
|
let lastReorder = 0;
|
||||||
|
let reinforcementsAdded = 0;
|
||||||
|
const condQueue = [...CONDITIONS];
|
||||||
|
|
||||||
|
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||||
|
const startRound = e.round;
|
||||||
|
const seenThisRound = [];
|
||||||
|
const cap = (e.participants.length + 2) * 2;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (e.round === startRound && guard < cap) {
|
||||||
|
// resume if paused (must precede nextTurn)
|
||||||
|
if (lastPaused) { e = apply(e, togglePause(e)); lastPaused = false; }
|
||||||
|
|
||||||
|
// advance
|
||||||
|
let t;
|
||||||
|
try { t = nextTurn(e); } catch (err) {
|
||||||
|
violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
e = apply(e, t);
|
||||||
|
totalTurns++;
|
||||||
|
// only count if turn belongs to THIS round (no wrap)
|
||||||
|
if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId);
|
||||||
|
|
||||||
|
const actor = currentParticipant(e);
|
||||||
|
|
||||||
|
// 1. damage
|
||||||
|
if (actor) {
|
||||||
|
const foes = e.participants.filter(
|
||||||
|
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false
|
||||||
|
);
|
||||||
|
if (foes.length > 0) {
|
||||||
|
const tgt = pick(foes);
|
||||||
|
const dmg = 1 + rnd(5);
|
||||||
|
e = apply(e, applyHpChange(e, tgt.id, 'damage', dmg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. heal (cleric)
|
||||||
|
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||||
|
const wounded = e.participants
|
||||||
|
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false)
|
||||||
|
.sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
|
||||||
|
if (wounded.length > 0) {
|
||||||
|
const tgt = wounded[0];
|
||||||
|
const amt = 2 + rnd(5);
|
||||||
|
e = apply(e, applyHpChange(e, tgt.id, 'heal', amt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. conditions
|
||||||
|
if (condQueue.length > 0) {
|
||||||
|
const cond = condQueue[0];
|
||||||
|
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try { e = apply(e, toggleCondition(e, tgt.id, cond)); condQueue.shift(); }
|
||||||
|
catch (err) { condQueue.shift(); }
|
||||||
|
}
|
||||||
|
} else if (totalTurns % 6 === 0) {
|
||||||
|
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
const cond = pick(CONDITIONS);
|
||||||
|
try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. toggleParticipantActive
|
||||||
|
if (totalTurns % 9 === 0) {
|
||||||
|
const living = e.participants.filter(p => p.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try { e = apply(e, toggleParticipantActive(e, tgt.id)); } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5. deathSave
|
||||||
|
if (actor && actor.currentHp <= 0 && !actor.isNpc) {
|
||||||
|
try { e = apply(e, deathSave(e, actor.id, 1)); } catch (err) {}
|
||||||
|
}
|
||||||
|
// 6. removeParticipant
|
||||||
|
if (totalTurns % 5 === 0) {
|
||||||
|
const dead = e.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf'));
|
||||||
|
if (dead) { try { e = apply(e, removeParticipant(e, dead.id)); } catch (err) {} }
|
||||||
|
}
|
||||||
|
// 7. addParticipant
|
||||||
|
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
|
||||||
|
const spec = pick([
|
||||||
|
{ name:`Reinforce${reinforcementsAdded+1}`, maxHp:120, initMod:1 },
|
||||||
|
{ name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 },
|
||||||
|
]);
|
||||||
|
const built = buildMonsterParticipant(spec).participant;
|
||||||
|
try { e = apply(e, addParticipant(e, built)); reinforcementsAdded++; } catch (err) {}
|
||||||
|
}
|
||||||
|
// 8. updateParticipant
|
||||||
|
if (totalTurns % 7 === 0) {
|
||||||
|
const living = e.participants.filter(p => p.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try { e = apply(e, updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` })); } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 9. pause
|
||||||
|
if (totalTurns % 12 === 0 && !lastPaused) { e = apply(e, togglePause(e)); lastPaused = true; }
|
||||||
|
// 10. reorderParticipants (mirror replay's buggy signature usage — swallowed no-op)
|
||||||
|
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
||||||
|
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (living.length >= 2) {
|
||||||
|
const tgt = living[0];
|
||||||
|
const newInit = (tgt.initiative || 0) + 1;
|
||||||
|
try {
|
||||||
|
const reordered = [...e.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p);
|
||||||
|
e = apply(e, reorderParticipants(e, reordered));
|
||||||
|
lastReorder = totalTurns;
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard++;
|
||||||
|
if (!e.isStarted) break;
|
||||||
|
}
|
||||||
|
if (!e.isStarted) break;
|
||||||
|
|
||||||
|
// === per-round invariants ===
|
||||||
|
const uniq = new Set(seenThisRound);
|
||||||
|
if (uniq.size !== seenThisRound.length) {
|
||||||
|
violations.push({ round: roundN, type: 'rotation-dupe',
|
||||||
|
seen: seenThisRound.map(id => e.participants.find(p=>p.id===id)?.name||id) });
|
||||||
|
}
|
||||||
|
// turnOrderIds no dup
|
||||||
|
const orderUniq = new Set(e.turnOrderIds);
|
||||||
|
if (orderUniq.size !== e.turnOrderIds.length) {
|
||||||
|
violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds });
|
||||||
|
}
|
||||||
|
// currentTurn valid + active
|
||||||
|
if (e.currentTurnParticipantId) {
|
||||||
|
const ct = e.participants.find(p => p.id === e.currentTurnParticipantId);
|
||||||
|
if (!ct) violations.push({ round: roundN, type: 'currentTurn-missing' });
|
||||||
|
else if (ct.isActive === false && e.isStarted) {
|
||||||
|
violations.push({ round: roundN, type: 'currentTurn-inactive', id: ct.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HP bounds
|
||||||
|
for (const part of e.participants) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// revive dead between rounds
|
||||||
|
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||||
|
for (const d of dead) {
|
||||||
|
try {
|
||||||
|
if (d.isActive === false) e = apply(e, toggleParticipantActive(e, d.id));
|
||||||
|
e = apply(e, applyHpChange(e, d.id, 'heal', d.maxHp));
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report
|
||||||
|
if (violations.length > 0) {
|
||||||
|
const byType = {};
|
||||||
|
violations.forEach(v => { byType[v.type] = (byType[v.type]||0) + 1; });
|
||||||
|
const summary = Object.entries(byType).sort((a,b)=>b[1]-a[1]).map(([k,n])=>`${n}x ${k}`).join(', ');
|
||||||
|
const first5 = violations.slice(0,5).map(v => `r${v.round} ${v.type}${v.seen?': '+JSON.stringify(v.seen):''}${v.order?': '+JSON.stringify(v.order):''}${v.msg?': '+v.msg:''}`).join('\n ');
|
||||||
|
// dump full state for first dupe for triage
|
||||||
|
throw new Error(`combat integrity violations: ${violations.length}\n ${summary}\n first 5:\n ${first5}`);
|
||||||
|
}
|
||||||
|
expect(violations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// M4 desired behavior: dead PC stays in turn order, turn still comes up,
|
||||||
|
// deathSave fires. Current code filters isActive (set false on death) so
|
||||||
|
// dead participants are SKIPPED. Test asserts desired state = RED on current.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function pc(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'character',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('M4: dead participants stay in turn order', () => {
|
||||||
|
test('dead PC not removed from turnOrderIds', () => {
|
||||||
|
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const orderBefore = e.turnOrderIds.slice();
|
||||||
|
// kill b
|
||||||
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||||
|
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dead PC turn still comes up (nextTurn visits them)', () => {
|
||||||
|
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
// kill b
|
||||||
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||||
|
// advance: a→b→c. b's turn should come up.
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
expect(e.currentTurnParticipantId).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dead PC on their turn can deathSave', () => {
|
||||||
|
const ps = [pc('a', 20), pc('b', 15)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
// kill b (current = a)
|
||||||
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
||||||
|
// advance to b's turn
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
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();
|
||||||
|
const b = r.patch.participants.find(x => x.id === 'b');
|
||||||
|
expect(b.deathSaves).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dead PC not auto-set isActive=false by applyHpChange', () => {
|
||||||
|
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 b = e.participants.find(x => x.id === 'b');
|
||||||
|
expect(b.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// DRY guard (BUG-5 fix): nextTurn and computeTurnOrderAfterRemoval share one
|
||||||
|
// advance core (nextActiveAfter). Both must pick the SAME next-active target
|
||||||
|
// for identical state. If this goes RED, the two paths drifted.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, startEncounter, nextTurn, toggleParticipantActive } = 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DRY: deact-current advance == nextTurn advance', () => {
|
||||||
|
test('mid-round: same target (not current)', () => {
|
||||||
|
// order a,b,c. a current. nextTurn → b. deact a → advance → b.
|
||||||
|
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true,
|
||||||
|
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
|
||||||
|
const nt = nextTurn(e).patch.currentTurnParticipantId;
|
||||||
|
const deact = toggleParticipantActive(e, 'a').patch.currentTurnParticipantId;
|
||||||
|
expect(deact).toBe(nt);
|
||||||
|
expect(deact).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mid-round with inactive skipper: same target', () => {
|
||||||
|
// order a,x,b,c; x inactive. a current. nextTurn → b. deact a → b.
|
||||||
|
const x = p('x',7,{ isActive:false });
|
||||||
|
const e = enc([p('a',10),x,p('b',5),p('c',3)], { isStarted:true,
|
||||||
|
turnOrderIds:['a','x','b','c'], currentTurnParticipantId:'a' });
|
||||||
|
const nt = nextTurn(e).patch.currentTurnParticipantId;
|
||||||
|
const deact = toggleParticipantActive(e, 'a').patch.currentTurnParticipantId;
|
||||||
|
expect(deact).toBe(nt);
|
||||||
|
expect(deact).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrap: same target + round bump', () => {
|
||||||
|
// order a,b,c. c current. nextTurn → wrap → a (r+1). deact c → wrap → a (r+1).
|
||||||
|
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true,
|
||||||
|
turnOrderIds:['a','b','c'], currentTurnParticipantId:'c', round:2 });
|
||||||
|
const nt = nextTurn(e).patch;
|
||||||
|
const deact = toggleParticipantActive(e, 'c').patch;
|
||||||
|
expect(deact.currentTurnParticipantId).toBe(nt.currentTurnParticipantId);
|
||||||
|
expect(deact.currentTurnParticipantId).toBe('a');
|
||||||
|
expect(deact.round).toBe(nt.round);
|
||||||
|
expect(deact.round).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// INVARIANT test: ONE list. turnOrderIds === participants.map(id) always.
|
||||||
|
// No re-sort after startEncounter. nextTurn follows list order, skipping inactive.
|
||||||
|
// Drag (reorder) overrides initiative — cross-init drag allowed + reflected.
|
||||||
|
// Display === rotation by construction (same array).
|
||||||
|
//
|
||||||
|
// RED now: current code has two lists (sort on display, frozen turnOrderIds),
|
||||||
|
// reorder throws on cross-init. Refactor (1-list model) greens these.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const {
|
||||||
|
makeParticipant,
|
||||||
|
startEncounter, nextTurn, addParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, togglePause, applyHpChange,
|
||||||
|
reorderParticipants, endEncounter,
|
||||||
|
} = 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;
|
||||||
|
|
||||||
|
// walk one full rotation from current, collect active ids in list order
|
||||||
|
function walkRotation(e) {
|
||||||
|
if (!e.isStarted || e.isPaused || !e.currentTurnParticipantId) return [];
|
||||||
|
let cur = e;
|
||||||
|
const start = cur.currentTurnParticipantId;
|
||||||
|
const seen = [];
|
||||||
|
for (let i = 0; i < (cur.turnOrderIds || []).length + 1; i++) {
|
||||||
|
const curP = (cur.participants || []).find(p => p.id === cur.currentTurnParticipantId);
|
||||||
|
if (curP && curP.isActive) seen.push(cur.currentTurnParticipantId);
|
||||||
|
const nxt = nextTurn(cur);
|
||||||
|
cur = apply(cur, nxt);
|
||||||
|
if (cur.currentTurnParticipantId === start) break;
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => {
|
||||||
|
test('startEncounter: list = sorted-active participants order', () => {
|
||||||
|
let e = enc([p('a',3),p('b',10),p('c',5)]);
|
||||||
|
e = apply(e, startEncounter(e));
|
||||||
|
expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startEncounter: inactive stays in list slot (skipped by nextTurn)', () => {
|
||||||
|
let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]);
|
||||||
|
e = apply(e, startEncounter(e));
|
||||||
|
// 1-list: inactive b stays in slot, nextTurn skips it
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant mid-encounter: inserted by init, list synced', () => {
|
||||||
|
let e = enc([p('a',10),p('c',3)]);
|
||||||
|
e = apply(e, startEncounter(e)); // [a,c]
|
||||||
|
e = apply(e, addParticipant(e, p('b',7))); // insert between a,c
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant: list === participants.map(id) after add', () => {
|
||||||
|
let e = enc([p('a',10)]);
|
||||||
|
e = apply(e, startEncounter(e));
|
||||||
|
e = apply(e, addParticipant(e, p('b',5)));
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeParticipant: list synced, order preserved', () => {
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = apply(e, startEncounter(e));
|
||||||
|
e = apply(e, removeParticipant(e, 'b'));
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','c']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorder cross-init: allowed, list + rotation reflect new order', () => {
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = apply(e, startEncounter(e)); // [a,b,c]
|
||||||
|
e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a
|
||||||
|
expect(e.turnOrderIds).toEqual(['c','a','b']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorder: rotation follows new list order', () => {
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = apply(e, startEncounter(e)); // [a,b,c], cur=a
|
||||||
|
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a
|
||||||
|
const rot = walkRotation(e); // start a, next c (wrap), next b, back a
|
||||||
|
expect(rot).toEqual(['a','c','b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle inactive: list unchanged (stays in rotation slot)', () => {
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = apply(e, startEncounter(e)); // [a,b,c]
|
||||||
|
e = apply(e, toggleParticipantActive(e, 'b')); // b off
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle inactive: nextTurn skips b, visits a→c', () => {
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = apply(e, startEncounter(e)); // cur=a
|
||||||
|
e = apply(e, toggleParticipantActive(e, 'b')); // b inactive
|
||||||
|
e = apply(e, nextTurn(e)); // skip b → c
|
||||||
|
expect(e.currentTurnParticipantId).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hp death + revive: list unchanged', () => {
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = apply(e, startEncounter(e));
|
||||||
|
e = apply(e, applyHpChange(e, 'b', 'damage', 100)); // b dies
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||||
|
e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Characterization test: addParticipant + pause/resume corrupts turn rotation.
|
||||||
|
// Audit found 56-77 violations/100 rounds starting round 20 in pure turn.js
|
||||||
|
// simulation. Visible in live replay (round 10: 17 turns, 6 duped actors,
|
||||||
|
// R-series stuck repeating forever).
|
||||||
|
//
|
||||||
|
// This test uses FRESH ids (crypto.randomUUID equivalent) — NOT the audit's
|
||||||
|
// self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`).
|
||||||
|
// Validates real bug reachable via normal UI flow (DM adds monster while paused,
|
||||||
|
// resumes).
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared;
|
||||||
|
|
||||||
|
function p(id, initiative, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(ps) {
|
||||||
|
return {
|
||||||
|
name: 'T', participants: ps,
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('addParticipant + pause/resume rotation corruption', () => {
|
||||||
|
test('add fresh participant while paused, resume, rotation completes full cycle', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const baseOrder = e.turnOrderIds.slice(); // [a,b,c]
|
||||||
|
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // pause
|
||||||
|
|
||||||
|
// add fresh participant x (initiative 25, would sort first)
|
||||||
|
const x = p('x', 25);
|
||||||
|
e = { ...e, ...addParticipant(e, x).patch };
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // resume (rebuilds order)
|
||||||
|
|
||||||
|
// after resume, complete one full round: visit each active participant once
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length - 1; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
// EXPECT: 4 unique (a,b,c,x). BUG: rotation may not visit all.
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple adds while paused, resume, rotation visits all', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // pause
|
||||||
|
|
||||||
|
// add 3 fresh participants
|
||||||
|
for (const id of ['x', 'y', 'z']) {
|
||||||
|
const np = p(id, 5 + Math.floor(Math.random() * 30));
|
||||||
|
e = { ...e, ...addParticipant(e, np).patch };
|
||||||
|
}
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // resume
|
||||||
|
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
// EXPECT: all 6 participants reachable. BUG: some stuck/repeated.
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add while running, then pause+resume, rotation stays valid', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||||
|
const x = p('x', 25);
|
||||||
|
e = { ...e, ...addParticipant(e, x).patch }; // add while running
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // pause
|
||||||
|
e = { ...e, ...togglePause(e).patch }; // resume
|
||||||
|
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// removeParticipant + computeTurnOrderAfterRemoval edge cases.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, startEncounter, nextTurn, removeParticipant, toggleParticipantActive, applyHpChange } = shared;
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('removeParticipant turn-order edges', () => {
|
||||||
|
test('removing current picks next active as current', () => {
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
e = { ...e, ...removeParticipant(e, 'a').patch }; // a was current
|
||||||
|
expect(e.currentTurnParticipantId).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing last in order wraps current to first', () => {
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // b
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // c (current)
|
||||||
|
e = { ...e, ...removeParticipant(e, 'c').patch };
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', () => {
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch }; // [a,b,c], cur=a
|
||||||
|
// deactivate b + c (stay in slot, inactive)
|
||||||
|
e = { ...e, ...toggleParticipantActive(e, 'b').patch };
|
||||||
|
e = { ...e, ...toggleParticipantActive(e, 'c').patch };
|
||||||
|
// remove current a
|
||||||
|
e = { ...e, ...removeParticipant(e, 'a').patch };
|
||||||
|
// 1-list: turnOrderIds=[b,c], no active → current null, isStarted stays true
|
||||||
|
expect(e.turnOrderIds).toEqual(['b', 'c']);
|
||||||
|
expect(e.currentTurnParticipantId).toBeNull();
|
||||||
|
// isStarted still true but no turn → nextTurn throws (stale state)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing non-current keeps currentTurn', () => {
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
e = { ...e, ...removeParticipant(e, 'b').patch };
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a');
|
||||||
|
expect(e.turnOrderIds).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing current that is dead (HP=0) - BUG-3 overlap', () => {
|
||||||
|
// Dead participant removed mid-combat. Desired (M4): they STAY in order.
|
||||||
|
// removeParticipant is explicit DM action, distinct from auto-skip.
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; // b dead
|
||||||
|
e = { ...e, ...removeParticipant(e, 'b').patch };
|
||||||
|
expect(e.turnOrderIds).not.toContain('b');
|
||||||
|
expect(e.participants.find(x => x.id === 'b')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Characterization for reorderParticipants correct usage.
|
||||||
|
// replay-combat.js calls it with wrong signature (swallowed by try/catch),
|
||||||
|
// so real behavior untested. Lock what it actually does.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, startEncounter, nextTurn, reorderParticipants } = shared;
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reorderParticipants', () => {
|
||||||
|
test('drag before target (1-list model)', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie
|
||||||
|
let e = enc(ps);
|
||||||
|
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');
|
||||||
|
// drag c before b: remove c → [b,a], insert before b → [c,b,a]
|
||||||
|
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-init drag allowed (1-list, DM override)', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 20)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch }; // [b,a]
|
||||||
|
const r = reorderParticipants(e, 'a', 'b');
|
||||||
|
expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws if id not found', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 20)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
expect(() => reorderParticipants(e, 'a', 'zzz')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 20), p('c', 20)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const r = reorderParticipants(e, 'c', 'b');
|
||||||
|
expect(r.patch.turnOrderIds).toEqual(['c', 'b', 'a']);
|
||||||
|
expect(r.patch.turnOrderIds).toEqual(r.patch.participants.map(p => p.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// BUG-6 candidate: reorder should affect turnOrderIds so mid-combat
|
||||||
|
// drag-drop changes who goes next within same-initiative tie.
|
||||||
|
// Currently RED (turnOrderIds not in patch).
|
||||||
|
test('reorder updates turnOrderIds to reflect new participant order', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 20), p('c', 20)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
// order: b,c,a
|
||||||
|
e = { ...e, ...reorderParticipants(e, 'c', 'b').patch };
|
||||||
|
expect(e.turnOrderIds).toEqual(['c', 'b', 'a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Regression test: full round must rotate through ALL active participants exactly once.
|
||||||
|
// Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter
|
||||||
|
// before any coverage action). nextTurn has core bug, not just coverage-path issue.
|
||||||
|
//
|
||||||
|
// This test is RED until nextTurn fixed.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { startEncounter, nextTurn, makeParticipant } = shared;
|
||||||
|
|
||||||
|
function p(id, initiative, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative, maxHp: 20, currentHp: 20,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(ps) {
|
||||||
|
return {
|
||||||
|
name: 'T', participants: ps,
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('round rotation integrity', () => {
|
||||||
|
test('3 participants: one full round visits each exactly once', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
|
||||||
|
// advance (len-1) turns: visits remaining participants, round NOT yet wrapped.
|
||||||
|
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(e.round).toBe(1); // still round 1
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(startOrder.length); // each exactly once
|
||||||
|
expect(visited.length).toBe(startOrder.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('8 participants (replay shape): one full round visits each exactly once', () => {
|
||||||
|
const ps = [
|
||||||
|
p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11),
|
||||||
|
p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10),
|
||||||
|
];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(e.round).toBe(1);
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(startOrder.length);
|
||||||
|
expect(visited.length).toBe(startOrder.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple rounds: each round visits each participant exactly once', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
const expectedRound = e.round;
|
||||||
|
|
||||||
|
// capture exactly one full round (current + len-1 advances), no wrap yet.
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(startOrder.length);
|
||||||
|
expect(e.round).toBe(expectedRound);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round rotation with mid-round state changes', () => {
|
||||||
|
const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared;
|
||||||
|
|
||||||
|
test('toggle a participant inactive mid-round, others still each visited once', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||||
|
// now mark 'a' inactive (already took its turn)
|
||||||
|
e = { ...e, ...toggleParticipantActive(e, 'a').patch };
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
|
||||||
|
// round should wrap, but 'a' inactive so only b,c,d visited
|
||||||
|
const visitedActive = visited.filter(id => id !== 'a');
|
||||||
|
const uniq = new Set(visitedActive);
|
||||||
|
expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reactivate inactive participant mid-round, it gets a turn this round', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||||
|
let e = enc(ps);
|
||||||
|
// start with 'c' inactive
|
||||||
|
e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
// 1-list: c stays in slot (inactive), skipped by nextTurn
|
||||||
|
expect(e.turnOrderIds).toEqual(['a', 'b', 'c', 'd']);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a'); // c inactive, a first
|
||||||
|
|
||||||
|
// advance one turn, then reactivate c
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // b
|
||||||
|
e = { ...e, ...toggleParticipantActive(e, 'c').patch };
|
||||||
|
|
||||||
|
// continue rotation - c should now be reachable
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
expect(visited).toContain('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant mid-round: new participant gets turn this round or next', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
|
||||||
|
e = { ...e, ...nextTurn(e).patch }; // advance one
|
||||||
|
// add new participant
|
||||||
|
const newP = p('x', 25);
|
||||||
|
e = { ...e, ...addParticipant(e, newP).patch };
|
||||||
|
|
||||||
|
// finish round - original 3 should still each get exactly one turn
|
||||||
|
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||||
|
while (e.round === 1) {
|
||||||
|
const r = nextTurn(e);
|
||||||
|
e = { ...e, ...r.patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
if (visited.length > 20) break; // safety
|
||||||
|
}
|
||||||
|
const originals = visited.filter(id => ['a','b','c'].includes(id));
|
||||||
|
const uniq = new Set(originals);
|
||||||
|
expect(uniq.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorderParticipants mid-round keeps rotation valid', () => {
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15)
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
// reorder: swap b,c (same initiative)
|
||||||
|
e = { ...e, ...reorderParticipants(e, 'b', 'c').patch };
|
||||||
|
|
||||||
|
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < startOrder.length; i++) {
|
||||||
|
e = { ...e, ...nextTurn(e).patch };
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Invariant: no real skip. Every active participant at round start (still
|
||||||
|
// active at round end) gets a turn. Tracks per ACTUAL round (e.round), so
|
||||||
|
// rounds spanning pause/resume across loop iterations count correctly.
|
||||||
|
//
|
||||||
|
// Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume).
|
||||||
|
// If this goes RED, turn order rotation is skipping participants again.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause, addParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
const apply = (e, r) => (r && r.patch) ? { ...e, ...r.patch } : e;
|
||||||
|
const nm = (enc) => (id) => {
|
||||||
|
const f = enc.participants.find(p => p.id === id);
|
||||||
|
return f ? f.name : id;
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const ps = [
|
||||||
|
buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant,
|
||||||
|
buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant,
|
||||||
|
buildCharacterParticipant({ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'Goblin1', maxHp: 100, initMod: 2 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }).participant,
|
||||||
|
];
|
||||||
|
let e = {
|
||||||
|
name: 't', participants: ps, isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
return apply(e, startEncounter(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
|
||||||
|
jest.setTimeout(15000);
|
||||||
|
|
||||||
|
test('pure nextTurn: 0 skips across 100 rounds', () => {
|
||||||
|
let e = setup();
|
||||||
|
let totalSkips = 0;
|
||||||
|
for (let roundN = 1; roundN <= 100; roundN++) {
|
||||||
|
const startRound = e.round;
|
||||||
|
const activeAtStart = new Set(e.participants.filter(p => p.isActive).map(p => p.id));
|
||||||
|
const acted = new Set();
|
||||||
|
acted.add(e.currentTurnParticipantId);
|
||||||
|
let guard = 0;
|
||||||
|
const cap = e.participants.length + 1;
|
||||||
|
while (e.round === startRound && guard < cap) {
|
||||||
|
e = apply(e, nextTurn(e));
|
||||||
|
if (e.round === startRound) acted.add(e.currentTurnParticipantId);
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
const skipped = [...activeAtStart].filter(id => {
|
||||||
|
const p = e.participants.find(x => x.id === id);
|
||||||
|
return p && p.isActive && !acted.has(id);
|
||||||
|
});
|
||||||
|
totalSkips += skipped.length;
|
||||||
|
}
|
||||||
|
expect(totalSkips).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', () => {
|
||||||
|
let e = setup();
|
||||||
|
const N = nm(e);
|
||||||
|
let curRound = null;
|
||||||
|
let activeAtRoundStart = new Set();
|
||||||
|
let actedThisRound = new Set();
|
||||||
|
const onRoundStart = (enc) => {
|
||||||
|
curRound = enc.round;
|
||||||
|
activeAtRoundStart = new Set(enc.participants.filter(p => p.isActive).map(p => p.id));
|
||||||
|
actedThisRound = new Set();
|
||||||
|
if (enc.currentTurnParticipantId) actedThisRound.add(enc.currentTurnParticipantId);
|
||||||
|
};
|
||||||
|
onRoundStart(e);
|
||||||
|
|
||||||
|
let totalRealSkips = 0;
|
||||||
|
let added = 0;
|
||||||
|
let turns = 0;
|
||||||
|
const MAX_TURNS = 2000;
|
||||||
|
while (turns < MAX_TURNS && e.isStarted) {
|
||||||
|
turns++;
|
||||||
|
if (e.isPaused) e = apply(e, togglePause(e));
|
||||||
|
if (turns % 7 === 0 && !e.isPaused) { e = apply(e, togglePause(e)); continue; }
|
||||||
|
const prevRound = e.round;
|
||||||
|
e = apply(e, nextTurn(e));
|
||||||
|
if (e.round !== prevRound) {
|
||||||
|
const skipped = [...activeAtRoundStart].filter(id => {
|
||||||
|
const p = e.participants.find(x => x.id === id);
|
||||||
|
return p && p.isActive && !actedThisRound.has(id);
|
||||||
|
});
|
||||||
|
totalRealSkips += skipped.length;
|
||||||
|
onRoundStart(e);
|
||||||
|
} else {
|
||||||
|
actedThisRound.add(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
if (turns % 9 === 0 && added < 8) {
|
||||||
|
const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant;
|
||||||
|
b.id = `reinforce${added + 1}`;
|
||||||
|
e = apply(e, addParticipant(e, b)); added++;
|
||||||
|
}
|
||||||
|
if (turns % 13 === 0) {
|
||||||
|
const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId);
|
||||||
|
if (cand.length) e = apply(e, removeParticipant(e, cand[0].id));
|
||||||
|
}
|
||||||
|
if (turns % 17 === 0) {
|
||||||
|
const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId);
|
||||||
|
if (cand.length) {
|
||||||
|
const t = cand[0];
|
||||||
|
e = apply(e, toggleParticipantActive(e, t.id));
|
||||||
|
e = apply(e, toggleParticipantActive(e, t.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(totalRealSkips).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Undo roundtrip: every op that returns log.undo must restore prior state.
|
||||||
|
// Apply op → patch → apply undo → assert deepEqual original.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const {
|
||||||
|
makeParticipant, startEncounter, nextTurn, togglePause,
|
||||||
|
addParticipant, removeParticipant, toggleParticipantActive,
|
||||||
|
applyHpChange, toggleCondition, reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||||
|
|
||||||
|
describe('undo roundtrip', () => {
|
||||||
|
test('startEncounter undo restores pre-start', () => {
|
||||||
|
const before = enc([p('a',10),p('b',20)]);
|
||||||
|
const r = startEncounter(before);
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
// undo restores isStarted/isPaused/round/current/turnOrderIds.
|
||||||
|
// participants[] may be reordered (1-list sort on start) — undo snapshot
|
||||||
|
// captures turn-state fields, not participant order.
|
||||||
|
const after = { ...before, ...r.patch, ...r.log.undo };
|
||||||
|
expect(after.isStarted).toBe(before.isStarted);
|
||||||
|
expect(after.isPaused).toBe(before.isPaused);
|
||||||
|
expect(after.round).toBe(before.round);
|
||||||
|
expect(after.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
|
||||||
|
expect(after.turnOrderIds).toEqual(before.turnOrderIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn undo restores prior currentTurn/round', () => {
|
||||||
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const before = snap(e);
|
||||||
|
const r = nextTurn(e);
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('togglePause undo restores prior paused state', () => {
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const before = snap(e);
|
||||||
|
const r = togglePause(e);
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyHpChange undo restores prior participants', () => {
|
||||||
|
let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const before = snap(e);
|
||||||
|
const r = applyHpChange(e, 'a', 'damage', 20);
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleCondition undo restores prior participants', () => {
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const before = snap(e);
|
||||||
|
const r = toggleCondition(e, 'a', 'stunned');
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleParticipantActive undo restores prior participants + turn order', () => {
|
||||||
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const before = snap(e);
|
||||||
|
const r = toggleParticipantActive(e, 'b');
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant undo restores prior participants', () => {
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const before = snap(e);
|
||||||
|
const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 });
|
||||||
|
const r = addParticipant(e, np);
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeParticipant undo restores prior participants + turn order', () => {
|
||||||
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const before = snap(e);
|
||||||
|
const r = removeParticipant(e, 'b');
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter undo restores prior state', () => {
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const before = snap(e);
|
||||||
|
const r = endEncounter(e);
|
||||||
|
expect(r.log.undo).toBeTruthy();
|
||||||
|
const after = { ...e, ...r.patch, ...r.log.undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorderParticipants has no undo (log: null) — BUG candidate', () => {
|
||||||
|
const ps = [p('a',10),p('b',20),p('c',20)]; // b,c tie
|
||||||
|
let e = enc(ps);
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
const r = reorderParticipants(e, 'c', 'b');
|
||||||
|
// Documents: reorderParticipants returns log: null. Cannot undo.
|
||||||
|
// If undo expected here, this is BUG-7.
|
||||||
|
expect(r.log).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+587
@@ -0,0 +1,587 @@
|
|||||||
|
// @ttrpg/shared — turn.js
|
||||||
|
// Pure turn-order logic. No I/O, no React, no Firebase.
|
||||||
|
// Ported VERBATIM from src/App.js (M1). Bugs preserved intentionally.
|
||||||
|
// Characterization tests lock current behavior. Fixes come in M4.
|
||||||
|
//
|
||||||
|
// Functions return NEW state (immutable). They never mutate input encounter.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Constants (mirror src/App.js)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEFAULT_MAX_HP = 10;
|
||||||
|
const DEFAULT_INIT_MOD = 0;
|
||||||
|
const MONSTER_DEFAULT_INIT_MOD = 2;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Utility functions (verbatim from src/App.js)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const generateId = () =>
|
||||||
|
(typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
|
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
||||||
|
|
||||||
|
const formatInitMod = (mod) => {
|
||||||
|
if (mod === undefined || mod === null) return 'N/A';
|
||||||
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort used ONLY at insert points (startEncounter, addParticipant) to position
|
||||||
|
// participants by initiative. Once positioned, turnOrderIds = participants.map(id)
|
||||||
|
// (1-list model). No re-sort after start — drag/edit are manual overrides.
|
||||||
|
const sortParticipantsByInitiative = (participants, originalOrder) => {
|
||||||
|
return [...participants].sort((a, b) => {
|
||||||
|
if (a.initiative === b.initiative) {
|
||||||
|
const indexA = originalOrder.findIndex(p => p.id === a.id);
|
||||||
|
const indexB = originalOrder.findIndex(p => p.id === b.id);
|
||||||
|
return indexA - indexB;
|
||||||
|
}
|
||||||
|
return b.initiative - a.initiative;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id).
|
||||||
|
// Call after any participants[] mutation. Returns turnOrderIds patch.
|
||||||
|
const syncTurnOrder = (participants) => ({
|
||||||
|
turnOrderIds: participants.map(p => p.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// SHARED ADVANCE CORE (BUG-5 DRY fix).
|
||||||
|
// Single source of truth for "who acts next". Both nextTurn and
|
||||||
|
// computeTurnOrderAfterRemoval delegate here — prevents drift where one path
|
||||||
|
// changes and the other doesn't.
|
||||||
|
//
|
||||||
|
// order: turnOrderIds (raw, may contain inactive/removed ids).
|
||||||
|
// fromPos: index of the last-acted slot (current participant, or the removed
|
||||||
|
// participant's old slot). Step +1 forward, skip fromPos itself.
|
||||||
|
// isActive: predicate id -> bool.
|
||||||
|
// Returns { nextId, wrapped }. wrapped = cycled past order end = new round.
|
||||||
|
const nextActiveAfter = (order, fromPos, isActive) => {
|
||||||
|
const n = order.length;
|
||||||
|
if (n === 0) return { nextId: null, wrapped: false };
|
||||||
|
for (let step = 1; step < n; step++) {
|
||||||
|
const idx = (fromPos + step) % n;
|
||||||
|
const id = order[idx];
|
||||||
|
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
|
||||||
|
}
|
||||||
|
return { nextId: null, wrapped: false }; // no other active participant
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verbatim from src/App.js. Returns turnOrderIds/currentTurnParticipantId updates
|
||||||
|
// when a participant leaves active combat.
|
||||||
|
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
|
||||||
|
if (!encounter.isStarted) return {};
|
||||||
|
// 1-list: turnOrderIds syncs from participants[].map(id) at call site.
|
||||||
|
// Here only handle current-advance if removed == current.
|
||||||
|
const updates = {};
|
||||||
|
if (encounter.currentTurnParticipantId === removedId) {
|
||||||
|
const removedPos = (encounter.turnOrderIds || []).indexOf(removedId);
|
||||||
|
const isActive = id => updatedParticipants.find(p => p.id === id && p.isActive);
|
||||||
|
const { nextId, wrapped } = nextActiveAfter(encounter.turnOrderIds || [], removedPos, isActive);
|
||||||
|
updates.currentTurnParticipantId = nextId;
|
||||||
|
if (nextId && wrapped) updates.round = (encounter.round || 1) + 1;
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert addedId into turnOrderIds by initiative. New participant slots into
|
||||||
|
// correct initiative position at add time (not appended to end). Preserves
|
||||||
|
// current pointer — no re-sort anywhere except startEncounter.
|
||||||
|
// Tie rule: insert AFTER existing same-init (preserves creation order).
|
||||||
|
// 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)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeParticipant(opts) {
|
||||||
|
return {
|
||||||
|
id: opts.id || generateId(),
|
||||||
|
name: opts.name,
|
||||||
|
type: opts.type, // 'character' | 'monster'
|
||||||
|
originalCharacterId: opts.originalCharacterId || null,
|
||||||
|
initiative: opts.initiative,
|
||||||
|
maxHp: opts.maxHp,
|
||||||
|
currentHp: opts.currentHp,
|
||||||
|
isNpc: opts.isNpc || false,
|
||||||
|
conditions: opts.conditions || [],
|
||||||
|
isActive: opts.isActive !== undefined ? opts.isActive : true,
|
||||||
|
deathSaves: opts.deathSaves || 0,
|
||||||
|
isDying: opts.isDying || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a character participant from a campaign character (rolls initiative).
|
||||||
|
function buildCharacterParticipant(character) {
|
||||||
|
const initiativeRoll = rollD20();
|
||||||
|
const modifier = character.defaultInitMod || 0;
|
||||||
|
const finalInitiative = initiativeRoll + modifier;
|
||||||
|
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
|
||||||
|
return {
|
||||||
|
participant: makeParticipant({
|
||||||
|
name: character.name,
|
||||||
|
type: 'character',
|
||||||
|
originalCharacterId: character.id,
|
||||||
|
initiative: finalInitiative,
|
||||||
|
maxHp,
|
||||||
|
currentHp: maxHp,
|
||||||
|
isNpc: false,
|
||||||
|
}),
|
||||||
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a monster participant (rolls initiative).
|
||||||
|
function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) {
|
||||||
|
const initiativeRoll = rollD20();
|
||||||
|
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
||||||
|
const finalInitiative = initiativeRoll + modifier;
|
||||||
|
const hp = maxHp || DEFAULT_MAX_HP;
|
||||||
|
return {
|
||||||
|
participant: makeParticipant({
|
||||||
|
name,
|
||||||
|
type: 'monster',
|
||||||
|
initiative: finalInitiative,
|
||||||
|
maxHp: hp,
|
||||||
|
currentHp: hp,
|
||||||
|
isNpc: isNpc || false,
|
||||||
|
}),
|
||||||
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Action handlers — pure: (encounter, action) => { encounter, patch, log }
|
||||||
|
// Return patch = partial fields to merge into stored encounter.
|
||||||
|
// Caller persists patch + broadcasts.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// START_ENCOUNTER — verbatim from InitiativeControls.handleStartEncounter
|
||||||
|
function startEncounter(encounter) {
|
||||||
|
if (!encounter.participants || encounter.participants.length === 0) {
|
||||||
|
throw new Error('Add participants first.');
|
||||||
|
}
|
||||||
|
// 1-list model: sort ALL participants by init (active + inactive) so display
|
||||||
|
// order = initiative. nextTurn skips inactive. turnOrderIds mirrors list.
|
||||||
|
const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants);
|
||||||
|
const firstActive = sortedParticipants.find(p => p.isActive);
|
||||||
|
if (!firstActive) {
|
||||||
|
throw new Error('No active participants.');
|
||||||
|
}
|
||||||
|
const orderedParticipants = sortedParticipants;
|
||||||
|
return {
|
||||||
|
patch: {
|
||||||
|
isStarted: true,
|
||||||
|
isPaused: false,
|
||||||
|
round: 1,
|
||||||
|
participants: orderedParticipants,
|
||||||
|
currentTurnParticipantId: firstActive.id,
|
||||||
|
turnOrderIds: orderedParticipants.map(p => p.id),
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`,
|
||||||
|
undo: {
|
||||||
|
isStarted: encounter.isStarted ?? false,
|
||||||
|
isPaused: encounter.isPaused ?? false,
|
||||||
|
round: encounter.round ?? 0,
|
||||||
|
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||||
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEXT_TURN — verbatim from InitiativeControls.handleNextTurn
|
||||||
|
// NOTE: this is the suspected skip-bug source. Preserved for M3 characterization.
|
||||||
|
function nextTurn(encounter) {
|
||||||
|
if (!encounter.isStarted || encounter.isPaused) {
|
||||||
|
throw new Error('Encounter not running.');
|
||||||
|
}
|
||||||
|
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) {
|
||||||
|
throw new Error('No active turn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePsInOrder = encounter.turnOrderIds
|
||||||
|
.map(id => encounter.participants.find(p => p.id === id && p.isActive))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (activePsInOrder.length === 0) {
|
||||||
|
// End encounter — no active participants left.
|
||||||
|
return {
|
||||||
|
patch: {
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
round: encounter.round,
|
||||||
|
},
|
||||||
|
log: { message: `Combat auto-ended: no active participants`, undo: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextRound = encounter.round;
|
||||||
|
let newTurnOrderIds = encounter.turnOrderIds;
|
||||||
|
|
||||||
|
// Delegate to shared advance core (BUG-5 DRY fix). Same math
|
||||||
|
// computeTurnOrderAfterRemoval uses → no drift. fromPos = current's slot
|
||||||
|
// in raw turnOrderIds; -1 path handles removed/stale current.
|
||||||
|
const order = encounter.turnOrderIds || [];
|
||||||
|
const fromPos = order.indexOf(encounter.currentTurnParticipantId);
|
||||||
|
const isActive = id => {
|
||||||
|
const p = encounter.participants.find(x => x.id === id);
|
||||||
|
return !!p && p.isActive;
|
||||||
|
};
|
||||||
|
const { nextId, wrapped } = nextActiveAfter(order, fromPos, isActive);
|
||||||
|
|
||||||
|
if (!nextId) {
|
||||||
|
throw new Error('Could not determine next participant.');
|
||||||
|
}
|
||||||
|
if (wrapped) nextRound += 1;
|
||||||
|
|
||||||
|
const nextParticipant = encounter.participants.find(p => p.id === nextId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
patch: {
|
||||||
|
currentTurnParticipantId: nextParticipant.id,
|
||||||
|
round: nextRound,
|
||||||
|
turnOrderIds: newTurnOrderIds,
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
message: `${nextParticipant.name}'s turn (Round ${nextRound})`,
|
||||||
|
undo: {
|
||||||
|
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||||
|
round: encounter.round,
|
||||||
|
turnOrderIds: [...encounter.turnOrderIds],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAUSE / RESUME — verbatim from InitiativeControls.handleTogglePause
|
||||||
|
function togglePause(encounter) {
|
||||||
|
if (!encounter || !encounter.isStarted) {
|
||||||
|
throw new Error('Encounter not started.');
|
||||||
|
}
|
||||||
|
const newPausedState = !encounter.isPaused;
|
||||||
|
let newTurnOrderIds = encounter.turnOrderIds;
|
||||||
|
if (!newPausedState && encounter.isPaused) {
|
||||||
|
// Resume: do NOT re-sort. Re-sorting displaces the current pointer —
|
||||||
|
// participants who already acted move earlier in order and nextTurn
|
||||||
|
// revisits them (whole round replays). Order is frozen at startEncounter
|
||||||
|
// and patched incrementally; resume keeps it stable.
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
patch: { isPaused: newPausedState, turnOrderIds: newTurnOrderIds },
|
||||||
|
log: {
|
||||||
|
message: `Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`,
|
||||||
|
undo: {
|
||||||
|
isPaused: encounter.isPaused ?? false,
|
||||||
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.)
|
||||||
|
// If encounter already started, also slot participant into turnOrderIds by
|
||||||
|
// initiative (via computeTurnOrderAfterAddition).
|
||||||
|
function addParticipant(encounter, participant) {
|
||||||
|
if ((encounter.participants || []).some(p => p.id === participant.id)) {
|
||||||
|
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
|
||||||
|
}
|
||||||
|
// 1-list: splice participant into participants[] by initiative position,
|
||||||
|
// then sync turnOrderIds = participants.map(id).
|
||||||
|
let updatedParticipants;
|
||||||
|
let insertAt;
|
||||||
|
if (!encounter.isStarted) {
|
||||||
|
updatedParticipants = [...(encounter.participants || []), participant];
|
||||||
|
} 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 {
|
||||||
|
patch: { participants: updatedParticipants, ...turnUpdates },
|
||||||
|
log: {
|
||||||
|
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
||||||
|
undo: {
|
||||||
|
participants: [...(encounter.participants || [])],
|
||||||
|
...(encounter.isStarted ? {
|
||||||
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
|
||||||
|
function addParticipants(encounter, newParticipants) {
|
||||||
|
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
||||||
|
return { patch: { participants: updatedParticipants }, log: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
|
||||||
|
function updateParticipant(encounter, participantId, updatedData) {
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
|
p.id === participantId ? { ...p, ...updatedData } : p
|
||||||
|
);
|
||||||
|
return { patch: { participants: updatedParticipants }, log: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
|
||||||
|
function removeParticipant(encounter, participantId) {
|
||||||
|
const updatedParticipants = (encounter.participants || []).filter(p => p.id !== participantId);
|
||||||
|
const advUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
|
||||||
|
const turnUpdates = encounter.isStarted ? { ...syncTurnOrder(updatedParticipants), ...advUpdates } : {};
|
||||||
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
|
return {
|
||||||
|
patch: { participants: updatedParticipants, ...turnUpdates },
|
||||||
|
log: {
|
||||||
|
message: `${participant ? participant.name : 'Participant'} removed from encounter`,
|
||||||
|
undo: {
|
||||||
|
participants: [...(encounter.participants || [])],
|
||||||
|
...(encounter.isStarted ? {
|
||||||
|
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||||
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOGGLE_ACTIVE — verbatim from ParticipantManager.toggleParticipantActive
|
||||||
|
function toggleParticipantActive(encounter, participantId) {
|
||||||
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
|
if (!participant) throw new Error('Participant not found.');
|
||||||
|
const newIsActive = !participant.isActive;
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
|
p.id === participantId ? { ...p, isActive: newIsActive } : p
|
||||||
|
);
|
||||||
|
// 1-list: participant stays in slot on toggle (active or not). nextTurn
|
||||||
|
// skips inactive. Only advance current if deact hits current.
|
||||||
|
let turnUpdates = {};
|
||||||
|
if (encounter.isStarted) {
|
||||||
|
turnUpdates = syncTurnOrder(updatedParticipants);
|
||||||
|
if (!newIsActive && encounter.currentTurnParticipantId === participantId) {
|
||||||
|
const adv = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
|
||||||
|
turnUpdates = { ...turnUpdates, ...adv };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
patch: { participants: updatedParticipants, ...turnUpdates },
|
||||||
|
log: {
|
||||||
|
message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`,
|
||||||
|
undo: {
|
||||||
|
participants: [...(encounter.participants || [])],
|
||||||
|
...(encounter.isStarted ? {
|
||||||
|
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||||
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// APPLY_HP_CHANGE — verbatim from ParticipantManager.applyHpChange
|
||||||
|
// changeType: 'damage' | 'heal'
|
||||||
|
function applyHpChange(encounter, participantId, changeType, amount) {
|
||||||
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
|
if (!participant) throw new Error('Participant not found.');
|
||||||
|
if (isNaN(amount) || amount === 0) {
|
||||||
|
return { patch: null, log: null }; // no-op
|
||||||
|
}
|
||||||
|
let newHp = participant.currentHp;
|
||||||
|
if (changeType === 'damage') newHp = Math.max(0, participant.currentHp - amount);
|
||||||
|
else if (changeType === 'heal') newHp = Math.min(participant.maxHp, participant.currentHp + amount);
|
||||||
|
|
||||||
|
const wasDead = participant.currentHp === 0;
|
||||||
|
const isDead = newHp === 0;
|
||||||
|
const wasResurrected = wasDead && newHp > 0;
|
||||||
|
|
||||||
|
// FEAT-1: death no longer flips isActive or touches turnOrderIds.
|
||||||
|
// Dead participants stay in turn order, nextTurn still visits them, PCs
|
||||||
|
// get their death-save turn. isActive = DM-controlled combatant toggle only.
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p => {
|
||||||
|
if (p.id !== participantId) return p;
|
||||||
|
const updates = { ...p, currentHp: newHp };
|
||||||
|
if (isDead && !wasDead) {
|
||||||
|
updates.deathSaves = p.deathSaves || 0;
|
||||||
|
updates.isDying = false;
|
||||||
|
}
|
||||||
|
if (wasResurrected) {
|
||||||
|
updates.deathSaves = 0;
|
||||||
|
updates.isDying = false;
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
});
|
||||||
|
|
||||||
|
// No turn-order updates on death/revive (FEAT-1).
|
||||||
|
const turnUpdates = {};
|
||||||
|
|
||||||
|
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
||||||
|
const deathSuffix = (isDead && !wasDead)
|
||||||
|
? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated')
|
||||||
|
: '';
|
||||||
|
const resurSuffix = wasResurrected ? ' — Revived' : '';
|
||||||
|
const message = changeType === 'damage'
|
||||||
|
? `${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`
|
||||||
|
: `${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
patch: { participants: updatedParticipants, ...turnUpdates },
|
||||||
|
log: {
|
||||||
|
message,
|
||||||
|
undo: {
|
||||||
|
participants: [...(encounter.participants || [])],
|
||||||
|
...((isDead && !wasDead) || wasResurrected ? {
|
||||||
|
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||||
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEATH_SAVE — verbatim from ParticipantManager.handleDeathSaveChange
|
||||||
|
// saveNumber: 1 | 2 | 3. Returns isDying flag if 3rd save hit (client triggers removal animation).
|
||||||
|
function deathSave(encounter, participantId, saveNumber) {
|
||||||
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
|
if (!participant) throw new Error('Participant not found.');
|
||||||
|
const currentSaves = participant.deathSaves || 0;
|
||||||
|
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;
|
||||||
|
|
||||||
|
if (newSaves === 3) {
|
||||||
|
// Mark dying — caller waits for animation, then calls removeParticipant.
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
|
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
patch: { participants: updatedParticipants },
|
||||||
|
log: null,
|
||||||
|
isDying: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
|
p.id === participantId ? { ...p, deathSaves: newSaves } : p
|
||||||
|
);
|
||||||
|
return { patch: { participants: updatedParticipants }, log: null, isDying: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition
|
||||||
|
function toggleCondition(encounter, participantId, conditionId) {
|
||||||
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
|
if (!participant) throw new Error('Participant not found.');
|
||||||
|
const wasActive = (participant.conditions || []).includes(conditionId);
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p => {
|
||||||
|
if (p.id !== participantId) return p;
|
||||||
|
const current = p.conditions || [];
|
||||||
|
const next = wasActive ? current.filter(c => c !== conditionId) : [...current, conditionId];
|
||||||
|
return { ...p, conditions: next };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
patch: { participants: updatedParticipants },
|
||||||
|
log: {
|
||||||
|
message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`,
|
||||||
|
undo: { participants: [...(encounter.participants || [])] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative
|
||||||
|
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds.
|
||||||
|
function reorderParticipants(encounter, draggedId, targetId) {
|
||||||
|
const participants = [...(encounter.participants || [])];
|
||||||
|
const draggedIndex = participants.findIndex(p => p.id === draggedId);
|
||||||
|
const targetIndex = participants.findIndex(p => p.id === targetId);
|
||||||
|
if (draggedIndex === -1 || targetIndex === -1) {
|
||||||
|
throw new Error('Dragged or target item not found.');
|
||||||
|
}
|
||||||
|
const [removedItem] = participants.splice(draggedIndex, 1);
|
||||||
|
// recompute targetIndex after removal (shift if dragged was before target)
|
||||||
|
const newTargetIndex = participants.findIndex(p => p.id === targetId);
|
||||||
|
participants.splice(newTargetIndex, 0, removedItem);
|
||||||
|
const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {};
|
||||||
|
return { patch: { participants, ...turnUpdates }, log: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter
|
||||||
|
function endEncounter(encounter) {
|
||||||
|
return {
|
||||||
|
patch: {
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
round: 0,
|
||||||
|
turnOrderIds: [],
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
message: `Combat ended: "${encounter.name}"`,
|
||||||
|
undo: {
|
||||||
|
isStarted: encounter.isStarted ?? false,
|
||||||
|
isPaused: encounter.isPaused ?? false,
|
||||||
|
round: encounter.round ?? 0,
|
||||||
|
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||||
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MAX_HP,
|
||||||
|
DEFAULT_INIT_MOD,
|
||||||
|
MONSTER_DEFAULT_INIT_MOD,
|
||||||
|
generateId,
|
||||||
|
rollD20,
|
||||||
|
formatInitMod,
|
||||||
|
sortParticipantsByInitiative,
|
||||||
|
syncTurnOrder,
|
||||||
|
computeTurnOrderAfterRemoval,
|
||||||
|
computeTurnOrderAfterAddition,
|
||||||
|
makeParticipant,
|
||||||
|
buildCharacterParticipant,
|
||||||
|
buildMonsterParticipant,
|
||||||
|
startEncounter,
|
||||||
|
nextTurn,
|
||||||
|
togglePause,
|
||||||
|
addParticipant,
|
||||||
|
addParticipants,
|
||||||
|
updateParticipant,
|
||||||
|
removeParticipant,
|
||||||
|
toggleParticipantActive,
|
||||||
|
applyHpChange,
|
||||||
|
deathSave,
|
||||||
|
toggleCondition,
|
||||||
|
reorderParticipants,
|
||||||
|
endEncounter,
|
||||||
|
};
|
||||||
+149
-181
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { initializeApp } from 'firebase/app';
|
import * as shared from '@ttrpg/shared';
|
||||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
import { initializeApp } from './storage';
|
||||||
import { getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch } from 'firebase/firestore';
|
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from './storage';
|
||||||
|
import { getFirestore, doc, setDoc, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, getStorage, getStorageMode } from './storage';
|
||||||
import {
|
import {
|
||||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||||
@@ -45,10 +46,8 @@ if (typeof document !== 'undefined') {
|
|||||||
// CONSTANTS
|
// CONSTANTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const APP_VERSION = 'v0.2.5';
|
const APP_VERSION = 'v0.3';
|
||||||
const DEFAULT_MAX_HP = 10;
|
const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval } = shared;
|
||||||
const DEFAULT_INIT_MOD = 0;
|
|
||||||
const MONSTER_DEFAULT_INIT_MOD = 2;
|
|
||||||
const ROLL_DISPLAY_DURATION = 5000;
|
const ROLL_DISPLAY_DURATION = 5000;
|
||||||
|
|
||||||
const CONDITIONS = [
|
const CONDITIONS = [
|
||||||
@@ -95,29 +94,45 @@ const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
|
|||||||
let app;
|
let app;
|
||||||
let db;
|
let db;
|
||||||
let auth;
|
let auth;
|
||||||
|
let storage;
|
||||||
|
|
||||||
// Initialize Firebase
|
const STORAGE_MODE = getStorageMode();
|
||||||
const initializeFirebase = () => {
|
|
||||||
|
// Initialize storage backend. firebase mode = real SDK init.
|
||||||
|
// ws/memory mode = mock auth, no firebase.
|
||||||
|
const initializeStorage = () => {
|
||||||
|
if (STORAGE_MODE === 'firebase') {
|
||||||
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
|
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
|
||||||
const missingKeys = requiredKeys.filter(key => !firebaseConfig[key]);
|
const missingKeys = requiredKeys.filter(key => !firebaseConfig[key]);
|
||||||
|
|
||||||
if (missingKeys.length > 0) {
|
if (missingKeys.length > 0) {
|
||||||
console.error(`CRITICAL: Missing Firebase config values: ${missingKeys.join(', ')}`);
|
console.error(`CRITICAL: Missing Firebase config values: ${missingKeys.join(', ')}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app = initializeApp(firebaseConfig);
|
app = initializeApp(firebaseConfig);
|
||||||
db = getFirestore(app);
|
db = getFirestore(app);
|
||||||
auth = getAuth(app);
|
auth = getAuth(app);
|
||||||
|
storage = getStorage();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing Firebase:", error);
|
console.error("Error initializing Firebase:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ws / memory mode: stub auth so App's anon-sign-in path works.
|
||||||
|
// db stays a truthy sentinel object so legacy `if (!db) return` guards pass;
|
||||||
|
// all real reads/writes route through `storage.*`, never the SDK `db`.
|
||||||
|
const FAKE_USER = { uid: 'local-user', isAnonymous: true };
|
||||||
|
auth = {
|
||||||
|
currentUser: FAKE_USER,
|
||||||
|
};
|
||||||
|
db = { __localStub: true };
|
||||||
|
storage = getStorage();
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFirebaseInitialized = initializeFirebase();
|
const isInitialized = initializeStorage();
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// FIRESTORE PATH HELPERS
|
// FIRESTORE PATH HELPERS
|
||||||
@@ -136,24 +151,8 @@ const getPath = {
|
|||||||
// UTILITY FUNCTIONS
|
// UTILITY FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const generateId = () => crypto.randomUUID();
|
// generateId, rollD20, formatInitMod, sortParticipantsByInitiative,
|
||||||
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
// computeTurnOrderAfterRemoval/Addition: imported from @ttrpg/shared (1-list model).
|
||||||
|
|
||||||
const formatInitMod = (mod) => {
|
|
||||||
if (mod === undefined || mod === null) return 'N/A';
|
|
||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortParticipantsByInitiative = (participants, originalOrder) => {
|
|
||||||
return [...participants].sort((a, b) => {
|
|
||||||
if (a.initiative === b.initiative) {
|
|
||||||
const indexA = originalOrder.findIndex(p => p.id === a.id);
|
|
||||||
const indexB = originalOrder.findIndex(p => p.id === b.id);
|
|
||||||
return indexA - indexB;
|
|
||||||
}
|
|
||||||
return b.initiative - a.initiative;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
|
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
|
||||||
|
|
||||||
@@ -162,34 +161,12 @@ const logAction = async (message, context = {}, undoData = null) => {
|
|||||||
try {
|
try {
|
||||||
const entry = { timestamp: Date.now(), message, ...context };
|
const entry = { timestamp: Date.now(), message, ...context };
|
||||||
if (undoData) entry.undo = undoData;
|
if (undoData) entry.undo = undoData;
|
||||||
await addDoc(collection(db, getPath.logs()), entry);
|
await storage.addDoc(getPath.logs(), entry);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error writing log:', err);
|
console.error('Error writing log:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Returns turnOrderIds/currentTurnParticipantId updates when a participant leaves active combat.
|
|
||||||
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
|
|
||||||
if (!encounter.isStarted) return {};
|
|
||||||
const currentIds = encounter.turnOrderIds || [];
|
|
||||||
const newIds = currentIds.filter(id => id !== removedId);
|
|
||||||
const updates = { turnOrderIds: newIds };
|
|
||||||
if (encounter.currentTurnParticipantId === removedId) {
|
|
||||||
const removedPos = currentIds.indexOf(removedId);
|
|
||||||
const candidates = [...currentIds.slice(removedPos + 1), ...currentIds.slice(0, removedPos)];
|
|
||||||
const nextId = candidates.find(id => updatedParticipants.find(p => p.id === id && p.isActive)) ?? null;
|
|
||||||
updates.currentTurnParticipantId = nextId;
|
|
||||||
}
|
|
||||||
return updates;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Returns turnOrderIds update when a participant re-enters active combat mid-encounter.
|
|
||||||
const computeTurnOrderAfterAddition = (encounter, addedId) => {
|
|
||||||
if (!encounter.isStarted) return {};
|
|
||||||
const currentIds = encounter.turnOrderIds || [];
|
|
||||||
if (currentIds.includes(addedId)) return {};
|
|
||||||
return { turnOrderIds: [...currentIds, addedId] };
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CUSTOM HOOKS
|
// CUSTOM HOOKS
|
||||||
@@ -201,32 +178,22 @@ function useFirestoreDocument(docPath) {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!db || !docPath) {
|
if (!docPath) {
|
||||||
setData(null);
|
setData(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(docPath ? "Firestore not available." : "Document path not provided.");
|
setError("Document path not provided.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const docRef = doc(db, docPath);
|
const storage = getStorage();
|
||||||
const unsubscribe = onSnapshot(
|
const unsubscribe = storage.subscribeDoc(docPath, (doc) => {
|
||||||
docRef,
|
setData(doc);
|
||||||
(docSnap) => {
|
|
||||||
setData(docSnap.exists() ? { id: docSnap.id, ...docSnap.data() } : null);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
});
|
||||||
(err) => {
|
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
|
||||||
console.error(`Error fetching document ${docPath}:`, err);
|
|
||||||
setError(err.message || "Failed to fetch document.");
|
|
||||||
setIsLoading(false);
|
|
||||||
setData(null);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => unsubscribe();
|
|
||||||
}, [docPath]);
|
}, [docPath]);
|
||||||
|
|
||||||
return { data, isLoading, error };
|
return { data, isLoading, error };
|
||||||
@@ -239,34 +206,23 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) {
|
|||||||
const queryString = useMemo(() => JSON.stringify(queryConstraints), [queryConstraints]);
|
const queryString = useMemo(() => JSON.stringify(queryConstraints), [queryConstraints]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!db || !collectionPath) {
|
if (!collectionPath) {
|
||||||
setData([]);
|
setData([]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(collectionPath ? "Firestore not available." : "Collection path not provided.");
|
setError("Collection path not provided.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const q = query(collection(db, collectionPath), ...queryConstraints);
|
const storage = getStorage();
|
||||||
const unsubscribe = onSnapshot(
|
const unsubscribe = storage.subscribeCollection(collectionPath, (items) => {
|
||||||
q,
|
|
||||||
(snapshot) => {
|
|
||||||
const items = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
|
||||||
setData(items);
|
setData(items);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
});
|
||||||
(err) => {
|
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
|
||||||
console.error(`Error fetching collection ${collectionPath}:`, err);
|
// queryString, not array ref
|
||||||
setError(err.message || "Failed to fetch collection.");
|
|
||||||
setIsLoading(false);
|
|
||||||
setData([]);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => unsubscribe();
|
|
||||||
// We use queryString instead of queryConstraints to avoid re-renders on array reference changes
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [collectionPath, queryString]);
|
}, [collectionPath, queryString]);
|
||||||
|
|
||||||
@@ -586,7 +542,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, getPath.campaign(campaignId)), {
|
await storage.updateDoc(getPath.campaign(campaignId), {
|
||||||
players: [...campaignCharacters, newCharacter]
|
players: [...campaignCharacters, newCharacter]
|
||||||
});
|
});
|
||||||
setCharacterName('');
|
setCharacterName('');
|
||||||
@@ -622,7 +578,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, getPath.campaign(campaignId)), { players: updatedCharacters });
|
await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters });
|
||||||
setEditingCharacter(null);
|
setEditingCharacter(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating character:", err);
|
console.error("Error updating character:", err);
|
||||||
@@ -641,7 +597,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
const updatedCharacters = campaignCharacters.filter(c => c.id !== itemToDelete.id);
|
const updatedCharacters = campaignCharacters.filter(c => c.id !== itemToDelete.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, getPath.campaign(campaignId)), { players: updatedCharacters });
|
await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error deleting character:", err);
|
console.error("Error deleting character:", err);
|
||||||
alert("Failed to delete character. Please try again.");
|
alert("Failed to delete character. Please try again.");
|
||||||
@@ -897,7 +853,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await storage.updateDoc(encounterPath, {
|
||||||
participants: [...participants, newParticipant]
|
participants: [...participants, newParticipant]
|
||||||
});
|
});
|
||||||
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, {
|
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, {
|
||||||
@@ -962,7 +918,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await storage.updateDoc(encounterPath, {
|
||||||
participants: [...participants, ...newParticipants]
|
participants: [...participants, ...newParticipants]
|
||||||
});
|
});
|
||||||
console.log(`Added ${newParticipants.length} characters to the encounter.`);
|
console.log(`Added ${newParticipants.length} characters to the encounter.`);
|
||||||
@@ -980,7 +936,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
|
||||||
setEditingParticipant(null);
|
setEditingParticipant(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating participant:", err);
|
console.error("Error updating participant:", err);
|
||||||
@@ -1009,9 +965,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await storage.updateDoc(encounterPath, {
|
||||||
participants: updatedParticipants,
|
participants: updatedParticipants,
|
||||||
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants)
|
...(encounter.isStarted ? {
|
||||||
|
...syncTurnOrder(updatedParticipants),
|
||||||
|
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants),
|
||||||
|
} : {}),
|
||||||
});
|
});
|
||||||
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData);
|
logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1034,12 +993,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
p.id === participantId ? { ...p, isActive: newIsActive } : p
|
p.id === participantId ? { ...p, isActive: newIsActive } : p
|
||||||
);
|
);
|
||||||
|
|
||||||
const turnUpdates = newIsActive
|
// 1-list: stay in slot, flip isActive only. Sync turnOrderIds. Advance
|
||||||
? computeTurnOrderAfterAddition(encounter, participantId)
|
// current only if deact hits current.
|
||||||
: computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
|
let turnUpdates = {};
|
||||||
|
if (encounter.isStarted) {
|
||||||
|
turnUpdates = syncTurnOrder(updatedParticipants);
|
||||||
|
if (!newIsActive && encounter.currentTurnParticipantId === participantId) {
|
||||||
|
turnUpdates = { ...turnUpdates, ...computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
|
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates });
|
||||||
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, {
|
logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, {
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: {
|
updates: {
|
||||||
@@ -1105,11 +1070,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
const turnUpdates = (isDead && !wasDead)
|
const turnUpdates = {};
|
||||||
? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants)
|
|
||||||
: wasResurrected
|
|
||||||
? computeTurnOrderAfterAddition(encounter, participantId)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const hpUndoData = {
|
const hpUndoData = {
|
||||||
encounterPath,
|
encounterPath,
|
||||||
@@ -1123,7 +1084,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
|
await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates });
|
||||||
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
|
||||||
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
||||||
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : '';
|
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : '';
|
||||||
@@ -1154,13 +1115,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
|
||||||
|
|
||||||
// Wait for animation to complete on player display (2 seconds) then remove participant
|
// Wait for animation to complete on player display (2 seconds) then remove participant
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const finalParticipants = participants.filter(p => p.id !== participantId);
|
const finalParticipants = participants.filter(p => p.id !== participantId);
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: finalParticipants });
|
await storage.updateDoc(encounterPath, { participants: finalParticipants });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error removing dead participant:", err);
|
console.error("Error removing dead participant:", err);
|
||||||
}
|
}
|
||||||
@@ -1175,7 +1136,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating death saves:", err);
|
console.error("Error updating death saves:", err);
|
||||||
}
|
}
|
||||||
@@ -1196,7 +1157,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
return { ...p, conditions: next };
|
return { ...p, conditions: next };
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
|
await storage.updateDoc(encounterPath, { participants: updatedParticipants });
|
||||||
const cond = CONDITIONS.find(c => c.id === conditionId);
|
const cond = CONDITIONS.find(c => c.id === conditionId);
|
||||||
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
|
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId;
|
||||||
logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, {
|
logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, {
|
||||||
@@ -1249,7 +1210,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
currentParticipants.splice(targetIndex, 0, removedItem);
|
currentParticipants.splice(targetIndex, 0, removedItem);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), { participants: currentParticipants });
|
await storage.updateDoc(encounterPath, { participants: currentParticipants });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error reordering participants:", err);
|
console.error("Error reordering participants:", err);
|
||||||
}
|
}
|
||||||
@@ -1257,7 +1218,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
setDraggedItemId(null);
|
setDraggedItemId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedParticipants = sortParticipantsByInitiative(participants, participants);
|
// 1-list model: participants[] IS the display order. No re-sort.
|
||||||
|
const sortedParticipants = participants;
|
||||||
|
|
||||||
const initiativeGroups = participants.reduce((acc, p) => {
|
const initiativeGroups = participants.reduce((acc, p) => {
|
||||||
acc[p.initiative] = (acc[p.initiative] || 0) + 1;
|
acc[p.initiative] = (acc[p.initiative] || 0) + 1;
|
||||||
@@ -1621,7 +1583,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
const handleToggleHidePlayerHp = async () => {
|
const handleToggleHidePlayerHp = async () => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
try {
|
try {
|
||||||
await setDoc(doc(db, getPath.activeDisplay()), { hidePlayerHp: !hidePlayerHp }, { merge: true });
|
await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error toggling hidePlayerHp:", err);
|
console.error("Error toggling hidePlayerHp:", err);
|
||||||
}
|
}
|
||||||
@@ -1639,18 +1601,22 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
|
// 1-list model: sort ALL participants by init (active+inactive),
|
||||||
|
// first active = current. Matches shared.startEncounter.
|
||||||
|
const sortedParticipants = sortParticipantsByInitiative(encounter.participants, encounter.participants);
|
||||||
|
const firstActive = sortedParticipants.find(p => p.isActive);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await storage.updateDoc(encounterPath, {
|
||||||
isStarted: true,
|
isStarted: true,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
round: 1,
|
round: 1,
|
||||||
currentTurnParticipantId: sortedParticipants[0].id,
|
participants: sortedParticipants,
|
||||||
|
currentTurnParticipantId: firstActive.id,
|
||||||
turnOrderIds: sortedParticipants.map(p => p.id)
|
turnOrderIds: sortedParticipants.map(p => p.id)
|
||||||
});
|
});
|
||||||
|
|
||||||
await setDoc(doc(db, getPath.activeDisplay()), {
|
await storage.setDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: campaignId,
|
activeCampaignId: campaignId,
|
||||||
activeEncounterId: encounter.id
|
activeEncounterId: encounter.id
|
||||||
}, { merge: true });
|
}, { merge: true });
|
||||||
@@ -1679,13 +1645,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
let newTurnOrderIds = encounter.turnOrderIds;
|
let newTurnOrderIds = encounter.turnOrderIds;
|
||||||
|
|
||||||
if (!newPausedState && encounter.isPaused) {
|
if (!newPausedState && encounter.isPaused) {
|
||||||
const activeParticipants = encounter.participants.filter(p => p.isActive);
|
// 1-list model: no re-sort on resume. turnOrderIds already mirrors
|
||||||
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
|
// participants[] (set at start/add/reorder). Resume = unpause only.
|
||||||
newTurnOrderIds = sortedParticipants.map(p => p.id);
|
newTurnOrderIds = encounter.turnOrderIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await storage.updateDoc(encounterPath, {
|
||||||
isPaused: newPausedState,
|
isPaused: newPausedState,
|
||||||
turnOrderIds: newTurnOrderIds
|
turnOrderIds: newTurnOrderIds
|
||||||
});
|
});
|
||||||
@@ -1711,7 +1677,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
|
|
||||||
if (activePsInOrder.length === 0) {
|
if (activePsInOrder.length === 0) {
|
||||||
alert("No active participants left.");
|
alert("No active participants left.");
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await storage.updateDoc(encounterPath, {
|
||||||
isStarted: false,
|
isStarted: false,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
currentTurnParticipantId: null,
|
currentTurnParticipantId: null,
|
||||||
@@ -1751,7 +1717,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
if (!nextParticipant) return;
|
if (!nextParticipant) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await storage.updateDoc(encounterPath, {
|
||||||
currentTurnParticipantId: nextParticipant.id,
|
currentTurnParticipantId: nextParticipant.id,
|
||||||
round: nextRound,
|
round: nextRound,
|
||||||
turnOrderIds: newTurnOrderIds,
|
turnOrderIds: newTurnOrderIds,
|
||||||
@@ -1773,7 +1739,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await storage.updateDoc(encounterPath, {
|
||||||
isStarted: false,
|
isStarted: false,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
currentTurnParticipantId: null,
|
currentTurnParticipantId: null,
|
||||||
@@ -1781,7 +1747,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
turnOrderIds: []
|
turnOrderIds: []
|
||||||
});
|
});
|
||||||
|
|
||||||
await setDoc(doc(db, getPath.activeDisplay()), {
|
await storage.setDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: null,
|
activeCampaignId: null,
|
||||||
activeEncounterId: null
|
activeEncounterId: null
|
||||||
}, { merge: true });
|
}, { merge: true });
|
||||||
@@ -1941,7 +1907,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
const newEncounterId = generateId();
|
const newEncounterId = generateId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(doc(db, getPath.encounters(campaignId), newEncounterId), {
|
await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
participants: [],
|
participants: [],
|
||||||
@@ -1970,14 +1936,14 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
const encounterId = itemToDelete.id;
|
const encounterId = itemToDelete.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteDoc(doc(db, getPath.encounter(campaignId, encounterId)));
|
await storage.deleteDoc(getPath.encounter(campaignId, encounterId));
|
||||||
|
|
||||||
if (selectedEncounterId === encounterId) {
|
if (selectedEncounterId === encounterId) {
|
||||||
setSelectedEncounterId(null);
|
setSelectedEncounterId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
|
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
|
||||||
await updateDoc(doc(db, getPath.activeDisplay()), {
|
await storage.updateDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: null,
|
activeCampaignId: null,
|
||||||
activeEncounterId: null
|
activeEncounterId: null
|
||||||
});
|
});
|
||||||
@@ -1999,12 +1965,12 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
|
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
|
||||||
|
|
||||||
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
|
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
|
||||||
await setDoc(doc(db, getPath.activeDisplay()), {
|
await storage.setDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: null,
|
activeCampaignId: null,
|
||||||
activeEncounterId: null,
|
activeEncounterId: null,
|
||||||
}, { merge: true });
|
}, { merge: true });
|
||||||
} else {
|
} else {
|
||||||
await setDoc(doc(db, getPath.activeDisplay()), {
|
await storage.setDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: campaignId,
|
activeCampaignId: campaignId,
|
||||||
activeEncounterId: encounterId,
|
activeEncounterId: encounterId,
|
||||||
}, { merge: true });
|
}, { merge: true });
|
||||||
@@ -2160,8 +2126,8 @@ function AdminView({ userId }) {
|
|||||||
let encounterCount = 0;
|
let encounterCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encountersSnapshot = await getDocs(collection(db, getPath.encounters(campaign.id)));
|
const encounters = await storage.getCollection(getPath.encounters(campaign.id));
|
||||||
encounterCount = encountersSnapshot.size;
|
encounterCount = encounters.length;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to fetch encounters for campaign ${campaign.id}:`, err);
|
console.error(`Failed to fetch encounters for campaign ${campaign.id}:`, err);
|
||||||
}
|
}
|
||||||
@@ -2185,11 +2151,10 @@ function AdminView({ userId }) {
|
|||||||
if (
|
if (
|
||||||
initialActiveInfo &&
|
initialActiveInfo &&
|
||||||
initialActiveInfo.activeCampaignId &&
|
initialActiveInfo.activeCampaignId &&
|
||||||
campaignsWithDetails.length > 0 &&
|
campaignsWithDetails.length > 0
|
||||||
!selectedCampaignId
|
|
||||||
) {
|
) {
|
||||||
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
|
||||||
if (campaignExists) {
|
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
|
||||||
setSelectedCampaignId(initialActiveInfo.activeCampaignId);
|
setSelectedCampaignId(initialActiveInfo.activeCampaignId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2201,7 +2166,7 @@ function AdminView({ userId }) {
|
|||||||
const newCampaignId = generateId();
|
const newCampaignId = generateId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(doc(db, getPath.campaign(newCampaignId)), {
|
await storage.setDoc(getPath.campaign(newCampaignId), {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
|
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
@@ -2229,23 +2194,23 @@ function AdminView({ userId }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const encountersPath = getPath.encounters(campaignId);
|
const encountersPath = getPath.encounters(campaignId);
|
||||||
const encountersSnapshot = await getDocs(collection(db, encountersPath));
|
const encounters = await storage.getCollection(encountersPath);
|
||||||
const batch = writeBatch(db);
|
const deleteOps = encounters.map(e => {
|
||||||
|
const id = e.id || e.path?.split('/').pop();
|
||||||
|
return { type: 'delete', path: `${encountersPath}/${id}` };
|
||||||
|
});
|
||||||
|
if (deleteOps.length > 0) await storage.batchWrite(deleteOps);
|
||||||
|
|
||||||
encountersSnapshot.docs.forEach(encounterDoc => batch.delete(encounterDoc.ref));
|
await storage.deleteDoc(getPath.campaign(campaignId));
|
||||||
await batch.commit();
|
|
||||||
|
|
||||||
await deleteDoc(doc(db, getPath.campaign(campaignId)));
|
|
||||||
|
|
||||||
if (selectedCampaignId === campaignId) {
|
if (selectedCampaignId === campaignId) {
|
||||||
setSelectedCampaignId(null);
|
setSelectedCampaignId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeDisplayRef = doc(db, getPath.activeDisplay());
|
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
|
||||||
const activeDisplaySnap = await getDoc(activeDisplayRef);
|
|
||||||
|
|
||||||
if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) {
|
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
|
||||||
await updateDoc(activeDisplayRef, {
|
await storage.updateDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: null,
|
activeCampaignId: null,
|
||||||
activeEncounterId: null
|
activeEncounterId: null
|
||||||
});
|
});
|
||||||
@@ -2318,6 +2283,11 @@ function AdminView({ userId }) {
|
|||||||
<span className="inline-flex items-center">
|
<span className="inline-flex items-center">
|
||||||
<Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters
|
<Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters
|
||||||
</span>
|
</span>
|
||||||
|
{campaign.createdAt && (
|
||||||
|
<span className="inline-flex items-center opacity-80">
|
||||||
|
{new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -2458,35 +2428,23 @@ function DisplayView() {
|
|||||||
setIsLoadingEncounter(true);
|
setIsLoadingEncounter(true);
|
||||||
setEncounterError(null);
|
setEncounterError(null);
|
||||||
|
|
||||||
const campaignDocRef = doc(db, getPath.campaign(activeCampaignId));
|
unsubscribeCampaign = storage.subscribeDoc(
|
||||||
unsubscribeCampaign = onSnapshot(
|
getPath.campaign(activeCampaignId),
|
||||||
campaignDocRef,
|
(camp) => {
|
||||||
(campSnap) => {
|
setCampaignBackgroundUrl((camp && camp.playerDisplayBackgroundUrl) || '');
|
||||||
if (campSnap.exists()) {
|
|
||||||
setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
|
|
||||||
} else {
|
|
||||||
setCampaignBackgroundUrl('');
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
(err) => console.error("Error fetching campaign background:", err)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const encounterPath = getPath.encounter(activeCampaignId, activeEncounterId);
|
unsubscribeEncounter = storage.subscribeDoc(
|
||||||
unsubscribeEncounter = onSnapshot(
|
getPath.encounter(activeCampaignId, activeEncounterId),
|
||||||
doc(db, encounterPath),
|
(enc) => {
|
||||||
(encDocSnap) => {
|
if (enc) {
|
||||||
if (encDocSnap.exists()) {
|
setActiveEncounterData({ id: activeEncounterId, ...enc });
|
||||||
setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
|
|
||||||
} else {
|
} else {
|
||||||
setActiveEncounterData(null);
|
setActiveEncounterData(null);
|
||||||
setEncounterError("Active encounter data not found.");
|
setEncounterError("Active encounter data not found.");
|
||||||
}
|
}
|
||||||
setIsLoadingEncounter(false);
|
setIsLoadingEncounter(false);
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
console.error("Error fetching active encounter details:", err);
|
|
||||||
setEncounterError("Error loading active encounter data.");
|
|
||||||
setIsLoadingEncounter(false);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -2542,9 +2500,10 @@ function DisplayView() {
|
|||||||
|
|
||||||
let participantsToRender = [];
|
let participantsToRender = [];
|
||||||
if (participants) {
|
if (participants) {
|
||||||
// Hide inactive monsters (pre-staged/summoned reserves) from the player view
|
// 1-list model: participants[] IS the display order (DM drag = source of
|
||||||
const visibleParticipants = participants.filter(p => p.isActive || p.type !== 'monster');
|
// truth). Do NOT re-sort by initiative — that diverges from AdminView /
|
||||||
participantsToRender = sortParticipantsByInitiative(visibleParticipants, visibleParticipants);
|
// turnOrderIds after any cross-init drag (BUG-15).
|
||||||
|
participantsToRender = participants.filter(p => p.isActive || p.type !== 'monster');
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayStyles = campaignBackgroundUrl
|
const displayStyles = campaignBackgroundUrl
|
||||||
@@ -2693,13 +2652,14 @@ function LogsView() {
|
|||||||
const [undoingId, setUndoingId] = useState(null);
|
const [undoingId, setUndoingId] = useState(null);
|
||||||
|
|
||||||
const handleClearLogs = async () => {
|
const handleClearLogs = async () => {
|
||||||
if (!db) return;
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await getDocs(collection(db, getPath.logs()));
|
const logs = await storage.getCollection(getPath.logs());
|
||||||
if (!snapshot.empty) {
|
if (logs.length > 0) {
|
||||||
const batch = writeBatch(db);
|
const ops = logs.map(l => {
|
||||||
snapshot.docs.forEach(d => batch.delete(d.ref));
|
const id = l.id || l.path?.split('/').pop();
|
||||||
await batch.commit();
|
return { type: 'delete', path: `${getPath.logs()}/${id}` };
|
||||||
|
});
|
||||||
|
await storage.batchWrite(ops);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error clearing logs:', err);
|
console.error('Error clearing logs:', err);
|
||||||
@@ -2711,8 +2671,8 @@ function LogsView() {
|
|||||||
if (!db || !entry.undo) return;
|
if (!db || !entry.undo) return;
|
||||||
setUndoingId(entry.id);
|
setUndoingId(entry.id);
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, entry.undo.encounterPath), entry.undo.updates);
|
await storage.updateDoc(entry.undo.encounterPath, entry.undo.updates);
|
||||||
await updateDoc(doc(db, getPath.logs(), entry.id), { undone: true });
|
await storage.updateDoc(`${getPath.logs()}/${entry.id}`, { undone: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error undoing action:', err);
|
console.error('Error undoing action:', err);
|
||||||
alert('Failed to roll back. The encounter may have changed or no longer exists.');
|
alert('Failed to roll back. The encounter may have changed or no longer exists.');
|
||||||
@@ -2822,12 +2782,20 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
setError("Firebase Auth not initialized. Check your Firebase configuration.");
|
setError("Auth not initialized.");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsAuthReady(false);
|
setIsAuthReady(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ws/memory mode: stub auth, no SDK sign-in. Unblock UI immediately.
|
||||||
|
if (STORAGE_MODE !== 'firebase') {
|
||||||
|
setUserId(auth.currentUser?.uid || 'local-user');
|
||||||
|
setIsAuthReady(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const token = window.__initial_auth_token;
|
const token = window.__initial_auth_token;
|
||||||
@@ -2861,11 +2829,11 @@ function App() {
|
|||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isFirebaseInitialized || !db || !auth) {
|
if (!isInitialized || !auth) {
|
||||||
return (
|
return (
|
||||||
<ErrorDisplay
|
<ErrorDisplay
|
||||||
critical
|
critical
|
||||||
message="Firebase is not properly configured or initialized. Please check your .env.local file and ensure all REACT_APP_FIREBASE_... variables are correctly set."
|
message={`${STORAGE_MODE === 'firebase' ? 'Firebase' : 'Storage'} is not properly configured. Check your .env.local file and ensure all REACT_APP_* variables are correctly set.`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Mock in-memory Firestore for jest tests.
|
||||||
|
// Reset via resetMockDb() in setupTests.js beforeEach.
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
docs: new Map(), // path -> data
|
||||||
|
subscribers: new Map(), // path -> Set<cb>
|
||||||
|
counter: 0,
|
||||||
|
calls: [], // recorded SDK calls
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_DB = {
|
||||||
|
get(path) { return state.docs.has(path) ? clone(state.docs.get(path)) : null; },
|
||||||
|
set(path, data) {
|
||||||
|
state.docs.set(path, clone(data));
|
||||||
|
this._notify(path);
|
||||||
|
},
|
||||||
|
merge(path, patch) {
|
||||||
|
const cur = state.docs.has(path) ? state.docs.get(path) : {};
|
||||||
|
const next = { ...cur, ...clone(patch) };
|
||||||
|
state.docs.set(path, next);
|
||||||
|
this._notify(path);
|
||||||
|
},
|
||||||
|
delete(path) {
|
||||||
|
state.docs.delete(path);
|
||||||
|
this._notify(path);
|
||||||
|
},
|
||||||
|
collection(collPath) {
|
||||||
|
const out = [];
|
||||||
|
for (const [p, data] of state.docs) {
|
||||||
|
const parent = p.split('/').slice(0, -1).join('/');
|
||||||
|
if (parent === collPath) out.push({ id: p.split('/').pop(), data: clone(data) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
subscribe(path, cb) {
|
||||||
|
if (!state.subscribers.has(path)) state.subscribers.set(path, new Set());
|
||||||
|
state.subscribers.get(path).add(cb);
|
||||||
|
return () => state.subscribers.get(path)?.delete(cb);
|
||||||
|
},
|
||||||
|
_notify(path) {
|
||||||
|
// notify exact doc path subscribers
|
||||||
|
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => cb());
|
||||||
|
// notify parent collection subscribers
|
||||||
|
const parent = path.split('/').slice(0, -1).join('/');
|
||||||
|
if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => cb());
|
||||||
|
},
|
||||||
|
nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); },
|
||||||
|
_state: state,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function recordCall(entry) {
|
||||||
|
state.calls.push({ ...entry, ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMockDb() {
|
||||||
|
state.docs.clear();
|
||||||
|
state.subscribers.clear();
|
||||||
|
state.calls.length = 0;
|
||||||
|
state.counter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalls() {
|
||||||
|
return [...state.calls];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(v) {
|
||||||
|
if (v === null || v === undefined) return v;
|
||||||
|
return JSON.parse(JSON.stringify(v));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// jest manual mock: firebase/app
|
||||||
|
const fakeApp = { name: '[fake-firebase-app]', options: {} };
|
||||||
|
export function initializeApp(config) { return fakeApp; }
|
||||||
|
export const getApp = () => fakeApp;
|
||||||
|
export const getApps = () => [fakeApp];
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// jest manual mock: firebase/auth
|
||||||
|
const fakeUser = { uid: 'test-user-123', isAnonymous: true };
|
||||||
|
const fakeAuth = { currentUser: fakeUser };
|
||||||
|
|
||||||
|
export function getAuth() { return fakeAuth; }
|
||||||
|
export function signInAnonymously(auth) { return Promise.resolve({ user: fakeUser }); }
|
||||||
|
export function signInWithCustomToken(auth, token) { return Promise.resolve({ user: fakeUser }); }
|
||||||
|
export function onAuthStateChanged(auth, cb) {
|
||||||
|
cb(fakeUser);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// jest manual mock: firebase/firestore
|
||||||
|
// Records all calls so tests can assert path/payload/semantics.
|
||||||
|
// Global __firestoreCalls reset per test (see setupTests.js).
|
||||||
|
|
||||||
|
import { MOCK_DB, recordCall } from './_mock-db.js';
|
||||||
|
|
||||||
|
const ref = (path) => ({ __ref: true, path, id: path.split('/').pop() });
|
||||||
|
|
||||||
|
export function getFirestore() { return { __db: true }; }
|
||||||
|
export function doc(db, path, extra) {
|
||||||
|
const p = extra ? `${path}/${extra}` : path;
|
||||||
|
return ref(p);
|
||||||
|
}
|
||||||
|
export function collection(db, path) { return ref(path); }
|
||||||
|
export function query(refOrColl, ...constraints) { return { ref: refOrColl, constraints }; }
|
||||||
|
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
|
||||||
|
export function limit(n) { return { __type: 'limit', n }; }
|
||||||
|
|
||||||
|
// writes
|
||||||
|
export async function setDoc(docRef, data, opts) {
|
||||||
|
recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null });
|
||||||
|
MOCK_DB.set(docRef.path, clone(data));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export async function updateDoc(docRef, patch) {
|
||||||
|
recordCall({ fn: 'updateDoc', path: docRef.path, data: clone(patch) });
|
||||||
|
MOCK_DB.merge(docRef.path, clone(patch));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export async function deleteDoc(docRef) {
|
||||||
|
recordCall({ fn: 'deleteDoc', path: docRef.path });
|
||||||
|
MOCK_DB.delete(docRef.path);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export async function addDoc(collRef, data) {
|
||||||
|
const id = `auto_${MOCK_DB.nextId()}`;
|
||||||
|
const path = `${collRef.path}/${id}`;
|
||||||
|
recordCall({ fn: 'addDoc', path, data: clone(data) });
|
||||||
|
MOCK_DB.set(path, clone(data));
|
||||||
|
return { id, path };
|
||||||
|
}
|
||||||
|
export function writeBatch(db) {
|
||||||
|
const ops = [];
|
||||||
|
return {
|
||||||
|
set: (r, d) => ops.push({ op: 'set', path: r.path, data: clone(d) }),
|
||||||
|
update: (r, d) => ops.push({ op: 'update', path: r.path, data: clone(d) }),
|
||||||
|
delete: (r) => ops.push({ op: 'delete', path: r.path }),
|
||||||
|
commit: async () => {
|
||||||
|
ops.forEach(o => {
|
||||||
|
recordCall({ fn: `batch.${o.op}`, path: o.path, data: o.data });
|
||||||
|
if (o.op === 'set') MOCK_DB.set(o.path, o.data);
|
||||||
|
else if (o.op === 'update') MOCK_DB.merge(o.path, o.data);
|
||||||
|
else if (o.op === 'delete') MOCK_DB.delete(o.path);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// reads (return from in-memory mock DB)
|
||||||
|
export async function getDoc(docRef) {
|
||||||
|
const data = MOCK_DB.get(docRef.path);
|
||||||
|
return { exists: () => data !== null, id: docRef.id, data: () => data };
|
||||||
|
}
|
||||||
|
export async function getDocs(collRefOrQuery) {
|
||||||
|
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
||||||
|
const docs = MOCK_DB.collection(collPath);
|
||||||
|
return { docs: docs.map(d => ({ id: d.id, data: () => d.data, ref: { path: `${collPath}/${d.id}` } })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// realtime — emit from mock DB, capture unsub
|
||||||
|
export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||||
|
const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
|
||||||
|
// fire immediately with current state
|
||||||
|
const emit = () => {
|
||||||
|
if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) {
|
||||||
|
const data = MOCK_DB.get(path);
|
||||||
|
onSuccess({
|
||||||
|
exists: () => data !== null,
|
||||||
|
id: path.split('/').pop(),
|
||||||
|
data: () => data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const docs = MOCK_DB.collection(path);
|
||||||
|
onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emit();
|
||||||
|
// register for future changes on this path
|
||||||
|
const unsub = MOCK_DB.subscribe(path, emit);
|
||||||
|
return unsub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(v) {
|
||||||
|
if (v === null || v === undefined) return v;
|
||||||
|
return JSON.parse(JSON.stringify(v));
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// jest setup: RTL jest-dom + mock DB reset per test.
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { resetMockDb } from './__mocks__/firebase/_mock-db';
|
||||||
|
|
||||||
|
// polyfill crypto.randomUUID for jsdom (used by generateId in App.js).
|
||||||
|
if (!global.crypto) global.crypto = {};
|
||||||
|
if (!global.crypto.randomUUID) {
|
||||||
|
global.crypto.randomUUID = () => 'test-uuid-' + Math.random().toString(36).slice(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub Firebase env vars so initializeFirebase() succeeds under test.
|
||||||
|
// Real SDK calls are mocked via __mocks__/firebase/*.
|
||||||
|
process.env.REACT_APP_FIREBASE_API_KEY = 'test-api-key';
|
||||||
|
process.env.REACT_APP_FIREBASE_AUTH_DOMAIN = 'test.firebaseapp.com';
|
||||||
|
process.env.REACT_APP_FIREBASE_PROJECT_ID = 'test-project';
|
||||||
|
process.env.REACT_APP_FIREBASE_STORAGE_BUCKET = 'test.appspot.com';
|
||||||
|
process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID = '1234567890';
|
||||||
|
process.env.REACT_APP_FIREBASE_APP_ID = '1:1234567890:web:abcdef';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMockDb();
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Storage interface contract.
|
||||||
|
// This is the SPEC. Runs against any storage impl (memory, ws, firebase).
|
||||||
|
// TDD: written first (RED), impl built to satisfy (GREEN).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const { runStorageContract } = require('./contract.test');
|
||||||
|
// runStorageContract('memory', () => createMemoryStorage());
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Each impl factory returns a fresh storage instance (async-creatable is fine).
|
||||||
|
// Interface every impl MUST provide:
|
||||||
|
// getDoc(path) -> Promise<obj|null>
|
||||||
|
// setDoc(path, data) -> Promise<void> (replace)
|
||||||
|
// updateDoc(path, patch) -> Promise<void> (shallow merge)
|
||||||
|
// deleteDoc(path) -> Promise<void>
|
||||||
|
// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id)
|
||||||
|
// getCollection(path) -> Promise<arr> (immediate child docs)
|
||||||
|
// batchWrite(ops) -> Promise<void> ops: [{type, path, data?}]
|
||||||
|
// subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null)
|
||||||
|
// subscribeCollection(path, cb) -> unsubscribe fn cb(arr)
|
||||||
|
|
||||||
|
function runStorageContract(name, factory) {
|
||||||
|
describe(`storage contract: ${name}`, () => {
|
||||||
|
let storage;
|
||||||
|
beforeEach(async () => { storage = await factory(); });
|
||||||
|
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
|
||||||
|
|
||||||
|
describe('getDoc / setDoc', () => {
|
||||||
|
test('setDoc then getDoc returns the doc', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ name: 'Alpha' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDoc on missing path returns null', async () => {
|
||||||
|
const doc = await storage.getDoc('campaigns/missing');
|
||||||
|
expect(doc).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setDoc overwrites entirely (not merge)', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Beta' });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ name: 'Beta' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateDoc (shallow merge)', () => {
|
||||||
|
test('merges patch into existing doc', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] });
|
||||||
|
await storage.updateDoc('campaigns/a', { players: [1, 2] });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ name: 'Alpha', players: [1, 2] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateDoc on missing doc creates it', async () => {
|
||||||
|
await storage.updateDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ name: 'Alpha' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteDoc', () => {
|
||||||
|
test('removes doc', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
await storage.deleteDoc('campaigns/a');
|
||||||
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete missing doc is no-op (no throw)', async () => {
|
||||||
|
await expect(storage.deleteDoc('campaigns/none')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addDoc', () => {
|
||||||
|
test('auto-generates id and stores doc at collection/id', async () => {
|
||||||
|
const { id, path } = await storage.addDoc('campaigns/a/encounters', { name: 'E1' });
|
||||||
|
expect(id).toBeTruthy();
|
||||||
|
expect(path).toBe(`campaigns/a/encounters/${id}`);
|
||||||
|
const doc = await storage.getDoc(path);
|
||||||
|
expect(doc).toEqual({ name: 'E1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two addDocs produce distinct ids', async () => {
|
||||||
|
const r1 = await storage.addDoc('logs', { m: 'one' });
|
||||||
|
const r2 = await storage.addDoc('logs', { m: 'two' });
|
||||||
|
expect(r1.id).not.toBe(r2.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('firebase-prefixed path identity', () => {
|
||||||
|
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
|
||||||
|
// Adapter must normalize internally so write+read at prefixed path round-trips
|
||||||
|
// AND collection queries at bare canonical path find prefixed-written docs.
|
||||||
|
// Catches replay-script bug (wrote prefixed, adapter reads bare, missed).
|
||||||
|
const PREFIX = 'artifacts/test-app/public/data';
|
||||||
|
|
||||||
|
test('setDoc prefixed then getCollection bare finds it', 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' });
|
||||||
|
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
|
||||||
|
expect(doc).toEqual({ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCollection', () => {
|
||||||
|
test('returns immediate child docs only (not nested)', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
await storage.setDoc('campaigns/b', { name: 'B' });
|
||||||
|
await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
|
||||||
|
const docs = await storage.getCollection('campaigns');
|
||||||
|
expect(docs).toHaveLength(2);
|
||||||
|
const names = docs.map(d => d.name).sort();
|
||||||
|
expect(names).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty collection returns []', async () => {
|
||||||
|
const docs = await storage.getCollection('campaigns');
|
||||||
|
expect(docs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subcollection returns only its direct children', async () => {
|
||||||
|
await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
|
||||||
|
await storage.setDoc('campaigns/a/encounters/e2', { name: 'E2' });
|
||||||
|
await storage.setDoc('campaigns/a/encounters/e1/participants/p1', { name: 'P1' });
|
||||||
|
const docs = await storage.getCollection('campaigns/a/encounters');
|
||||||
|
expect(docs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batchWrite', () => {
|
||||||
|
test('applies multiple deletes atomically', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
await storage.setDoc('campaigns/b', { name: 'B' });
|
||||||
|
await storage.batchWrite([
|
||||||
|
{ type: 'delete', path: 'campaigns/a' },
|
||||||
|
{ type: 'delete', path: 'campaigns/b' },
|
||||||
|
]);
|
||||||
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||||
|
expect(await storage.getDoc('campaigns/b')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies set + delete mixed', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
await storage.batchWrite([
|
||||||
|
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
|
||||||
|
{ type: 'delete', path: 'campaigns/a' },
|
||||||
|
]);
|
||||||
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||||
|
expect(await storage.getDoc('campaigns/b')).toEqual({ name: 'B' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeDoc', () => {
|
||||||
|
test('fires cb immediately with current value', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||||
|
await flush();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]).toEqual({ name: 'Alpha' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires cb on subsequent change', async () => {
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||||
|
await flush();
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
await flush();
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last).toEqual({ name: 'Alpha' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsubscribe stops callbacks', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const unsub = storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||||
|
await flush();
|
||||||
|
unsub();
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'X' });
|
||||||
|
await flush();
|
||||||
|
expect(calls.filter(Boolean)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeCollection', () => {
|
||||||
|
test('fires cb with current docs', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
|
||||||
|
await flush();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires on add to collection', async () => {
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
|
||||||
|
await flush();
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
await flush();
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush so async subscribers settle. WS roundtrip needs real delay (network),
|
||||||
|
// memory fires near-instant. 50ms covers localhost WS comfortably.
|
||||||
|
function flush() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runStorageContract, flush };
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
|
||||||
|
// Matches interface of memory.js / ws.js so App.js calls stay identical.
|
||||||
|
//
|
||||||
|
// NOTE: App.js currently imports SDK directly. This adapter extracted verbatim.
|
||||||
|
// Two-phase refactor:
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
import { initializeApp } from 'firebase/app';
|
||||||
|
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
||||||
|
import {
|
||||||
|
getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection,
|
||||||
|
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, serverTimestamp,
|
||||||
|
} from 'firebase/firestore';
|
||||||
|
|
||||||
|
// Adapter call recorder (instrumentation, no behavior change).
|
||||||
|
// Tests assert adapter.subscribeDoc called (catches raw-SDK bypass like DisplayView).
|
||||||
|
const ADAPTER_CALLS = [];
|
||||||
|
function recordAdapterCall(entry) { ADAPTER_CALLS.push({ ...entry, ts: Date.now() }); }
|
||||||
|
export function getAdapterCalls() { return [...ADAPTER_CALLS]; }
|
||||||
|
export function resetAdapterCalls() { ADAPTER_CALLS.length = 0; }
|
||||||
|
|
||||||
|
// Path helpers mirror App.js getPath object.
|
||||||
|
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||||
|
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
|
||||||
|
|
||||||
|
export const getPath = {
|
||||||
|
campaigns: () => `${PUBLIC_DATA_PATH}/campaigns`,
|
||||||
|
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
|
||||||
|
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
|
||||||
|
encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`,
|
||||||
|
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
|
||||||
|
logs: () => `${PUBLIC_DATA_PATH}/logs`
|
||||||
|
};
|
||||||
|
|
||||||
|
let firebaseApp = null;
|
||||||
|
let dbInstance = null;
|
||||||
|
let authInstance = null;
|
||||||
|
|
||||||
|
export function initFirebase() {
|
||||||
|
const config = {
|
||||||
|
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
||||||
|
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
|
||||||
|
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
|
||||||
|
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
|
||||||
|
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
|
||||||
|
appId: process.env.REACT_APP_FIREBASE_APP_ID
|
||||||
|
};
|
||||||
|
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
|
||||||
|
const missing = requiredKeys.filter(k => !config[k]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(`CRITICAL: Missing Firebase config: ${missing.join(', ')}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
firebaseApp = initializeApp(config);
|
||||||
|
dbInstance = getFirestore(firebaseApp);
|
||||||
|
authInstance = getAuth(firebaseApp);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Firebase init failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb() { return dbInstance; }
|
||||||
|
export function getAuthInstance() { return authInstance; }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STORAGE ADAPTER
|
||||||
|
// ============================================================================
|
||||||
|
// Wraps SDK in the storage interface (getDoc/setDoc/etc).
|
||||||
|
// App.js can now import { storage } and call storage.setDoc(path, data).
|
||||||
|
// Hooks (useFirestoreDocument etc) still use SDK directly for now.
|
||||||
|
|
||||||
|
export function createFirebaseStorage() {
|
||||||
|
const db = dbInstance;
|
||||||
|
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getDoc(path) {
|
||||||
|
const snap = await getDocReal(doc(db, path));
|
||||||
|
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setDoc(path, data, opts = {}) {
|
||||||
|
recordAdapterCall({ fn: 'setDoc', path, data, opts });
|
||||||
|
await setDoc(doc(db, path), data, opts.merge ? { merge: true } : undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDoc(path, patch) {
|
||||||
|
recordAdapterCall({ fn: 'updateDoc', path, patch });
|
||||||
|
await updateDoc(doc(db, path), patch);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDoc(path) {
|
||||||
|
await deleteDoc(doc(db, path));
|
||||||
|
},
|
||||||
|
|
||||||
|
async addDoc(collectionPath, data) {
|
||||||
|
const ref = await addDoc(collection(db, collectionPath), data);
|
||||||
|
return { id: ref.id, path: `${collectionPath}/${ref.id}` };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCollection(collectionPath) {
|
||||||
|
const snapshot = await getDocsReal(collection(db, collectionPath));
|
||||||
|
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchWrite(ops) {
|
||||||
|
const batch = writeBatch(db);
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.type === 'set') batch.set(doc(db, op.path), op.data);
|
||||||
|
else if (op.type === 'delete') batch.delete(doc(db, op.path));
|
||||||
|
else if (op.type === 'update') batch.update(doc(db, op.path), op.data);
|
||||||
|
}
|
||||||
|
await batch.commit();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
|
||||||
|
subscribeDoc(path, cb) {
|
||||||
|
recordAdapterCall({ fn: 'subscribeDoc', path });
|
||||||
|
return onSnapshot(doc(db, path), (snap) => {
|
||||||
|
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
|
||||||
|
}, (err) => console.error(`subscribeDoc ${path}:`, err));
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeCollection(collectionPath, cb, queryConstraints = []) {
|
||||||
|
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
||||||
|
const q = queryConstraints.length > 0
|
||||||
|
? query(collection(db, collectionPath), ...queryConstraints)
|
||||||
|
: collection(db, collectionPath);
|
||||||
|
return onSnapshot(q, (snap) => {
|
||||||
|
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
||||||
|
}, (err) => console.error(`subscribeCollection ${collectionPath}:`, err));
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() { /* SDK managed; no-op */ },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export SDK pieces App.js uses directly (until full refactor).
|
||||||
|
export {
|
||||||
|
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
|
||||||
|
query, orderBy, limit, writeBatch,
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// src/storage/index.js — storage factory + SDK re-exports.
|
||||||
|
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=ws: backend.
|
||||||
|
// App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending).
|
||||||
|
|
||||||
|
import { initializeApp } from 'firebase/app';
|
||||||
|
import {
|
||||||
|
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
|
||||||
|
} from 'firebase/auth';
|
||||||
|
import {
|
||||||
|
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
|
||||||
|
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
|
||||||
|
} from 'firebase/firestore';
|
||||||
|
import { initFirebase, createFirebaseStorage } from './firebase';
|
||||||
|
import { createWsStorage } from './ws';
|
||||||
|
import { createMemoryStorage } from './memory';
|
||||||
|
|
||||||
|
let storageInstance = null;
|
||||||
|
|
||||||
|
// Returns adapter instance implementing interface (getDoc/setDoc/subscribeDoc/etc).
|
||||||
|
export function getStorage() {
|
||||||
|
if (storageInstance) return storageInstance;
|
||||||
|
const mode = process.env.REACT_APP_STORAGE || 'firebase';
|
||||||
|
if (mode === 'firebase') {
|
||||||
|
const ok = initFirebase();
|
||||||
|
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
|
||||||
|
storageInstance = createFirebaseStorage();
|
||||||
|
} else if (mode === 'ws') {
|
||||||
|
storageInstance = createWsStorage({
|
||||||
|
baseUrl: process.env.REACT_APP_BACKEND_URL || '',
|
||||||
|
wsUrl: process.env.REACT_APP_BACKEND_WS || '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
storageInstance = createMemoryStorage();
|
||||||
|
}
|
||||||
|
return storageInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageMode() {
|
||||||
|
return process.env.REACT_APP_STORAGE || 'firebase';
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
initializeApp,
|
||||||
|
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
|
||||||
|
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
|
||||||
|
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// 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 };
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// ws.js — thin storage adapter over generic KV backend (HTTP + WebSocket).
|
||||||
|
// Passthrough: no shape translation. Backend = firebase mirror.
|
||||||
|
// Implements same interface as memory.js. Tested by storage contract vs running server.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Native browser WebSocket if present, else ws pkg (Node/jest).
|
||||||
|
// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require().
|
||||||
|
let WebSocketImpl;
|
||||||
|
if (typeof WebSocket !== 'undefined') {
|
||||||
|
WebSocketImpl = WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||||
|
// Same-origin by default: empty baseUrl = relative fetch (Caddy/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(/\/$/, '');
|
||||||
|
let WS;
|
||||||
|
if (wsUrl) {
|
||||||
|
WS = wsUrl;
|
||||||
|
} else if (typeof window !== 'undefined' && window.location) {
|
||||||
|
// derive from current origin (http→ws, https→wss), same host/port.
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
WS = `${proto}//${window.location.host}/ws`;
|
||||||
|
} else {
|
||||||
|
WS = 'ws://127.0.0.1:4001/ws';
|
||||||
|
}
|
||||||
|
|
||||||
|
// App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
|
||||||
|
// Backend uses canonical paths. Strip prefix.
|
||||||
|
function norm(p) {
|
||||||
|
if (!p) return p;
|
||||||
|
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const docSubs = new Map(); // path -> Set<cb>
|
||||||
|
const collSubs = new Map(); // collPath -> Set<cb>
|
||||||
|
let ws = null;
|
||||||
|
let wsReady = null;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let everConnected = false;
|
||||||
|
const RECONNECT_DELAY = 500;
|
||||||
|
|
||||||
|
function ensureWs() {
|
||||||
|
if (wsReady) return wsReady;
|
||||||
|
wsReady = new Promise((resolve, reject) => {
|
||||||
|
(async () => {
|
||||||
|
// Node/jest only: load ws pkg via dynamic import. Browser uses global
|
||||||
|
// WebSocket. Avoids require() in CRA prod ESM bundle (webpack crash).
|
||||||
|
let WsClass = WebSocketImpl;
|
||||||
|
if (!WsClass) {
|
||||||
|
const wsPkg = await import('ws');
|
||||||
|
WsClass = wsPkg.WebSocket;
|
||||||
|
}
|
||||||
|
ws = new WsClass(WS);
|
||||||
|
const onOpen = () => {
|
||||||
|
const isReconnect = everConnected;
|
||||||
|
everConnected = true;
|
||||||
|
// resubscribe all existing subscribers after (re)connect
|
||||||
|
for (const p of docSubs.keys()) {
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
||||||
|
}
|
||||||
|
for (const p of collSubs.keys()) {
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||||
|
}
|
||||||
|
// On RECONNECT only: re-fetch current values — catches writes that
|
||||||
|
// happened while disconnected (broadcast missed). Skip on first connect
|
||||||
|
// (initial REST fetch in subscribeDoc/subscribeCollection already did).
|
||||||
|
if (isReconnect) {
|
||||||
|
for (const [p, cbs] of docSubs) {
|
||||||
|
storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const [p, cbs] of collSubs) {
|
||||||
|
storage.getCollection(p).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(ws);
|
||||||
|
};
|
||||||
|
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
|
||||||
|
const onClose = () => {
|
||||||
|
wsReady = null;
|
||||||
|
ws = null;
|
||||||
|
if (disposed) return;
|
||||||
|
// auto-reconnect (BUG-8): try again after delay. ensureWs() re-arms.
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
if (!disposed) ensureWs().catch(() => {});
|
||||||
|
}, RECONNECT_DELAY);
|
||||||
|
if (reconnectTimer && typeof reconnectTimer.unref === 'function') reconnectTimer.unref();
|
||||||
|
};
|
||||||
|
const onMessage = (ev) => {
|
||||||
|
const raw = typeof ev === 'string' ? ev : (ev.data !== undefined ? ev.data : ev);
|
||||||
|
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
|
||||||
|
handleMessage(msg);
|
||||||
|
};
|
||||||
|
ws.onopen = onOpen;
|
||||||
|
ws.onerror = onError;
|
||||||
|
ws.onclose = onClose;
|
||||||
|
ws.onmessage = onMessage;
|
||||||
|
if (typeof ws.addEventListener === 'function') {
|
||||||
|
ws.addEventListener('open', onOpen);
|
||||||
|
ws.addEventListener('error', onError);
|
||||||
|
ws.addEventListener('close', onClose);
|
||||||
|
ws.addEventListener('message', onMessage);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
return wsReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend pushes change notices keyed by path. Re-fetch affected subscribers.
|
||||||
|
async function handleMessage(msg) {
|
||||||
|
if (msg.type !== 'change' || !msg.change) return;
|
||||||
|
const c = msg.change;
|
||||||
|
// doc subscriber at exact changed path
|
||||||
|
const docCbs = docSubs.get(c.path);
|
||||||
|
if (docCbs) {
|
||||||
|
const doc = await storage.getDoc(c.path);
|
||||||
|
docCbs.forEach(cb => cb(doc));
|
||||||
|
}
|
||||||
|
// collection subscribers at parent path (doc belongs to this collection)
|
||||||
|
if (c.parent) {
|
||||||
|
const collCbs = collSubs.get(c.parent);
|
||||||
|
if (collCbs) {
|
||||||
|
const docs = await storage.getCollection(c.parent);
|
||||||
|
collCbs.forEach(cb => cb(docs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, query, body) {
|
||||||
|
let url = `${API}${path}`;
|
||||||
|
if (query) {
|
||||||
|
const qs = new URLSearchParams(query).toString();
|
||||||
|
url += `?${qs}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text().catch(() => '');
|
||||||
|
throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
async getDoc(rawPath) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
const res = await api('GET', '/api/doc', { path: p });
|
||||||
|
return res && res.data !== undefined ? res.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setDoc(rawPath, data) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
await api('PUT', '/api/doc', null, { path: p, data });
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDoc(rawPath, patch) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
await api('PATCH', '/api/doc', null, { path: p, patch });
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDoc(rawPath) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
await api('DELETE', '/api/doc', { path: p });
|
||||||
|
},
|
||||||
|
|
||||||
|
async addDoc(rawCollPath, data) {
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
const res = await api('POST', '/api/collection', null, { path: p, data });
|
||||||
|
return { id: res.id, path: res.path };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCollection(rawCollPath) {
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
return await api('GET', '/api/collection', { path: p });
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchWrite(ops) {
|
||||||
|
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
|
||||||
|
await api('POST', '/api/batch', null, { ops: normOps });
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeDoc(rawPath, cb) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
// Initial value via REST (independent of WS connect).
|
||||||
|
storage.getDoc(p).then(cb).catch(() => {});
|
||||||
|
// WS only for subsequent change notifications.
|
||||||
|
ensureWs().then(() => {
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
||||||
|
}).catch(() => {});
|
||||||
|
if (!docSubs.has(p)) docSubs.set(p, new Set());
|
||||||
|
docSubs.get(p).add(cb);
|
||||||
|
return () => { docSubs.get(p)?.delete(cb); };
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeCollection(rawCollPath, cb) {
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
// Initial value via REST (independent of WS connect).
|
||||||
|
storage.getCollection(p).then(cb).catch(() => {});
|
||||||
|
// WS only for subsequent change notifications.
|
||||||
|
ensureWs().then(() => {
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||||
|
}).catch(() => {});
|
||||||
|
if (!collSubs.has(p)) collSubs.set(p, new Set());
|
||||||
|
collSubs.get(p).add(cb);
|
||||||
|
return () => { collSubs.get(p)?.delete(cb); };
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose(cb) {
|
||||||
|
disposed = true;
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
if (ws) ws.close();
|
||||||
|
docSubs.clear(); collSubs.clear();
|
||||||
|
if (typeof cb === 'function') cb();
|
||||||
|
},
|
||||||
|
|
||||||
|
_api: api,
|
||||||
|
_test: {
|
||||||
|
getWs: () => ws,
|
||||||
|
forceDrop: () => { if (ws) ws.close(); },
|
||||||
|
getReady: () => wsReady,
|
||||||
|
docSubs, collSubs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createWsStorage };
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// App.characterization.test.js
|
||||||
|
// Characterize App -> Firebase calls. Lock path + payload shape per action.
|
||||||
|
// Mock SDK, render AdminView, fire action, assert recorded calls.
|
||||||
|
// Purpose: refactor (path-shape rewrite) must not change these calls.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { renderApp, createCampaignViaUI, selectCampaignByName } from './testHelpers';
|
||||||
|
|
||||||
|
function findCall(fn, pathSub) {
|
||||||
|
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||||
|
}
|
||||||
|
function findCalls(fn, pathSub) {
|
||||||
|
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CAMPAIGN GROUP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('Campaign -> Firebase', () => {
|
||||||
|
test('createCampaign: setDoc with campaign path + payload', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const id = await createCampaignViaUI('Alpha');
|
||||||
|
const call = findCall('setDoc', '/campaigns/');
|
||||||
|
expect(call.path).toMatch(/campaigns\/.+$/);
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
name: 'Alpha',
|
||||||
|
playerDisplayBackgroundUrl: '',
|
||||||
|
players: [],
|
||||||
|
});
|
||||||
|
expect(call.data).toHaveProperty('ownerId');
|
||||||
|
expect(call.data).toHaveProperty('createdAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createCampaign: path includes APP_ID namespace', async () => {
|
||||||
|
await renderApp();
|
||||||
|
await createCampaignViaUI('NS Test');
|
||||||
|
const call = findCall('setDoc', '/campaigns/');
|
||||||
|
expect(call.path).toContain('artifacts/');
|
||||||
|
expect(call.path).toContain('/public/data/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createCampaign: optional background URL stored', async () => {
|
||||||
|
await renderApp();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||||
|
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||||
|
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'With BG' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/Background URL/i), { target: { value: 'https://img.test/bg.png' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||||
|
await waitFor(() => findCall('setDoc', '/campaigns/'));
|
||||||
|
const call = findCall('setDoc', '/campaigns/');
|
||||||
|
expect(call.data.playerDisplayBackgroundUrl).toBe('https://img.test/bg.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCharacter: updateDoc on campaign doc, players array grows', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const cid = await createCampaignViaUI('Roster');
|
||||||
|
await selectCampaignByName('Roster');
|
||||||
|
|
||||||
|
// CharacterManager form
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
|
||||||
|
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||||
|
const call = findCall('updateDoc', `/campaigns/${cid}`);
|
||||||
|
expect(call.data.players).toHaveLength(1);
|
||||||
|
expect(call.data.players[0]).toMatchObject({
|
||||||
|
name: 'Brog',
|
||||||
|
defaultMaxHp: 25,
|
||||||
|
defaultInitMod: 3,
|
||||||
|
});
|
||||||
|
expect(call.data.players[0]).toHaveProperty('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateCharacter: updateDoc with updated players array', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const cid = await createCampaignViaUI('EditRoster');
|
||||||
|
await selectCampaignByName('EditRoster');
|
||||||
|
|
||||||
|
// add one first
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Old Name' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||||
|
|
||||||
|
// click edit
|
||||||
|
const editBtn = await screen.findByRole('button', { name: /Edit character/i });
|
||||||
|
fireEvent.click(editBtn);
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Old Name'));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } });
|
||||||
|
// Save button is icon-only (no text); submit its form.
|
||||||
|
const form = screen.getByDisplayValue('New Name').closest('form');
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = findCalls('updateDoc', `/campaigns/${cid}`);
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last.data.players[0].name).toBe('New Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteCharacter: updateDoc with character removed', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const cid = await createCampaignViaUI('DeleteRoster');
|
||||||
|
await selectCampaignByName('DeleteRoster');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Gone' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||||
|
|
||||||
|
const delBtn = await screen.findByRole('button', { name: /Delete character/i });
|
||||||
|
fireEvent.click(delBtn);
|
||||||
|
// confirmation modal
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = findCalls('updateDoc', `/campaigns/${cid}`);
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last.data.players).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteCampaign: deletes encounters batch + campaign doc + activeDisplay null', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const cid = await createCampaignViaUI('Doomed');
|
||||||
|
await selectCampaignByName('Doomed');
|
||||||
|
|
||||||
|
// campaign card delete button has no aria-label; find trash by text via grid
|
||||||
|
const allDeletes = screen.getAllByText(/Delete/i);
|
||||||
|
// campaign card Delete is in card grid, last one rendered
|
||||||
|
fireEvent.click(allDeletes[allDeletes.length - 1]);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`));
|
||||||
|
const delCall = findCall('deleteDoc', `/campaigns/${cid}`);
|
||||||
|
expect(delCall).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Combat characterization. Lock updateDoc/setDoc patch for combat controls.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||||
|
|
||||||
|
function findCallsEnc() {
|
||||||
|
return getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
}
|
||||||
|
function lastEncCall() {
|
||||||
|
const calls = findCallsEnc();
|
||||||
|
return calls[calls.length - 1];
|
||||||
|
}
|
||||||
|
function findCallActiveDisplay(fn) {
|
||||||
|
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupWithMonsters(names = ['A', 'B', 'C']) {
|
||||||
|
await setupReady('CombatCamp', 'CombatEnc');
|
||||||
|
for (const n of names) {
|
||||||
|
await addMonsterViaUI(n, 20, Number(n.charCodeAt(0) % 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Combat -> Firebase', () => {
|
||||||
|
test('startEncounter: updateDoc sets isStarted/round/turn/current', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
const call = lastEncCall();
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
isStarted: true,
|
||||||
|
isPaused: false,
|
||||||
|
round: 1,
|
||||||
|
});
|
||||||
|
expect(call.data.currentTurnParticipantId).toBeTruthy();
|
||||||
|
expect(call.data.turnOrderIds).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startEncounter: also sets activeDisplay to this encounter', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data.activeCampaignId).toBeTruthy();
|
||||||
|
expect(last.data.activeEncounterId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn: advances currentTurnParticipantId', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
const beforeId = lastEncCall().data.currentTurnParticipantId;
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId !== beforeId);
|
||||||
|
expect(lastEncCall().data.currentTurnParticipantId).not.toBe(beforeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn wrapping to round 1->2 increments round', async () => {
|
||||||
|
await setupWithMonsters(['A', 'B']);
|
||||||
|
await startCombatViaUI();
|
||||||
|
|
||||||
|
// advance through all participants to wrap
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // A->B (or 2nd)
|
||||||
|
await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // wrap
|
||||||
|
await waitFor(() => lastEncCall()?.data?.round === 2);
|
||||||
|
expect(lastEncCall().data.round).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause: updateDoc sets isPaused true', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.isPaused === true);
|
||||||
|
expect(lastEncCall().data.isPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume: updateDoc sets isPaused false + recomputes turnOrder', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.isPaused === true);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.isPaused === false);
|
||||||
|
const call = lastEncCall();
|
||||||
|
expect(call.data.isPaused).toBe(false);
|
||||||
|
expect(call.data.turnOrderIds).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter: updateDoc resets all combat state', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.isStarted === false);
|
||||||
|
const call = lastEncCall();
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
turnOrderIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter: clears activeDisplay', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
return last && last.data.activeCampaignId === null;
|
||||||
|
});
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleHidePlayerHp: setDoc merge on activeDisplay/status', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
const switchBtn = screen.getByRole('switch');
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
return last && 'hidePlayerHp' in last.data;
|
||||||
|
});
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data).toHaveProperty('hidePlayerHp');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
// Combat.scenario.test.js
|
||||||
|
// Full combat scenario: campaign -> encounter -> participants -> 100 rounds of
|
||||||
|
// 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
|
||||||
|
// long combat, surfacing behavioral bugs characterization tests miss.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import App from '../App';
|
||||||
|
import {
|
||||||
|
renderApp, createCampaignViaUI, selectCampaignByName,
|
||||||
|
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
|
||||||
|
} from './testHelpers';
|
||||||
|
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
|
||||||
|
// ---------- scenario helpers (UI only, same buttons as human) ----------
|
||||||
|
|
||||||
|
const RESULTS = [];
|
||||||
|
function record(phase, fn) {
|
||||||
|
try { fn(); RESULTS.push({ phase, ok: true }); }
|
||||||
|
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||||
|
}
|
||||||
|
async function recordAsync(phase, fn) {
|
||||||
|
try { await fn(); RESULTS.push({ phase, ok: true }); }
|
||||||
|
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParticipantForm() {
|
||||||
|
const heading = screen.getByText('Add Participants');
|
||||||
|
let node = heading;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
node = node.parentElement;
|
||||||
|
if (!node) break;
|
||||||
|
if (node.querySelector('form')) return within(node);
|
||||||
|
}
|
||||||
|
return within(heading.parentElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a participant's encounter <li> row by name. Scoped to the encounter
|
||||||
|
// participant list (NOT the CharacterManager roster, which also shows names).
|
||||||
|
// Encounter participant rows render an 'Init:' label; roster rows do not.
|
||||||
|
function getParticipantRow(name) {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character roster (CharacterManager). Assumes campaign selected.
|
||||||
|
async function addCharacterViaUI(name, maxHp, initMod) {
|
||||||
|
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 setParticipantType(type) {
|
||||||
|
// The Type select is inside the Add Participants form.
|
||||||
|
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 applyDamage(name, amount) {
|
||||||
|
const row = getParticipantRow(name);
|
||||||
|
const dmgBtn = row.queryByTitle('Damage');
|
||||||
|
if (!dmgBtn) {
|
||||||
|
// 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) {
|
||||||
|
const row = getParticipantRow(name);
|
||||||
|
const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal');
|
||||||
|
if (!healBtn) throw new Error(`${name} has no Heal button`);
|
||||||
|
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
|
||||||
|
fireEvent.click(healBtn);
|
||||||
|
}
|
||||||
|
function toggleActive(name) {
|
||||||
|
const row = getParticipantRow(name);
|
||||||
|
const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive');
|
||||||
|
if (!btn) throw new Error(`${name} has no active toggle`);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
}
|
||||||
|
function openConditions(name) {
|
||||||
|
const row = getParticipantRow(name);
|
||||||
|
const btn = row.getByTitle('Conditions');
|
||||||
|
// idempotent: ensure panel open. Click toggles; if another participant's panel
|
||||||
|
// 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) {
|
||||||
|
const row = getParticipantRow(name);
|
||||||
|
fireEvent.click(row.getByTitle('Edit'));
|
||||||
|
// EditParticipantModal. Scope to the modal via its form inputs.
|
||||||
|
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) {
|
||||||
|
fireEvent.click(getParticipantRow(name).getByTitle('Remove'));
|
||||||
|
}
|
||||||
|
async function deathSave(name, saveNum) {
|
||||||
|
const row = getParticipantRow(name);
|
||||||
|
const btn = row.getByTitle(`Death save ${saveNum}`);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
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() {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||||
|
if (!last) throw new Error('nextTurn no write');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function pauseCombat() {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Pause 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 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 ----------
|
||||||
|
|
||||||
|
const ROUNDS = 100;
|
||||||
|
|
||||||
|
test('full 100-round combat scenario', async () => {
|
||||||
|
await setupReady('ScenarioCamp', 'BigBoss');
|
||||||
|
|
||||||
|
// roster
|
||||||
|
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||||
|
await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||||
|
await recordAsync('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
|
||||||
|
|
||||||
|
// monsters + npcs
|
||||||
|
await recordAsync('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
|
||||||
|
await recordAsync('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
|
||||||
|
await recordAsync('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
|
||||||
|
await recordAsync('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
|
||||||
|
await recordAsync('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
||||||
|
|
||||||
|
// add chars into encounter
|
||||||
|
await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
|
||||||
|
await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
|
||||||
|
await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
|
||||||
|
await recordAsync('addAllChars', () => addAllCharacters());
|
||||||
|
|
||||||
|
// hidden hp toggle
|
||||||
|
record('toggleHidePlayerHp', () => toggleHidePlayerHp());
|
||||||
|
record('toggleHidePlayerHp back', () => toggleHidePlayerHp());
|
||||||
|
|
||||||
|
await recordAsync('startCombat', () => startCombat());
|
||||||
|
|
||||||
|
// 100 rounds of mixed actions
|
||||||
|
for (let r = 1; r <= ROUNDS; r++) {
|
||||||
|
await recordAsync(`round ${r} nextTurn`, () => nextTurn());
|
||||||
|
|
||||||
|
// rotation integrity: turnOrderIds no dup, currentTurn valid
|
||||||
|
if (r % 10 === 0) {
|
||||||
|
record(`round ${r} rotation-check`, () => {
|
||||||
|
const enc = currentEncDoc();
|
||||||
|
if (!enc) throw new Error('no encounter doc');
|
||||||
|
const order = enc.turnOrderIds || [];
|
||||||
|
const uniq = new Set(order);
|
||||||
|
if (uniq.size !== order.length) {
|
||||||
|
throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
|
||||||
|
}
|
||||||
|
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
|
||||||
|
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// damage front monster every other round
|
||||||
|
if (r % 2 === 0) record(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||||
|
if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||||||
|
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
|
||||||
|
if (r % 10 === 0) {
|
||||||
|
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
||||||
|
await recordAsync(`round ${r} addReinforcement`, () =>
|
||||||
|
addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||||
|
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 () => {
|
||||||
|
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 ----------
|
||||||
|
const failed = RESULTS.filter(r => !r.ok);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
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([]);
|
||||||
|
}, 240000); // long timeout: 100 rounds
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// DisplayView.characterization.test.js
|
||||||
|
// Lock DisplayView uses storage adapter (subscribeDoc), NOT raw SDK onSnapshot(doc(db)).
|
||||||
|
// Blind spot caught: M2 refactor missed DisplayView; raw SDK + ws stub db = crash.
|
||||||
|
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import App from '../App';
|
||||||
|
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
|
||||||
|
|
||||||
|
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
|
||||||
|
function seedActiveDisplay() {
|
||||||
|
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
|
||||||
|
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
|
||||||
|
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||||
|
|
||||||
|
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
|
||||||
|
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
|
||||||
|
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DisplayView characterization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/display');
|
||||||
|
global.alert = jest.fn();
|
||||||
|
window.open = jest.fn();
|
||||||
|
resetAdapterCalls();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
|
||||||
|
seedActiveDisplay();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// wait for DisplayView to mount and attempt subscriptions
|
||||||
|
await waitFor(() => {
|
||||||
|
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc');
|
||||||
|
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
// must subscribe to campaign doc (for background url) and encounter doc
|
||||||
|
const docSubs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc').map(c => c.path);
|
||||||
|
expect(docSubs.some(p => p.includes('/campaigns/c1'))).toBe(true);
|
||||||
|
expect(docSubs.some(p => p.includes('/encounters/e1'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DisplayView also subscribes to activeDisplay status doc via adapter', async () => {
|
||||||
|
seedActiveDisplay();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc' && c.path.includes('activeDisplay'));
|
||||||
|
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// RED test: DisplayView must render participants in turnOrderIds (drag) order,
|
||||||
|
// NOT re-sort by initiative. 1-list model: participants[] = display source.
|
||||||
|
// Bug: DisplayView line ~2505 calls sortParticipantsByInitiative(), ignoring
|
||||||
|
// DM drag order. After cross-init drag, display diverges from AdminView/turnOrderIds.
|
||||||
|
import React from 'react';
|
||||||
|
import { render, waitFor, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import App from '../App';
|
||||||
|
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { resetAdapterCalls } from '../storage/firebase';
|
||||||
|
|
||||||
|
function seedDragOrder() {
|
||||||
|
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
|
||||||
|
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
|
||||||
|
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||||
|
// Three monsters, init-sorted would be: high(20), mid(11), low(10).
|
||||||
|
// But participants[] = DRAG order: low BEFORE mid (DM dragged across init).
|
||||||
|
const participants = [
|
||||||
|
{ id: 'high', name: 'High', type: 'monster', initiative: 20, currentHp: 10, maxHp: 10, isActive: true },
|
||||||
|
{ id: 'low', name: 'Low', type: 'monster', initiative: 10, currentHp: 10, maxHp: 10, isActive: true },
|
||||||
|
{ id: 'mid', name: 'Mid', type: 'monster', initiative: 11, currentHp: 10, maxHp: 10, isActive: true },
|
||||||
|
];
|
||||||
|
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
|
||||||
|
MOCK_DB.set(encounterPath, {
|
||||||
|
name: 'Enc',
|
||||||
|
participants,
|
||||||
|
turnOrderIds: participants.map(p => p.id),
|
||||||
|
round: 1,
|
||||||
|
currentTurnParticipantId: 'high',
|
||||||
|
isStarted: true,
|
||||||
|
});
|
||||||
|
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DisplayView drag order (BUG-15)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/display');
|
||||||
|
global.alert = jest.fn();
|
||||||
|
window.open = jest.fn();
|
||||||
|
// jsdom lacks scrollIntoView (DisplayView auto-scrolls current actor)
|
||||||
|
Element.prototype.scrollIntoView = jest.fn();
|
||||||
|
resetAdapterCalls();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders participants in participants[] order, not init-sorted', async () => {
|
||||||
|
seedDragOrder();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// wait for participant names to render
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText(/High|Mid|Low/i).length).toBeGreaterThanOrEqual(3);
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
// collect name elements in DOM order (strip Current marker)
|
||||||
|
const names = screen.getAllByText(/High|Mid|Low/i).map(el => el.textContent.replace(/\(Current\)/i, '').trim());
|
||||||
|
// participants[] order = High, Low, Mid (drag moved Low before Mid).
|
||||||
|
// Display must mirror this. Init-sorted would be High, Mid, Low.
|
||||||
|
expect(names).toEqual(['High', 'Low', 'Mid']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Encounter characterization. Lock setDoc path + payload on encounter actions.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName } from './testHelpers';
|
||||||
|
|
||||||
|
function findCall(fn, pathSub) {
|
||||||
|
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||||
|
}
|
||||||
|
function findCalls(fn, pathSub) {
|
||||||
|
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupCampaignAndEncounter(campName, encName) {
|
||||||
|
await renderApp();
|
||||||
|
await createCampaignViaUI(campName);
|
||||||
|
await selectCampaignByName(campName);
|
||||||
|
await createEncounterViaUI(encName);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Encounter -> Firebase', () => {
|
||||||
|
test('createEncounter: setDoc with encounter path + payload', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp E', 'Boss Fight');
|
||||||
|
const call = findCall('setDoc', '/encounters/');
|
||||||
|
expect(call.path).toMatch(/encounters\/.+$/);
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
name: 'Boss Fight',
|
||||||
|
participants: [],
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
});
|
||||||
|
expect(call.data).toHaveProperty('createdAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createEncounter: path nested under campaign', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp N', 'Enc N');
|
||||||
|
const call = findCall('setDoc', '/encounters/');
|
||||||
|
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('togglePlayerDisplay: setDoc merge on activeDisplay/status', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp D', 'Enc D');
|
||||||
|
await selectEncounterByName('Enc D');
|
||||||
|
|
||||||
|
// Eye button (icon-only, title attr)
|
||||||
|
const eyeBtn = await screen.findByTitle('Activate for Player Display');
|
||||||
|
fireEvent.click(eyeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||||
|
const call = findCall('setDoc', 'activeDisplay/status');
|
||||||
|
// activeDisplay/status setDoc is called with merge option in App
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
activeCampaignId: expect.any(String),
|
||||||
|
activeEncounterId: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('togglePlayerDisplay off: setDoc nulls active ids', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp O', 'Enc O');
|
||||||
|
await selectEncounterByName('Enc O');
|
||||||
|
|
||||||
|
// turn ON
|
||||||
|
const onBtn = await screen.findByTitle('Activate for Player Display');
|
||||||
|
fireEvent.click(onBtn);
|
||||||
|
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||||
|
|
||||||
|
// turn OFF
|
||||||
|
const offBtn = await screen.findByTitle('Deactivate for Player Display');
|
||||||
|
fireEvent.click(offBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = findCalls('setDoc', 'activeDisplay/status');
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
return last.data.activeCampaignId === null;
|
||||||
|
});
|
||||||
|
const calls = findCalls('setDoc', 'activeDisplay/status');
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteEncounter: deleteDoc on encounter path', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp X', 'Enc X');
|
||||||
|
await selectEncounterByName('Enc X');
|
||||||
|
|
||||||
|
// trash icon on encounter card
|
||||||
|
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
|
||||||
|
fireEvent.click(trashBtn);
|
||||||
|
// confirm modal
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => findCall('deleteDoc', '/encounters/'));
|
||||||
|
const del = findCall('deleteDoc', '/encounters/');
|
||||||
|
expect(del.path).toMatch(/campaigns\/[^/]+\/encounters\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteEncounter clears activeDisplay if it was active', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp A', 'Enc A');
|
||||||
|
await selectEncounterByName('Enc A');
|
||||||
|
|
||||||
|
// activate display first
|
||||||
|
const onBtn = await screen.findByTitle('Activate for Player Display');
|
||||||
|
fireEvent.click(onBtn);
|
||||||
|
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||||
|
|
||||||
|
// delete the active encounter
|
||||||
|
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
|
||||||
|
fireEvent.click(trashBtn);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
return last.data.activeEncounterId === null;
|
||||||
|
});
|
||||||
|
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// BUG-4 repro: toggling hidePlayerHp must not clobber activeDisplay doc.
|
||||||
|
// setDoc = replace (contract). {merge:true} arg ignored.
|
||||||
|
// Toggling hide-HP writes {hidePlayerHp:X} alone → activeCampaignId + activeEncounterId → null.
|
||||||
|
// Display reads null → "Game Session Paused". Recover requires re-activating encounter.
|
||||||
|
// Fix: use updateDoc (patch), not setDoc.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import App from '../App';
|
||||||
|
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
|
||||||
|
import { selectCampaignByName } from './testHelpers';
|
||||||
|
|
||||||
|
function seedAdminWithActiveEncounter() {
|
||||||
|
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
|
||||||
|
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
|
||||||
|
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||||
|
|
||||||
|
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
|
||||||
|
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
|
||||||
|
// active encounter set, HP NOT hidden
|
||||||
|
MOCK_DB.set(activeDisplayPath, {
|
||||||
|
activeCampaignId: 'c1',
|
||||||
|
activeEncounterId: 'e1',
|
||||||
|
hidePlayerHp: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
global.alert = jest.fn();
|
||||||
|
resetAdapterCalls();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggling hidePlayerHp does NOT clear activeCampaignId/activeEncounterId', async () => {
|
||||||
|
seedAdminWithActiveEncounter();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// wait for admin to mount + load active display
|
||||||
|
await waitFor(() => screen.getByText('Camp'), { timeout: 3000 });
|
||||||
|
await selectCampaignByName('Camp');
|
||||||
|
|
||||||
|
// find the hide-player-HP toggle (role switch)
|
||||||
|
const toggle = await screen.findByRole('switch', { name: /hide/i }, { timeout: 3000 });
|
||||||
|
|
||||||
|
// toggle ON
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const writes = getAdapterCalls().filter(
|
||||||
|
c => c.fn === 'setDoc' && c.path.includes('activeDisplay/status')
|
||||||
|
);
|
||||||
|
expect(writes.length).toBeGreaterThan(0);
|
||||||
|
const last = writes[writes.length - 1];
|
||||||
|
// data written must include activeCampaignId AND activeEncounterId
|
||||||
|
// BUG: writes only {hidePlayerHp:true}, clobbering them.
|
||||||
|
expect(last.data.activeCampaignId).toBe('c1');
|
||||||
|
expect(last.data.activeEncounterId).toBe('e1');
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Logs + deathSave characterization. Lock paths for log writes, undo, clear, death save.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||||
|
|
||||||
|
function findLogCalls() {
|
||||||
|
return getCalls().filter(c => c.fn === 'addDoc' && c.path.includes('/logs'));
|
||||||
|
}
|
||||||
|
function lastEncCall() {
|
||||||
|
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
return calls[calls.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to /logs view. App reads pathname at mount; must re-render with path preset.
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import App from '../App';
|
||||||
|
async function goToLogs() {
|
||||||
|
// unmount current tree isn't needed; App checks pathname in useEffect.
|
||||||
|
// Re-render a fresh App instance in same container.
|
||||||
|
window.history.replaceState({}, '', '/logs');
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
render(<App />);
|
||||||
|
await waitFor(() => screen.getByText(/Combat Log/i));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Logs -> Firebase', () => {
|
||||||
|
test('logAction: addDoc to logs collection on combat start', async () => {
|
||||||
|
await setupReady('LogCamp', 'LogEnc');
|
||||||
|
await addMonsterViaUI('Mob', 10, 2);
|
||||||
|
await startCombatViaUI();
|
||||||
|
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
|
||||||
|
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
|
||||||
|
expect(logCall.data).toHaveProperty('message');
|
||||||
|
expect(logCall.data).toHaveProperty('timestamp');
|
||||||
|
expect(logCall.data.message).toMatch(/Combat started/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logAction: includes undo payload', async () => {
|
||||||
|
await setupReady('UndoCamp', 'UndoEnc');
|
||||||
|
await addMonsterViaUI('Mob', 10, 2);
|
||||||
|
await startCombatViaUI();
|
||||||
|
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
|
||||||
|
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
|
||||||
|
expect(logCall.data.undo).toBeTruthy();
|
||||||
|
expect(logCall.data.undo).toHaveProperty('updates');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearLogs: writeBatch deletes all log docs', async () => {
|
||||||
|
const { renderApp } = require('./testHelpers');
|
||||||
|
// seed a log entry via combat start
|
||||||
|
await setupReady('ClearCamp', 'ClearEnc');
|
||||||
|
await addMonsterViaUI('Mob', 10, 2);
|
||||||
|
await startCombatViaUI();
|
||||||
|
await waitFor(() => findLogCalls().length > 0);
|
||||||
|
|
||||||
|
await goToLogs();
|
||||||
|
const clearBtn = await screen.findByRole('button', { name: /Clear Log/i });
|
||||||
|
fireEvent.click(clearBtn);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
|
||||||
|
return batchDeletes.length > 0;
|
||||||
|
});
|
||||||
|
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
|
||||||
|
expect(batchDeletes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undo: updateDoc on encounter path + marks log undone', async () => {
|
||||||
|
// seed log via combat start
|
||||||
|
await setupReady('UndoFlowCamp', 'UndoFlowEnc');
|
||||||
|
await addMonsterViaUI('Mob', 10, 2);
|
||||||
|
await startCombatViaUI();
|
||||||
|
await waitFor(() => findLogCalls().length > 0);
|
||||||
|
const logId = findLogCalls()[0].path.split('/').pop();
|
||||||
|
|
||||||
|
await goToLogs();
|
||||||
|
const undoBtns = await screen.findAllByRole('button', { name: /Undo/i });
|
||||||
|
fireEvent.click(undoBtns[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`) && c.data.undone === true);
|
||||||
|
return und;
|
||||||
|
});
|
||||||
|
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`));
|
||||||
|
expect(markUndone.data.undone).toBe(true);
|
||||||
|
// encounter path updated with undo payload (any encounter update after undo click)
|
||||||
|
const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
expect(encUndo.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeathSave -> Firebase', () => {
|
||||||
|
test('first death save: updateDoc increments deathSaves', async () => {
|
||||||
|
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
|
||||||
|
const { within } = require('@testing-library/react');
|
||||||
|
await renderApp();
|
||||||
|
await createCampaignViaUI('DSC2');
|
||||||
|
await selectCampaignByName('DSC2');
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Hero' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
|
||||||
|
|
||||||
|
await createEncounterViaUI('DSEnc2');
|
||||||
|
await selectEncounterByName('DSEnc2');
|
||||||
|
// switch to character type and add
|
||||||
|
const form = within(getParticipantForm());
|
||||||
|
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
|
||||||
|
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
|
||||||
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Hero');
|
||||||
|
|
||||||
|
await startCombatViaUI();
|
||||||
|
// damage to 0
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
|
||||||
|
fireEvent.click(screen.getByTitle('Damage'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||||
|
|
||||||
|
// death save buttons appear
|
||||||
|
const save1 = screen.getByTitle('Death save 1');
|
||||||
|
fireEvent.click(save1);
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
|
||||||
|
expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('third death save: marks isDying true', async () => {
|
||||||
|
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } = require('./testHelpers');
|
||||||
|
const { within } = require('@testing-library/react');
|
||||||
|
await renderApp();
|
||||||
|
await createCampaignViaUI('DSDie');
|
||||||
|
await selectCampaignByName('DSDie');
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Martyr' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
|
||||||
|
|
||||||
|
await createEncounterViaUI('DSEncDie');
|
||||||
|
await selectEncounterByName('DSEncDie');
|
||||||
|
const form = within(getParticipantForm());
|
||||||
|
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
|
||||||
|
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
|
||||||
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Martyr');
|
||||||
|
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
|
||||||
|
fireEvent.click(screen.getByTitle('Damage'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle('Death save 1'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
|
||||||
|
fireEvent.click(screen.getByTitle('Death save 2'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 2);
|
||||||
|
fireEvent.click(screen.getByTitle('Death save 3'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
|
||||||
|
expect(lastEncCall().data.participants[0].isDying).toBe(true);
|
||||||
|
expect(lastEncCall().data.participants[0].deathSaves).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Participant characterization. Lock updateDoc patch shape for participant ops.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { setupReady, addMonsterViaUI, getParticipantForm, startCombatViaUI } from './testHelpers';
|
||||||
|
|
||||||
|
function findCallsEnc() {
|
||||||
|
return getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
}
|
||||||
|
function lastEncCall() {
|
||||||
|
const calls = findCallsEnc();
|
||||||
|
return calls[calls.length - 1];
|
||||||
|
}
|
||||||
|
// First participant list item (the participant card <li>).
|
||||||
|
function firstParticipantItem() {
|
||||||
|
const list = screen.getByText('Victim') ||
|
||||||
|
[...document.querySelectorAll('li')].find(li => li.querySelector('[title="Remove"]'));
|
||||||
|
return list.closest('li');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Participant -> Firebase', () => {
|
||||||
|
test('addMonster: updateDoc appends participant with full shape', async () => {
|
||||||
|
await setupReady();
|
||||||
|
await addMonsterViaUI('Goblin', 7, 2);
|
||||||
|
const call = lastEncCall();
|
||||||
|
expect(call.data.participants).toHaveLength(1);
|
||||||
|
const p = call.data.participants[0];
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7,
|
||||||
|
isNpc: false, isActive: true, deathSaves: 0, isDying: false, conditions: [],
|
||||||
|
});
|
||||||
|
expect(p).toHaveProperty('id');
|
||||||
|
expect(p).toHaveProperty('initiative');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addMonster: initiative = d20 roll (1-20) + mod', async () => {
|
||||||
|
await setupReady();
|
||||||
|
await addMonsterViaUI('Orc', 12, 3);
|
||||||
|
const p = lastEncCall().data.participants[0];
|
||||||
|
expect(p.initiative).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(p.initiative).toBeLessThanOrEqual(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addMonster as NPC: isNpc true', async () => {
|
||||||
|
await setupReady();
|
||||||
|
const form = within(getParticipantForm());
|
||||||
|
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } });
|
||||||
|
fireEvent.click(form.getByLabelText(/Is NPC/i));
|
||||||
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const p = lastEncCall()?.data?.participants?.[0];
|
||||||
|
return p && p.name === 'Guard';
|
||||||
|
});
|
||||||
|
expect(lastEncCall().data.participants[0].isNpc).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteParticipant: updateDoc removes participant', async () => {
|
||||||
|
await setupReady();
|
||||||
|
await addMonsterViaUI('Victim', 10, 0);
|
||||||
|
fireEvent.click(screen.getByTitle('Remove'));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
await waitFor(() => (lastEncCall()?.data?.participants?.length === 0));
|
||||||
|
expect(lastEncCall().data.participants).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleActive: updateDoc flips isActive', async () => {
|
||||||
|
await setupReady();
|
||||||
|
await addMonsterViaUI('Toggle', 10, 0);
|
||||||
|
fireEvent.click(screen.getByTitle('Mark Inactive'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false);
|
||||||
|
expect(lastEncCall().data.participants[0].isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyDamage: updateDoc reduces currentHp, clamps 0', async () => {
|
||||||
|
await setupReady();
|
||||||
|
await addMonsterViaUI('Hurt', 10, 0);
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
|
||||||
|
fireEvent.click(screen.getByTitle('Damage'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 7);
|
||||||
|
expect(lastEncCall().data.participants[0].currentHp).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage to 0 deactivates participant', async () => {
|
||||||
|
await setupReady();
|
||||||
|
await addMonsterViaUI('Doom', 5, 0);
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '5' } });
|
||||||
|
fireEvent.click(screen.getByTitle('Damage'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||||
|
const p = lastEncCall().data.participants[0];
|
||||||
|
expect(p.currentHp).toBe(0);
|
||||||
|
expect(p.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal revives from 0 (reactivates, resets death saves)', async () => {
|
||||||
|
await setupReady();
|
||||||
|
await addMonsterViaUI('Revive', 5, 0);
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '5' } });
|
||||||
|
fireEvent.click(screen.getByTitle('Damage'));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
|
||||||
|
fireEvent.click(screen.getByTitle(/Heal/i));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 3);
|
||||||
|
const p = lastEncCall().data.participants[0];
|
||||||
|
expect(p.currentHp).toBe(3);
|
||||||
|
expect(p.isActive).toBe(true);
|
||||||
|
expect(p.deathSaves).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleCondition: updateDoc adds condition to array', async () => {
|
||||||
|
await setupReady();
|
||||||
|
await addMonsterViaUI('Cond', 10, 0);
|
||||||
|
fireEvent.click(screen.getByTitle('Conditions'));
|
||||||
|
await waitFor(() => screen.getByRole('button', { name: /Blinded/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const p = lastEncCall()?.data?.participants?.[0];
|
||||||
|
return p && p.conditions?.includes('blinded');
|
||||||
|
});
|
||||||
|
expect(lastEncCall().data.participants[0].conditions).toContain('blinded');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// RED test: campaign selection must follow activeDisplay.activeCampaignId changes.
|
||||||
|
// Bug: once selected, new activeDisplay writes ignored (guard `!selectedCampaignId`).
|
||||||
|
// Scenario: replay tool writes activeDisplay to new campaign -> UI must switch.
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { renderApp, createCampaignViaUI, selectCampaignByName } from './testHelpers';
|
||||||
|
|
||||||
|
const PUBLIC_DATA = 'artifacts/ttrpg-initiative-tracker-default/public/data';
|
||||||
|
|
||||||
|
describe('Selection follows activeDisplay (BUG-12)', () => {
|
||||||
|
test('new activeCampaignId switches selection', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const idA = await createCampaignViaUI('Campaign A');
|
||||||
|
const idB = await createCampaignViaUI('Campaign B');
|
||||||
|
|
||||||
|
// seed activeDisplay so useFirestoreDocument has a value to emit
|
||||||
|
const activePath = Object.keys(MOCK_DB._state.docs).find(p => p.includes('/activeDisplay/status'))
|
||||||
|
|| `${PUBLIC_DATA}/activeDisplay/status`;
|
||||||
|
MOCK_DB.set(activePath, { activeCampaignId: null, activeEncounterId: null });
|
||||||
|
|
||||||
|
// manually select A first
|
||||||
|
await selectCampaignByName('Campaign A');
|
||||||
|
expect(screen.getByText(/Managing:/i).textContent).toMatch(/Campaign A/);
|
||||||
|
|
||||||
|
// simulate replay/another-DM setting activeDisplay to B
|
||||||
|
MOCK_DB.merge(activePath, { activeCampaignId: idB });
|
||||||
|
|
||||||
|
// selection should now follow -> Managing: Campaign B
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Managing:/i).textContent).toMatch(/Campaign B/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activeDisplay cleared (null) does not force-select', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const idA = await createCampaignViaUI('Persist A');
|
||||||
|
const activePath = Object.keys(MOCK_DB._state.docs).find(p => p.includes('/activeDisplay/status'))
|
||||||
|
|| `${PUBLIC_DATA}/activeDisplay/status`;
|
||||||
|
MOCK_DB.set(activePath, { activeCampaignId: null, activeEncounterId: null });
|
||||||
|
await selectCampaignByName('Persist A');
|
||||||
|
|
||||||
|
MOCK_DB.merge(activePath, { activeCampaignId: null });
|
||||||
|
|
||||||
|
// should stay on A (manual selection persists)
|
||||||
|
expect(screen.getByText(/Managing:/i).textContent).toMatch(/Persist A/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Lock: storage adapters must use ESM exports (no module.exports).
|
||||||
|
// Regression guard: CJS in src/ crashes CRA prod build (ESM strict).
|
||||||
|
// Bug history: ws.js + memory.js used module.exports. Dev lenient (masked),
|
||||||
|
// prod bundle crashed blank page. firebase.js always ESM.
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const ADAPTER_DIR = path.join(__dirname, '..', 'storage');
|
||||||
|
|
||||||
|
describe('storage adapters use ESM (no CJS)', () => {
|
||||||
|
const adapters = ['ws.js', 'memory.js', 'firebase.js', 'index.js'];
|
||||||
|
test.each(adapters)('%s has no module.exports', (file) => {
|
||||||
|
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
|
||||||
|
const src = full.replace(/^\s*\/\/.*$/gm, '');
|
||||||
|
expect(src).not.toMatch(/module\.exports\s*=/);
|
||||||
|
expect(src).not.toMatch(/\brequire\s*\(/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 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());
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// test helpers: drive App UI to states. Used across characterization suites.
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||||
|
import App from '../App';
|
||||||
|
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
|
||||||
|
// Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager).
|
||||||
|
export function getParticipantForm() {
|
||||||
|
const heading = screen.getByText('Add Participants');
|
||||||
|
// closest section/div wrapping the form
|
||||||
|
let node = heading;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
node = node.parentElement;
|
||||||
|
if (!node) break;
|
||||||
|
if (node.querySelector('form')) return node;
|
||||||
|
}
|
||||||
|
return heading.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render app, wait for auth + campaign list.
|
||||||
|
export async function renderApp() {
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
global.alert = jest.fn();
|
||||||
|
window.open = jest.fn();
|
||||||
|
const utils = render(<App />);
|
||||||
|
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
|
||||||
|
return utils;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open create-campaign modal, fill name, submit. Returns campaign id from recorded call.
|
||||||
|
export async function createCampaignViaUI(name = 'Test Campaign') {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||||
|
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||||
|
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: name } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||||
|
// wait for setDoc recorded with this name (latest match)
|
||||||
|
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||||
|
await waitFor(() => getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/campaigns/') && c.data.name === name));
|
||||||
|
const call = getCalls().filter(c => c.fn === 'setDoc' && c.path.includes('/campaigns/') && c.data.name === name).pop();
|
||||||
|
return call.path.split('/').pop(); // campaign id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click campaign card by name to select it. Returns selected campaign id.
|
||||||
|
export async function selectCampaignByName(name) {
|
||||||
|
const card = await waitFor(() => screen.getByText(name));
|
||||||
|
fireEvent.click(card);
|
||||||
|
await waitFor(() => screen.getByText(/Managing:/i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open create-encounter modal, fill name, submit. Assumes campaign selected.
|
||||||
|
export async function createEncounterViaUI(name = 'Test Encounter') {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Create Encounter/i }));
|
||||||
|
await waitFor(() => screen.getByLabelText(/Encounter Name/i));
|
||||||
|
fireEvent.change(screen.getByLabelText(/Encounter Name/i), { target: { value: name } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||||
|
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||||
|
await waitFor(() => getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/encounters/') && c.data.name === name));
|
||||||
|
const call = getCalls().filter(c => c.fn === 'setDoc' && c.path.includes('/encounters/') && c.data.name === name).pop();
|
||||||
|
return call.path.split('/').pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click encounter card by name. Assumes campaign selected.
|
||||||
|
export async function selectEncounterByName(name) {
|
||||||
|
const card = await waitFor(() => screen.getByText(name));
|
||||||
|
fireEvent.click(card);
|
||||||
|
await waitFor(() => screen.getByText(/Managing Encounter:/i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a monster participant via the ParticipantManager form. Assumes encounter selected.
|
||||||
|
export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
|
||||||
|
const form = within(getParticipantForm());
|
||||||
|
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) } });
|
||||||
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
|
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
return last && last.data.participants && last.data.participants.some(p => p.name === name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full setup: app -> campaign -> encounter selected.
|
||||||
|
export async function setupReady(campName = 'Camp', encName = 'Enc') {
|
||||||
|
await renderApp();
|
||||||
|
await createCampaignViaUI(campName);
|
||||||
|
await selectCampaignByName(campName);
|
||||||
|
await createEncounterViaUI(encName);
|
||||||
|
await selectEncounterByName(encName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start combat. Assumes encounter selected with active participants.
|
||||||
|
export async function startCombatViaUI() {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Start Combat/i }));
|
||||||
|
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
return last && last.data.isStarted === true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MOCK_DB };
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// DEPRECATED — DO NOT USE.
|
||||||
|
// Random simulation gave false 0-violations while replay (exact ops)
|
||||||
|
// reproduced real bugs. Replay-mirror approach = duplicate work.
|
||||||
|
// Kept for now in case parts reusable. Will delete once log analyzer
|
||||||
|
// (scratch/) + unit tests cover the ground.
|
||||||
|
//
|
||||||
|
// Prefer: replay-combat.js dumps turnOrderIds per turn, log analyzer
|
||||||
|
// finds dupes/skips from real run. Unit tests lock confirmed bugs.
|
||||||
|
//
|
||||||
|
// To revive: delete this early-return block below.
|
||||||
|
if (require.main === module) {
|
||||||
|
console.error('audit-rotation.js DEPRECATED. See header comment.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === original (below) — exploratory rotation audit, kept for reference ===
|
||||||
|
// Pure turn.js simulation of replay op sequence. Detects first round where
|
||||||
|
// rotation breaks (skip or dupe). Prints minimal repro + preceding ops.
|
||||||
|
// No backend, no WS, no sleep. Fast.
|
||||||
|
|
||||||
|
const shared = require('../../shared');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause,
|
||||||
|
addParticipant, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, applyHpChange, deathSave,
|
||||||
|
toggleCondition, reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
function makeParticipant(opts) { return shared.makeParticipant(opts); }
|
||||||
|
|
||||||
|
const ps = [
|
||||||
|
makeParticipant({ id: 'c1', name: 'Fighter', type: 'character', initiative: 14, maxHp: 200, currentHp: 200 }),
|
||||||
|
makeParticipant({ id: 'c2', name: 'Cleric', type: 'character', initiative: 10, maxHp: 180, currentHp: 180 }),
|
||||||
|
makeParticipant({ id: 'c3', name: 'Rogue', type: 'character', initiative: 15, maxHp: 160, currentHp: 160 }),
|
||||||
|
makeParticipant({ id: 'm1', name: 'Goblin1', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }),
|
||||||
|
makeParticipant({ id: 'm2', name: 'Goblin2', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }),
|
||||||
|
makeParticipant({ id: 'm3', name: 'OrcBoss', type: 'monster', initiative: 11, maxHp: 500, currentHp: 500 }),
|
||||||
|
makeParticipant({ id: 'm4', name: 'Wolf', type: 'monster', initiative: 13, maxHp: 120, currentHp: 120 }),
|
||||||
|
makeParticipant({ id: 'n1', name: 'Merchant', type: 'monster', initiative: 8, maxHp: 150, currentHp: 150, isNpc: true }),
|
||||||
|
];
|
||||||
|
|
||||||
|
let enc = {
|
||||||
|
name: 'audit', participants: ps,
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const opLog = [];
|
||||||
|
function log(label) { opLog.push({ round: enc.round, turn: currentName(enc), label }); }
|
||||||
|
|
||||||
|
function apply(result, label) {
|
||||||
|
if (!result || !result.patch) return;
|
||||||
|
enc = { ...enc, ...result.patch };
|
||||||
|
log(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentName(e) {
|
||||||
|
if (!e.currentTurnParticipantId) return '(none)';
|
||||||
|
const p = e.participants.find(x => x.id === e.currentTurnParticipantId);
|
||||||
|
return p ? p.name : '(missing)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// start
|
||||||
|
apply(startEncounter(enc), 'startEncounter');
|
||||||
|
console.log(`start: order=${enc.turnOrderIds.join(',')} first=${currentName(enc)}`);
|
||||||
|
|
||||||
|
const ROUNDS = 100;
|
||||||
|
let totalTurns = 0;
|
||||||
|
let violations = [];
|
||||||
|
|
||||||
|
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||||
|
const startRound = enc.round;
|
||||||
|
const seenThisRound = [];
|
||||||
|
// record starting turn (already current at top of round)
|
||||||
|
seenThisRound.push(enc.currentTurnParticipantId);
|
||||||
|
const cap = (enc.participants.length + 2) * 2;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
// BISECT: dmg+heal+cond+add+pause
|
||||||
|
const actor = enc.participants.find(p => p.id === enc.currentTurnParticipantId);
|
||||||
|
if (actor) {
|
||||||
|
const foes = enc.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (foes.length > 0) {
|
||||||
|
const tgt = foes[Math.floor(Math.random() * foes.length)];
|
||||||
|
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||||
|
apply(applyHpChange(enc, tgt.id, 'damage', dmg), `damage ${actor.name}→${tgt.name} -${dmg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||||
|
const wounded = enc.participants.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp).sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
|
||||||
|
if (wounded.length > 0) {
|
||||||
|
const tgt = wounded[0]; const amt = 2 + Math.floor(Math.random()*5);
|
||||||
|
apply(applyHpChange(enc, tgt.id, 'heal', amt), `heal ${tgt.name} +${amt}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalTurns % 4 === 0) {
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||||
|
apply(toggleCondition(enc, tgt.id, 'stunned'), `condition stunned on ${tgt.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalTurns % 9 === 0 && totalTurns > 0) {
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||||
|
apply(toggleParticipantActive(enc, tgt.id), `toggleActive ${tgt.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalTurns % 5 === 0 && totalTurns > 0) {
|
||||||
|
const dead = enc.participants.find(p => p.currentHp <= 0);
|
||||||
|
if (dead) apply(removeParticipant(enc, dead.id), `remove ${dead.name}`);
|
||||||
|
}
|
||||||
|
if (totalTurns % 10 === 0 && totalTurns > 0) {
|
||||||
|
const newP = makeParticipant({ id: `r${totalTurns}`, name: `R${totalTurns}`, type: 'monster', initiative: 9, maxHp: 100, currentHp: 100 });
|
||||||
|
apply(addParticipant(enc, newP), `add ${newP.name}`);
|
||||||
|
}
|
||||||
|
//REMOVED
|
||||||
|
//REMOVED
|
||||||
|
// 9. pause — re-enabled, isolating interaction
|
||||||
|
if (totalTurns % 12 === 0 && totalTurns > 0) {
|
||||||
|
apply(togglePause(enc), 'pause');
|
||||||
|
}
|
||||||
|
|
||||||
|
while (enc.round === startRound && guard < cap) {
|
||||||
|
// advance FIRST, then check wrap before recording
|
||||||
|
let t;
|
||||||
|
try { t = nextTurn(enc); } catch (e) { log(`nextTurn ERR: ${e.message}`); break; }
|
||||||
|
apply(t, 'nextTurn');
|
||||||
|
// stop at round wrap — nextTurn just rolled into new round
|
||||||
|
if (enc.round !== startRound) break;
|
||||||
|
totalTurns++;
|
||||||
|
seenThisRound.push(enc.currentTurnParticipantId);
|
||||||
|
guard++;
|
||||||
|
if (!enc.isStarted) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit this round
|
||||||
|
const uniq = new Set(seenThisRound);
|
||||||
|
const dupes = seenThisRound.filter(id => seenThisRound.indexOf(id) !== seenThisRound.lastIndexOf(id));
|
||||||
|
if (dupes.length > 0 || uniq.size < seenThisRound.length) {
|
||||||
|
violations.push({ round: roundN, seen: seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id), dupes });
|
||||||
|
if (violations.length <= 3) {
|
||||||
|
console.log(`\n=== VIOLATION round ${roundN} ===`);
|
||||||
|
console.log(` seen: ${seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id).join(' → ')}`);
|
||||||
|
console.log(` dupes: ${[...new Set(dupes)].map(id => enc.participants.find(p=>p.id===id)?.name||id).join(', ')}`);
|
||||||
|
// print op log for this round
|
||||||
|
const roundOps = opLog.filter(o => o.round === startRound || o.round === roundN);
|
||||||
|
console.log(` ops: ${roundOps.map(o => o.label).join(' | ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enc.isStarted) { console.log('encounter ended'); break; }
|
||||||
|
|
||||||
|
// revive dead
|
||||||
|
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||||
|
for (const d of dead) {
|
||||||
|
if (d.isActive === false) apply(toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`);
|
||||||
|
apply(applyHpChange(enc, d.id, 'heal', d.maxHp), `revive-heal ${d.name} →${d.maxHp}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\ntotal violations: ${violations.length} / ${ROUNDS} rounds`);
|
||||||
|
if (violations.length > 0) {
|
||||||
|
console.log('first 5:', violations.slice(0,5).map(v => `r${v.round}`));
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// DEPRECATED — DO NOT USE.
|
||||||
|
// Random simulation gave false 0-violations while replay (exact ops)
|
||||||
|
// reproduced real bugs. Replay-mirror approach = duplicate work.
|
||||||
|
// Kept for now in case parts reusable. Will delete once log analyzer
|
||||||
|
// (scratch/) + unit tests cover the ground.
|
||||||
|
//
|
||||||
|
// Prefer: replay-combat.js dumps turnOrderIds per turn, log analyzer
|
||||||
|
// finds dupes/skips from real run. Unit tests lock confirmed bugs.
|
||||||
|
//
|
||||||
|
// To revive: delete this early-return block below.
|
||||||
|
if (require.main === module) {
|
||||||
|
console.error('audit-state.js DEPRECATED. See header comment.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === original (below) — exploratory bug-finder, kept for reference ===
|
||||||
|
// Expanded bug-finder: runs combat through pure turn.js, audits invariant
|
||||||
|
// checks per round across multiple bug classes (not just rotation).
|
||||||
|
// NOT a unit test (Math.random, exploratory). Unit tests lock known bugs.
|
||||||
|
//
|
||||||
|
// Bug classes audited:
|
||||||
|
// 1. Rotation integrity (skip/dupe per round) — BUG-1, BUG-3
|
||||||
|
// 2. HP invariants (0<=hp<=max, no NaN)
|
||||||
|
// 3. Condition toggles (consistent, applied/removed)
|
||||||
|
// 4. isActive consistency (dead=inactive, alive=active after ops)
|
||||||
|
// 5. turnOrderIds (no dup ids, no orphan/dead ids, subset of active)
|
||||||
|
// 6. currentTurn (valid id, in turnOrderIds, isActive)
|
||||||
|
// 7. deathSave counter (0<=saves<=3, reset on revive)
|
||||||
|
// 8. removeParticipant (turnOrderIds updated, currentTurn updated)
|
||||||
|
// 9. Undo (every op.patch has .log.undo; roundtrip restores)
|
||||||
|
//
|
||||||
|
// Run: node scripts/audit-state.js [rounds]
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
const shared = require('../../shared');
|
||||||
|
const {
|
||||||
|
makeParticipant, startEncounter, nextTurn, togglePause,
|
||||||
|
addParticipant, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, applyHpChange, deathSave,
|
||||||
|
toggleCondition, reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 200, currentHp: 200,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'a', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ps = [
|
||||||
|
p('c1', 14, { type:'character' }), p('c2', 10, { type:'character' }),
|
||||||
|
p('c3', 15, { type:'character' }), p('m1', 12), p('m2', 12),
|
||||||
|
p('m3', 11, { maxHp:500, currentHp:500 }), p('m4', 13),
|
||||||
|
p('n1', 8, { maxHp:150, currentHp:150, isNpc:true }),
|
||||||
|
];
|
||||||
|
|
||||||
|
let e = enc(ps);
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
function check(label, cond, detail) {
|
||||||
|
if (!cond) violations.push({ label, detail, round: e.round, state: snap(e) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function snap(x) {
|
||||||
|
return JSON.stringify({
|
||||||
|
round: x.round, isStarted: x.isStarted, isPaused: x.isPaused,
|
||||||
|
current: x.currentTurnParticipantId,
|
||||||
|
order: x.turnOrderIds,
|
||||||
|
hp: x.participants.map(p => `${p.id}:${p.currentHp}/${p.maxHp}${p.isActive===false?'-': ''}`),
|
||||||
|
dead: x.participants.filter(p => p.currentHp <= 0).map(p => p.id),
|
||||||
|
inactive: x.participants.filter(p => p.isActive === false).map(p => p.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// start
|
||||||
|
e = { ...e, ...startEncounter(e).patch };
|
||||||
|
let totalTurns = 0;
|
||||||
|
|
||||||
|
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||||
|
const startRound = e.round;
|
||||||
|
|
||||||
|
// ops (mirror replay)
|
||||||
|
const actor = e.participants.find(p => p.id === e.currentTurnParticipantId);
|
||||||
|
if (actor) {
|
||||||
|
const foes = e.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (foes.length > 0) {
|
||||||
|
const tgt = foes[Math.floor(Math.random() * foes.length)];
|
||||||
|
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||||
|
try { e = { ...e, ...applyHpChange(e, tgt.id, 'damage', dmg).patch }; } catch (err) {}
|
||||||
|
}
|
||||||
|
if (actor.name === 'c2' && totalTurns % 2 === 0) {
|
||||||
|
const wounded = e.participants.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp)
|
||||||
|
.sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
|
||||||
|
if (wounded.length > 0) {
|
||||||
|
try { e = { ...e, ...applyHpChange(e, wounded[0].id, 'heal', 2+Math.floor(Math.random()*5)).patch }; } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalTurns % 4 === 0) {
|
||||||
|
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||||
|
try { e = { ...e, ...toggleCondition(e, tgt.id, 'stunned').patch }; } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalTurns % 9 === 0) {
|
||||||
|
const living = e.participants.filter(p => p.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = living[Math.floor(Math.random()*living.length)];
|
||||||
|
try { e = { ...e, ...toggleParticipantActive(e, tgt.id).patch }; } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalTurns % 5 === 0) {
|
||||||
|
const dead = e.participants.find(p => p.currentHp <= 0);
|
||||||
|
if (dead) { try { e = { ...e, ...removeParticipant(e, dead.id).patch }; } catch (err) {} }
|
||||||
|
}
|
||||||
|
// mid-round revive: DM reactivates a downed participant's turn (mirrors
|
||||||
|
// replay-combat.js + real play). Triggers same path as revive-between-rounds
|
||||||
|
// but INSIDE rotation — where BUG-5 lives.
|
||||||
|
if (totalTurns % 7 === 0 && totalTurns > 0) {
|
||||||
|
const down = e.participants.find(p => p.currentHp <= 0 || p.isActive === false);
|
||||||
|
if (down) {
|
||||||
|
try {
|
||||||
|
if (down.isActive === false) e = { ...e, ...toggleParticipantActive(e, down.id).patch };
|
||||||
|
e = { ...e, ...applyHpChange(e, down.id, 'heal', down.maxHp).patch };
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalTurns % 10 === 0 && totalTurns > 0) {
|
||||||
|
const np = makeParticipant({ id: `r${totalTurns}`, name:`R${totalTurns}`, type:'monster', initiative:9, maxHp:100, currentHp:100 });
|
||||||
|
try { e = { ...e, ...addParticipant(e, np).patch }; } catch (err) {}
|
||||||
|
}
|
||||||
|
if (totalTurns % 12 === 0) {
|
||||||
|
try { e = { ...e, ...togglePause(e).patch }; } catch (err) {}
|
||||||
|
try { e = { ...e, ...togglePause(e).patch }; } catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance until round wraps or cap
|
||||||
|
const cap = (e.participants.length + 4) * 2;
|
||||||
|
let guard = 0;
|
||||||
|
const seenThisRound = [];
|
||||||
|
while (e.round === startRound && guard < cap) {
|
||||||
|
if (e.currentTurnParticipantId) seenThisRound.push(e.currentTurnParticipantId);
|
||||||
|
if (e.isPaused) { check('advance-while-paused', false, 'paused at advance'); break; }
|
||||||
|
let t;
|
||||||
|
try { t = nextTurn(e); } catch (err) { check('nextTurn-throws', false, err.message); break; }
|
||||||
|
e = { ...e, ...t.patch };
|
||||||
|
if (e.round !== startRound) break;
|
||||||
|
totalTurns++;
|
||||||
|
guard++;
|
||||||
|
if (!e.isStarted) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === audits ===
|
||||||
|
// 1. rotation (this round, before wrap)
|
||||||
|
const uniq = new Set(seenThisRound);
|
||||||
|
check('rotation-dupes', uniq.size >= seenThisRound.length,
|
||||||
|
`seen ${seenThisRound.length} uniq ${uniq.size}: ${JSON.stringify(seenThisRound)}`);
|
||||||
|
|
||||||
|
// 2. HP invariants
|
||||||
|
for (const p of e.participants) {
|
||||||
|
check(`hp-valid:${p.id}`, typeof p.currentHp === 'number' && !isNaN(p.currentHp) && p.currentHp >= 0 && p.currentHp <= p.maxHp,
|
||||||
|
`hp=${p.currentHp} max=${p.maxHp}`);
|
||||||
|
}
|
||||||
|
// 3. isActive consistency: dead should be inactive (after applyHpChange)
|
||||||
|
for (const p of e.participants) {
|
||||||
|
check(`dead-inactive:${p.id}`, p.currentHp > 0 || p.isActive === false,
|
||||||
|
`hp=${p.currentHp} isActive=${p.isActive}`);
|
||||||
|
}
|
||||||
|
// 4. turnOrderIds no dup
|
||||||
|
const orderUniq = new Set(e.turnOrderIds);
|
||||||
|
check('turnOrder-no-dup', orderUniq.size === e.turnOrderIds.length,
|
||||||
|
`order ${JSON.stringify(e.turnOrderIds)}`);
|
||||||
|
// 5. turnOrderIds all active
|
||||||
|
for (const id of e.turnOrderIds) {
|
||||||
|
const p = e.participants.find(x => x.id === id);
|
||||||
|
check(`turnOrder-active:${id}`, p && p.isActive !== false,
|
||||||
|
`isActive=${p && p.isActive}`);
|
||||||
|
}
|
||||||
|
// 6. currentTurn valid
|
||||||
|
if (e.isStarted && e.currentTurnParticipantId) {
|
||||||
|
const ct = e.participants.find(x => x.id === e.currentTurnParticipantId);
|
||||||
|
check('currentTurn-exists', !!ct, `id=${e.currentTurnParticipantId}`);
|
||||||
|
if (ct) check('currentTurn-active', ct.isActive !== false, `isActive=${ct.isActive}`);
|
||||||
|
}
|
||||||
|
// 7. deathSave range
|
||||||
|
for (const p of e.participants) {
|
||||||
|
check(`deathSaves-range:${p.id}`, (p.deathSaves||0) >= 0 && (p.deathSaves||0) <= 3,
|
||||||
|
`saves=${p.deathSaves}`);
|
||||||
|
if (p.currentHp > 0 && !p.isDying) {
|
||||||
|
check(`deathSaves-reset:${p.id}`, (p.deathSaves||0) === 0,
|
||||||
|
`alive but saves=${p.deathSaves}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 8. remove: turnOrderIds doesn't contain removed ids
|
||||||
|
const ids = new Set(e.participants.map(p => p.id));
|
||||||
|
for (const id of e.turnOrderIds) {
|
||||||
|
check(`turnOrder-present:${id}`, ids.has(id), `orphan id in order`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.isStarted) { console.log('encounter ended early'); break; }
|
||||||
|
|
||||||
|
// revive dead each round (sustain combat)
|
||||||
|
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||||
|
for (const d of dead) {
|
||||||
|
try {
|
||||||
|
if (d.isActive === false) e = { ...e, ...toggleParticipantActive(e, d.id).patch };
|
||||||
|
e = { ...e, ...applyHpChange(e, d.id, 'heal', d.maxHp).patch };
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. undo: every op returns log.undo
|
||||||
|
const undoOps = ['startEncounter','nextTurn','applyHpChange','toggleCondition','toggleParticipantActive','addParticipant','removeParticipant','togglePause'];
|
||||||
|
console.log('\n=== undo support (static check) ===');
|
||||||
|
console.log('checked via log fields at runtime; this harness discards logs');
|
||||||
|
|
||||||
|
console.log(`\n=== VIOLATIONS: ${violations.length} / ${ROUNDS} rounds ===`);
|
||||||
|
const byLabel = {};
|
||||||
|
for (const v of violations) byLabel[v.label] = (byLabel[v.label]||0) + 1;
|
||||||
|
const sorted = Object.entries(byLabel).sort((a,b)=>b[1]-a[1]);
|
||||||
|
for (const [label, count] of sorted) console.log(` ${count}x ${label}`);
|
||||||
|
console.log('\nfirst 5 examples:');
|
||||||
|
for (const v of violations.slice(0,5)) console.log(` r${v.round} ${v.label}: ${v.detail}`);
|
||||||
Reference in New Issue
Block a user