diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..2fc8c38 --- /dev/null +++ b/.githooks/pre-push @@ -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 diff --git a/.gitignore b/.gitignore index 02e711e..d559aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# .gitignore node_modules build dist @@ -6,4 +5,11 @@ dist .env.local .env.development.local .env.test.local -.env.production.local \ No newline at end of file +.env.production.local +*.log +data/*.sqlite +data/*.sqlite-* +server/data/*.sqlite +server/data/*.sqlite-* +/data +/scratch diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8bee996 --- /dev/null +++ b/TODO.md @@ -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) diff --git a/.dockerignore b/docker/.dockerignore similarity index 65% rename from .dockerignore rename to docker/.dockerignore index 27a8caa..3c76f3b 100644 --- a/.dockerignore +++ b/docker/.dockerignore @@ -4,28 +4,36 @@ # Ignore Node.js modules (they will be installed in the Docker image) node_modules +**/node_modules # Ignore build output (it will be generated in the Docker image) build dist -# Ignore Docker files themselves +# Ignore Docker files themselves (Caddyfile MUST stay in context for frontend build) Dockerfile +Dockerfile.ws .dockerignore +docker-compose.yml # Ignore any local environment files if you have them .env -# .env.local +.env.local .env.development.local .env.test.local .env.production.local # Ignore IDE and OS-specific files .vscode/ -.idea/ +.idea *.suo *.user *.userosscache *.sln.docstates Thumbs.db .DS_Store + +# Ignore local sqlite data + scratch diagnostics (never bake into image) +data/ +scratch/ +tmp/ diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..65861c1 --- /dev/null +++ b/docker/Caddyfile @@ -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 + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..0eee034 --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..e30f716 --- /dev/null +++ b/docker/docker-compose.yml @@ -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: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..108dc84 --- /dev/null +++ b/docker/entrypoint.sh @@ -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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..70d18c4 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -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 +``` diff --git a/docs/ENCOUNTER_BUILDER.md b/docs/ENCOUNTER_BUILDER.md new file mode 100644 index 0000000..6eb5014 --- /dev/null +++ b/docs/ENCOUNTER_BUILDER.md @@ -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. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md new file mode 100644 index 0000000..bddf0b6 --- /dev/null +++ b/docs/GLOSSARY.md @@ -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. | diff --git a/docs/REWORK_PLAN.md b/docs/REWORK_PLAN.md new file mode 100644 index 0000000..e5804c7 --- /dev/null +++ b/docs/REWORK_PLAN.md @@ -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. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..6b099a9 --- /dev/null +++ b/docs/TESTING.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 49b8dbe..d1428ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "ttrpg-initiative-tracker", "version": "0.1.0", + "workspaces": [ + "server", + "shared" + ], "dependencies": { "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", @@ -3471,6 +3475,20 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/expect-utils": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", @@ -3483,6 +3501,387 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/expect/node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/expect/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/expect/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/expect/node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/@jest/expect/node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@jest/expect/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/expect/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/expect/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/expect/node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/@jest/fake-timers": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", @@ -4265,6 +4664,19 @@ "node": ">=4.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4300,6 +4712,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", @@ -4910,6 +5332,14 @@ "node": ">=10.13.0" } }, + "node_modules/@ttrpg/server": { + "resolved": "server", + "link": true + }, + "node_modules/@ttrpg/shared": { + "resolved": "shared", + "link": true + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -6676,6 +7106,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.7", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", @@ -6691,6 +7141,17 @@ "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/bfj": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", @@ -6728,6 +7189,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -6871,6 +7352,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7090,6 +7595,12 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -7322,6 +7833,16 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -7430,6 +7951,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js": { "version": "3.47.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", @@ -7471,6 +7999,23 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -7487,6 +8032,955 @@ "node": ">=10" } }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/create-jest/node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/create-jest/node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/create-jest/node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/create-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/create-jest/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/create-jest/node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/create-jest/node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/create-jest/node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/create-jest/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/create-jest/node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/create-jest/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/create-jest/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/create-jest/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/create-jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest/node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/create-jest/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/create-jest/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/create-jest/node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7986,6 +9480,21 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -8024,6 +9533,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8131,6 +9649,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -8178,6 +9705,17 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -8435,6 +9973,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -9414,6 +10961,15 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", @@ -9538,6 +11094,13 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -9651,6 +11214,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -10007,6 +11576,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -10038,6 +11625,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -10224,6 +11817,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10479,9 +12078,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10810,6 +12409,26 @@ "node": ">=4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -14893,6 +16512,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -14961,6 +16592,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -15009,6 +16646,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -15046,6 +16689,18 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-forge": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", @@ -16929,6 +18584,33 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -17089,6 +18771,16 @@ "url": "https://github.com/sponsors/lupomontero" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -17098,6 +18790,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -17204,6 +18913,30 @@ "node": ">=0.10.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -18472,14 +20205,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -18491,13 +20224,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -18549,6 +20282,51 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -19133,6 +20911,99 @@ "node": ">= 6" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -19431,6 +21302,23 @@ } } }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -19444,6 +21332,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -19761,6 +21677,18 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -19891,9 +21819,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", "peer": true, "bin": { @@ -19901,7 +21829,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { @@ -21073,6 +23001,2350 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "server": { + "name": "@ttrpg/server", + "version": "0.1.0", + "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" + } + }, + "server/node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "server/node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "server/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "server/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "server/node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "server/node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "server/node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "server/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "server/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "server/node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "server/node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "server/node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "server/node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "server/node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "server/node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "server/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "server/node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "server/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "server/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "server/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "server/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "server/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "server/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "server/node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "server/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "server/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "server/node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "server/node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "server/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "shared": { + "name": "@ttrpg/shared", + "version": "0.1.0", + "devDependencies": { + "jest": "^29.7.0" + } + }, + "shared/node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "shared/node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "shared/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "shared/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "shared/node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "shared/node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "shared/node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "shared/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "shared/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "shared/node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "shared/node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "shared/node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "shared/node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "shared/node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "shared/node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "shared/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "shared/node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "shared/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "shared/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "shared/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "shared/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "shared/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "shared/node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "shared/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "shared/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "shared/node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "shared/node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } } } } diff --git a/package.json b/package.json index 33693ec..c67ab0a 100644 --- a/package.json +++ b/package.json @@ -2,25 +2,33 @@ "name": "ttrpg-initiative-tracker", "version": "0.1.0", "private": true, + "workspaces": [ + "server", + "shared" + ], "dependencies": { "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "autoprefixer": "^10.4.19", "firebase": "^10.12.2", "lucide-react": "^0.395.0", + "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", "react-scripts": "5.0.1", - "web-vitals": "^2.1.4", - "autoprefixer": "^10.4.19", - "postcss": "^8.4.38", - "tailwindcss": "^3.4.3" + "tailwindcss": "^3.4.3", + "web-vitals": "^2.1.4" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "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": { "extends": [ @@ -40,4 +48,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..c7b0a26 --- /dev/null +++ b/scripts/README.md @@ -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 diff --git a/scripts/analyze-turns.js b/scripts/analyze-turns.js new file mode 100644 index 0000000..64d233d --- /dev/null +++ b/scripts/analyze-turns.js @@ -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(); diff --git a/scripts/replay-combat.js b/scripts/replay-combat.js new file mode 100644 index 0000000..cbc6210 --- /dev/null +++ b/scripts/replay-combat.js @@ -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); }); diff --git a/server/babel.config.js b/server/babel.config.js new file mode 100644 index 0000000..c74fb53 --- /dev/null +++ b/server/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [['@babel/preset-env', { targets: { node: 'current' } }]], +}; diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..63ac4de --- /dev/null +++ b/server/db.js @@ -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 }; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..48b104f --- /dev/null +++ b/server/index.js @@ -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. + // 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 + const collSubscribers = new Map(); // collPath -> Set + + 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 }; diff --git a/server/jest.config.js b/server/jest.config.js new file mode 100644 index 0000000..ab5859e --- /dev/null +++ b/server/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + rootDir: '.', + testEnvironment: 'node', + testMatch: ['/tests/**/*.test.js'], + testTimeout: 10000, +}; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..51ffdcf --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/tests/ws-contract.test.js b/server/tests/ws-contract.test.js new file mode 100644 index 0000000..0f6b9cb --- /dev/null +++ b/server/tests/ws-contract.test.js @@ -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); diff --git a/server/tests/ws-reconnect.test.js b/server/tests/ws-reconnect.test.js new file mode 100644 index 0000000..c36692e --- /dev/null +++ b/server/tests/ws-reconnect.test.js @@ -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); +}); diff --git a/shared/index.js b/shared/index.js new file mode 100644 index 0000000..5cd2ed2 --- /dev/null +++ b/shared/index.js @@ -0,0 +1,2 @@ +// @ttrpg/shared — barrel export. +module.exports = require('./turn.js'); diff --git a/shared/jest.config.js b/shared/jest.config.js new file mode 100644 index 0000000..610cf77 --- /dev/null +++ b/shared/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + rootDir: '.', + testEnvironment: 'node', + testMatch: ['/tests/**/*.test.js'], + collectCoverageFrom: ['turn.js'], +}; diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..355f76c --- /dev/null +++ b/shared/package.json @@ -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" + } +} diff --git a/shared/tests/turn.characterization.test.js b/shared/tests/turn.characterization.test.js new file mode 100644 index 0000000..a9e51b6 --- /dev/null +++ b/shared/tests/turn.characterization.test.js @@ -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(); + }); +}); diff --git a/shared/tests/turn.combat.test.js b/shared/tests/turn.combat.test.js new file mode 100644 index 0000000..edf552d --- /dev/null +++ b/shared/tests/turn.combat.test.js @@ -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); + }); +}); diff --git a/shared/tests/turn.dead-skip.test.js b/shared/tests/turn.dead-skip.test.js new file mode 100644 index 0000000..9d68e3b --- /dev/null +++ b/shared/tests/turn.dead-skip.test.js @@ -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); + }); +}); diff --git a/shared/tests/turn.dry.test.js b/shared/tests/turn.dry.test.js new file mode 100644 index 0000000..1438dca --- /dev/null +++ b/shared/tests/turn.dry.test.js @@ -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); + }); +}); diff --git a/shared/tests/turn.invariant.test.js b/shared/tests/turn.invariant.test.js new file mode 100644 index 0000000..5d02251 --- /dev/null +++ b/shared/tests/turn.invariant.test.js @@ -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)); + }); +}); diff --git a/shared/tests/turn.pause-add.test.js b/shared/tests/turn.pause-add.test.js new file mode 100644 index 0000000..88c4eba --- /dev/null +++ b/shared/tests/turn.pause-add.test.js @@ -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); + }); +}); diff --git a/shared/tests/turn.remove.test.js b/shared/tests/turn.remove.test.js new file mode 100644 index 0000000..21d0392 --- /dev/null +++ b/shared/tests/turn.remove.test.js @@ -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(); + }); +}); diff --git a/shared/tests/turn.reorder.test.js b/shared/tests/turn.reorder.test.js new file mode 100644 index 0000000..e2129da --- /dev/null +++ b/shared/tests/turn.reorder.test.js @@ -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']); + }); +}); diff --git a/shared/tests/turn.round-rotation.test.js b/shared/tests/turn.round-rotation.test.js new file mode 100644 index 0000000..b12d586 --- /dev/null +++ b/shared/tests/turn.round-rotation.test.js @@ -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); + }); +}); + diff --git a/shared/tests/turn.skip.test.js b/shared/tests/turn.skip.test.js new file mode 100644 index 0000000..97ee59e --- /dev/null +++ b/shared/tests/turn.skip.test.js @@ -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); + }); +}); diff --git a/shared/tests/turn.undo.test.js b/shared/tests/turn.undo.test.js new file mode 100644 index 0000000..777729a --- /dev/null +++ b/shared/tests/turn.undo.test.js @@ -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(); + }); +}); diff --git a/shared/turn.js b/shared/turn.js new file mode 100644 index 0000000..dcd8206 --- /dev/null +++ b/shared/turn.js @@ -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, +}; diff --git a/src/App.js b/src/App.js index bb901fb..d451fa0 100644 --- a/src/App.js +++ b/src/App.js @@ -1,7 +1,8 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; -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 * as shared from '@ttrpg/shared'; +import { initializeApp } from './storage'; +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 { PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, @@ -46,9 +47,7 @@ if (typeof document !== 'undefined') { // ============================================================================ const APP_VERSION = 'v0.3'; -const DEFAULT_MAX_HP = 10; -const DEFAULT_INIT_MOD = 0; -const MONSTER_DEFAULT_INIT_MOD = 2; +const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval } = shared; const ROLL_DISPLAY_DURATION = 5000; const CONDITIONS = [ @@ -95,29 +94,45 @@ const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`; let app; let db; let auth; +let storage; -// Initialize Firebase -const initializeFirebase = () => { - const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId']; - const missingKeys = requiredKeys.filter(key => !firebaseConfig[key]); +const STORAGE_MODE = getStorageMode(); - if (missingKeys.length > 0) { - console.error(`CRITICAL: Missing Firebase config values: ${missingKeys.join(', ')}`); - return false; +// 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 missingKeys = requiredKeys.filter(key => !firebaseConfig[key]); + if (missingKeys.length > 0) { + console.error(`CRITICAL: Missing Firebase config values: ${missingKeys.join(', ')}`); + return false; + } + try { + app = initializeApp(firebaseConfig); + db = getFirestore(app); + auth = getAuth(app); + storage = getStorage(); + return true; + } catch (error) { + console.error("Error initializing Firebase:", error); + return false; + } } - try { - app = initializeApp(firebaseConfig); - db = getFirestore(app); - auth = getAuth(app); - return true; - } catch (error) { - console.error("Error initializing Firebase:", error); - 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 @@ -136,24 +151,8 @@ const getPath = { // UTILITY FUNCTIONS // ============================================================================ -const generateId = () => crypto.randomUUID(); -const rollD20 = () => Math.floor(Math.random() * 20) + 1; - -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; - }); -}; +// generateId, rollD20, formatInitMod, sortParticipantsByInitiative, +// computeTurnOrderAfterRemoval/Addition: imported from @ttrpg/shared (1-list model). const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)]; @@ -162,34 +161,12 @@ const logAction = async (message, context = {}, undoData = null) => { try { const entry = { timestamp: Date.now(), message, ...context }; if (undoData) entry.undo = undoData; - await addDoc(collection(db, getPath.logs()), entry); + await storage.addDoc(getPath.logs(), entry); } catch (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 @@ -201,32 +178,22 @@ function useFirestoreDocument(docPath) { const [error, setError] = useState(null); useEffect(() => { - if (!db || !docPath) { + if (!docPath) { setData(null); setIsLoading(false); - setError(docPath ? "Firestore not available." : "Document path not provided."); + setError("Document path not provided."); return; } setIsLoading(true); setError(null); - const docRef = doc(db, docPath); - const unsubscribe = onSnapshot( - docRef, - (docSnap) => { - setData(docSnap.exists() ? { id: docSnap.id, ...docSnap.data() } : null); - setIsLoading(false); - }, - (err) => { - console.error(`Error fetching document ${docPath}:`, err); - setError(err.message || "Failed to fetch document."); - setIsLoading(false); - setData(null); - } - ); - - return () => unsubscribe(); + const storage = getStorage(); + const unsubscribe = storage.subscribeDoc(docPath, (doc) => { + setData(doc); + setIsLoading(false); + }); + return () => { if (typeof unsubscribe === 'function') unsubscribe(); }; }, [docPath]); return { data, isLoading, error }; @@ -239,34 +206,23 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) { const queryString = useMemo(() => JSON.stringify(queryConstraints), [queryConstraints]); useEffect(() => { - if (!db || !collectionPath) { + if (!collectionPath) { setData([]); setIsLoading(false); - setError(collectionPath ? "Firestore not available." : "Collection path not provided."); + setError("Collection path not provided."); return; } setIsLoading(true); setError(null); - const q = query(collection(db, collectionPath), ...queryConstraints); - const unsubscribe = onSnapshot( - q, - (snapshot) => { - const items = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); - setData(items); - setIsLoading(false); - }, - (err) => { - console.error(`Error fetching collection ${collectionPath}:`, err); - 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 + const storage = getStorage(); + const unsubscribe = storage.subscribeCollection(collectionPath, (items) => { + setData(items); + setIsLoading(false); + }); + return () => { if (typeof unsubscribe === 'function') unsubscribe(); }; + // queryString, not array ref // eslint-disable-next-line react-hooks/exhaustive-deps }, [collectionPath, queryString]); @@ -586,7 +542,7 @@ function CharacterManager({ campaignId, campaignCharacters }) { }; try { - await updateDoc(doc(db, getPath.campaign(campaignId)), { + await storage.updateDoc(getPath.campaign(campaignId), { players: [...campaignCharacters, newCharacter] }); setCharacterName(''); @@ -622,7 +578,7 @@ function CharacterManager({ campaignId, campaignCharacters }) { ); try { - await updateDoc(doc(db, getPath.campaign(campaignId)), { players: updatedCharacters }); + await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters }); setEditingCharacter(null); } catch (err) { console.error("Error updating character:", err); @@ -641,7 +597,7 @@ function CharacterManager({ campaignId, campaignCharacters }) { const updatedCharacters = campaignCharacters.filter(c => c.id !== itemToDelete.id); try { - await updateDoc(doc(db, getPath.campaign(campaignId)), { players: updatedCharacters }); + await storage.updateDoc(getPath.campaign(campaignId), { players: updatedCharacters }); } catch (err) { console.error("Error deleting character:", err); alert("Failed to delete character. Please try again."); @@ -897,7 +853,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { }; try { - await updateDoc(doc(db, encounterPath), { + await storage.updateDoc(encounterPath, { participants: [...participants, newParticipant] }); logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, { @@ -962,7 +918,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { } try { - await updateDoc(doc(db, encounterPath), { + await storage.updateDoc(encounterPath, { participants: [...participants, ...newParticipants] }); console.log(`Added ${newParticipants.length} characters to the encounter.`); @@ -980,7 +936,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { ); try { - await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); + await storage.updateDoc(encounterPath, { participants: updatedParticipants }); setEditingParticipant(null); } catch (err) { console.error("Error updating participant:", err); @@ -1009,9 +965,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { }; try { - await updateDoc(doc(db, encounterPath), { + await storage.updateDoc(encounterPath, { 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); } catch (err) { @@ -1034,12 +993,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { p.id === participantId ? { ...p, isActive: newIsActive } : p ); - const turnUpdates = newIsActive - ? computeTurnOrderAfterAddition(encounter, participantId) - : computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); + // 1-list: stay in slot, flip isActive only. Sync turnOrderIds. Advance + // current only if deact hits current. + let turnUpdates = {}; + if (encounter.isStarted) { + turnUpdates = syncTurnOrder(updatedParticipants); + if (!newIsActive && encounter.currentTurnParticipantId === participantId) { + turnUpdates = { ...turnUpdates, ...computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants) }; + } + } 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 }, { encounterPath, updates: { @@ -1105,11 +1070,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { return p; }); - const turnUpdates = (isDead && !wasDead) - ? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants) - : wasResurrected - ? computeTurnOrderAfterAddition(encounter, participantId) - : {}; + const turnUpdates = {}; const hpUndoData = { encounterPath, @@ -1123,7 +1084,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { }; try { - await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates }); + await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates }); setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); const hpLine = `${participant.currentHp} → ${newHp} HP`; const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : ''; @@ -1154,13 +1115,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { ); 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 setTimeout(async () => { const finalParticipants = participants.filter(p => p.id !== participantId); try { - await updateDoc(doc(db, encounterPath), { participants: finalParticipants }); + await storage.updateDoc(encounterPath, { participants: finalParticipants }); } catch (err) { console.error("Error removing dead participant:", err); } @@ -1175,7 +1136,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { ); try { - await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); + await storage.updateDoc(encounterPath, { participants: updatedParticipants }); } catch (err) { console.error("Error updating death saves:", err); } @@ -1196,7 +1157,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { return { ...p, conditions: next }; }); try { - await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); + await storage.updateDoc(encounterPath, { participants: updatedParticipants }); const cond = CONDITIONS.find(c => c.id === conditionId); const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId; logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, { @@ -1249,7 +1210,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { currentParticipants.splice(targetIndex, 0, removedItem); try { - await updateDoc(doc(db, encounterPath), { participants: currentParticipants }); + await storage.updateDoc(encounterPath, { participants: currentParticipants }); } catch (err) { console.error("Error reordering participants:", err); } @@ -1257,7 +1218,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { 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) => { acc[p.initiative] = (acc[p.initiative] || 0) + 1; @@ -1621,7 +1583,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { const handleToggleHidePlayerHp = async () => { if (!db) return; try { - await setDoc(doc(db, getPath.activeDisplay()), { hidePlayerHp: !hidePlayerHp }, { merge: true }); + await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true }); } catch (err) { console.error("Error toggling hidePlayerHp:", err); } @@ -1639,18 +1601,22 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { 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 { - await updateDoc(doc(db, encounterPath), { + await storage.updateDoc(encounterPath, { isStarted: true, isPaused: false, round: 1, - currentTurnParticipantId: sortedParticipants[0].id, + participants: sortedParticipants, + currentTurnParticipantId: firstActive.id, turnOrderIds: sortedParticipants.map(p => p.id) }); - await setDoc(doc(db, getPath.activeDisplay()), { + await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true }); @@ -1679,13 +1645,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { let newTurnOrderIds = encounter.turnOrderIds; if (!newPausedState && encounter.isPaused) { - const activeParticipants = encounter.participants.filter(p => p.isActive); - const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants); - newTurnOrderIds = sortedParticipants.map(p => p.id); + // 1-list model: no re-sort on resume. turnOrderIds already mirrors + // participants[] (set at start/add/reorder). Resume = unpause only. + newTurnOrderIds = encounter.turnOrderIds; } try { - await updateDoc(doc(db, encounterPath), { + await storage.updateDoc(encounterPath, { isPaused: newPausedState, turnOrderIds: newTurnOrderIds }); @@ -1711,7 +1677,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { if (activePsInOrder.length === 0) { alert("No active participants left."); - await updateDoc(doc(db, encounterPath), { + await storage.updateDoc(encounterPath, { isStarted: false, isPaused: false, currentTurnParticipantId: null, @@ -1751,7 +1717,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { if (!nextParticipant) return; try { - await updateDoc(doc(db, encounterPath), { + await storage.updateDoc(encounterPath, { currentTurnParticipantId: nextParticipant.id, round: nextRound, turnOrderIds: newTurnOrderIds, @@ -1773,7 +1739,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { if (!db) return; try { - await updateDoc(doc(db, encounterPath), { + await storage.updateDoc(encounterPath, { isStarted: false, isPaused: false, currentTurnParticipantId: null, @@ -1781,7 +1747,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { turnOrderIds: [] }); - await setDoc(doc(db, getPath.activeDisplay()), { + await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true }); @@ -1941,7 +1907,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const newEncounterId = generateId(); try { - await setDoc(doc(db, getPath.encounters(campaignId), newEncounterId), { + await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, { name: name.trim(), createdAt: new Date().toISOString(), participants: [], @@ -1970,14 +1936,14 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const encounterId = itemToDelete.id; try { - await deleteDoc(doc(db, getPath.encounter(campaignId, encounterId))); + await storage.deleteDoc(getPath.encounter(campaignId, encounterId)); if (selectedEncounterId === encounterId) { setSelectedEncounterId(null); } if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) { - await updateDoc(doc(db, getPath.activeDisplay()), { + await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }); @@ -1999,12 +1965,12 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const currentActiveEncounter = activeDisplayInfo?.activeEncounterId; if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { - await setDoc(doc(db, getPath.activeDisplay()), { + await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null, }, { merge: true }); } else { - await setDoc(doc(db, getPath.activeDisplay()), { + await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounterId, }, { merge: true }); @@ -2160,8 +2126,8 @@ function AdminView({ userId }) { let encounterCount = 0; try { - const encountersSnapshot = await getDocs(collection(db, getPath.encounters(campaign.id))); - encounterCount = encountersSnapshot.size; + const encounters = await storage.getCollection(getPath.encounters(campaign.id)); + encounterCount = encounters.length; } catch (err) { console.error(`Failed to fetch encounters for campaign ${campaign.id}:`, err); } @@ -2185,11 +2151,10 @@ function AdminView({ userId }) { if ( initialActiveInfo && initialActiveInfo.activeCampaignId && - campaignsWithDetails.length > 0 && - !selectedCampaignId + campaignsWithDetails.length > 0 ) { const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId); - if (campaignExists) { + if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) { setSelectedCampaignId(initialActiveInfo.activeCampaignId); } } @@ -2201,7 +2166,7 @@ function AdminView({ userId }) { const newCampaignId = generateId(); try { - await setDoc(doc(db, getPath.campaign(newCampaignId)), { + await storage.setDoc(getPath.campaign(newCampaignId), { name: name.trim(), playerDisplayBackgroundUrl: backgroundUrl.trim() || '', ownerId: userId, @@ -2229,23 +2194,23 @@ function AdminView({ userId }) { try { const encountersPath = getPath.encounters(campaignId); - const encountersSnapshot = await getDocs(collection(db, encountersPath)); - const batch = writeBatch(db); + const encounters = await storage.getCollection(encountersPath); + 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 batch.commit(); - - await deleteDoc(doc(db, getPath.campaign(campaignId))); + await storage.deleteDoc(getPath.campaign(campaignId)); if (selectedCampaignId === campaignId) { setSelectedCampaignId(null); } - const activeDisplayRef = doc(db, getPath.activeDisplay()); - const activeDisplaySnap = await getDoc(activeDisplayRef); + const activeDisplay = await storage.getDoc(getPath.activeDisplay()); - if (activeDisplaySnap.exists() && activeDisplaySnap.data().activeCampaignId === campaignId) { - await updateDoc(activeDisplayRef, { + if (activeDisplay && activeDisplay.activeCampaignId === campaignId) { + await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }); @@ -2318,6 +2283,11 @@ function AdminView({ userId }) { {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters + {campaign.createdAt && ( + + {new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} + + )}