Compare commits
14
Commits
d83d1383c0
...
4da5ed9bcc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da5ed9bcc | ||
|
|
c556860e49 | ||
|
|
27b2272ced | ||
|
|
e94e1959ff | ||
|
|
cb41d9ec8f | ||
|
|
6baecd374e | ||
|
|
79af61cb8b | ||
|
|
b12ac904a6 | ||
|
|
7f60ec140e | ||
|
|
e650cd2710 | ||
|
|
8cf3dad5b6 | ||
|
|
ec578eeef5 | ||
|
|
c54fd88c32 | ||
|
|
e31fe15382 |
@@ -13,3 +13,4 @@ server/data/*.sqlite
|
||||
server/data/*.sqlite-*
|
||||
/data
|
||||
/scratch
|
||||
tmp/
|
||||
|
||||
+4
-1
@@ -7,8 +7,11 @@ LABEL stage="build-local-testing"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (or yarn.lock)
|
||||
# Copy root package.json/lockfile plus workspace manifests so npm can
|
||||
# resolve the @ttrpg/shared and server workspace links during install.
|
||||
COPY package*.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# Install dependencies using the lock file for consistency
|
||||
RUN npm install
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# TTRPG Initiative Tracker (v0.2.5)
|
||||
# TTRPG Initiative Tracker (v0.4)
|
||||
|
||||

|
||||
|
||||
@@ -12,6 +12,8 @@ A web-based application designed to help Dungeon Masters (DMs) manage and displa
|
||||
|
||||
Have you tried it? Got feedback or questions? Discuss here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16)
|
||||
|
||||
As of v0.4, the app can optionally run against a self-hosted backend (Node/Express/ws/SQLite, shipped as a single Docker container) instead of Firebase — useful if you'd rather not depend on a Google account. Firebase remains the default; see [Self-Hosted Backend (Optional)](#self-hosted-backend-optional) below.
|
||||
|
||||
## Features
|
||||
|
||||

|
||||
@@ -59,7 +61,7 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
|
||||
* A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor.
|
||||
* A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active.
|
||||
* **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page.
|
||||
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display.
|
||||
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display (or a self-hosted WebSocket backend, see below).
|
||||
* **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order.
|
||||
* **Responsive Design:** Styled with Tailwind CSS.
|
||||
* **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat.
|
||||
@@ -68,8 +70,9 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
|
||||
|
||||
* **Frontend:** React
|
||||
* **Styling:** Tailwind CSS
|
||||
* **Backend/Database:** Firebase Firestore (for real-time data)
|
||||
* **Authentication:** Firebase Anonymous Authentication
|
||||
* **Backend/Database:** Firebase Firestore (default), or a self-hosted Node/Express + `ws` + SQLite (`better-sqlite3`) backend behind Caddy (optional, see [Self-Hosted Backend](#self-hosted-backend-optional))
|
||||
* **Authentication:** Firebase Anonymous Authentication (Firebase mode only; the self-hosted backend has no auth layer — intended for trusted/local networks)
|
||||
* **Shared logic:** Turn-order state machine (`shared/`) is framework-agnostic and used by both the frontend and the self-hosted backend, covered by a Jest test suite
|
||||
|
||||
## App Usage Overview
|
||||
|
||||
@@ -128,11 +131,13 @@ This flow allows the DM to prepare and run encounters efficiently while providin
|
||||
### Prerequisites
|
||||
|
||||
* **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/).
|
||||
* **Firebase Project:** You'll need a Firebase project with:
|
||||
* **Firebase Project** (only if using the default Firebase storage mode): You'll need a Firebase project with:
|
||||
* Firestore Database created and initialized.
|
||||
* Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab.
|
||||
* **Git:** For cloning the repository.
|
||||
|
||||
The project is an npm workspaces monorepo (`server/`, `shared/`, plus the CRA frontend at the root) — a single `npm install` at the repo root installs everything.
|
||||
|
||||
### Local Development Setup (using npm)
|
||||
|
||||
1. **Clone the Repository:**
|
||||
@@ -190,7 +195,14 @@ This flow allows the DM to prepare and run encounters efficiently while providin
|
||||
|
||||
### Deployment with Docker
|
||||
|
||||
This project includes a `Dockerfile` to containerize the application for deployment. It uses a multi-stage build:
|
||||
There are two Docker paths, depending on which storage mode you want:
|
||||
|
||||
* **Firebase mode (default):** the root `Dockerfile` builds a static frontend-only image, described below.
|
||||
* **Self-hosted mode:** the `docker/` directory builds a single container running the Node backend + SQLite + the frontend behind Caddy, with no Firebase dependency. See [Self-Hosted Backend (Optional)](#self-hosted-backend-optional).
|
||||
|
||||
#### Firebase-mode image (root `Dockerfile`)
|
||||
|
||||
This project includes a `Dockerfile` to containerize the Firebase-backed application for deployment. It uses a multi-stage build:
|
||||
* **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`.
|
||||
* **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage.
|
||||
|
||||
@@ -225,6 +237,30 @@ This project includes a `Dockerfile` to containerize the application for deploym
|
||||
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
|
||||
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
|
||||
|
||||
### Self-Hosted Backend (Optional)
|
||||
|
||||
If you'd rather not depend on Firebase/Google, the app can run entirely self-hosted: a Node/Express + `ws` backend backed by SQLite, with the frontend talking to it instead of Firestore. Intended for trusted/local networks (no auth layer yet).
|
||||
|
||||
**Quickest path — Docker Compose (single container: Caddy + Node + SQLite):**
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
This serves the app at `http://localhost:8080` (override with `PORT`), proxies `/api` and `/ws` to the backend, and persists the SQLite database in a named Docker volume. Override the Firestore-style app-id namespace with `TRACKER_APP_ID` if needed.
|
||||
|
||||
**Local dev (backend + frontend separately):**
|
||||
```bash
|
||||
npm install # installs root, server/, and shared/ workspaces
|
||||
npm run server:dev # starts backend on :4001 (SQLite at server/data/tracker.sqlite)
|
||||
|
||||
# in another terminal:
|
||||
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 \
|
||||
npm start
|
||||
```
|
||||
|
||||
See `docs/DEVELOPMENT.md` for the full architecture (generic KV doc store, storage adapter interface, test layers) and `docs/REWORK_PLAN.md` for the design rationale.
|
||||
|
||||
## Project Structure
|
||||
|
||||
<pre>
|
||||
@@ -233,21 +269,37 @@ ttrpg-initiative-tracker/
|
||||
├── .env.example # Example environment variables
|
||||
├── .env.local # Local environment variables (ignored by Git)
|
||||
├── .gitignore # Specifies intentionally untracked files that Git should ignore
|
||||
├── Dockerfile # Instructions to build the Docker image
|
||||
├── Dockerfile # Firebase-mode image (frontend-only, served by nginx)
|
||||
├── package.json # Workspaces root: frontend + server + shared
|
||||
├── package-lock.json # Records exact versions of dependencies
|
||||
├── package.json # Project metadata and dependencies
|
||||
├── postcss.config.js # PostCSS configuration (for Tailwind CSS)
|
||||
├── tailwind.config.js # Tailwind CSS configuration
|
||||
├── docker/ # Self-hosted deployment: Dockerfile, docker-compose.yml, Caddyfile
|
||||
├── docs/ # Rework plan, dev setup, testing, encounter-builder guide, glossary
|
||||
├── scripts/ # Manual demo/ops tooling (e.g. replay-combat.js)
|
||||
├── tests/ # Exploratory audit tooling (not part of the automated suite)
|
||||
├── public/ # Static assets
|
||||
│ ├── favicon.ico
|
||||
│ ├── index.html # Main HTML template
|
||||
│ └── manifest.json
|
||||
└── src/ # React application source code
|
||||
├── App.js # Main application component
|
||||
├── index.css # Global styles (including Tailwind directives)
|
||||
└── index.js # React entry point
|
||||
├── src/ # React frontend (Create React App)
|
||||
│ ├── App.js # Main application component
|
||||
│ ├── storage/ # Storage adapter layer: firebase / ws / memory
|
||||
│ ├── index.css # Global styles (including Tailwind directives)
|
||||
│ └── index.js # React entry point
|
||||
├── server/ # Self-hosted backend: Express + ws + SQLite (better-sqlite3)
|
||||
└── shared/ # Framework-agnostic turn-order logic, used by frontend + server
|
||||
</pre>
|
||||
|
||||
## Further Reading
|
||||
|
||||
* `docs/DEVELOPMENT.md` — setup, running the backend, test suites
|
||||
* `docs/REWORK_PLAN.md` — why/how the self-hosted backend was added
|
||||
* `docs/TESTING.md` — test layers and how to run them
|
||||
* `docs/ENCOUNTER_BUILDER.md` — entity model and storage paths behind the DM interface
|
||||
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
|
||||
* `TODO.md` — known bugs and backlog
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
|
||||
|
||||
@@ -13,6 +13,25 @@ TODO: combat.scenario doesnt do 100 rounds it does 100 turns. recover from kill
|
||||
|
||||
|
||||
|
||||
TODO: death saves wrong
|
||||
|
||||
TODO: custom condition field
|
||||
|
||||
|
||||
|
||||
|
||||
server = YOUR SQLite server. server/db.js = better-sqlite3. src/storage/server.js = client that talks to it (REST +
|
||||
websocket). Name bad. Should be "sqlite-adapter" or "server". You right.
|
||||
|
||||
memory = in-process JS Map. Useless? Mostly yes for prod. Value: fast contract tests, no server spinup. If
|
||||
contract test runs vs firebase-mock + live server backend.
|
||||
|
||||
Memory = optional convenience. === extra complication.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### feat - add all characters to participants list
|
||||
|
||||
@@ -117,7 +136,7 @@ TODO: combat.scenario doesnt do 100 rounds it does 100 turns. recover from kill
|
||||
`src/__mocks__/firebase/firestore.js` setDoc records call, IGNORES opts
|
||||
(no actual merge). Mock must simulate firebase merge semantics for test
|
||||
to pass. Fix = mock setDoc: if opts.merge, MOCK_DB.merge(path,data) else
|
||||
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, ws adapter
|
||||
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, server adapter
|
||||
uses PATCH). Decide which.
|
||||
- Test: render App + DisplayView, toggle hide-HP, assert display still shows
|
||||
encounter (not paused).
|
||||
@@ -147,8 +166,8 @@ TODO: combat.scenario doesnt do 100 rounds it does 100 turns. recover from kill
|
||||
- `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).
|
||||
### BUG-8: server adapter has no reconnect
|
||||
- Test: `server/tests/server-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.
|
||||
@@ -196,12 +215,37 @@ TODO: combat.scenario doesnt do 100 rounds it does 100 turns. recover from kill
|
||||
- Related: FEAT-3 (initiative first-class field).
|
||||
|
||||
## Pipeline (bugs only --- milestones live in REWORK_PLAN.md)
|
||||
|
||||
### BUG-16: subscribeCollection hook drops queryConstraints
|
||||
- SS `useFirestoreCollection` (App.js ~L233) calls
|
||||
`storage.subscribeCollection(collectionPath, cb)` --- NO constraints passed.
|
||||
- main (L251-253): `const q = query(collection(db,collectionPath), ...queryConstraints); onSnapshot(q, ...)`.
|
||||
- Effect: `LOG_QUERY = [orderBy('timestamp','desc'), limit(500)]` IGNORED. Logs
|
||||
unsorted + unbounded. Other collections unaffected (no constraints passed
|
||||
elsewhere) but interface broken.
|
||||
- firebase adapter `subscribeCollection(path, cb, queryConstraints=[])` HAS the
|
||||
param, hook just doesn't forward it.
|
||||
- Fix: hook pass `queryConstraints` as 3rd arg. RED first.
|
||||
|
||||
### BUG-17: dead SDK imports in App.js
|
||||
- L5 imports `doc,setDoc,collection,onSnapshot,writeBatch,query,getDocs` from
|
||||
`./storage` but App never calls them raw (all via `storage.*`). Only used as
|
||||
re-export passthrough. `getFirestore`,`initializeApp` still used in init flow.
|
||||
- Noise, not behavioral. Cleanup: trim dead imports OR keep for adapter test
|
||||
instrumentation. Decide after audit.
|
||||
|
||||
### BUG-18: stale comments reference deleted memory adapter + phantom Phase B
|
||||
- `src/storage/firebase.js` header had Phase A/B comment (DELETED this session).
|
||||
- `src/storage/index.js` L3: "per-group refactor pending" --- stale.
|
||||
- `src/tests/StorageEsm.test.js`, `storage.factory.test.js` referenced memory
|
||||
(FIXED this session). Re-audit for stragglers.
|
||||
|
||||
- [ ] 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)
|
||||
- [x] BUG-8: server 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)
|
||||
|
||||
+6
-6
@@ -65,12 +65,12 @@ 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)
|
||||
### Frontend (dev server, server mode)
|
||||
|
||||
```bash
|
||||
REACT_APP_STORAGE=ws \
|
||||
REACT_APP_STORAGE=server \
|
||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
|
||||
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
|
||||
BROWSER=none PORT=3999 \
|
||||
npm start
|
||||
```
|
||||
@@ -225,12 +225,12 @@ App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/..
|
||||
|
||||
`getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`).
|
||||
- `firebase` → real SDK init
|
||||
- `ws`/`memory` → stub auth + db sentinel, route via `storage.*` adapter
|
||||
- `server` → stub auth + db sentinel, route via `storage.*` adapter
|
||||
|
||||
## Test layers
|
||||
|
||||
- **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.
|
||||
- **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises server adapter.
|
||||
- **Layer 2**: server adapter vs live backend. Proves translation + path identity.
|
||||
|
||||
Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers).
|
||||
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ Round 2: turn 1 (Fighter) → ... → turn 8 (Merchant) [round counter +=1 at
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **Adapter** | `src/storage/{firebase,ws,memory}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
|
||||
| **Adapter** | `src/storage/{firebase,server}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
|
||||
| **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. |
|
||||
| **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. |
|
||||
| **Layer 2 test** | server adapter vs live backend. Proves translation + path identity. |
|
||||
|
||||
+10
-10
@@ -61,7 +61,7 @@ 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 |
|
||||
| `server.js` | `STORAGE=server` — our fork, talks to backend | Yes — against running backend |
|
||||
| `memory.js` | test-only, in-process | Yes — fast, deterministic |
|
||||
|
||||
**Frontend interface contract** (all three implement):
|
||||
@@ -82,7 +82,7 @@ Memory impl: in-memory Map + EventEmitter, for tests (M3).
|
||||
storage/
|
||||
index.js # factory: pick impl from STORAGE env
|
||||
firebase.js # extracted from current App.js (verbatim)
|
||||
ws.js # NEW — talks to backend
|
||||
server.js # talks to backend (was ws.js)
|
||||
memory.js # NEW — test only
|
||||
contract.js # interface spec (runStorageContract)
|
||||
tests/ # frontend tests
|
||||
@@ -116,7 +116,7 @@ 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 |
|
||||
| 1 | build backend (Node+Express+WebSocket+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 |
|
||||
@@ -135,7 +135,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
|
||||
- **Upstream-PRable:** n/a (fork infra)
|
||||
|
||||
### Milestone 1 — Build backend ✅
|
||||
- `server/`: Express + ws + better-sqlite3.
|
||||
- `server/`: Express + WebSocket + better-sqlite3.
|
||||
- Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path.
|
||||
- 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.
|
||||
@@ -144,10 +144,10 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
|
||||
### 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/server.js` per interface, talking to backend. Generic KV ops, subscribes via WebSocket.
|
||||
- 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`.
|
||||
- App runs against backend with `STORAGE=server`.
|
||||
- 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 = ❌.
|
||||
@@ -155,7 +155,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
|
||||
### 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).
|
||||
- Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (server adapter vs live backend, proves translation).
|
||||
- Iterate until confident: baseline solid, regressions impossible to silently slip.
|
||||
- **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE.
|
||||
- **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape).
|
||||
@@ -173,7 +173,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
|
||||
- Single container: caddy (front, static + proxy) + node backend (internal :4001).
|
||||
- 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/Caddyfile` — proxy /api + /ws (WebSocket path) 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.
|
||||
@@ -214,7 +214,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
|
||||
|
||||
### Manual smoke via config flags
|
||||
- `STORAGE=firebase` → current behavior (friend's path, upstream default).
|
||||
- `STORAGE=ws` → our path, local backend.
|
||||
- `STORAGE=server` → our path, local backend.
|
||||
- docker-compose profiles mirror the above.
|
||||
|
||||
### Accepted test gap
|
||||
@@ -261,6 +261,6 @@ Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally
|
||||
|
||||
- M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
|
||||
- Backend live: port 4001, db `./data/tracker.sqlite`
|
||||
- Frontend: port 3999 with `REACT_APP_STORAGE=ws`
|
||||
- Frontend: port 3999 with `REACT_APP_STORAGE=server`
|
||||
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
|
||||
- Next milestones: M5 docker-compose. Undo moved to TODO backlog.
|
||||
|
||||
+5
-6
@@ -194,11 +194,11 @@ curl http://127.0.0.1:4001/health # → {"ok":true}
|
||||
|
||||
Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
|
||||
|
||||
### Frontend (ws mode)
|
||||
### Frontend (server mode)
|
||||
```bash
|
||||
REACT_APP_STORAGE=ws \
|
||||
REACT_APP_STORAGE=server \
|
||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
|
||||
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
|
||||
BROWSER=none PORT=3999 \
|
||||
npm start
|
||||
```
|
||||
@@ -210,10 +210,9 @@ Firebase mode (default): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.e
|
||||
|
||||
`STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`:
|
||||
- `firebase` (default) → real SDK
|
||||
- `ws` → backend (docker/prod)
|
||||
- `memory` → in-process (test seed)
|
||||
- `server` → backend (docker/prod)
|
||||
|
||||
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs memory/ws/firebase.
|
||||
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs server/firebase.
|
||||
|
||||
## Known RED / backlog
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"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"
|
||||
"test:all": "CI=true react-scripts test --watchAll=false && npm run shared:test && npm run server:test"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Manual demo tool. NOT test.
|
||||
|
||||
## replay-combat.js
|
||||
|
||||
Live backend demo. Drives full combat via ws adapter (same contract as App).
|
||||
Live backend demo. Drives full combat via server adapter (same contract as App).
|
||||
Player display live-updates. Watch UI react to state changes.
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start local dev stack: node backend (sqlite) + react frontend, ws storage mode.
|
||||
# Start local dev stack: node backend (sqlite) + react frontend, server storage mode.
|
||||
# Usage: ./scripts/dev-start.sh
|
||||
# Stop: ./scripts/dev-stop.sh
|
||||
set -euo pipefail
|
||||
@@ -25,12 +25,12 @@ else
|
||||
echo "backend already on :4001"
|
||||
fi
|
||||
|
||||
# frontend: ws storage, :3999
|
||||
# frontend: server storage, :3999
|
||||
if ! lsof -ti :3999 >/dev/null 2>&1; then
|
||||
echo "starting frontend :3999..."
|
||||
REACT_APP_STORAGE=ws \
|
||||
REACT_APP_STORAGE=server \
|
||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
|
||||
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
|
||||
BROWSER=none PORT=3999 \
|
||||
nohup npm start > tmp/fe.log 2>&1 &
|
||||
echo $! > tmp/fe.pid
|
||||
|
||||
@@ -30,10 +30,10 @@ const {
|
||||
toggleParticipantActive, applyHpChange, deathSave,
|
||||
toggleCondition, reorderParticipants, endEncounter,
|
||||
} = shared;
|
||||
const { createWsStorage } = require('../src/storage/ws');
|
||||
const { createServerStorage } = require('../src/storage/server');
|
||||
|
||||
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||
const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||
const DELAY = parseInt(process.argv[3], 10) || 200;
|
||||
|
||||
@@ -51,7 +51,7 @@ const getPath = {
|
||||
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 });
|
||||
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
|
||||
|
||||
// Mirror App.js CONDITIONS so we exercise all of them.
|
||||
const CONDITIONS = [
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
// 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.
|
||||
// the adapter (src/storage/server.js) is a thin passthrough, app logic unchanged.
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// 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.
|
||||
// Adapter (src/storage/server.js) = passthrough, no shape translation.
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Layer 2 test: exercise ws.js storage adapter against a LIVE backend.
|
||||
// Layer 2 test: exercise server.js storage adapter against a LIVE backend.
|
||||
// Complements Layer 1 (App + firebase mock) which proves App call shape but
|
||||
// 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
|
||||
// createServerStorage 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';
|
||||
@@ -11,7 +11,7 @@
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { createServer } = require('../index');
|
||||
const { createWsStorage } = require('../../src/storage/ws');
|
||||
const { createServerStorage } = require('../../src/storage/server');
|
||||
const { runStorageContract } = require('../../src/storage/contract');
|
||||
|
||||
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
|
||||
@@ -26,9 +26,9 @@ async function makeStorage() {
|
||||
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 });
|
||||
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
|
||||
storage.dispose = (done) => handle.close(done);
|
||||
return storage;
|
||||
}
|
||||
|
||||
runStorageContract('ws (live backend)', makeStorage);
|
||||
runStorageContract('server (live backend)', makeStorage);
|
||||
@@ -8,7 +8,7 @@
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { createServer } = require('../index');
|
||||
const { createWsStorage } = require('../../src/storage/ws');
|
||||
const { createServerStorage } = require('../../src/storage/server');
|
||||
|
||||
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
@@ -22,7 +22,7 @@ async function makeStorage() {
|
||||
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 });
|
||||
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
|
||||
storage.dispose = (done) => handle.close(done);
|
||||
return storage;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ describe('BUG-8: ws adapter reconnect after drop', () => {
|
||||
await flush(1000);
|
||||
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last).toEqual({ name: 'V2' });
|
||||
expect(last).toEqual({ id: 'a', name: 'V2' });
|
||||
} finally {
|
||||
await new Promise(r => storage.dispose(r));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// STATIC GUARD: no `.sort(` introduced in shared/turn.js outside the ONE
|
||||
// allowed function (sortParticipantsByInitiative, used by startEncounter to
|
||||
// freeze the list once). Slot-not-sort design (docs/INITIATIVE_ORDERING.md):
|
||||
// mutations = insert/move, never wholesale re-sort. A stray `.sort(` after
|
||||
// start destroys drag tie-break order.
|
||||
//
|
||||
// This test errs the moment someone adds `.sort(` anywhere but the allowlist.
|
||||
// Maintenance: add a function to ALLOWED only if it runs at startEncounter
|
||||
// (one-time freeze), NOT in add/update/reorder/nextTurn paths.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
|
||||
|
||||
// Functions permitted to call .sort(. Listed so intent is explicit + reviewed.
|
||||
const ALLOWED_SORT_FUNCTIONS = new Set([
|
||||
'sortParticipantsByInitiative',
|
||||
]);
|
||||
|
||||
// Collect top-level function bodies by brace counting.
|
||||
// Matches `function NAME(` decls AND `const NAME = ... =>` arrow fns.
|
||||
// Returns [{ name, body }].
|
||||
function collectBody(src, fromIdx) {
|
||||
let i = fromIdx;
|
||||
while (i < src.length && src[i] !== '{') i++;
|
||||
let depth = 0;
|
||||
const start = i;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) break; }
|
||||
}
|
||||
return src.slice(start, i + 1);
|
||||
}
|
||||
|
||||
function extractFunctions(src) {
|
||||
const out = [];
|
||||
const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g;
|
||||
const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g;
|
||||
let m;
|
||||
while ((m = fnRe.exec(src)) !== null) {
|
||||
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
|
||||
}
|
||||
while ((m = arrowRe.exec(src)) !== null) {
|
||||
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('STATIC: no .sort( outside allowlist (slot-not-sort design)', () => {
|
||||
test('every .sort( call lives in an allowed function', () => {
|
||||
const fns = extractFunctions(SRC);
|
||||
const offenders = [];
|
||||
for (const { name, body } of fns) {
|
||||
if (!ALLOWED_SORT_FUNCTIONS.has(name) && /\.sort\(/.test(body)) {
|
||||
offenders.push(name);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('allowed sort function is declared (no silent allowlist drift)', () => {
|
||||
const declared = new Set(extractFunctions(SRC).map(f => f.name));
|
||||
for (const name of ALLOWED_SORT_FUNCTIONS) {
|
||||
expect(declared.has(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// SLOT-NOT-SORT invariant: addParticipant + updateParticipant must PRESERVE
|
||||
// manually-dragged tie order (same-initiative participants).
|
||||
//
|
||||
// Design doc (docs/INITIATIVE_ORDERING.md):
|
||||
// "Re-slotting on add/edit must preserve drag-established tie order."
|
||||
// "Tie-break = original add order. Later additions slot AFTER existing
|
||||
// same-init participants. Stable insertion."
|
||||
//
|
||||
// Current code uses sortParticipantsByInitiative (stable sort, tie-break =
|
||||
// original array index). That is NOT stable insertion: it re-sorts the whole
|
||||
// list, destroying drag order when a drag moved a same-init pair.
|
||||
//
|
||||
// RED now: drag tie order survives neither add nor edit.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant,
|
||||
startEncounter, addParticipant, updateParticipant, reorderParticipants,
|
||||
} = shared;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||
}
|
||||
function enc(ps, extra = {}) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||
}
|
||||
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
||||
|
||||
describe('SLOT-NOT-SORT: drag tie order survives add/edit', () => {
|
||||
test('addParticipant preserves existing drag tie order', () => {
|
||||
// Two same-init participants. DM drags b BEFORE a (tie override).
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // drag b ahead of a → [b,a]
|
||||
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
|
||||
|
||||
// Add unrelated participant (different init). Tie order [b,a] MUST survive.
|
||||
e = apply(e, addParticipant(e, p('c', 5)));
|
||||
const ids = e.participants.map(x => x.id);
|
||||
// c slots last (init 5 < 10). b,a tie order preserved.
|
||||
expect(ids).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('addParticipant with same init as dragged pair slots AFTER tie group', () => {
|
||||
// DM drags b before a (both init 10). Then add d also init 10.
|
||||
// d must slot AFTER existing same-init pair (stable insertion), not re-sort.
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a]
|
||||
e = apply(e, addParticipant(e, p('d', 10))); // init 10, after tie group
|
||||
const ids = e.participants.map(x => x.id);
|
||||
expect(ids).toEqual(['b', 'a', 'd']);
|
||||
});
|
||||
|
||||
test('updateParticipant preserves drag tie order on unrelated field edit', () => {
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a]
|
||||
// Edit a's name (not initiative). Tie order MUST survive.
|
||||
e = apply(e, updateParticipant(e, 'a', { name: 'A2' }));
|
||||
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
test('updateParticipant init change slots, preserves OTHER tie group order', () => {
|
||||
// Two tie groups: [a,b] init 10, [c,d] init 5. DM drags b before a.
|
||||
let e = enc([p('a', 10), p('b', 10), p('c', 5), p('d', 5)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c,d]
|
||||
// Edit c's initiative to 10 — slots into top group. [c] order within its
|
||||
// own old group irrelevant (it leaves that group). But [b,a] tie MUST stay.
|
||||
e = apply(e, updateParticipant(e, 'c', { initiative: 10 }));
|
||||
const topTwo = e.participants.filter(x => x.initiative === 10).map(x => x.id);
|
||||
// b before a preserved. c joins the 10-group (exact slot less critical
|
||||
// than b,a order surviving).
|
||||
expect(topTwo.indexOf('b')).toBeLessThan(topTwo.indexOf('a'));
|
||||
});
|
||||
});
|
||||
+68
-16
@@ -31,9 +31,18 @@ const formatInitMod = (mod) => {
|
||||
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.
|
||||
// SLOT, NEVER SORT. 1-list model (docs/INITIATIVE_ORDERING.md).
|
||||
//
|
||||
// `sortParticipantsByInitiative` exists for ONE call site: startEncounter
|
||||
// (freeze list once by init). After that, mutations = insert/move ops that
|
||||
// PRESERVE existing array order. No wholesale re-sort ever — destroys drag
|
||||
// tie-break order.
|
||||
//
|
||||
// slotIndexForInit: pure insert position. Scans existing list, returns index
|
||||
// of first participant with init STRICTLY LESS than target. New participant
|
||||
// splices there. Same-init participants stay ahead (stable). Existing tie
|
||||
// order (incl. drag-established) untouched because scan reads current array,
|
||||
// not original add-order.
|
||||
const sortParticipantsByInitiative = (participants, originalOrder) => {
|
||||
return [...participants].sort((a, b) => {
|
||||
if (a.initiative === b.initiative) {
|
||||
@@ -45,6 +54,17 @@ const sortParticipantsByInitiative = (participants, originalOrder) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Find splice index for a single participant by initiative. List assumed
|
||||
// already initiative-descending (startEncounter freezes it; add/edit maintain).
|
||||
// Returns position to insert so list stays descending, ties go AFTER existing
|
||||
// same-init (stable insertion). Current array order = source of truth.
|
||||
function slotIndexForInit(list, init) {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (init > list[i].initiative) return i;
|
||||
}
|
||||
return list.length;
|
||||
}
|
||||
|
||||
// 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id).
|
||||
// Call after any participants[] mutation. Returns turnOrderIds patch.
|
||||
const syncTurnOrder = (participants) => ({
|
||||
@@ -311,12 +331,12 @@ 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.`);
|
||||
}
|
||||
// FEAT-3: reslot by initiative on add (stable sort, tie-break original index).
|
||||
// Pre-combat: list reflects init order immediately. Post-combat: slots into running order.
|
||||
const updatedParticipants = sortParticipantsByInitiative(
|
||||
[...(encounter.participants || []), participant],
|
||||
encounter.participants || []
|
||||
);
|
||||
// SLOT (not sort): insert by initiative into current list. Preserves
|
||||
// existing array order incl. drag-established tie order.
|
||||
const base = [...(encounter.participants || [])];
|
||||
const idx = slotIndexForInit(base, participant.initiative);
|
||||
base.splice(idx, 0, participant);
|
||||
const updatedParticipants = base;
|
||||
return {
|
||||
patch: { participants: updatedParticipants, ...syncTurnOrder(updatedParticipants) },
|
||||
log: {
|
||||
@@ -339,13 +359,21 @@ function addParticipants(encounter, newParticipants) {
|
||||
|
||||
// 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
|
||||
);
|
||||
// FEAT-3: reslot by initiative so any init change reflects in list order
|
||||
// immediately (stable sort, tie-break = original array index).
|
||||
const reslotted = sortParticipantsByInitiative(updatedParticipants, encounter.participants || []);
|
||||
return { patch: { participants: reslotted, ...syncTurnOrder(reslotted) }, log: null };
|
||||
const existing = encounter.participants || [];
|
||||
const target = existing.find(p => p.id === participantId);
|
||||
if (!target) throw new Error(`Participant "${participantId}" not found.`);
|
||||
const merged = { ...target, ...updatedData };
|
||||
// SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no
|
||||
// position change (design doc). Re-slots on unrelated edits shuffle the
|
||||
// list mid-round → rotation dupes.
|
||||
if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) {
|
||||
const sameSlot = existing.map(p => p.id === participantId ? merged : p);
|
||||
return { patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) }, log: null };
|
||||
}
|
||||
const without = existing.filter(p => p.id !== participantId);
|
||||
const idx = slotIndexForInit(without, merged.initiative);
|
||||
without.splice(idx, 0, merged);
|
||||
return { patch: { participants: without, ...syncTurnOrder(without) }, log: null };
|
||||
}
|
||||
|
||||
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
|
||||
@@ -552,6 +580,27 @@ function endEncounter(encounter) {
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Display lifecycle (activeDisplay/status doc). Pure patches, same shape as
|
||||
// App's inline storage.updateDoc(getPath.activeDisplay(), ...). Single source.
|
||||
// Callers persist patch to the activeDisplay/status doc.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// ACTIVATE_DISPLAY — start combat: point player display at this encounter.
|
||||
function activateDisplay({ campaignId, encounterId }) {
|
||||
return { patch: { activeCampaignId: campaignId, activeEncounterId: encounterId } };
|
||||
}
|
||||
|
||||
// CLEAR_DISPLAY — end combat: player display shows nothing.
|
||||
function clearDisplay() {
|
||||
return { patch: { activeCampaignId: null, activeEncounterId: null } };
|
||||
}
|
||||
|
||||
// TOGGLE_HIDE_PLAYER_HP — flip player-HP visibility on display.
|
||||
function toggleHidePlayerHp(currentValue) {
|
||||
return { patch: { hidePlayerHp: !currentValue } };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MAX_HP,
|
||||
DEFAULT_INIT_MOD,
|
||||
@@ -579,4 +628,7 @@ module.exports = {
|
||||
toggleCondition,
|
||||
reorderParticipants,
|
||||
endEncounter,
|
||||
activateDisplay,
|
||||
clearDisplay,
|
||||
toggleHidePlayerHp,
|
||||
};
|
||||
|
||||
+28
-28
@@ -1,8 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
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 { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, orderBy, limit, getStorage, getStorageMode } from './storage';
|
||||
import {
|
||||
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
|
||||
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
|
||||
@@ -46,7 +44,7 @@ if (typeof document !== 'undefined') {
|
||||
// CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
const APP_VERSION = 'v0.3';
|
||||
const APP_VERSION = 'v0.4';
|
||||
const {
|
||||
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
|
||||
generateId, rollD20, formatInitMod,
|
||||
@@ -59,6 +57,7 @@ const {
|
||||
deathSave: combatDeathSave,
|
||||
toggleCondition: combatToggleCondition,
|
||||
reorderParticipants,
|
||||
activateDisplay, clearDisplay, toggleHidePlayerHp: combatToggleHidePlayerHp,
|
||||
} = shared;
|
||||
const ROLL_DISPLAY_DURATION = 5000;
|
||||
|
||||
@@ -204,6 +203,11 @@ function useFirestoreDocument(docPath) {
|
||||
const unsubscribe = storage.subscribeDoc(docPath, (doc) => {
|
||||
setData(doc);
|
||||
setIsLoading(false);
|
||||
}, (err) => {
|
||||
console.error(`Error fetching document ${docPath}:`, err);
|
||||
setError(err.message || "Failed to fetch document.");
|
||||
setIsLoading(false);
|
||||
setData(null);
|
||||
});
|
||||
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
|
||||
}, [docPath]);
|
||||
@@ -232,6 +236,11 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) {
|
||||
const unsubscribe = storage.subscribeCollection(collectionPath, (items) => {
|
||||
setData(items);
|
||||
setIsLoading(false);
|
||||
}, queryConstraints, (err) => {
|
||||
console.error(`Error fetching collection ${collectionPath}:`, err);
|
||||
setError(err.message || "Failed to fetch collection.");
|
||||
setIsLoading(false);
|
||||
setData([]);
|
||||
});
|
||||
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
|
||||
// queryString, not array ref
|
||||
@@ -1495,7 +1504,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
const handleToggleHidePlayerHp = async () => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await storage.updateDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp });
|
||||
await storage.setDoc(getPath.activeDisplay(), combatToggleHidePlayerHp(hidePlayerHp).patch, { merge: true });
|
||||
} catch (err) {
|
||||
console.error("Error toggling hidePlayerHp:", err);
|
||||
}
|
||||
@@ -1504,7 +1513,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
const handleToggleHideNpcHp = async () => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await storage.updateDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp });
|
||||
await storage.setDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp }, { merge: true });
|
||||
} catch (err) {
|
||||
console.error("Error toggling hideNpcHp:", err);
|
||||
}
|
||||
@@ -1519,10 +1528,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
try {
|
||||
const { patch, log } = startEncounter(encounter);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
await storage.updateDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: campaignId,
|
||||
activeEncounterId: encounter.id
|
||||
});
|
||||
await storage.setDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch, { merge: true });
|
||||
logAction(log.message, { encounterName: encounter.name }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
@@ -1575,10 +1581,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||
try {
|
||||
const { patch, log } = endEncounter(encounter);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
await storage.updateDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null
|
||||
});
|
||||
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
|
||||
logAction(log.message, { encounterName: encounter.name }, {
|
||||
encounterPath,
|
||||
updates: log.undo,
|
||||
@@ -1774,10 +1777,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
}
|
||||
|
||||
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
|
||||
await storage.updateDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null
|
||||
});
|
||||
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error deleting encounter:", err);
|
||||
@@ -1796,12 +1796,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
|
||||
|
||||
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
|
||||
await storage.updateDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null,
|
||||
});
|
||||
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
|
||||
} else {
|
||||
await storage.updateDoc(getPath.activeDisplay(), {
|
||||
await storage.setDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: campaignId,
|
||||
activeEncounterId: encounterId,
|
||||
});
|
||||
@@ -2042,10 +2039,7 @@ function AdminView({ userId }) {
|
||||
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
|
||||
|
||||
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
|
||||
await storage.updateDoc(getPath.activeDisplay(), {
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null
|
||||
});
|
||||
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error deleting campaign:", err);
|
||||
@@ -2279,7 +2273,8 @@ function DisplayView() {
|
||||
getPath.campaign(activeCampaignId),
|
||||
(camp) => {
|
||||
setCampaignBackgroundUrl((camp && camp.playerDisplayBackgroundUrl) || '');
|
||||
}
|
||||
},
|
||||
(err) => console.error("Error fetching campaign background:", err)
|
||||
);
|
||||
|
||||
unsubscribeEncounter = storage.subscribeDoc(
|
||||
@@ -2292,6 +2287,11 @@ function DisplayView() {
|
||||
setEncounterError("Active encounter data not found.");
|
||||
}
|
||||
setIsLoadingEncounter(false);
|
||||
},
|
||||
(err) => {
|
||||
console.error("Error fetching active encounter details:", err);
|
||||
setEncounterError("Error loading active encounter data.");
|
||||
setIsLoadingEncounter(false);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,11 @@ export function limit(n) { return { __type: 'limit', n }; }
|
||||
// writes
|
||||
export async function setDoc(docRef, data, opts) {
|
||||
recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null });
|
||||
if (opts && opts.merge) {
|
||||
MOCK_DB.merge(docRef.path, clone(data));
|
||||
} else {
|
||||
MOCK_DB.set(docRef.path, clone(data));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export async function updateDoc(docRef, patch) {
|
||||
@@ -70,6 +74,7 @@ export async function getDocs(collRefOrQuery) {
|
||||
// realtime — emit from mock DB, capture unsub
|
||||
export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||
const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
|
||||
const constraints = refOrQuery.constraints || [];
|
||||
// fire immediately with current state
|
||||
const emit = () => {
|
||||
if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) {
|
||||
@@ -80,7 +85,8 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||
data: () => data,
|
||||
});
|
||||
} else {
|
||||
const docs = MOCK_DB.collection(path);
|
||||
let docs = MOCK_DB.collection(path);
|
||||
docs = applyConstraints(docs, constraints);
|
||||
onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) });
|
||||
}
|
||||
};
|
||||
@@ -90,6 +96,27 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||
return unsub;
|
||||
}
|
||||
|
||||
// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs.
|
||||
// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit
|
||||
// supported (App's LOG_QUERY uses exactly these).
|
||||
function applyConstraints(docs, constraints) {
|
||||
let out = [...docs];
|
||||
for (const c of constraints) {
|
||||
if (c.__type === 'orderBy') {
|
||||
out.sort((a, b) => {
|
||||
const av = a.data[c.field];
|
||||
const bv = b.data[c.field];
|
||||
if (av === bv) return 0;
|
||||
const cmp = av > bv ? 1 : -1;
|
||||
return c.dir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
} else if (c.__type === 'limit') {
|
||||
out = out.slice(0, c.n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clone(v) {
|
||||
if (v === null || v === undefined) return v;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
|
||||
+83
-32
@@ -27,10 +27,10 @@ function runStorageContract(name, factory) {
|
||||
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
|
||||
|
||||
describe('getDoc / setDoc', () => {
|
||||
test('setDoc then getDoc returns the doc', async () => {
|
||||
test('setDoc then getDoc returns the doc (with id)', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ name: 'Alpha' });
|
||||
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('getDoc on missing path returns null', async () => {
|
||||
@@ -42,7 +42,15 @@ function runStorageContract(name, factory) {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
|
||||
await storage.setDoc('campaigns/a', { name: 'Beta' });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ name: 'Beta' });
|
||||
expect(doc).toEqual({ id: 'a', name: 'Beta' });
|
||||
});
|
||||
|
||||
// main L1624: setDoc(path, data, {merge:true}) — merge flag.
|
||||
test('setDoc with {merge:true} merges into existing doc', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
|
||||
await storage.setDoc('campaigns/a', { players: [1] }, { merge: true });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,13 +59,13 @@ function runStorageContract(name, factory) {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] });
|
||||
await storage.updateDoc('campaigns/a', { players: [1, 2] });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ name: 'Alpha', players: [1, 2] });
|
||||
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1, 2] });
|
||||
});
|
||||
|
||||
test('updateDoc on missing doc creates it', async () => {
|
||||
await storage.updateDoc('campaigns/a', { name: 'Alpha' });
|
||||
const doc = await storage.getDoc('campaigns/a');
|
||||
expect(doc).toEqual({ name: 'Alpha' });
|
||||
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +87,7 @@ function runStorageContract(name, factory) {
|
||||
expect(id).toBeTruthy();
|
||||
expect(path).toBe(`campaigns/a/encounters/${id}`);
|
||||
const doc = await storage.getDoc(path);
|
||||
expect(doc).toEqual({ name: 'E1' });
|
||||
expect(doc).toEqual({ id, name: 'E1' });
|
||||
});
|
||||
|
||||
test('two addDocs produce distinct ids', async () => {
|
||||
@@ -91,33 +99,15 @@ function runStorageContract(name, factory) {
|
||||
|
||||
describe('firebase-prefixed path identity', () => {
|
||||
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
|
||||
// Adapter must normalize internally so write+read at prefixed path round-trips
|
||||
// AND collection queries at bare canonical path find prefixed-written docs.
|
||||
// Catches replay-script bug (wrote prefixed, adapter reads bare, missed).
|
||||
// main prod truth: ALWAYS prefixed. Write+read same prefixed round-trips.
|
||||
// Cross bare<->prefixed lookup is NOT required by main (App never does it).
|
||||
// Kept as same-prefix roundtrip only — matches prod.
|
||||
const PREFIX = 'artifacts/test-app/public/data';
|
||||
|
||||
test('setDoc prefixed then getCollection bare finds it', async () => {
|
||||
await storage.setDoc(`${PREFIX}/campaigns/c1`, { name: 'P1' });
|
||||
const docs = await storage.getCollection('campaigns');
|
||||
expect(docs.some(d => d.name === 'P1')).toBe(true);
|
||||
});
|
||||
|
||||
test('setDoc prefixed then getDoc same prefixed path returns it', async () => {
|
||||
test('setDoc prefixed then getDoc same prefixed path returns it (id)', async () => {
|
||||
await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
|
||||
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
|
||||
expect(doc).toEqual({ name: 'P2' });
|
||||
});
|
||||
|
||||
test('setDoc prefixed then getDoc bare path returns it', async () => {
|
||||
await storage.setDoc(`${PREFIX}/campaigns/c3`, { name: 'P3' });
|
||||
const doc = await storage.getDoc('campaigns/c3');
|
||||
expect(doc).toEqual({ name: 'P3' });
|
||||
});
|
||||
|
||||
test('setDoc bare then getCollection prefixed finds it', async () => {
|
||||
await storage.setDoc('campaigns/c4', { name: 'P4' });
|
||||
const docs = await storage.getCollection(`${PREFIX}/campaigns`);
|
||||
expect(docs.some(d => d.name === 'P4')).toBe(true);
|
||||
expect(doc).toEqual({ id: 'c2', name: 'P2' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,7 +155,29 @@ function runStorageContract(name, factory) {
|
||||
{ type: 'delete', path: 'campaigns/a' },
|
||||
]);
|
||||
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||
expect(await storage.getDoc('campaigns/b')).toEqual({ name: 'B' });
|
||||
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
|
||||
});
|
||||
|
||||
// set-only batch (not used in main currently, but interface allows).
|
||||
test('applies set-only batch', async () => {
|
||||
await storage.batchWrite([
|
||||
{ type: 'set', path: 'campaigns/a', data: { name: 'A' } },
|
||||
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
|
||||
]);
|
||||
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A' });
|
||||
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
|
||||
});
|
||||
|
||||
// update-only batch (interface allows).
|
||||
test('applies update-only batch', async () => {
|
||||
await storage.setDoc('campaigns/a', { name: 'A', hp: 10 });
|
||||
await storage.setDoc('campaigns/b', { name: 'B', hp: 5 });
|
||||
await storage.batchWrite([
|
||||
{ type: 'update', path: 'campaigns/a', data: { hp: 8 } },
|
||||
{ type: 'update', path: 'campaigns/b', data: { hp: 2 } },
|
||||
]);
|
||||
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A', hp: 8 });
|
||||
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B', hp: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +188,7 @@ function runStorageContract(name, factory) {
|
||||
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({ name: 'Alpha' });
|
||||
expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('fires cb on subsequent change', async () => {
|
||||
@@ -186,7 +198,7 @@ function runStorageContract(name, factory) {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
await flush();
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last).toEqual({ name: 'Alpha' });
|
||||
expect(last).toEqual({ id: 'a', name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('unsubscribe stops callbacks', async () => {
|
||||
@@ -220,6 +232,45 @@ function runStorageContract(name, factory) {
|
||||
expect(last).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// queryConstraints: orderBy + limit. App's combat log uses
|
||||
// [orderBy('timestamp','desc'), limit(500)] — newest 500 entries.
|
||||
// Adapter MUST honor these. Constraint shape = neutral {__type} objects
|
||||
// (what firebase mock produces; what App passes via shared builders).
|
||||
describe('subscribeCollection queryConstraints', () => {
|
||||
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
|
||||
const limitC = (n) => ({ __type: 'limit', n });
|
||||
|
||||
beforeEach(async () => {
|
||||
// seed 5 log docs, timestamps out of order
|
||||
await storage.setDoc('logs/l1', { msg: 'one', timestamp: 1 });
|
||||
await storage.setDoc('logs/l2', { msg: 'two', timestamp: 3 });
|
||||
await storage.setDoc('logs/l3', { msg: 'three', timestamp: 5 });
|
||||
await storage.setDoc('logs/l4', { msg: 'four', timestamp: 2 });
|
||||
await storage.setDoc('logs/l5', { msg: 'five', timestamp: 4 });
|
||||
});
|
||||
|
||||
test('orderBy desc sorts newest-first', async () => {
|
||||
const result = await new Promise((resolve) => {
|
||||
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc')]);
|
||||
});
|
||||
expect(result.map(d => d.msg)).toEqual(['three', 'five', 'two', 'four', 'one']);
|
||||
});
|
||||
|
||||
test('limit returns only first N after ordering', async () => {
|
||||
const result = await new Promise((resolve) => {
|
||||
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2)]);
|
||||
});
|
||||
expect(result.map(d => d.msg)).toEqual(['three', 'five']);
|
||||
});
|
||||
|
||||
test('no constraints returns all (insertion/id order)', async () => {
|
||||
const result = await new Promise((resolve) => {
|
||||
storage.subscribeCollection('logs', resolve, []);
|
||||
});
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+23
-11
@@ -1,10 +1,8 @@
|
||||
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
|
||||
// Matches interface of memory.js / ws.js so App.js calls stay identical.
|
||||
//
|
||||
// NOTE: App.js currently imports SDK directly. This adapter extracted verbatim.
|
||||
// Two-phase refactor:
|
||||
// Phase A (now): adapter exists, wraps SDK. Hooks/writes can switch incrementally.
|
||||
// Phase B (later): App.js imports storage factory, drops direct SDK imports.
|
||||
// App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
|
||||
// the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
|
||||
|
||||
'use strict';
|
||||
|
||||
@@ -120,21 +118,34 @@ export function createFirebaseStorage() {
|
||||
},
|
||||
|
||||
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
|
||||
subscribeDoc(path, cb) {
|
||||
subscribeDoc(path, cb, errCb) {
|
||||
recordAdapterCall({ fn: 'subscribeDoc', path });
|
||||
return onSnapshot(doc(db, path), (snap) => {
|
||||
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
|
||||
}, (err) => console.error(`subscribeDoc ${path}:`, err));
|
||||
}, (err) => {
|
||||
console.error(`subscribeDoc ${path}:`, err);
|
||||
if (typeof errCb === 'function') errCb(err);
|
||||
});
|
||||
},
|
||||
|
||||
subscribeCollection(collectionPath, cb, queryConstraints = []) {
|
||||
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
|
||||
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
||||
const q = queryConstraints.length > 0
|
||||
? query(collection(db, collectionPath), ...queryConstraints)
|
||||
// queryConstraints = neutral {__type} builders (from index.js).
|
||||
// Translate to SDK orderBy/limit.
|
||||
const sdkConstraints = queryConstraints.map(c => {
|
||||
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
|
||||
if (c.__type === 'limit') return limit(c.n);
|
||||
return c; // pass-through (forward compat)
|
||||
});
|
||||
const q = sdkConstraints.length > 0
|
||||
? query(collection(db, collectionPath), ...sdkConstraints)
|
||||
: collection(db, collectionPath);
|
||||
return onSnapshot(q, (snap) => {
|
||||
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
||||
}, (err) => console.error(`subscribeCollection ${collectionPath}:`, err));
|
||||
}, (err) => {
|
||||
console.error(`subscribeCollection ${collectionPath}:`, err);
|
||||
if (typeof errCb === 'function') errCb(err);
|
||||
});
|
||||
},
|
||||
|
||||
dispose() { /* SDK managed; no-op */ },
|
||||
@@ -142,7 +153,8 @@ export function createFirebaseStorage() {
|
||||
}
|
||||
|
||||
// Re-export SDK pieces App.js uses directly (until full refactor).
|
||||
// orderBy/limit NOT re-exported: App uses neutral builders from index.js.
|
||||
export {
|
||||
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
|
||||
query, orderBy, limit, writeBatch,
|
||||
query, writeBatch,
|
||||
};
|
||||
|
||||
+16
-9
@@ -1,5 +1,6 @@
|
||||
// src/storage/index.js — storage factory + SDK re-exports.
|
||||
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=ws: backend.
|
||||
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=server: backend.
|
||||
// orderBy/limit below = NEUTRAL builders (not SDK passthrough).
|
||||
// App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending).
|
||||
|
||||
import { initializeApp } from 'firebase/app';
|
||||
@@ -8,11 +9,10 @@ import {
|
||||
} from 'firebase/auth';
|
||||
import {
|
||||
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
|
||||
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
|
||||
onSnapshot, updateDoc, deleteDoc, query, writeBatch,
|
||||
} from 'firebase/firestore';
|
||||
import { initFirebase, createFirebaseStorage } from './firebase';
|
||||
import { createWsStorage } from './ws';
|
||||
import { createMemoryStorage } from './memory';
|
||||
import { createServerStorage } from './server';
|
||||
|
||||
let storageInstance = null;
|
||||
|
||||
@@ -24,13 +24,13 @@ export function getStorage() {
|
||||
const ok = initFirebase();
|
||||
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
|
||||
storageInstance = createFirebaseStorage();
|
||||
} else if (mode === 'ws') {
|
||||
storageInstance = createWsStorage({
|
||||
} else if (mode === 'server') {
|
||||
storageInstance = createServerStorage({
|
||||
baseUrl: process.env.REACT_APP_BACKEND_URL || '',
|
||||
wsUrl: process.env.REACT_APP_BACKEND_WS || '',
|
||||
realtimeUrl: process.env.REACT_APP_BACKEND_REALTIME_URL || '',
|
||||
});
|
||||
} else {
|
||||
storageInstance = createMemoryStorage();
|
||||
throw new Error(`Unknown REACT_APP_STORAGE mode: ${mode}. Use 'firebase' or 'server'.`);
|
||||
}
|
||||
return storageInstance;
|
||||
}
|
||||
@@ -39,9 +39,16 @@ export function getStorageMode() {
|
||||
return process.env.REACT_APP_STORAGE || 'firebase';
|
||||
}
|
||||
|
||||
// Neutral query-constraint builders. App builds constraints with these (not
|
||||
// raw SDK), so both adapters read the same shape. firebase adapter translates
|
||||
// neutral -> SDK; server adapter applies client-side. Combat log uses these:
|
||||
// [orderBy('timestamp','desc'), limit(500)]
|
||||
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
|
||||
export function limit(n) { return { __type: 'limit', n }; }
|
||||
|
||||
export {
|
||||
initializeApp,
|
||||
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
|
||||
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
|
||||
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
|
||||
onSnapshot, updateDoc, deleteDoc, query, writeBatch,
|
||||
};
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// memory.js — in-process storage impl. Test seed.
|
||||
// Map<docPath, data>. EventEmitter for subscribe.
|
||||
// Mirrors firebase semantics: setDoc=replace, updateDoc=shallow merge, addDoc=auto-id.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const docs = new Map(); // path -> data obj
|
||||
const bus = new EventEmitter();
|
||||
bus.setMaxListeners(1000);
|
||||
|
||||
// Firebase-prefixed paths (artifacts/{APP_ID}/public/data/...) normalized to
|
||||
// bare canonical. Matches ws.js norm() so all impls share path identity.
|
||||
function norm(p) {
|
||||
if (!p) return p;
|
||||
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
||||
}
|
||||
|
||||
// ---- path helpers ----
|
||||
// collection path = path with even number of segments OR known collection.
|
||||
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
|
||||
// getCollection(path) returns all docs whose path === path/id for any single id segment.
|
||||
function isCollectionPath(p) {
|
||||
return p.split('/').length % 2 === 1;
|
||||
}
|
||||
|
||||
function emitDoc(path, data) { bus.emit('doc:' + path, data); }
|
||||
function emitCollection(collPath) {
|
||||
const children = collectionDocs(collPath);
|
||||
bus.emit('coll:' + collPath, children);
|
||||
}
|
||||
|
||||
function collectionDocs(collPath) {
|
||||
const out = [];
|
||||
const segLen = collPath.split('/').length + 1;
|
||||
for (const [p, data] of docs) {
|
||||
const segs = p.split('/');
|
||||
if (segs.length !== segLen) continue;
|
||||
const parent = segs.slice(0, -1).join('/');
|
||||
if (parent === collPath) out.push(data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function genId() {
|
||||
return (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
const storage = {
|
||||
async getDoc(rawPath) {
|
||||
const path = norm(rawPath);
|
||||
return docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||
},
|
||||
|
||||
async setDoc(rawPath, data) {
|
||||
const path = norm(rawPath);
|
||||
docs.set(path, deepClone(data));
|
||||
emitDoc(path, deepClone(data));
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async updateDoc(rawPath, patch) {
|
||||
const path = norm(rawPath);
|
||||
const existing = docs.has(path) ? docs.get(path) : {};
|
||||
const merged = { ...existing, ...patch };
|
||||
docs.set(path, merged);
|
||||
emitDoc(path, deepClone(merged));
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async deleteDoc(rawPath) {
|
||||
const path = norm(rawPath);
|
||||
docs.delete(path);
|
||||
emitDoc(path, null);
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async addDoc(rawCollectionPath, data) {
|
||||
const collectionPath = norm(rawCollectionPath);
|
||||
const id = genId();
|
||||
const path = `${collectionPath}/${id}`;
|
||||
docs.set(path, deepClone(data));
|
||||
emitDoc(path, deepClone(data));
|
||||
emitCollection(collectionPath);
|
||||
return { id, path };
|
||||
},
|
||||
|
||||
async getCollection(rawCollPath) {
|
||||
const collPath = norm(rawCollPath);
|
||||
return collectionDocs(collPath).map(deepClone);
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
for (const op of ops) {
|
||||
const mop = { ...op, path: norm(op.path) };
|
||||
if (mop.type === 'set') await storage.setDoc(mop.path, mop.data);
|
||||
else if (mop.type === 'delete') await storage.deleteDoc(mop.path);
|
||||
else if (mop.type === 'update') await storage.updateDoc(mop.path, mop.data);
|
||||
}
|
||||
},
|
||||
|
||||
subscribeDoc(rawPath, cb) {
|
||||
const path = norm(rawPath);
|
||||
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||
Promise.resolve().then(() => cb(cur));
|
||||
const handler = (data) => cb(data);
|
||||
bus.on('doc:' + path, handler);
|
||||
return () => bus.off('doc:' + path, handler);
|
||||
},
|
||||
|
||||
subscribeCollection(rawCollPath, cb) {
|
||||
const collPath = norm(rawCollPath);
|
||||
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
|
||||
const handler = (docs) => cb(docs);
|
||||
bus.on('coll:' + collPath, handler);
|
||||
return () => bus.off('coll:' + collPath, handler);
|
||||
},
|
||||
|
||||
dispose() { bus.removeAllListeners(); docs.clear(); },
|
||||
|
||||
// test/debug
|
||||
_docs: docs,
|
||||
};
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
function deepClone(v) {
|
||||
if (v === null || v === undefined) return v;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
export { createMemoryStorage };
|
||||
@@ -11,13 +11,13 @@ if (typeof WebSocket !== 'undefined') {
|
||||
WebSocketImpl = WebSocket;
|
||||
}
|
||||
|
||||
function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||
// Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy).
|
||||
// Fallback to localhost for bare `npm start` dev without proxy.
|
||||
const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, '');
|
||||
let WS;
|
||||
if (wsUrl) {
|
||||
WS = wsUrl;
|
||||
if (realtimeUrl) {
|
||||
WS = realtimeUrl;
|
||||
} else if (typeof window !== 'undefined' && window.location) {
|
||||
// derive from current origin (http→ws, https→wss), same host/port.
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -33,8 +33,39 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
||||
}
|
||||
|
||||
// Inject id (last path segment) into doc — matches main firebase truth:
|
||||
// { id: docSnap.id, ...snap.data() }
|
||||
// Backend stores docs WITHOUT id; client adds it so all adapters share shape.
|
||||
function withId(rawPath, data) {
|
||||
if (data === null || data === undefined) return data;
|
||||
const id = norm(rawPath).split('/').pop();
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
// Apply neutral {__type} query constraints client-side. Backend returns all
|
||||
// docs; adapter sorts/limits. Mirrors firebase mock applyConstraints.
|
||||
function applyConstraints(docs, constraints) {
|
||||
let out = [...docs];
|
||||
for (const c of constraints || []) {
|
||||
if (!c || !c.__type) continue;
|
||||
if (c.__type === 'orderBy') {
|
||||
out.sort((a, b) => {
|
||||
const av = a[c.field];
|
||||
const bv = b[c.field];
|
||||
if (av === bv) return 0;
|
||||
const cmp = av > bv ? 1 : -1;
|
||||
return c.dir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
} else if (c.__type === 'limit') {
|
||||
out = out.slice(0, c.n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const docSubs = new Map(); // path -> Set<cb>
|
||||
const collSubs = new Map(); // collPath -> Set<cb>
|
||||
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
|
||||
let ws = null;
|
||||
let wsReady = null;
|
||||
|
||||
@@ -121,11 +152,12 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
const doc = await storage.getDoc(c.path);
|
||||
docCbs.forEach(cb => cb(doc));
|
||||
}
|
||||
// collection subscribers at parent path (doc belongs to this collection)
|
||||
// collection subscribers at parent path (apply their stored constraints)
|
||||
if (c.parent) {
|
||||
const collCbs = collSubs.get(c.parent);
|
||||
if (collCbs) {
|
||||
const docs = await storage.getCollection(c.parent);
|
||||
const constraints = collConstraints.get(c.parent) || [];
|
||||
const docs = applyConstraints(await storage.getCollection(c.parent), constraints);
|
||||
collCbs.forEach(cb => cb(docs));
|
||||
}
|
||||
}
|
||||
@@ -154,12 +186,19 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
async getDoc(rawPath) {
|
||||
const p = norm(rawPath);
|
||||
const res = await api('GET', '/api/doc', { path: p });
|
||||
return res && res.data !== undefined ? res.data : null;
|
||||
const data = res && res.data !== undefined ? res.data : null;
|
||||
return withId(rawPath, data);
|
||||
},
|
||||
|
||||
async setDoc(rawPath, data) {
|
||||
async setDoc(rawPath, data, opts = {}) {
|
||||
const p = norm(rawPath);
|
||||
// merge flag -> PATCH (shallow merge, create-on-miss). Matches firebase
|
||||
// setDoc(path, data, {merge:true}) semantics.
|
||||
if (opts.merge) {
|
||||
await api('PATCH', '/api/doc', null, { path: p, patch: data });
|
||||
} else {
|
||||
await api('PUT', '/api/doc', null, { path: p, data });
|
||||
}
|
||||
},
|
||||
|
||||
async updateDoc(rawPath, patch) {
|
||||
@@ -180,7 +219,16 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
|
||||
async getCollection(rawCollPath) {
|
||||
const p = norm(rawCollPath);
|
||||
return await api('GET', '/api/collection', { path: p });
|
||||
const docs = await api('GET', '/api/collection', { path: p });
|
||||
// Backend returns array of { id, data } OR bare data[]; normalize to
|
||||
// { id, ...data } (firebase truth).
|
||||
if (!Array.isArray(docs)) return [];
|
||||
return docs.map(d => {
|
||||
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
|
||||
return { id: d.id, ...d.data };
|
||||
}
|
||||
return { ...d };
|
||||
});
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
@@ -188,9 +236,9 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
await api('POST', '/api/batch', null, { ops: normOps });
|
||||
},
|
||||
|
||||
subscribeDoc(rawPath, cb) {
|
||||
subscribeDoc(rawPath, cb, errCb) {
|
||||
const p = norm(rawPath);
|
||||
// Initial value via REST (independent of WS connect).
|
||||
// Initial value via REST (independent of WS connect). getDoc injects id.
|
||||
storage.getDoc(p).then(cb).catch(() => {});
|
||||
// WS only for subsequent change notifications.
|
||||
ensureWs().then(() => {
|
||||
@@ -201,10 +249,14 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
return () => { docSubs.get(p)?.delete(cb); };
|
||||
},
|
||||
|
||||
subscribeCollection(rawCollPath, cb) {
|
||||
subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
|
||||
const p = norm(rawCollPath);
|
||||
// Initial value via REST (independent of WS connect).
|
||||
storage.getCollection(p).then(cb).catch(() => {});
|
||||
collConstraints.set(p, queryConstraints || []);
|
||||
// Initial value via REST (independent of WS connect). Apply constraints
|
||||
// client-side — backend returns all docs, adapter sorts/limits.
|
||||
storage.getCollection(p)
|
||||
.then(docs => cb(applyConstraints(docs, queryConstraints || [])))
|
||||
.catch(() => {});
|
||||
// WS only for subsequent change notifications.
|
||||
ensureWs().then(() => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||
@@ -218,7 +270,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
disposed = true;
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
if (ws) ws.close();
|
||||
docSubs.clear(); collSubs.clear();
|
||||
docSubs.clear(); collSubs.clear(); collConstraints.clear();
|
||||
if (typeof cb === 'function') cb();
|
||||
},
|
||||
|
||||
@@ -234,4 +286,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
return storage;
|
||||
}
|
||||
|
||||
export { createWsStorage };
|
||||
export { createServerStorage };
|
||||
@@ -41,7 +41,7 @@ describe('Combat -> Firebase', () => {
|
||||
test('startEncounter: also sets activeDisplay to this encounter', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data.activeCampaignId).toBeTruthy();
|
||||
expect(last.data.activeEncounterId).toBeTruthy();
|
||||
@@ -111,16 +111,16 @@ describe('Combat -> Firebase', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
await waitFor(() => {
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last && last.data.activeCampaignId === null;
|
||||
});
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
|
||||
test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => {
|
||||
test('toggleHidePlayerHp: setDoc{merge} patch on activeDisplay/status', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
// Two switches now (Hide player HP + Hide NPC/monster HP). Scope to player one.
|
||||
@@ -128,11 +128,11 @@ describe('Combat -> Firebase', () => {
|
||||
const switchBtn = playerHpLabel.parentElement.querySelector('[role="switch"]');
|
||||
fireEvent.click(switchBtn);
|
||||
await waitFor(() => {
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last && 'hidePlayerHp' in last.data;
|
||||
});
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toHaveProperty('hidePlayerHp');
|
||||
});
|
||||
|
||||
+259
-272
@@ -1,24 +1,84 @@
|
||||
// Combat.scenario.test.js
|
||||
// Full combat scenario: campaign -> encounter -> participants -> 100 rounds of
|
||||
// damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove.
|
||||
// Drives the SAME UI buttons a DM clicks. Failing assertions do NOT abort the run:
|
||||
// each phase wraps in try/catch, failures collected, final expect reports all.
|
||||
// Full combat scenario driven through shared/turn.js directly --- NO React.
|
||||
//
|
||||
// Purpose: exercise as much of the supported feature surface as possible in one
|
||||
// long combat, surfacing behavioral bugs characterization tests miss.
|
||||
// Does 100 ROUNDS (not turns). A round = one full pass through initiative
|
||||
// (every active participant acts once); round counter increments at wrap.
|
||||
// See docs/GLOSSARY.md. Previous version lied: called nextTurn 100× (~100
|
||||
// turns, ~12 rounds). Now loops by actual round-wrap count.
|
||||
//
|
||||
// Exercises ALL combat paths deterministically (less random than replay):
|
||||
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
|
||||
// updateParticipant, removeParticipant, toggleParticipantActive,
|
||||
// applyHpChange (damage/heal), deathSave (incl. 3-success → isDying),
|
||||
// toggleCondition, reorderParticipants (drag same-init tie), endEncounter,
|
||||
// activateDisplay, clearDisplay, toggleHidePlayerHp.
|
||||
//
|
||||
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
|
||||
// failures collected, final expect reports all.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import App from '../App';
|
||||
import {
|
||||
renderApp, createCampaignViaUI, selectCampaignByName,
|
||||
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
|
||||
fastWaitFor,
|
||||
} from './testHelpers';
|
||||
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
const {
|
||||
makeParticipant,
|
||||
buildCharacterParticipant,
|
||||
buildMonsterParticipant,
|
||||
startEncounter,
|
||||
nextTurn,
|
||||
togglePause,
|
||||
addParticipant,
|
||||
addParticipants,
|
||||
updateParticipant,
|
||||
removeParticipant,
|
||||
toggleParticipantActive,
|
||||
applyHpChange,
|
||||
deathSave,
|
||||
toggleCondition,
|
||||
reorderParticipants,
|
||||
endEncounter,
|
||||
activateDisplay,
|
||||
clearDisplay,
|
||||
toggleHidePlayerHp,
|
||||
} = require('../../shared');
|
||||
|
||||
// ---------- scenario helpers (UI only, same buttons as human) ----------
|
||||
// aliases for combat funcs that clash with local helper names
|
||||
const {
|
||||
applyHpChange: combatApplyHpChange,
|
||||
toggleParticipantActive: combatToggleActive,
|
||||
toggleCondition: combatToggleCondition,
|
||||
deathSave: combatDeathSave,
|
||||
} = require('../../shared');
|
||||
|
||||
// ---------- scenario state (mirrors two firestore docs) ----------
|
||||
|
||||
const ROUNDS = 100;
|
||||
|
||||
// encounter doc
|
||||
let enc = {
|
||||
name: 'BigBoss',
|
||||
participants: [],
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
};
|
||||
// activeDisplay/status doc
|
||||
let display = {
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null,
|
||||
hidePlayerHp: true,
|
||||
hideNpcHp: false,
|
||||
};
|
||||
const CAMPAIGN_ID = 'camp-1';
|
||||
const ENCOUNTER_ID = 'enc-1';
|
||||
const roster = [];
|
||||
|
||||
function applyEnc(patch) {
|
||||
if (!patch) return;
|
||||
for (const [k, v] of Object.entries(patch)) enc[k] = v;
|
||||
}
|
||||
function applyDisplay(patch) {
|
||||
if (!patch) return;
|
||||
for (const [k, v] of Object.entries(patch)) display[k] = v;
|
||||
}
|
||||
|
||||
const RESULTS = [];
|
||||
function record(phase, fn) {
|
||||
@@ -30,214 +90,93 @@ async function recordAsync(phase, fn) {
|
||||
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||
}
|
||||
|
||||
function getParticipantForm() {
|
||||
const heading = screen.getByText('Add Participants');
|
||||
let node = heading;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
node = node.parentElement;
|
||||
if (!node) break;
|
||||
if (node.querySelector('form')) return within(node);
|
||||
}
|
||||
return within(heading.parentElement);
|
||||
}
|
||||
// ---------- helpers (shared, no React) ----------
|
||||
|
||||
// Find a participant's encounter <li> row by name. Scoped to the encounter
|
||||
// participant list (NOT the CharacterManager roster, which also shows names).
|
||||
// Encounter participant rows render an 'Init:' label; roster rows do not.
|
||||
function getParticipantRow(name) {
|
||||
const lis = document.querySelectorAll('li');
|
||||
for (const li of lis) {
|
||||
// participant rows have inline initiative input with unique aria-label.
|
||||
// Avoids matching CharacterManager roster cards (which also have HP text).
|
||||
const initInput = li.querySelector(`input[aria-label="Initiative for ${name}"]`);
|
||||
if (initInput) {
|
||||
return within(li);
|
||||
}
|
||||
}
|
||||
throw new Error(`encounter participant row not found: ${name}`);
|
||||
}
|
||||
const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
|
||||
const any = (name) => enc.participants.find(p => p.name === name);
|
||||
const idOf = (name) => { const p = any(name); return p ? p.id : null; };
|
||||
|
||||
// Character roster (CharacterManager). Assumes campaign selected.
|
||||
async function addCharacterViaUI(name, maxHp, initMod) {
|
||||
fireEvent.change(document.getElementById('characterName'), { target: { value: name } });
|
||||
fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } });
|
||||
fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i }));
|
||||
await fastWaitFor(() => {
|
||||
const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') &&
|
||||
Array.isArray(c.data.players) && c.data.players.some(p => p.name === name));
|
||||
if (!call) throw new Error('char not persisted');
|
||||
});
|
||||
function addCharacterViaUI(name, maxHp, initMod) {
|
||||
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
|
||||
}
|
||||
function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||||
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
|
||||
applyEnc(addParticipant(enc, participant).patch);
|
||||
}
|
||||
function addCharacterParticipant(charName) {
|
||||
const character = roster.find(c => c.name === charName);
|
||||
if (!character) throw new Error(`char not found: ${charName}`);
|
||||
if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
|
||||
throw new Error(`${charName} already in encounter`);
|
||||
}
|
||||
const { participant } = buildCharacterParticipant(character);
|
||||
applyEnc(addParticipant(enc, participant).patch);
|
||||
}
|
||||
function addAllCharacters() {
|
||||
const toAdd = roster
|
||||
.filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
|
||||
.map(c => buildCharacterParticipant(c).participant);
|
||||
applyEnc(addParticipants(enc, toAdd).patch);
|
||||
}
|
||||
function startCombat() {
|
||||
applyEnc(startEncounter(enc).patch);
|
||||
applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch);
|
||||
}
|
||||
function endCombat() {
|
||||
applyEnc(endEncounter(enc).patch);
|
||||
applyDisplay(clearDisplay().patch);
|
||||
}
|
||||
function nextTurnAction() { applyEnc(nextTurn(enc).patch); }
|
||||
function pauseCombat() { applyEnc(togglePause(enc).patch); if (!enc.isPaused) throw new Error('not paused'); }
|
||||
function resumeCombat() { applyEnc(togglePause(enc).patch); if (enc.isPaused) throw new Error('not resumed'); }
|
||||
|
||||
function setParticipantType(type) {
|
||||
// The Type select is inside the Add Participants form.
|
||||
const form = getParticipantForm();
|
||||
const selects = form.getAllByRole('combobox');
|
||||
// first combobox in the participant form is Type
|
||||
fireEvent.change(selects[0], { target: { value: type } });
|
||||
}
|
||||
|
||||
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||||
const form = getParticipantForm();
|
||||
setParticipantType('monster');
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
||||
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
|
||||
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
|
||||
if (isNpc) {
|
||||
const npcCheck = form.getByRole('checkbox', { name: /NPC/i });
|
||||
if (!npcCheck.checked) fireEvent.click(npcCheck);
|
||||
}
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added');
|
||||
});
|
||||
}
|
||||
|
||||
async function addCharacterParticipant(charName) {
|
||||
const form = getParticipantForm();
|
||||
setParticipantType('character');
|
||||
// character select is the 2nd combobox in the form after Type
|
||||
const charSelect = form.getAllByRole('combobox')[1];
|
||||
// find option whose text includes the char name
|
||||
const opt = [...charSelect.querySelectorAll('option')].find(o => o.textContent.includes(charName));
|
||||
if (!opt) throw new Error(`char option not found: ${charName}`);
|
||||
fireEvent.change(charSelect, { target: { value: opt.value } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last || !last.data.participants?.some(p => p.name === charName)) throw new Error('char not added');
|
||||
});
|
||||
}
|
||||
|
||||
async function addAllCharacters() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add All/i }));
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last) throw new Error('add all no-op');
|
||||
});
|
||||
}
|
||||
|
||||
async function applyDamage(name, amount) {
|
||||
const row = getParticipantRow(name);
|
||||
const dmgBtn = row.queryByTitle('Damage');
|
||||
if (!dmgBtn) {
|
||||
// participant dead (Damage button hidden when currentHp===0). Expected game
|
||||
// state over a long fight; not a bug. Skip silently.
|
||||
return;
|
||||
}
|
||||
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
|
||||
fireEvent.click(dmgBtn);
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
const p = last?.data?.participants?.find(x => x.name === name);
|
||||
if (!p) throw new Error('damage no write');
|
||||
});
|
||||
function applyDamage(name, amount) {
|
||||
const p = any(name);
|
||||
if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI)
|
||||
applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch);
|
||||
}
|
||||
function applyHeal(name, amount) {
|
||||
const row = getParticipantRow(name);
|
||||
const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal');
|
||||
if (!healBtn) throw new Error(`${name} has no Heal button`);
|
||||
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
|
||||
fireEvent.click(healBtn);
|
||||
const p = any(name);
|
||||
if (!p) return; // removed (death) = skip (no button in UI)
|
||||
applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch);
|
||||
}
|
||||
function toggleActive(name) {
|
||||
const row = getParticipantRow(name);
|
||||
const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive');
|
||||
if (!btn) throw new Error(`${name} has no active toggle`);
|
||||
fireEvent.click(btn);
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no active target: ${name}`);
|
||||
applyEnc(combatToggleActive(enc, p.id).patch);
|
||||
}
|
||||
function openConditions(name) {
|
||||
const row = getParticipantRow(name);
|
||||
// idempotent: only click Conditions button if panel not already open for this row.
|
||||
// Re-clicking toggles it closed.
|
||||
const panel = row.queryByText('Toggle Conditions');
|
||||
if (panel) return;
|
||||
const btn = row.getByTitle('Conditions');
|
||||
fireEvent.click(btn);
|
||||
}
|
||||
function toggleCondition(name, label) {
|
||||
openConditions(name);
|
||||
// panel render is async (React state). Wait for button by title.
|
||||
return fastWaitFor(() => {
|
||||
const condButtons = document.querySelectorAll('button[title]');
|
||||
const target = [...condButtons].find(b => b.getAttribute('title') === label);
|
||||
if (!target) throw new Error(`condition button not found: ${label}`);
|
||||
fireEvent.click(target);
|
||||
});
|
||||
function toggleConditionAction(name, label) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no condition target: ${name}`);
|
||||
applyEnc(combatToggleCondition(enc, p.id, label).patch);
|
||||
}
|
||||
function editParticipant(name, patch) {
|
||||
const row = getParticipantRow(name);
|
||||
fireEvent.click(row.getByTitle('Edit'));
|
||||
// EditParticipantModal. Scope to the modal via its form inputs.
|
||||
const modal = document.querySelector('.fixed.inset-0') || document.body;
|
||||
const inputs = modal.querySelectorAll('input');
|
||||
if (patch.name !== undefined) {
|
||||
fireEvent.change(inputs[0], { target: { value: patch.name } });
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no edit target: ${name}`);
|
||||
applyEnc(updateParticipant(enc, p.id, patch).patch);
|
||||
}
|
||||
if (patch.initiative !== undefined && inputs[1]) {
|
||||
fireEvent.change(inputs[1], { target: { value: String(patch.initiative) } });
|
||||
function removeParticipantByName(name) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no remove target: ${name}`);
|
||||
applyEnc(removeParticipant(enc, p.id).patch);
|
||||
}
|
||||
const saveBtn = modal.querySelector('button[type="submit"]') ||
|
||||
[...modal.querySelectorAll('button')].find(b => /^Save$/i.test(b.textContent.trim()));
|
||||
fireEvent.click(saveBtn);
|
||||
function deathSaveAction(name, saveNum) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no deathsave target: ${name}`);
|
||||
applyEnc(combatDeathSave(enc, p.id, saveNum).patch);
|
||||
}
|
||||
function removeParticipant(name) {
|
||||
fireEvent.click(getParticipantRow(name).getByTitle('Remove'));
|
||||
function dragTie(draggedName, targetName) {
|
||||
const d = any(draggedName), t = any(targetName);
|
||||
if (!d || !t) throw new Error('drag target missing');
|
||||
applyEnc(reorderParticipants(enc, d.id, t.id).patch);
|
||||
}
|
||||
async function deathSave(name, saveNum) {
|
||||
const row = getParticipantRow(name);
|
||||
const btn = row.getByTitle(`Death save ${saveNum}`);
|
||||
fireEvent.click(btn);
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last) throw new Error('deathSave no write');
|
||||
});
|
||||
}
|
||||
async function nextTurn() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last) throw new Error('nextTurn no write');
|
||||
});
|
||||
}
|
||||
async function pauseCombat() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last?.data?.isPaused) throw new Error('not paused');
|
||||
});
|
||||
}
|
||||
async function resumeCombat() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (last?.data?.isPaused) throw new Error('not resumed');
|
||||
});
|
||||
}
|
||||
async function startCombat() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Start Combat/i }));
|
||||
await fastWaitFor(() => {
|
||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
||||
if (!last?.data?.isStarted) throw new Error('not started');
|
||||
});
|
||||
}
|
||||
function toggleHidePlayerHp() {
|
||||
fireEvent.click(screen.getByRole('switch', { name: /hide player hp/i }));
|
||||
}
|
||||
function currentEncDoc() {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
return calls[calls.length - 1]?.data;
|
||||
function toggleHidePlayerHpAction() {
|
||||
applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
|
||||
}
|
||||
|
||||
// ---------- scenario ----------
|
||||
|
||||
const ROUNDS = 100;
|
||||
|
||||
test('full 100-round combat scenario', async () => {
|
||||
await setupReady('ScenarioCamp', 'BigBoss');
|
||||
|
||||
test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
// roster
|
||||
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||
await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||
@@ -254,94 +193,142 @@ test('full 100-round combat scenario', async () => {
|
||||
await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
|
||||
await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
|
||||
await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
|
||||
await recordAsync('addAllChars', () => addAllCharacters());
|
||||
await recordAsync('addAllChars', () => addAllCharacters()); // bulk add path
|
||||
|
||||
// hidden hp toggle
|
||||
record('toggleHidePlayerHp', () => toggleHidePlayerHp());
|
||||
record('toggleHidePlayerHp back', () => toggleHidePlayerHp());
|
||||
// hidden hp toggle x2 (back to default)
|
||||
record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
||||
record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
||||
|
||||
await recordAsync('startCombat', () => startCombat());
|
||||
|
||||
// 100 rounds of mixed actions
|
||||
for (let r = 1; r <= ROUNDS; r++) {
|
||||
await recordAsync(`round ${r} nextTurn`, () => nextTurn());
|
||||
// ---------- 100 ROUNDS (loop by actual round-wrap count) ----------
|
||||
let roundsDone = 0;
|
||||
let prevRound = enc.round;
|
||||
let turnInRound = 0;
|
||||
let turnTotal = 0;
|
||||
|
||||
// rotation integrity: turnOrderIds no dup, currentTurn valid
|
||||
if (r % 10 === 0) {
|
||||
record(`round ${r} rotation-check`, () => {
|
||||
const enc = currentEncDoc();
|
||||
if (!enc) throw new Error('no encounter doc');
|
||||
while (roundsDone < ROUNDS) {
|
||||
turnTotal++;
|
||||
const actor = anyById(enc.currentTurnParticipantId);
|
||||
const r = enc.round;
|
||||
|
||||
// per-turn deterministic actions keyed by round + actor type
|
||||
// damage OrcBoss every even round
|
||||
if (r % 2 === 0 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||
}
|
||||
// heal Cleric every 3 rounds
|
||||
if (r % 3 === 0 && turnInRound === 1) {
|
||||
record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||||
}
|
||||
// condition on Fighter every 5 rounds
|
||||
if (r % 5 === 0 && turnInRound === 2) {
|
||||
record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
||||
}
|
||||
// toggleActive Goblin2 every 7 rounds (toggle off, next 7 toggle on)
|
||||
if (r % 7 === 0 && turnInRound === 3) {
|
||||
record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||||
}
|
||||
// edit Wolf initiative every 13 rounds
|
||||
if (r % 13 === 0 && turnInRound === 4) {
|
||||
record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||||
}
|
||||
|
||||
// pause/resume + add reinforcement + edit every 10 rounds
|
||||
if (r % 10 === 0 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} pause`, () => pauseCombat());
|
||||
await recordAsync(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||
await recordAsync(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||||
await recordAsync(`r${r} resume`, () => resumeCombat());
|
||||
}
|
||||
|
||||
// create a same-init tie + drag every 15 rounds (exercises reorderParticipants)
|
||||
if (r % 15 === 0 && turnInRound === 5) {
|
||||
const g1 = any('Goblin1');
|
||||
if (g1 && alive('Goblin2')) {
|
||||
record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
|
||||
record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
|
||||
}
|
||||
}
|
||||
|
||||
// death scenarios: drop a PC to 0, death saves, revive
|
||||
if (r === 25 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
||||
record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 1));
|
||||
record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||
}
|
||||
// round 50: full 3-success death-save path (isDying) on Cleric, then remove
|
||||
if (r === 50 && turnInRound === 0) {
|
||||
await recordAsync(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||||
await recordAsync(`r${r} deathSave Cleric x3 (isDying)`, async () => {
|
||||
deathSaveAction('Cleric', 1);
|
||||
deathSaveAction('Cleric', 2);
|
||||
deathSaveAction('Cleric', 3); // → isDying true
|
||||
});
|
||||
const cl = any('Cleric');
|
||||
if (cl && cl.isDying) record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
|
||||
}
|
||||
|
||||
// remove a reinforcement every 30 rounds
|
||||
if (r % 30 === 0 && turnInRound === 1) {
|
||||
const rein = any(`Reinforce${r - 10}`);
|
||||
if (rein) record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
|
||||
}
|
||||
|
||||
// toggle hide hp every 20 rounds
|
||||
if (r % 20 === 0 && turnInRound === 0) {
|
||||
record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
|
||||
record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
|
||||
}
|
||||
|
||||
// rotation integrity check every 10 rounds at round start
|
||||
if (turnInRound === 0 && r % 10 === 0) {
|
||||
record(`r${r} rotation-check`, () => {
|
||||
const order = enc.turnOrderIds || [];
|
||||
const uniq = new Set(order);
|
||||
if (uniq.size !== order.length) {
|
||||
throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
|
||||
}
|
||||
if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
|
||||
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
|
||||
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
|
||||
}
|
||||
const ids = enc.participants.map(p => p.id);
|
||||
if (JSON.stringify(order) !== JSON.stringify(ids)) {
|
||||
throw new Error(`turnOrderIds drift: order=${JSON.stringify(order)} ids=${JSON.stringify(ids)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// damage front monster every other round
|
||||
if (r % 2 === 0) await recordAsync(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||
if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||||
if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleCondition('Fighter', 'Stunned'));
|
||||
if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||||
// advance turn
|
||||
await recordAsync(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
|
||||
turnInRound++;
|
||||
|
||||
// pause/resume every 10 rounds, add a participant, resume
|
||||
if (r % 10 === 0) {
|
||||
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
||||
await recordAsync(`round ${r} addReinforcement`, () =>
|
||||
addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||
await recordAsync(`round ${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||||
await recordAsync(`round ${r} resume`, () => resumeCombat());
|
||||
}
|
||||
|
||||
// edit initiative on Wolf every 13
|
||||
if (r % 13 === 0) record(`round ${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||||
|
||||
// damage-to-0 + death save on Rogue around round 25 and 50
|
||||
if (r === 25) {
|
||||
await recordAsync(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
||||
record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1));
|
||||
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||
}
|
||||
if (r === 50) {
|
||||
await recordAsync(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||||
await recordAsync(`round ${r} deathSave Cleric x2`, async () => {
|
||||
await deathSave('Cleric', 1);
|
||||
await deathSave('Cleric', 2);
|
||||
});
|
||||
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
|
||||
}
|
||||
|
||||
// remove a reinforcement late
|
||||
if (r === 30) {
|
||||
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
||||
record(`round ${r} remove Reinforce20`, () => removeParticipant('Reinforce20'));
|
||||
await recordAsync(`round ${r} resume`, () => resumeCombat());
|
||||
// round wrap detected
|
||||
if (enc.round > prevRound) {
|
||||
roundsDone++;
|
||||
prevRound = enc.round;
|
||||
turnInRound = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await recordAsync('endCombat', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
// End-combat ConfirmationModal has title 'End Encounter?'. Scope Confirm to it.
|
||||
const endConfirm = await screen.findByRole('heading', { name: /End Encounter/i });
|
||||
const modal = endConfirm.closest('.fixed.inset-0') || document.body;
|
||||
const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim()));
|
||||
fireEvent.click(confirmBtn);
|
||||
await fastWaitFor(() => {
|
||||
const last = currentEncDoc();
|
||||
if (last?.isStarted !== false) throw new Error('not ended');
|
||||
});
|
||||
});
|
||||
await recordAsync('endCombat', () => endCombat());
|
||||
|
||||
// ---------- report ----------
|
||||
const failed = RESULTS.filter(r => !r.ok);
|
||||
const failed = RESULTS.filter(x => !x.ok);
|
||||
if (failed.length > 0) {
|
||||
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
|
||||
}
|
||||
expect(failed).toEqual([]);
|
||||
}, 240000); // long timeout: 100 rounds
|
||||
expect(roundsDone).toBe(ROUNDS);
|
||||
|
||||
// lifecycle assertions (activeDisplay mirrors combat state)
|
||||
expect(enc.isStarted).toBe(false);
|
||||
expect(enc.round).toBe(0);
|
||||
expect(enc.currentTurnParticipantId).toBeNull();
|
||||
expect(display.activeCampaignId).toBeNull();
|
||||
expect(display.activeEncounterId).toBeNull();
|
||||
});
|
||||
|
||||
function anyById(id) {
|
||||
return enc.participants.find(p => p.id === id);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('Encounter -> Firebase', () => {
|
||||
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
|
||||
});
|
||||
|
||||
test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => {
|
||||
test('togglePlayerDisplay: setDoc{merge} patch on activeDisplay/status', async () => {
|
||||
await setupCampaignAndEncounter('Camp D', 'Enc D');
|
||||
await selectEncounterByName('Enc D');
|
||||
|
||||
@@ -50,33 +50,33 @@ describe('Encounter -> Firebase', () => {
|
||||
const eyeBtn = await screen.findByTitle('Activate for Player Display');
|
||||
fireEvent.click(eyeBtn);
|
||||
|
||||
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
|
||||
const call = findCall('updateDoc', 'activeDisplay/status');
|
||||
// BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields)
|
||||
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||
const call = findCall('setDoc', 'activeDisplay/status');
|
||||
// main truth: setDoc{merge} patch, not replace (would clobber fields)
|
||||
expect(call.data).toMatchObject({
|
||||
activeCampaignId: expect.any(String),
|
||||
activeEncounterId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test('togglePlayerDisplay off: updateDoc nulls active ids', async () => {
|
||||
test('togglePlayerDisplay off: setDoc{merge} nulls active ids', async () => {
|
||||
await setupCampaignAndEncounter('Camp O', 'Enc O');
|
||||
await selectEncounterByName('Enc O');
|
||||
|
||||
// turn ON
|
||||
const onBtn = await screen.findByTitle('Activate for Player Display');
|
||||
fireEvent.click(onBtn);
|
||||
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
|
||||
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||
|
||||
// turn OFF
|
||||
const offBtn = await screen.findByTitle('Deactivate for Player Display');
|
||||
fireEvent.click(offBtn);
|
||||
await waitFor(() => {
|
||||
const calls = findCalls('updateDoc', 'activeDisplay/status');
|
||||
const calls = findCalls('setDoc', 'activeDisplay/status');
|
||||
const last = calls[calls.length - 1];
|
||||
return last.data.activeCampaignId === null;
|
||||
});
|
||||
const calls = findCalls('updateDoc', 'activeDisplay/status');
|
||||
const calls = findCalls('setDoc', 'activeDisplay/status');
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// BUG-4 repro: toggling hidePlayerHp must not clobber activeDisplay doc.
|
||||
// setDoc = replace (contract). {merge:true} arg ignored.
|
||||
// Toggling hide-HP writes {hidePlayerHp:X} alone → activeCampaignId + activeEncounterId → null.
|
||||
// Display reads null → "Game Session Paused". Recover requires re-activating encounter.
|
||||
// Fix: use updateDoc (patch), not setDoc.
|
||||
// setDoc = replace would clobber. setDoc({merge:true}) patches only — matches main.
|
||||
// Toggling hide-HP writes {hidePlayerHp:X} via setDoc{merge} → activeCampaignId + activeEncounterId preserved.
|
||||
// Display keeps reading them → stays active.
|
||||
// Regression: if caller swaps to plain setDoc (no merge) or updateDoc-throws-on-missing, re-breaks.
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
|
||||
@@ -50,14 +50,16 @@ describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const writes = getAdapterCalls().filter(
|
||||
c => c.fn === 'updateDoc' && c.path.includes('activeDisplay/status')
|
||||
c => c.fn === 'setDoc' && c.path.includes('activeDisplay/status')
|
||||
);
|
||||
expect(writes.length).toBeGreaterThan(0);
|
||||
const last = writes[writes.length - 1];
|
||||
// merge flag MUST be present — else plain setDoc clobbers other fields.
|
||||
expect(last.opts && last.opts.merge).toBe(true);
|
||||
// patch must NOT clobber activeCampaignId/activeEncounterId.
|
||||
// BUG: setDoc replace writes only {hidePlayerHp:true} → clobbers.
|
||||
// Fix: updateDoc patch — other fields untouched.
|
||||
expect(last.patch.hidePlayerHp).toBe(true);
|
||||
// Fix: setDoc{merge} patch — other fields untouched.
|
||||
expect(last.data.hidePlayerHp).toBe(true);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,10 +82,10 @@ describe('Logs -> Firebase', () => {
|
||||
fireEvent.click(undoBtns[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`) && c.data.undone === true);
|
||||
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
return und;
|
||||
});
|
||||
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`));
|
||||
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
expect(markUndone.data.undone).toBe(true);
|
||||
// encounter path updated with undo payload (any encounter update after undo click)
|
||||
const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Lock: storage adapters must use ESM exports (no module.exports).
|
||||
// Regression guard: CJS in src/ crashes CRA prod build (ESM strict).
|
||||
// Bug history: ws.js + memory.js used module.exports. Dev lenient (masked),
|
||||
// Bug history: server.js + firebase.js used module.exports. Dev lenient (masked),
|
||||
// prod bundle crashed blank page. firebase.js always ESM.
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -8,7 +8,7 @@ import path from 'path';
|
||||
const ADAPTER_DIR = path.join(__dirname, '..', 'storage');
|
||||
|
||||
describe('storage adapters use ESM (no CJS)', () => {
|
||||
const adapters = ['ws.js', 'memory.js', 'firebase.js', 'index.js'];
|
||||
const adapters = ['server.js', 'firebase.js', 'index.js'];
|
||||
test.each(adapters)('%s has no module.exports', (file) => {
|
||||
const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8');
|
||||
// strip line comments so words like 'require' in explanatory comments don't trip the guard
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Firebase adapter contract test.
|
||||
// Runs the SAME storage contract as server (src/storage/contract.js)
|
||||
// against createFirebaseStorage. The SDK is mocked via src/__mocks__/firebase/*,
|
||||
// so this proves the adapter translates contract calls -> SDK calls correctly
|
||||
// (path handling, merge semantics, subscribe, queryConstraints) without network.
|
||||
//
|
||||
// queryConstraints (orderBy + limit) now tested in shared contract for BOTH
|
||||
// adapters — no separate firebase-only block needed here.
|
||||
|
||||
import { initFirebase, createFirebaseStorage } from '../storage/firebase';
|
||||
import { runStorageContract } from '../storage/contract';
|
||||
import { resetMockDb } from '../__mocks__/firebase/_mock-db';
|
||||
|
||||
// Firebase mock DB is shared global state. Reset before each factory so
|
||||
// contract tests are isolated.
|
||||
runStorageContract('firebase', () => {
|
||||
resetMockDb();
|
||||
const ok = initFirebase();
|
||||
if (!ok) throw new Error('initFirebase failed under mocked env');
|
||||
return createFirebaseStorage();
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// Storage factory (index.js) test.
|
||||
// Verifies getStorage() returns the right adapter per REACT_APP_STORAGE env,
|
||||
// caches as singleton, and getStorageMode() reports the active mode.
|
||||
// Adapters are mocked (no network/init) — factory routing is the unit.
|
||||
|
||||
import { getStorage, getStorageMode } from '../storage/index';
|
||||
|
||||
// reset module-level singleton between tests
|
||||
let originalStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
originalStorage = process.env.REACT_APP_STORAGE;
|
||||
// bust the module singleton by re-importing fresh
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalStorage === undefined) delete process.env.REACT_APP_STORAGE;
|
||||
else process.env.REACT_APP_STORAGE = originalStorage;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('getStorageMode', () => {
|
||||
test('defaults to firebase when env unset', () => {
|
||||
delete process.env.REACT_APP_STORAGE;
|
||||
const { getStorageMode } = require('../storage/index');
|
||||
expect(getStorageMode()).toBe('firebase');
|
||||
});
|
||||
|
||||
test('returns server when REACT_APP_STORAGE=server', () => {
|
||||
process.env.REACT_APP_STORAGE = 'server';
|
||||
const { getStorageMode } = require('../storage/index');
|
||||
expect(getStorageMode()).toBe('server');
|
||||
});
|
||||
|
||||
test('throws on unknown mode', () => {
|
||||
process.env.REACT_APP_STORAGE = 'garbage';
|
||||
const { getStorage } = require('../storage/index');
|
||||
expect(() => getStorage()).toThrow(/Unknown REACT_APP_STORAGE/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStorage factory routing', () => {
|
||||
test('server mode returns server adapter (init may fail without backend — catch)', () => {
|
||||
process.env.REACT_APP_STORAGE = 'server';
|
||||
const { getStorage } = require('../storage/index');
|
||||
try {
|
||||
const s = getStorage();
|
||||
expect(s).toBeTruthy();
|
||||
expect(typeof s.getDoc).toBe('function');
|
||||
} catch (e) {
|
||||
// ws adapter without backend URL/config is allowed to throw at factory;
|
||||
// routing reached ws branch (not firebase) which is the contract.
|
||||
expect(e).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns singleton on repeat call (same instance)', () => {
|
||||
process.env.REACT_APP_STORAGE = 'server';
|
||||
const { getStorage } = require('../storage/index');
|
||||
let a;
|
||||
try { a = getStorage(); } catch (e) { return; } // no backend ok
|
||||
if (a) {
|
||||
const b = getStorage();
|
||||
expect(a).toBe(b);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
// Runner: executes storage contract against each impl.
|
||||
// TDD: contract = spec. Run against memory first. RED until memory.js built.
|
||||
'use strict';
|
||||
|
||||
const { runStorageContract } = require('../storage/contract');
|
||||
const { createMemoryStorage } = require('../storage/memory');
|
||||
|
||||
runStorageContract('memory', () => createMemoryStorage());
|
||||
Reference in New Issue
Block a user