18 Commits
Author SHA1 Message Date
david raistrick 3d0dc79206 feat(UI): hide NPC/monster HP toggle (player display)
Mirrors hidePlayerHp. New hideNpcHp flag on activeDisplay doc.
- AdminView: 'Hide NPC/monster HP' switch in Player Display settings.
- DisplayView: HP bar gated by !(hideNpcHp && p.type !== 'character').
  Covers monsters + NPCs (all non-player).
- Default false (show NPC HP) — opposite of player HP default true.

updateDoc patch (BUG-4 pattern). No clobber.

Test: HideHpToggle selector scoped to 'hide player hp' (now 2 switches).
2026-07-02 11:29:02 -04:00
david raistrick 4b8b8bccfb feat(UI): collapsible Campaigns section
Campaigns heading -> clickable button. ChevronDown expanded,
ChevronRight collapsed. Toggles grid visibility. Shows count
(e.g. 'Campaigns (61)'). Preserves Create Campaign button.

State: campaignsCollapsed in AdminView. aria-expanded/controls for a11y.
2026-07-02 11:17:04 -04:00
david raistrick c0998da0a7 fix(BUG-4): updateDoc patch on activeDisplay (not setDoc replace)
All 5 storage.setDoc(activeDisplay, {...}, {merge:true}) →
storage.updateDoc(activeDisplay, {...}).

setDoc merge:true worked in prod (firebase honors merge) but ws adapter
+ mock ignore opts arg entirely → clobbers doc. updateDoc uses PATCH
across all adapters (firebase real updateDoc, ws PATCH endpoint, mock
merge). Consistent, no clobber.

Sites fixed:
- hidePlayerHp toggle
- startEncounter (set active ids)
- endEncounter (null active ids)
- deactivate active display
- activate new display

TDD: HideHpToggle.test RED first (assert updateDoc patch, impl still
setDoc → 0 calls found). GREEN after switch.

Char tests updated: Encounter.characterization (2) + Combat.characterization
(2) assert updateDoc on activeDisplay, not setDoc.

BUG-4: prod was already fixed (merge:true), test was RED due to mock
ignoring opts. Now all 3 adapters consistent via updateDoc.
2026-07-01 23:32:23 -04:00
david raistrick d00cc104c9 feat(FEAT-3): reslot on all participant mutation paths
Reslot (stable sort by init desc, tie-break = original array index) now
fires on all 4 paths that can change order:

1. Add participant — sortParticipantsByInitiative([...parts, new], parts)
2. Edit modal save (handleUpdateParticipant) — reslot + syncTurnOrder
3. Drag reorder — splice move (already correct, untouched)
4. Inline init field — reslot (already committed 08c27c1)

Before: add appended (ignored init), edit modal overwrote value without
moving slot. Both caused list order to drift from init order until
startEncounter (sorts once). Now any init change immediately reslots
into correct position. Display + AdminView reflect order.

Stable sort preserves drag order within ties (tie-break = original index
= reflects prior drag). Move-one semantics: only changed element moves.

EditParticipantModal: added htmlFor/id link on Initiative label (was
missing — a11y + testable).

Tests: ReslotAllPaths.test.js (2). RED first (add appended, edit modal
no reslot), green after impl.
2026-07-01 23:00:40 -04:00
david raistrick 36d7186a54 scripts: dev-start/dev-stop for local stack (backend+frontend)
dev-start.sh: starts node backend (better-sqlite3, :4001) + react frontend
(ws storage mode, :3999). Uses absolute DB_PATH to avoid workspace cwd
ambiguity (npm run server:dev runs in server/ subdir). Idempotent — skips
ports already in use.

dev-stop.sh: kills procs on :3999/:4001, sweeps node --watch + react-scripts.

Both write tmp/*.log + tmp/*.pid for debugging.
2026-07-01 22:55:37 -04:00
david raistrick 08c27c1ca5 feat(FEAT-3): reslot on inline init change + gate field
Reslot: handleInlineInitiative now sorts participants[] by init desc
(stable, tie-break original index) via sortParticipantsByInitiative.
Display + AdminView reflect new order after init edit. Not a blind
re-sort — only moved element changes position.

Gate: inline init field disabled when combat active + not paused.
Matches drag gating. DM must pause to edit initiative mid-combat.

Tests: InitiativeReslot.test.js (2). RED first (no reslot, Goblin stayed
at idx 1), green after impl (reslots to idx 0). Field gate test.
2026-07-01 22:29:38 -04:00
david raistrick 0514939c51 feat(FEAT-3): initiative first-class field (add + inline edit)
Add form: optional initiative field (monster + character). Empty = roll
d20+mod (current behavior). Filled = use value, skip roll. 'blank=roll'
hint + 'auto' placeholder for clarity.

Inline edit: ALL participants. Number input in participant row. Blur or
Enter commits. Capped 2 digits (max 99). Auto-select on focus for quick
overwrite. Styled to match other fields (border-stone-700, rounded-md,
shadow-sm, w-10).

handleAddParticipant: manualInit detects set value. lastRollDetails
adapts display (manual flag shows 'Set initiative' vs 'Rolled d20').

Campaign card date: Clock icon + 'Created:' prefix, own row, muted
stone-300 opacity-70. Was crammed inline with character/encounter counts.

Tests: InitiativeField.test.js (2 tests - set value + empty=roll).
RED first (field missing), then green after impl. App + Participant
characterization still green (18 total).

Note: inline init edit does NOT re-sort. Known followup — displays + list
order must reflect changed initiative. Tracked separately.
2026-07-01 22:25:52 -04:00
david raistrick a2c63cc77f docs(REWORK_PLAN): M5 done, PRable
M5 Docker: single container (caddy+node) verified working. REST roundtrip,
WS push, 20-round replay CLEAN, UI styled. Done.

PRable: separate docker/ tree, root Dockerfile untouched, firebase
default preserved (STORAGE=firebase). Friend merges, gets our docker
infra without touching his firebase path.

M0-M5 all done.
2026-07-01 19:26:23 -04:00
david raistrick e22f412c52 fix(docker): drop image: field so compose never pulls service image
Image: ttrpg-app:local named a registry image. Without --build flag,
compose tried pull first -> 'pull access denied' (private, unpublished).
Then fell back to build. Confusing error.

Removed image: field. Compose now auto-names (docker-app), always builds
local, never attempts registry pull. Base images (node/caddy) pull once
on first build, then cache. No pull_policy needed.

docker compose up (from docker/ dir) now works clean.
2026-07-01 19:22:31 -04:00
david raistrick 4406fd2045 docs: ENCOUNTER_BUILDER + TESTING guides for LLM session handoff
ENCOUNTER_BUILDER.md: DM interface — entity model (campaign/encounter/
participant), build flow (campaign→chars→encounter→participants), combat
controls (start/next/pause/HP/deathsaves/conditions), player display,
1-list turn order model, storage paths quick-ref.

TESTING.md: test+automation ops — commands, suites (90+24+66+4), layers
(L1 mock vs L2 live backend), types, TDD discipline, replay tool,
analyze-turns.js, audit tools, docker stack (single caddy+node container),
dev servers, storage modes, known RED backlog.

Both aimed at another LLM session picking up repo. DEVELOPMENT.md
cross-refs updated.
2026-07-01 19:16:12 -04:00
david raistrick 81c0b26b71 fix(docker): copy tailwind+postcss config so CSS compiles in build
Build produced 308-byte stub CSS (raw @tailwind directives, unprocessed)
instead of real 27KB compiled stylesheet. Dockerfile missed copying
tailwind.config.js + postcss.config.js into build context. Page rendered
as unstyled white text.

Added COPY for both configs. Rebuild: CSS now 27146 bytes. App styled.
2026-07-01 19:07:21 -04:00
david raistrick da25f46e3e fix(docker): single container (caddy+node), ESM adapters fix blank page
Docker: moved all docker files to docker/ tree (was conflated with
upstream Dockerfile at root + server/Dockerfile). Single container now:
caddy (front, serves static + proxies /api /ws) + node backend (internal
:4001). Node never exposed. entrypoint.sh runs both. Compose: one service.

Blank page root cause: storage adapters had inconsistent module systems.
firebase.js = ESM (export). ws.js + memory.js = CJS (module.exports).
CRA prod build = ESM strict -> CJS runtime crash, blank root. Dev mode
lenient, masked bug. First ws prod build (docker) = first exposure.
Never dev/prod split intended; just inconsistency from M2 era.

Fix: all adapters ESM. ws.js lazy-loads 'ws' pkg via dynamic import()
(Node/jest only; browser uses global WebSocket). index.js static
imports. server jest: added babel.config.js (preset-env, node target)
to transform ESM for jest.

Test: src/tests/StorageEsm.test.js — 4 tests grep all adapters for
module.exports / require(). Regression guard catches CJS leak.

Verified: docker page renders (root 4534 chars, UI visible).
server 24 green, shared 90 green, FE ESM 4 green.
2026-07-01 19:03:59 -04:00
david raistrick c1d982b4a4 fix(BUG-8): ws adapter auto-reconnect after drop
WS adapter had no reconnect. WS dies (idle/error/close) → wsReady=null,
subscribers dead forever, display frozen until full reload.

Changes (src/storage/ws.js):
- onClose: schedule reconnect via setTimeout(500ms), ensureWs re-arms.
  Guard: disposed flag stops reconnect after dispose.
- onOpen: resubscribe all existing doc/coll subscribers (backend state
  may have changed). Re-fetch current values on RECONNECT only (skip
  first connect — initial REST fetch in subscribe* already did). Added
  everConnected flag to distinguish first vs reconnect.
- reconnectTimer unref'd (Node) to avoid hanging event loop.
- dispose(cb): set disposed, clear timer, close ws, then cb.

Also fixed test teardown leaks:
- server/index.js close(): terminate all wss.clients before wss.close().
  Reconnect test spawned new ws to server; old close hung on live conn.
- both ws test factories: port 0 (OS picks free) instead of module-local
  nextPort counter. Parallel jest workers collided on EADDRINUSE.

Tests: ws-reconnect GREEN (1.7s), ws-contract 23 GREEN. No regression.
server suite 24/24. shared 90/90.
2026-07-01 18:26:42 -04:00
david raistrick afdd72e829 fix(analyzer): match new 'round N starting' marker
Replay marker changed 'complete'→'starting' (commit d734057). Analyzer
regex only matched 'complete' = 0 rounds parsed. Now matches both.

6 rounds parse, skips only in truncated final round (incomplete run).
2026-07-01 17:36:17 -04:00
david raistrick 58ae04b400 fix(BUG-15): DisplayView no longer re-sorts participants by initiative
DisplayView called sortParticipantsByInitiative() on visibleParticipants,
ignoring DM drag order. 1-list model = participants[] IS display source.
After cross-init drag, player view diverged from AdminView/turnOrderIds.

Repro: round 4 replay. [reorder Summon1(10)→before Merchant(11)] made
turnOrderIds = [...,Summon2,Summon1,Merchant,OrcBoss]. AdminView correct.
DisplayView re-sorted = Summon2,Merchant,Summon1 (init order) = visually
Merchant appeared between Summon2 and Summon1, NOT at end. DM confused.

Fix: removed sort. DisplayView now renders participants[] order directly
(filter inactive monsters only), matching AdminView line 1222.

Test: RED → GREEN (src/tests/DisplayView.drag-order.test.js). Seeds 3
monsters in drag order [High:20, Low:10, Mid:11]. Asserts DOM order =
participants[] order, not init-sorted. No DisplayView regressions.
2026-07-01 17:31:40 -04:00
david raistrick d73405753a fix(replay): round markers align with turn-line round labels
Round-complete marker logged roundN (just completed) while turn lines
logged enc.round (post-increment, new round). Result: 'turn 8 (round 2)'
appeared BEFORE 'round 1 complete' — confusing off-by-one.

Replaced bottom 'round N complete' marker with top 'round N starting'
marker. Turn lines for round N now appear after its start marker.

Logic unchanged. 4-round smoke verified.
2026-07-01 17:21:55 -04:00
david raistrick 3b07fc27b0 docs(TODO): add BUG-13 (reorder cross-pointer), BUG-14 (addParticipant post-drag)
BUG-12 marked done (selection follows activeDisplay).
2026-07-01 17:19:31 -04:00
david raistrick af165f4491 fix(replay): no-op + pointer-crossing reorder picks
Replay reorder picker used living[0]→living[1] (HP-sorted). Wolf(20)
already before Merchant(19) = no-op drag. Fired every 8 turns = UI
animated drag, nothing changed = visual funk. 3 useless Wolf→Merchant
drags in round 16-17 log.

Also fixed pointer-cross: old picker dragged arbitrary pair. If swap
crossed current pointer → ambiguous who-acted semantics (skip/double).

New picker: swap two ADJACENT UPCOMING actors (both strictly after
current pointer). Always real move, never crosses pointer.

13-round replay: 0 skips, 0 double-acts, 0 order shifts (was 2 skips,
4 double-acts with arbitrary swaps).

Note: reorderParticipants itself has no pointer logic — pure drag.
Crossing pointer behavior in real app untested (potential BUG-13).
2026-07-01 17:14:06 -04:00
34 changed files with 1266 additions and 272 deletions
-36
View File
@@ -1,36 +0,0 @@
# Caddyfile — serve static frontend, proxy /api + /ws to backend
# handle blocks are mutually exclusive + ordered: API/WS first, static last.
# (try_files at site level would rewrite /api/* → /index.html before proxy.)
{
# admin off for docker
admin off
}
:80 {
encode gzip
# REST API → backend service (path preserved: /api/doc etc.)
handle /api/* {
reverse_proxy backend:4001 {
header_up Host {host}
}
}
# WebSocket upgrade → backend
handle /ws {
reverse_proxy backend:4001
}
# Everything else: static SPA with client-side routing fallback
handle {
root * /srv
try_files {path} /index.html
file_server
}
# HTTP basic auth (in-house only). Uncomment + set CADDY_BASIC_AUTH env.
# basic_auth {
# {$CADDY_BASIC_AUTH}
# }
}
-29
View File
@@ -1,29 +0,0 @@
# Dockerfile.ws — frontend build (STORAGE=ws) served by Caddy
# Same-origin: Caddy proxies /api + /ws to backend. No backend URL baked at build.
FROM node:18-alpine AS build
WORKDIR /app
# workspaces root
COPY package*.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
RUN npm install --include-workspace-root
COPY shared/ ./shared/
COPY src/ ./src/
COPY public/ ./public/
# Build with ws storage (no backend URL — same-origin via Caddy proxy)
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
ENV REACT_APP_STORAGE=ws
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
# Stage 2: Caddy serves static + proxies API/WS
FROM caddy:2-alpine
COPY --from=build /app/build /srv
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 80
+50 -7
View File
@@ -5,6 +5,13 @@ REWORK_PLAN.md.
## Feature backlog ## Feature backlog
### CRITICAL BUG - storage
- docker for sql is not using persistant storage...
### feat - campaign section rollup
### feat - add all characters to participants list
### FEAT-M6: Transactional undo (moved from REWORK_PLAN) ### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`. - Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
- Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional, - Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional,
@@ -17,7 +24,7 @@ REWORK_PLAN.md.
## Architecture: 1-list turn order model (DONE) ## Architecture: 1-list turn order model (DONE)
- Single source: turnOrderIds === participants.map(id). No re-sort after - Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) overrides initiative cross-init allowed, DM choice. - Drag (reorder) overrides initiative --- cross-init allowed, DM choice.
- startEncounter sorts ALL participants by init once, then frozen. - startEncounter sorts ALL participants by init once, then frozen.
- addParticipant splices by init pos. remove/toggle/reorder sync list. - addParticipant splices by init pos. remove/toggle/reorder sync list.
- Display renders participants[] directly (no sortParticipantsByInitiative). - Display renders participants[] directly (no sortParticipantsByInitiative).
@@ -31,7 +38,7 @@ REWORK_PLAN.md.
- Separate design + RED. Own work item. - Separate design + RED. Own work item.
- Related: tie-break = drag order (current, works). Expose clearly. - Related: tie-break = drag order (current, works). Expose clearly.
### FEAT-1: Dead participants stay in turn order DONE ### FEAT-1: Dead participants stay in turn order --- DONE
- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds` - Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds`
on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get
death-save turn. `isActive` = DM toggle only. death-save turn. `isActive` = DM toggle only.
@@ -40,7 +47,7 @@ REWORK_PLAN.md.
### FEAT-2: upgrade app internal logs to be parseable ### FEAT-2: upgrade app internal logs to be parseable
- Goal: combat logs in Firestore store enough structured state to run - Goal: combat logs in Firestore store enough structured state to run
skip/rotation analysis on ANY historic round not just replay stdout. skip/rotation analysis on ANY historic round --- not just replay stdout.
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must - Current logs: `{timestamp, message, encounterName, undo}`. Parser must
guess roster from message strings. Brittle. guess roster from message strings. Brittle.
- Upgrade: add structured fields at turn-state mutation log sites in - Upgrade: add structured fields at turn-state mutation log sites in
@@ -83,7 +90,7 @@ REWORK_PLAN.md.
### bug-3 was a halucination has been removed ### bug-3 was a halucination has been removed
### BUG-4: hide-player-HP breaks display view (preexisting) ### BUG-4: hide-player-HP breaks display view (preexisting) --- PROD FIXED, TEST RED (mock bug)
- **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls - **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls
use `{merge:true}` which is IGNORED (setDoc = replace per contract). use `{merge:true}` which is IGNORED (setDoc = replace per contract).
Each write clobbers other fields on activeDisplay/status doc. Each write clobbers other fields on activeDisplay/status doc.
@@ -101,15 +108,22 @@ REWORK_PLAN.md.
activeEncounterId with null (setDoc replace vs updateDoc patch). activeEncounterId with null (setDoc replace vs updateDoc patch).
- Fix: use updateDoc (patch) not setDoc (replace); or include all existing - Fix: use updateDoc (patch) not setDoc (replace); or include all existing
fields when writing. fields when writing.
- Status update (2026-07): all 5 sites now use `{merge:true}`. Real firebase
adapter honors merge → production works. BUT jsdom test still RED because
`src/__mocks__/firebase/firestore.js` setDoc records call, IGNORES opts
(no actual merge). Mock must simulate firebase merge semantics for test
to pass. Fix = mock setDoc: if opts.merge, MOCK_DB.merge(path,data) else
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, ws adapter
uses PATCH). Decide which.
- Test: render App + DisplayView, toggle hide-HP, assert display still shows - Test: render App + DisplayView, toggle hide-HP, assert display still shows
encounter (not paused). encounter (not paused).
### BUG-5: mid-round addParticipant/revive corrupts rotation FIXED ### BUG-5: mid-round addParticipant/revive corrupts rotation --- FIXED
- Fixed (commit `494327f`). Slot-array turn order + DRY advance core - Fixed (commit `494327f`). Slot-array turn order + DRY advance core
`nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate. `nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate.
- 500-round replay: 0 skips, 0 double-acts. - 500-round replay: 0 skips, 0 double-acts.
### BUG-6: reorderParticipants doesn't update turnOrderIds FIXED ### BUG-6: reorderParticipants doesn't update turnOrderIds --- FIXED
- Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds = - Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds =
participants.map(id) always. reorder cross-init allowed (DM override). participants.map(id) always. reorder cross-init allowed (DM override).
Display === rotation by construction. Display === rotation by construction.
@@ -152,10 +166,39 @@ REWORK_PLAN.md.
- Baseline (my changes removed) also exit=1. Pre-existing, not regression. - Baseline (my changes removed) also exit=1. Pre-existing, not regression.
- Crashes whole FE test run (process dies). - Crashes whole FE test run (process dies).
### BUG-13: reorderParticipants crossing current pointer = ambiguous acted-semantics
- Discovered 7/1 replay. `reorderParticipants` (shared/turn.js:522) = pure
drag, no pointer logic. Swapping two actors across current pointer mid-round
= ambiguous who-acted-this-round. Earlier replay arbitrary swaps showed
skip/double (R9 Summon3 2x, R11 Goblin1 2x) before fix restricted swaps to
upcoming-only.
- Replay now avoids crossing (adjacent upcoming pair only, commit af165f4).
Real app untested: if DM drags actor past current pointer mid-round, skip/
double behavior undefined.
- Decide: block cross-pointer reorder, or define acted-semantics. RED needed.
### BUG-14: addParticipant init-insertion breaks after drag-reorder
- Discovered 7/1 replay. `computeTurnOrderAfterAddition` scans for first id
with init < addedInit, assumes list init-sorted. After drag, list NOT sorted
→ scan hits wrong slot.
- Trace turn 30→31: list `[Goblin1:20,Goblin2:22,...]` (drag moved Goblin1
before Goblin2). Add Reinforce3 init 21 → scan hits Goblin1:20 (idx 0, <21)
first → insert at 0. Should slot after Goblin2:22. WRONG.
- Root conflict: 1-list model = drag source of truth (no re-sort); addParticipant
= init-based insertion (needs sorted list). After ANY drag, add-insertion
meaningless.
- Proposed fix: append to end always (option A). DM drags to position. Matches
drag = source of truth. Makes `computeTurnOrderAfterAddition` trivial.
- Related: FEAT-3 (initiative first-class field).
## Pipeline (bugs only --- milestones live in REWORK_PLAN.md) ## Pipeline (bugs only --- milestones live in REWORK_PLAN.md)
- [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites - [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites
- [x] BUG-5: fixed (1-list model, 500 rounds clean) - [x] BUG-5: fixed (1-list model, 500 rounds clean)
- [x] BUG-6: fixed structurally (1-list model) - [x] BUG-6: fixed structurally (1-list model)
- [ ] BUG-8: ws adapter reconnect - [x] BUG-12: fixed --- campaign selection follows activeDisplay
- [x] BUG-15: fixed --- DisplayView no longer re-sorts (drag order preserved)
- [x] BUG-8: ws adapter reconnect (implemented + GREEN)
- [ ] BUG-10: deact+reactivate double-act - [ ] BUG-10: deact+reactivate double-act
- [ ] BUG-11: FE Combat.scenario crash - [ ] BUG-11: FE Combat.scenario crash
- [ ] BUG-13: reorder cross-pointer semantics (RED + decide block/allow)
- [ ] BUG-14: addParticipant init-insert breaks post-drag (append? + RED)
-59
View File
@@ -1,59 +0,0 @@
# docker-compose.yml — two profiles:
# firebase: existing Dockerfile (nginx + firebase build), upstream path
# backend: full stack (caddy frontend + node backend + sqlite volume)
#
# Usage:
# docker compose --profile backend up --build # full self-hosted stack
# docker compose --profile firebase up --build # firebase-only (upstream)
#
# Run local in OrbStack; remote docker context later (just change context).
services:
# ---- full self-hosted stack (STORAGE=ws) ----
backend:
profiles: ["backend"]
build:
context: .
dockerfile: server/Dockerfile
image: ttrpg-backend:local
volumes:
- backend-data:/data
environment:
- DB_PATH=/data/tracker.sqlite
- PORT=4001
# - CORS_ORIGIN=* # Caddy same-origin, cors not strictly needed
expose:
- "4001"
restart: unless-stopped
frontend:
profiles: ["backend"]
build:
context: .
dockerfile: Dockerfile.ws
args:
- REACT_APP_TRACKER_APP_ID=${TRACKER_APP_ID:-ttrpg-initiative-tracker-default}
image: ttrpg-frontend:local
ports:
- "${FRONTEND_PORT:-8080}:80"
depends_on:
- backend
# Optional basic auth: set in .env as CADDY_BASIC_AUTH="user <hashed-pass>"
# Generate hash: caddy hash-password
environment:
- CADDY_BASIC_AUTH=${CADDY_BASIC_AUTH:-}
restart: unless-stopped
# ---- firebase-only path (upstream, existing Dockerfile) ----
firebase:
profiles: ["firebase"]
build:
context: .
dockerfile: Dockerfile
image: ttrpg-firebase:local
ports:
- "${FRONTEND_PORT:-8080}:80"
restart: unless-stopped
volumes:
backend-data:
+18
View File
@@ -0,0 +1,18 @@
# Caddyfile — single-container (caddy + node)
# Caddy serves built frontend, proxies /api + /ws to node backend on :4001.
# Node never exposed directly; only caddy on :80.
:80 {
handle /api/* {
reverse_proxy 127.0.0.1:4001
}
handle /ws {
reverse_proxy 127.0.0.1:4001
}
# catch-all: static frontend (SPA fallback)
handle {
root * /srv
try_files {path} /index.html
file_server
}
}
+54
View File
@@ -0,0 +1,54 @@
# docker/Dockerfile — single container: caddy (front) + node (back).
# Build context = repo root.
# ---- build stage: frontend + install backend deps ----
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
RUN npm install --include-workspace-root
COPY shared/ ./shared/
COPY server/ ./server/
COPY src/ ./src/
COPY public/ ./public/
COPY tailwind.config.js postcss.config.js ./
# better-sqlite3 native build (alpine musl)
RUN cd server && npm rebuild better-sqlite3
# build frontend (ws storage, same-origin via caddy)
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
ENV REACT_APP_STORAGE=ws
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
# prune backend dev deps for runtime
RUN npm prune --omit=dev
# ---- runtime stage: caddy + node ----
FROM node:18-alpine
RUN apk add --no-cache caddy
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/shared/node_modules ./shared/node_modules
COPY --from=build /app/server/node_modules ./server/node_modules
COPY --from=build /app/package*.json ./
COPY --from=build /app/shared/package.json ./shared/
COPY --from=build /app/server/package.json ./server/
COPY shared/ ./shared/
COPY server/ ./server/
# built frontend served by caddy
COPY --from=build /app/build /srv
COPY docker/Caddyfile /etc/caddy/Caddyfile
COPY docker/entrypoint.sh /entrypoint.sh
ENV NODE_ENV=production
ENV PORT=4001
ENV DB_PATH=/data/tracker.sqlite
EXPOSE 80
WORKDIR /app
CMD ["/entrypoint.sh"]
+23
View File
@@ -0,0 +1,23 @@
# docker/docker-compose.yml — single container: caddy (front) + node (back).
# Usage (from repo root):
# docker compose -f docker/docker-compose.yml up --build
services:
app:
# no image: field => compose auto-names (docker-app), never pulls,
# always builds local. Service image private, never published.
build:
context: ..
dockerfile: docker/Dockerfile
args:
- REACT_APP_TRACKER_APP_ID=${TRACKER_APP_ID:-ttrpg-initiative-tracker-default}
ports:
- "${PORT:-8080}:80"
volumes:
- app-data:/data
environment:
- DB_PATH=/data/tracker.sqlite
- PORT=4001
restart: unless-stopped
volumes:
app-data:
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# docker/entrypoint.sh — run node backend + caddy proxy in one container.
# Caddy foreground (PID 1, handles signals). Node background.
set -e
# node backend (internal :4001)
cd /app/server
node index.js &
NODE_PID=$!
# caddy proxy (foreground, :80)
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
+2
View File
@@ -35,6 +35,8 @@ TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm wor
REWORK_PLAN.md REWORK_PLAN.md
DEVELOPMENT.md # this file DEVELOPMENT.md # this file
GLOSSARY.md # domain terms (turn vs round, etc) GLOSSARY.md # domain terms (turn vs round, etc)
ENCOUNTER_BUILDER.md # DM interface guide
TESTING.md # test + automation ops
``` ```
## Setup ## Setup
+208
View File
@@ -0,0 +1,208 @@
# Encounter Builder — DM Interface Guide
How a DM (or LLM automating the DM role) builds and runs encounters via the UI and storage layer. Covers entity model, build flow, combat controls, and the storage paths backing each action.
## Entity model
Three nested entities. All stored as opaque JSON docs in the KV store (generic doc store — see `docs/DEVELOPMENT.md`).
```
Campaign
└─ Encounter(s)
└─ Participant(s)
```
Plus two global docs:
- `activeDisplay/status` — controls player view (which campaign+encounter, hide-HP flag)
- `logs/{id}` — append-only action log entries
### Campaign
Path: `artifacts/{APP_ID}/public/data/campaigns/{campaignId}`
| Field | Type | Notes |
|---|---|---|
| `name` | string | |
| `playerDisplayBackgroundUrl` | string | optional, image URL for player display bg |
| `ownerId` | string | user id |
| `createdAt` | ISO string | |
| `players` | array | campaign-level character roster (templates, NOT combatants) |
Campaign characters = reusable templates. Default HP + init mod. Added to any encounter via ParticipantManager. Not combatants themselves.
### Encounter
Path: `artifacts/{APP_ID}/public/data/campaigns/{campaignId}/encounters/{encounterId}`
| Field | Type | Notes |
|---|---|---|
| `name` | string | |
| `createdAt` | ISO string | |
| `participants` | array | the combatants (see below) |
| `round` | int | 0 = not started |
| `currentTurnParticipantId` | string\|null | who acts now |
| `isStarted` | bool | combat active |
| `isPaused` | bool | frozen turn order (add/remove/edit allowed) |
| `turnOrderIds` | array | participant ids in turn order = participants[] order (1-list model) |
### Participant
Object in `encounter.participants[]`:
| Field | Type | Notes |
|---|---|---|
| `id` | string | `generateId()` |
| `name` | string | |
| `type` | `'character'` \| `'monster'` | character = PC (death saves), monster = hostile/NPC |
| `originalCharacterId` | string\|null | links back to campaign character if type=character |
| `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. |
| `maxHp` | int | |
| `currentHp` | int | 0 = dead/dying |
| `isNpc` | bool | monster flagged NPC (display color, no death saves) |
| `conditions` | array | condition ids from `CONDITIONS` list |
| `isActive` | bool | in turn rotation? false = skipped by nextTurn |
| `deathSaves` | int | PC only, 0-3 fails |
| `isDying` | bool | death animation flag (player display) |
## Build flow (UI)
Admin view at `/`. Steps:
### 1. Create campaign
- Click **Create Campaign** button
- Enter name + optional background URL
- Submits → `setDoc(campaigns/{id}, { name, playerDisplayBackgroundUrl, ownerId, createdAt, players:[] })`
### 2. Select campaign
- Click campaign card → `setSelectedCampaignId(campaign.id)`
- Now managing: CharacterManager + EncounterManager visible
### 3. Add campaign characters (optional templates)
CharacterManager section. Per character:
- **Name**
- **Default HP** (`DEFAULT_MAX_HP` = 10)
- **Init Mod** (`DEFAULT_INIT_MOD` = 0)
`updateDoc(campaign, { players:[...existing, newChar] })`
These are reusable across encounters. Add to encounter later (auto-rolls initiative).
### 4. Create encounter
- Click **Create Encounter**
- Enter name
`setDoc(campaigns/{cid}/encounters/{eid}, { name, createdAt, participants:[], round:0, currentTurnParticipantId:null, isStarted:false, isPaused:false })`
### 5. Add participants
ParticipantManager section. Two paths:
**Monster/NPC:**
- **Monster Name** (`placeholder: "e.g., Dire Wolf"`)
- **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2)
- **Max HP** (`DEFAULT_MAX_HP` = 10)
- **Is NPC?** checkbox (flag, changes display color)
- Click **Add to Encounter**
- Initiative auto-rolled: `rollD20() + mod`
**Character (from campaign roster):**
- Select character from dropdown
- Click **Add to Encounter**
- OR **Add All (Roll Init)** — bulk-adds all campaign chars, each rolls own initiative
**Duplicate guard:** same `originalCharacterId` blocked (alerts "already in this encounter"). Monsters no dedup.
Participant object added:
```js
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp,
isNpc, conditions:[], isActive:true, deathSaves:0, isDying:false }
```
### 6. Reorder before start (tie-break)
Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`.
Post-start drag: see BUG-13/14 in `TODO.md` (cross-init + pointer semantics untested).
## Combat flow (UI)
InitiativeControls panel (sticky, right side).
### Start
- **Start Combat** button (disabled if no active participants)
- Sorts ALL participants by initiative (1-list: `participants[]` = display + turn order)
- `round=1`, `currentTurnParticipantId` = first active, `isStarted=true`, `isPaused=false`
- Sets `activeDisplay` → this campaign+encounter (player display syncs)
- Initiative fixed at start. NOT re-derived from mod after.
### Next Turn
- **Next Turn** button (disabled if paused)
- Advances to next active participant in `turnOrderIds`
- Wraps at end → `round += 1`, re-sorts active by initiative at round start
- Dead (`isActive:false`) skipped, stay in rotation
### Pause / Resume
- **Pause Combat** → `isPaused=true`, Next Turn disabled
- While paused: add/remove participants, adjust HP, edit initiative, reorder ties
- **Resume Combat** → `isPaused=false`, no re-sort (1-list: turnOrderIds already current)
### HP adjustments (combat only)
Per-participant input + buttons:
- Number input
- **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)`
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)`
- Death: hp→0 sets `isActive:false`, PC gets `deathSaves` tracking
### Death saves (PC only, at 0 HP)
3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal.
### Conditions
- Click participant → expand conditions picker (all 22 from `CONDITIONS`)
- Active conditions show as badges, click to remove
### End combat
- **End Combat** button → resets `isStarted:false`, `round:0`, `currentTurn:null`, `turnOrderIds:[]`
- Clears `activeDisplay` (player view goes blank)
## Player display
Separate view at `/display` or `?playerView=true`. Read-only second screen.
What it shows:
- Current encounter name
- Round + current turn participant
- All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix)
- HP bars, conditions, death saves
- Inactive monsters hidden (pre-staged reserves)
Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets).
## 1-list turn order model
Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single source of truth.
- **Display** = `participants[]` order (AdminView + DisplayView, no re-sort)
- **Turn rotation** = `turnOrderIds` (mirrors participants[])
- **Drag** = source of truth, overrides initiative
- **Add mid-combat** = append to participants[] + sync (BUG-14: init-insert broken post-drag)
- **Toggle active** = flip `isActive` only, stay in slot
- **Remove** = drop from participants[] + sync, advance current if needed
No re-sort after `startEncounter` except round-wrap (re-sorts active by init at top of round).
## Storage paths quick reference
```
campaigns/{cid} campaign doc
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
campaigns/{cid}/encounters/{eid}/participants ❌ NOT a path — participants inline
activeDisplay/status player display control
logs/{logId} action log entry
```
## DM tips
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
- Pause before big roster changes (adds/removes). Resume re-syncs cleanly.
- Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added).
- Dead monsters stay in rotation, skipped. Remove via trash icon to clean list.
- Player display auto-follows Start Combat. Manual control via Open Player Window.
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs.
+15 -13
View File
@@ -120,7 +120,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
| 2 | frontend WS adapter — app runs vs backend, cross-device works | yes | | 2 | frontend WS adapter — app runs vs backend, cross-device works | yes |
| 3 | characterization tests lock current behavior | yes | | 3 | characterization tests lock current behavior | yes |
| 4 | resolve initiative rotation corruption (BUG-5) | yes | | 4 | resolve initiative rotation corruption (BUG-5) | yes |
| 5 | docker compose in-house | smoke | | 5 | docker single container (caddy+node) | smoke |
| 6 | _moved to TODO backlog (feature work)_ | - | | 6 | _moved to TODO backlog (feature work)_ | - |
| 7 | playwright multi-window e2e (deferred) | e2e | | 7 | playwright multi-window e2e (deferred) | e2e |
| 8 | (future) public exposure | - | | 8 | (future) public exposure | - |
@@ -169,16 +169,18 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
- Tests: `turn.skip.test.js`, `turn.dry.test.js` (advance parity lock). - Tests: `turn.skip.test.js`, `turn.dry.test.js` (advance parity lock).
- **Upstream-PRable:** ✅ bug fix. - **Upstream-PRable:** ✅ bug fix.
### Milestone 5 — Docker compose ### Milestone 5 — Docker compose
- `docker-compose.yml`: - Single container: caddy (front, static + proxy) + node backend (internal :4001).
- `backend` service (Node + sqlite volume) - Files in `docker/` tree (kept separate from upstream root Dockerfile):
- `frontend` service (static build served via **Caddy**) - `docker/Dockerfile` — build FE + BE, runtime caddy+node
- Caddy reverse-proxies `/api` + `/ws` → backend, auto WS upgrade, HTTP basic auth - `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback
- Caddy chosen over nginx: simpler config, native WS, one file `Caddyfile`. - `docker/entrypoint.sh` — node bg + caddy fg
- Profiles: `firebase` (frontend only, current behavior) vs `backend` (full stack). - `docker/docker-compose.yml` — one `app` service, volume for sqlite
- Run: OrbStack local now; remote docker context later. - Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080.
- **Exit criteria:** `docker compose up` runs full stack in-house. - No `image:` field => compose auto-names, never pulls service image (private).
- **Upstream-PRable:** ❌ divergence. - **Exit criteria:** `docker compose up` runs full stack in-house. ✅ DONE.
- Verified: REST roundtrip, WS subscribe+push, replay 20 rounds CLEAN (0 skips/doubles/shifts), UI styled (Tailwind compiles).
- **Upstream-PRable:** ✅ separate docker/ tree, root Dockerfile untouched, firebase default preserved.
### Milestone 6 — Undo rework — _MOVED to TODO backlog_ ### Milestone 6 — Undo rework — _MOVED to TODO backlog_
- Moved: feature work (transactional undo), not infra. Lives in `TODO.md` now. - Moved: feature work (transactional undo), not infra. Lives in `TODO.md` now.
@@ -230,7 +232,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
| 2 WS adapter | ⚠️ partial | interface + firebase extract ✅, WS ❌ | | 2 WS adapter | ⚠️ partial | interface + firebase extract ✅, WS ❌ |
| 3 characterization tests | ✅ | if storage-agnostic | | 3 characterization tests | ✅ | if storage-agnostic |
| 4 BUG-5 rotation fix | ✅ | bug fix | | 4 BUG-5 rotation fix | ✅ | bug fix |
| 5 docker compose | ❌ | divergence | | 5 docker | ✅ | separate docker/ tree, root Dockerfile untouched, firebase preserved |
| 6 undo (moved to TODO) | - | - | | 6 undo (moved to TODO) | - | - |
| 7 playwright | ✅ | if test infra shared | | 7 playwright | ✅ | if test infra shared |
@@ -257,7 +259,7 @@ Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally
## Current status ## Current status
- M0 ✅, M1 ✅, M2 ✅, M3 ✅ - M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
- Backend live: port 4001, db `./data/tracker.sqlite` - Backend live: port 4001, db `./data/tracker.sqlite`
- Frontend: port 3999 with `REACT_APP_STORAGE=ws` - Frontend: port 3999 with `REACT_APP_STORAGE=ws`
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`. - Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
+234
View File
@@ -0,0 +1,234 @@
# Testing & Automation — Operating Guide
How to run tests, demos, audits, docker stack, and understand the test layers. For any LLM session picking up this repo.
## Test commands
```bash
npm run test:all # shared + server (fast, ~2s) — pre-push gate
npm run shared:test # pure turn logic (shared/turn.js)
npm run server:test # ws adapter vs live backend
npm test # CRA frontend (src/tests/, slow w/ scenario)
```
Pre-push hook (`.githooks/pre-push`) runs `npm run test:all`. Frontend not gated (slow). Skip: `git push --no-verify`.
Setup hook once per clone:
```bash
git config core.hooksPath .githooks
```
## Test suites
| Suite | Location | What | Count |
|---|---|---|---|
| Unit (turn logic) | `shared/tests/` | pure nextTurn, rotation, pause-add, dead-skip, reorder, round, invariant, dry | 90 |
| Integration (adapter vs backend) | `server/tests/` | ws adapter through live REST/WS | 24 |
| Characterization (UI) | `src/tests/` | locks current App.js behavior | 66 |
| ESM guard | `src/tests/StorageEsm.test.js` | no CJS in adapters | 4 |
Total: ~184. 1 known RED (BUG-4 HideHpToggle, backlog).
### Run one file / pattern
```bash
npm test --workspace shared -- --testPathPattern=round-rotation
npm run server:test -- tests/ws-reconnect
CI=true npx react-scripts test --watchAll=false --testPathPattern="DisplayView.drag-order"
```
Frontend uses `react-scripts test` (CRA). Always set `CI=true` + `--watchAll=false` for single runs.
## Test layers (important)
Two layers, both required:
- **Layer 1**: App vs firebase mock (`src/__mocks__/firebase/`). Proves adapter call shape. Never exercises ws adapter.
- **Layer 2**: ws adapter vs live backend (`server/tests/`). Proves translation + path identity.
Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws event handler bugs). Layer 2 catches those.
## Test types
| Type | Purpose |
|---|---|
| **Unit** | pure logic, fast, no I/O. Locks single function behavior. |
| **Integration** | real backend per test (port 0 = OS picks free). Adapter translation verified. |
| **Characterization** | render App via mock, assert current UI behavior (buggy or not). NOT desired-state. |
| **Contract** | same spec run against every storage impl (memory, ws, firebase). Catches adapter drift. |
| **Scenario** | end-to-end flow through rendered App. `Combat.scenario.test.js` = 100 rounds, ~240s. Pre-existing crash (BUG-11). |
## TDD discipline
RED first → fix → GREEN. Never change functional code to pass tests for existing state without test driving it.
- Find bug → write failing test (RED)
- Fix code → test passes (GREEN)
- Log confirmed bug in `TODO.md`
- One bug at a time, commit with evidence
## Replay tool (demo, NOT unit test)
`scripts/replay-combat.js` — drives full combat via ws adapter (same contract as App) against live backend. UI updates in real-time if frontend running.
```bash
# start backend + frontend first
node scripts/replay-combat.js [rounds] [delayMs]
# defaults: 100 rounds, 200ms/step
# faster: 20 400 = 20 rounds, 400ms each
# against docker stack:
BACKEND_URL=http://127.0.0.1:8080 node scripts/replay-combat.js 20 400
```
Coverage per round: damage, heal, all 22 conditions, toggleActive, removeParticipant, addParticipant (reinforcements), updateParticipant, pause/resume, reorderParticipants, endEncounter. Revives dead each round to sustain count.
Output → log file, then analyze:
```bash
node scripts/replay-combat.js 20 400 > tmp/run.log 2>&1
node scripts/analyze-turns.js tmp/run.log
```
Exit 0 = clean. Reports skips, double-acts, order shifts.
### analyze-turns.js
Parses replay log. Detects:
- **real skips**: active participant not acted in a round
- **double-acts**: same participant twice in a round
- **order shifts**: turnOrderIds changed unexpectedly
Handles `[pointer X→Y wrap]` events (mutation-driven advance) and `[reorder A→before B]`. Logs `order=[Name:init,...]` + `parts=[Name:init,...]` per turn. Parser blind to DisplayView render (separate concern — FE test covers that).
Round marker: `--- round N starting ---` (top of loop, post-fix).
## Audit tools (NOT unit tests)
`tests/audit/` — exploratory, `Math.random`, non-deterministic. Manual run. NOT jest.
### audit-rotation.js
Pure turn.js simulation of replay op sequence. Detects rotation violations. Found BUG-1.
```bash
node tests/audit/audit-rotation.js
```
### audit-state.js
Runs pure turn.js combat. Audits 9 invariant classes per round:
1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN)
3. isActive consistency (dead = inactive)
4. turnOrder no dup ids
5. turnOrder ids all active
6. currentTurn valid + active
7. deathSave range (0-3, reset on revive)
8. removeParticipant orphans
9. undo support
```bash
node tests/audit/audit-state.js [rounds] # default 100
```
Current state: 0 violations / 100 rounds (post BUG-1/2 fix).
## Docker stack
Single container: caddy (front, static + proxy) + node backend (internal :4001).
```bash
# build + run (from repo root)
docker compose -f docker/docker-compose.yml up --build -d
# → http://127.0.0.1:8080
# logs
docker compose -f docker/docker-compose.yml logs app --tail 20
# stop
docker compose -f docker/docker-compose.yml down
# rebuild after code change
docker compose -f docker/docker-compose.yml up -d --build
```
Files:
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback
- `docker/entrypoint.sh` — runs node bg + caddy fg
- `docker/docker-compose.yml` — one `app` service, volume for sqlite
### Verify docker stack
```bash
# REST roundtrip
curl -s -X PUT http://127.0.0.1:8080/api/doc -H 'Content-Type: application/json' \
-d '{"path":"campaigns/test","data":{"name":"X"}}' >/dev/null
curl -s "http://127.0.0.1:8080/api/doc?path=campaigns/test"
# WS subscribe + push (node one-liner, see scripts)
# Full combat: replay against docker
BACKEND_URL=http://127.0.0.1:8080 node scripts/replay-combat.js 20 400 > tmp/docker.log 2>&1
node scripts/analyze-turns.js tmp/docker.log
```
### Inspect docker sqlite
```bash
docker exec docker-app-1 sh -c 'node -e "
const db=require(\"better-sqlite3\")(\"/data/tracker.sqlite\");
const rows=db.prepare(\"SELECT path, substr(data,1,50) as d FROM docs\").all();
console.log(\"count=\"+rows.length);
rows.forEach(r=>console.log(r.path+\" => \"+r.d));
"'
```
## Dev servers (non-docker)
### Backend
```bash
npm run server:dev # :4001, db: ./data/tracker.sqlite
# or:
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
curl http://127.0.0.1:4001/health # → {"ok":true}
```
Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Frontend (ws mode)
```bash
REACT_APP_STORAGE=ws \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
npm start
```
→ http://127.0.0.1:3999/. Admin `/`, player `/display`.
Firebase mode (default): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.example`).
## Storage modes
`STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`:
- `firebase` (default) → real SDK
- `ws` → backend (docker/prod)
- `memory` → in-process (test seed)
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs memory/ws/firebase.
## Known RED / backlog
- BUG-4: HideHpToggle RED (setDoc→updateDoc, clobbers activeDisplay)
- BUG-10: deact+reactivate double-act
- BUG-11: Combat.scenario test crash
- BUG-13: reorder cross-pointer semantics
- BUG-14: addParticipant init-insert post-drag
See `TODO.md` for full list + status.
## Scratch
`scratch/` — gitignored throwaway. Repro scripts, exploration, debug. Not committed. Use freely, delete anytime.
## Status
See `docs/REWORK_PLAN.md` for milestones, `TODO.md` for bugs, `docs/DEVELOPMENT.md` for setup, `docs/GLOSSARY.md` for terms, `docs/ENCOUNTER_BUILDER.md` for DM interface.
+1 -1
View File
@@ -27,7 +27,7 @@ const ADD_RE = /^\s*\[(?:add)\s+(.+?)\]\s*$/;
const REMOVE_RE = /^\s*\[(?:remove dead|remove)\s+(.+?)\]\s*$/; const REMOVE_RE = /^\s*\[(?:remove dead|remove)\s+(.+?)\]\s*$/;
const PAUSE_RE = /^\s*\[pause\]\s*$/; const PAUSE_RE = /^\s*\[pause\]\s*$/;
const RESUME_RE = /^\s*\[resume\]\s*$/; const RESUME_RE = /^\s*\[resume\]\s*$/;
const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+complete/; const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+(?:complete|starting)/;
const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/; const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/;
const REORDER_RE = /^\s*\[reorder\s+(.+?)→before\s+(.+?)\]\s*$/; const REORDER_RE = /^\s*\[reorder\s+(.+?)→before\s+(.+?)\]\s*$/;
const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/; const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/;
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Start local dev stack: node backend (sqlite) + react frontend, ws storage mode.
# Usage: ./scripts/dev-start.sh
# Stop: ./scripts/dev-stop.sh
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p tmp data
# kill anything on the ports (zombies)
for port in 3999 4001; do
pids=$(lsof -ti :$port 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "port $port in use by: $pids — leaving as-is."
echo " (run ./scripts/dev-stop.sh first to restart clean)"
fi
done
# backend: better-sqlite3, :4001
if ! lsof -ti :4001 >/dev/null 2>&1; then
echo "starting backend :4001..."
DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 \
nohup npm run server:dev > tmp/server.log 2>&1 &
echo $! > tmp/server.pid
else
echo "backend already on :4001"
fi
# frontend: ws storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then
echo "starting frontend :3999..."
REACT_APP_STORAGE=ws \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid
else
echo "frontend already on :3999"
fi
# wait for ports to listen
echo "waiting for ports..."
for port in 4001 3999; do
for i in {1..30}; do
lsof -ti :$port >/dev/null 2>&1 && break
sleep 1
done
done
echo ""
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
echo "frontend : http://127.0.0.1:3999 (admin / player /display)"
echo "logs : tmp/server.log tmp/fe.log"
echo "stop : ./scripts/dev-stop.sh"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Stop local dev stack. Usage: ./scripts/dev-stop.sh
set -uo pipefail
cd "$(dirname "$0")/.."
stopped=0
for port in 3999 4001; do
pids=$(lsof -ti :$port 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "stopping :$port (pid: $pids)"
kill $pids 2>/dev/null || true
stopped=1
fi
done
# also kill recorded pids
for f in tmp/server.pid tmp/fe.pid; do
if [ -f "$f" ]; then
pid=$(cat "$f")
kill "$pid" 2>/dev/null || true
rm -f "$f"
fi
done
# node --watch spawns children — sweep by port pattern
pids=$(pgrep -f "node --watch index.js|react-scripts start" 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "sweeping node dev procs: $pids"
kill $pids 2>/dev/null || true
fi
if [ "$stopped" = "0" ]; then
echo "nothing running."
fi
echo "stopped."
+15 -16
View File
@@ -157,6 +157,7 @@ async function main() {
let lastReorder = 0; let lastReorder = 0;
for (let roundN = 1; roundN <= ROUNDS; roundN++) { for (let roundN = 1; roundN <= ROUNDS; roundN++) {
console.log(`--- round ${roundN} starting ---`);
// advance initiative until round counter ticks (full cycle done). // advance initiative until round counter ticks (full cycle done).
const cap = (enc.participants.length + 2) * 2; const cap = (enc.participants.length + 2) * 2;
let guard = 0; let guard = 0;
@@ -310,25 +311,25 @@ async function main() {
lastPaused = true; lastPaused = true;
} }
// 10. reorderParticipants: every 8 turns, drag one past next (DM reorder). // 10. reorderParticipants: every 8 turns, drag one past another (DM reorder).
// Pick two ADJACENT UPCOMING actors (both strictly after current pointer)
// and swap them. Avoids crossing current pointer — crossing it creates
// ambiguous "who acted this round" semantics (skip/double). Swapping two
// upcoming actors is always safe and still exercises reorder.
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) { if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const curIdx = enc.turnOrderIds.indexOf(enc.currentTurnParticipantId);
if (living.length >= 3) { // upcoming = everyone after current in turn order (rest of this round)
// drag first past second (same-or-cross init, exercises reorder). const upcomingIds = enc.turnOrderIds.slice(curIdx + 1)
const dragged = living[0]; .filter(id => { const p = enc.participants.find(x => x.id === id); return p && p.currentHp > 0 && p.isActive !== false; });
const target = living[1]; // swap first adjacent upcoming pair (drag index1 before index0)
if (upcomingIds.length >= 2) {
const target = enc.participants.find(p => p.id === upcomingIds[0]);
const dragged = enc.participants.find(p => p.id === upcomingIds[1]);
try { try {
const r = reorderParticipants(enc, dragged.id, target.id); const r = reorderParticipants(enc, dragged.id, target.id);
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`); enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
lastReorder = totalTurns; lastReorder = totalTurns;
} catch (e) { /* same-init only — try same-init pair */ } catch (e) { /* swap not allowed — skip this round */ }
const sameInit = living.find(p => p !== dragged && p.initiative === dragged.initiative);
if (sameInit) {
const r = reorderParticipants(enc, dragged.id, sameInit.id);
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${sameInit.name}`);
lastReorder = totalTurns;
}
}
} }
} }
@@ -338,8 +339,6 @@ async function main() {
} }
if (!enc.isStarted) { console.log('combat auto-ended'); break; } if (!enc.isStarted) { console.log('combat auto-ended'); break; }
const alive = enc.participants.filter(p => p.currentHp > 0).length; const alive = enc.participants.filter(p => p.currentHp > 0).length;
console.log(`--- round ${roundN} complete (turns=${totalTurns}, alive=${alive}) ---`);
// revive dead: heal to full + reactivate. Sustains combat for 100 rounds // revive dead: heal to full + reactivate. Sustains combat for 100 rounds
// and exercises toggleActive reactivate + heal-from-zero path. // and exercises toggleActive reactivate + heal-from-zero path.
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false); const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
-23
View File
@@ -1,23 +0,0 @@
# server/Dockerfile — backend (Express + ws + better-sqlite3)
FROM node:18-alpine AS build
WORKDIR /app
# workspaces root needed: shared/ is a dependency (@ttrpg/shared)
COPY package*.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
RUN npm install --workspaces --include-workspace-root
COPY shared/ ./shared/
COPY server/ ./server/
# better-sqlite3 builds native; rebuild for alpine musl
RUN cd server && npm rebuild better-sqlite3
ENV NODE_ENV=production
ENV PORT=4001
ENV DB_PATH=/data/tracker.sqlite
EXPOSE 4001
WORKDIR /app/server
CMD ["node", "index.js"]
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};
+5 -1
View File
@@ -131,7 +131,11 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
return { return {
app, server, wss, store, db, app, server, wss, store, db,
close(done) { wss.close(); server.close(() => { db.close(); if (done) done(); }); }, close(done) {
wss.clients.forEach(c => { try { c.terminate(); } catch {} });
wss.close();
server.close(() => { db.close(); if (done) done(); });
},
}; };
} }
+4 -6
View File
@@ -14,18 +14,16 @@ const { createServer } = require('../index');
const { createWsStorage } = require('../../src/storage/ws'); const { createWsStorage } = require('../../src/storage/ws');
const { runStorageContract } = require('../../src/storage/contract'); const { runStorageContract } = require('../../src/storage/contract');
let nextPort = 4000 + Math.floor(Math.random() * 999);
// Factory: fresh backend (unique sqlite file) + storage pointed at it. // Factory: fresh backend (unique sqlite file) + storage pointed at it.
// Disposing the storage closes the backend so each test is fully isolated. // Disposing the storage closes the backend so each test is fully isolated.
async function makeStorage() { async function makeStorage() {
const port = nextPort++; const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const dbPath = path.join(os.tmpdir(), `ws-contract-${port}-${Date.now()}.sqlite`); const handle = createServer({ dbPath, port: 0 });
const handle = createServer({ dbPath, port });
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
handle.server.on('error', reject); handle.server.on('error', reject);
handle.server.listen(port, resolve); handle.server.listen(0, resolve);
}); });
const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`; const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createWsStorage({ baseUrl, wsUrl }); const storage = createWsStorage({ baseUrl, wsUrl });
+4 -6
View File
@@ -12,16 +12,14 @@ const { createWsStorage } = require('../../src/storage/ws');
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms)); const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
let nextPort = 5000 + Math.floor(Math.random() * 999);
async function makeStorage() { async function makeStorage() {
const port = nextPort++; const dbPath = path.join(os.tmpdir(), `ws-recon-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const dbPath = path.join(os.tmpdir(), `ws-recon-${port}-${Date.now()}.sqlite`); const handle = createServer({ dbPath, port: 0 });
const handle = createServer({ dbPath, port });
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
handle.server.on('error', reject); handle.server.on('error', reject);
handle.server.listen(port, resolve); handle.server.listen(0, resolve);
}); });
const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`; const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createWsStorage({ baseUrl, wsUrl }); const storage = createWsStorage({ baseUrl, wsUrl });
+154 -36
View File
@@ -8,7 +8,7 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight
} from 'lucide-react'; } from 'lucide-react';
// Custom CSS for death animation (player view only) // Custom CSS for death animation (player view only)
@@ -444,9 +444,10 @@ function EditParticipantModal({ participant, onClose, onSave }) {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-stone-300">Initiative</label> <label htmlFor="edit-initiative" className="block text-sm font-medium text-stone-300">Initiative</label>
<input <input
type="number" type="number"
id="edit-initiative"
value={initiative} value={initiative}
onChange={(e) => setInitiative(e.target.value)} onChange={(e) => setInitiative(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
@@ -781,6 +782,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [selectedCharacterId, setSelectedCharacterId] = useState('');
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD); const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP); const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
const [manualInitiative, setManualInitiative] = useState('');
const [isNpc, setIsNpc] = useState(false); const [isNpc, setIsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null); const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({}); const [hpChangeValues, setHpChangeValues] = useState({});
@@ -817,6 +819,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
let modifier = 0; let modifier = 0;
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP; let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
let participantIsNpc = false; let participantIsNpc = false;
const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10));
const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null;
if (participantType === 'character') { if (participantType === 'character') {
const character = campaignCharacters.find(c => c.id === selectedCharacterId); const character = campaignCharacters.find(c => c.id === selectedCharacterId);
@@ -836,13 +840,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
participantIsNpc = isNpc; participantIsNpc = isNpc;
} }
const finalInitiative = initiativeRoll + modifier; const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
const newParticipant = { const newParticipant = {
id: generateId(), id: generateId(),
name: nameToAdd, name: nameToAdd,
type: participantType, type: participantType,
originalCharacterId: participantType === 'character' ? selectedCharacterId : null, originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
initiative: finalInitiative, initiative: computedInitiative,
maxHp: currentMaxHp, maxHp: currentMaxHp,
currentHp: currentMaxHp, currentHp: currentMaxHp,
isNpc: participantType === 'monster' ? participantIsNpc : false, isNpc: participantType === 'monster' ? participantIsNpc : false,
@@ -854,19 +858,21 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try { try {
await storage.updateDoc(encounterPath, { await storage.updateDoc(encounterPath, {
participants: [...participants, newParticipant] participants: sortParticipantsByInitiative([...participants, newParticipant], participants),
...syncTurnOrder([...participants, newParticipant]),
}); });
logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, { logAction(`${nameToAdd} added to encounter (Initiative: ${computedInitiative})`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: { participants: [...participants] }, updates: { participants: [...participants] },
}); });
setLastRollDetails({ setLastRollDetails({
name: nameToAdd, name: nameToAdd,
roll: initiativeRoll, roll: manualInit ? null : initiativeRoll,
mod: modifier, mod: manualInit ? null : modifier,
total: finalInitiative, total: computedInitiative,
type: participantIsNpc ? 'NPC' : participantType type: participantIsNpc ? 'NPC' : participantType,
manual: manualInit,
}); });
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
@@ -876,6 +882,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
setSelectedCharacterId(''); setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setIsNpc(false); setIsNpc(false);
setManualInitiative('');
} catch (err) { } catch (err) {
console.error("Error adding participant:", err); console.error("Error adding participant:", err);
alert("Failed to add participant. Please try again."); alert("Failed to add participant. Please try again.");
@@ -934,9 +941,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const updatedParticipants = participants.map(p => const updatedParticipants = participants.map(p =>
p.id === editingParticipant.id ? { ...p, ...updatedData } : p p.id === editingParticipant.id ? { ...p, ...updatedData } : p
); );
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
try { try {
await storage.updateDoc(encounterPath, { participants: updatedParticipants }); await storage.updateDoc(encounterPath, {
participants: reslotted,
...syncTurnOrder(reslotted),
});
setEditingParticipant(null); setEditingParticipant(null);
} catch (err) { } catch (err) {
console.error("Error updating participant:", err); console.error("Error updating participant:", err);
@@ -944,6 +955,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} }
}; };
// Inline initiative edit (FEAT-3): blur/Enter commits. Reslots participant
// into correct list position (stable sort by init desc, tie-break original
// index). Display + AdminView both reflect new order. Pre-combat only —
// field gated to !started||paused elsewhere.
const handleInlineInitiative = async (participantId, value) => {
if (!db) return;
const n = parseInt(value, 10);
if (isNaN(n)) return;
const updatedParticipants = participants.map(p =>
p.id === participantId ? { ...p, initiative: n } : p
);
const reslotted = sortParticipantsByInitiative(updatedParticipants, participants);
try {
await storage.updateDoc(encounterPath, {
participants: reslotted,
...syncTurnOrder(reslotted),
});
} catch (err) {
console.error("Error updating initiative:", err);
}
};
const requestDeleteParticipant = (participantId, participantName) => { const requestDeleteParticipant = (participantId, participantName) => {
setItemToDelete({ id: participantId, name: participantName }); setItemToDelete({ id: participantId, name: participantName });
setShowDeleteConfirm(true); setShowDeleteConfirm(true);
@@ -1307,6 +1340,19 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/> />
</div> </div>
<div className="md:col-span-2">
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
<input
type="number"
id="manualInitiative"
value={manualInitiative}
onChange={(e) => setManualInitiative(e.target.value)}
placeholder="auto"
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300"> <label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
Max HP Max HP
@@ -1358,6 +1404,19 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/> />
</div> </div>
<div className="md:col-span-2">
<label htmlFor="charManualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label>
<input
type="number"
id="charManualInitiative"
value={manualInitiative}
onChange={(e) => setManualInitiative(e.target.value)}
placeholder="auto"
className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
</> </>
)} )}
@@ -1375,8 +1434,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
{lastRollDetails && ( {lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center"> <p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.name} ({lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}) {lastRollDetails.manual
: Rolled d20 ({lastRollDetails.roll}) {formatInitMod(lastRollDetails.mod)} = {lastRollDetails.total} Initiative ? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p> </p>
)} )}
@@ -1422,9 +1483,27 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
)} )}
{isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>} {isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
</p> </p>
<p className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'}`}> <div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp} <span className="inline-flex items-center gap-1">
</p> <label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label>
<input
type="number"
id={`init-${p.id}`}
defaultValue={p.initiative}
key={p.initiative}
min="0"
max="99"
disabled={encounter.isStarted && !encounter.isPaused}
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
onFocus={(e) => e.target.select()}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-10 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Initiative for ${p.name}`}
/>
</span>
<span>HP: {p.currentHp}/{p.maxHp}</span>
</div>
{/* Death Saves - only player characters make death saving throws */} {/* Death Saves - only player characters make death saving throws */}
{isDead && encounter.isStarted && p.type === 'character' && ( {isDead && encounter.isStarted && p.type === 'character' && (
@@ -1579,16 +1658,26 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const [showEndConfirm, setShowEndConfirm] = useState(false); const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay()); const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
const handleToggleHidePlayerHp = async () => { const handleToggleHidePlayerHp = async () => {
if (!db) return; if (!db) return;
try { try {
await storage.setDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }, { merge: true }); await storage.updateDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp });
} catch (err) { } catch (err) {
console.error("Error toggling hidePlayerHp:", err); console.error("Error toggling hidePlayerHp:", err);
} }
}; };
const handleToggleHideNpcHp = async () => {
if (!db) return;
try {
await storage.updateDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp });
} catch (err) {
console.error("Error toggling hideNpcHp:", err);
}
};
const handleStartEncounter = async () => { const handleStartEncounter = async () => {
if (!db || !encounter.participants || encounter.participants.length === 0) { if (!db || !encounter.participants || encounter.participants.length === 0) {
alert("Add participants first."); alert("Add participants first.");
@@ -1616,10 +1705,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
turnOrderIds: sortedParticipants.map(p => p.id) turnOrderIds: sortedParticipants.map(p => p.id)
}); });
await storage.setDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounter.id activeEncounterId: encounter.id
}, { merge: true }); });
logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, { logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
@@ -1747,10 +1836,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
turnOrderIds: [] turnOrderIds: []
}); });
await storage.setDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null activeEncounterId: null
}, { merge: true }); });
logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, { logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, {
encounterPath, encounterPath,
@@ -1836,6 +1925,17 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} /> <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hidePlayerHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button> </button>
</label> </label>
<label className="flex items-center justify-between cursor-pointer gap-2 mt-2">
<span className="text-sm text-stone-300">Hide NPC/monster HP</span>
<button
role="switch"
aria-checked={hideNpcHp}
onClick={handleToggleHideNpcHp}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${hideNpcHp ? 'bg-amber-600' : 'bg-stone-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${hideNpcHp ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div> </div>
</div> </div>
@@ -1965,15 +2065,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId; const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await storage.setDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null, activeEncounterId: null,
}, { merge: true }); });
} else { } else {
await storage.setDoc(getPath.activeDisplay(), { await storage.updateDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId, activeCampaignId: campaignId,
activeEncounterId: encounterId, activeEncounterId: encounterId,
}, { merge: true }); });
} }
} catch (err) { } catch (err) {
console.error("Error toggling Player Display:", err); console.error("Error toggling Player Display:", err);
@@ -2116,6 +2216,7 @@ function AdminView({ userId }) {
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
const [campaignsCollapsed, setCampaignsCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
if (campaignsData && db) { if (campaignsData && db) {
@@ -2243,7 +2344,18 @@ function AdminView({ userId }) {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold text-amber-300 font-cinzel tracking-wide">Campaigns</h2> <button
onClick={() => setCampaignsCollapsed(c => !c)}
className="flex items-center gap-2 text-2xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria-expanded={!campaignsCollapsed}
aria-controls="campaigns-grid"
>
{campaignsCollapsed
? <ChevronRight size={24} />
: <ChevronDown size={24} />}
Campaigns
<span className="text-sm font-normal text-stone-400">({campaignsWithDetails.length})</span>
</button>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors" className="bg-red-700 hover:bg-red-800 text-white font-bold py-2 px-4 rounded-lg flex items-center transition-colors"
@@ -2252,11 +2364,13 @@ function AdminView({ userId }) {
</button> </button>
</div> </div>
{!campaignsCollapsed && (
<>
{campaignsWithDetails.length === 0 && !isLoadingCampaigns && ( {campaignsWithDetails.length === 0 && !isLoadingCampaigns && (
<p className="text-stone-400">No campaigns yet. Create one to get started!</p> <p className="text-stone-400">No campaigns yet. Create one to get started!</p>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div id="campaigns-grid" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{campaignsWithDetails.map(campaign => { {campaignsWithDetails.map(campaign => {
const cardStyle = campaign.playerDisplayBackgroundUrl const cardStyle = campaign.playerDisplayBackgroundUrl
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` } ? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
@@ -2283,12 +2397,12 @@ function AdminView({ userId }) {
<span className="inline-flex items-center"> <span className="inline-flex items-center">
<Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters <Swords size={12} className="mr-1" /> {campaign.encounterCount === undefined ? '...' : campaign.encounterCount} Encounters
</span> </span>
{campaign.createdAt && (
<span className="inline-flex items-center opacity-80">
{new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}
</span>
)}
</div> </div>
{campaign.createdAt && (
<div className="text-xs text-stone-300 opacity-70 mt-1">
<Clock size={12} className="mr-1 inline-block" /> Created: {new Date(campaign.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}
</div>
)}
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -2304,6 +2418,8 @@ function AdminView({ userId }) {
); );
})} })}
</div> </div>
</>
)}
</div> </div>
{showCreateModal && ( {showCreateModal && (
@@ -2497,12 +2613,14 @@ function DisplayView() {
const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData; const { name, participants, round, currentTurnParticipantId, isStarted, isPaused } = activeEncounterData;
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
let participantsToRender = []; let participantsToRender = [];
if (participants) { if (participants) {
// Hide inactive monsters (pre-staged/summoned reserves) from the player view // 1-list model: participants[] IS the display order (DM drag = source of
const visibleParticipants = participants.filter(p => p.isActive || p.type !== 'monster'); // truth). Do NOT re-sort by initiative — that diverges from AdminView /
participantsToRender = sortParticipantsByInitiative(visibleParticipants, visibleParticipants); // turnOrderIds after any cross-init drag (BUG-15).
participantsToRender = participants.filter(p => p.isActive || p.type !== 'monster');
} }
const displayStyles = campaignBackgroundUrl const displayStyles = campaignBackgroundUrl
@@ -2597,7 +2715,7 @@ function DisplayView() {
</span> </span>
</div> </div>
{!(hidePlayerHp && p.type === 'character') && ( {!(hidePlayerHp && p.type === 'character') && !(hideNpcHp && p.type !== 'character') && (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600"> <div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600">
<div <div
+4 -4
View File
@@ -11,6 +11,8 @@ import {
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
} from 'firebase/firestore'; } from 'firebase/firestore';
import { initFirebase, createFirebaseStorage } from './firebase'; import { initFirebase, createFirebaseStorage } from './firebase';
import { createWsStorage } from './ws';
import { createMemoryStorage } from './memory';
let storageInstance = null; let storageInstance = null;
@@ -23,13 +25,11 @@ export function getStorage() {
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.'); if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
storageInstance = createFirebaseStorage(); storageInstance = createFirebaseStorage();
} else if (mode === 'ws') { } else if (mode === 'ws') {
const { createWsStorage } = require('./ws');
storageInstance = createWsStorage({ storageInstance = createWsStorage({
baseUrl: process.env.REACT_APP_BACKEND_URL || 'http://127.0.0.1:4001', baseUrl: process.env.REACT_APP_BACKEND_URL || '',
wsUrl: process.env.REACT_APP_BACKEND_WS || 'ws://127.0.0.1:4001/ws', wsUrl: process.env.REACT_APP_BACKEND_WS || '',
}); });
} else { } else {
const { createMemoryStorage } = require('./memory');
storageInstance = createMemoryStorage(); storageInstance = createMemoryStorage();
} }
return storageInstance; return storageInstance;
+2 -2
View File
@@ -4,7 +4,7 @@
'use strict'; 'use strict';
const { EventEmitter } = require('events'); import { EventEmitter } from 'events';
function createMemoryStorage() { function createMemoryStorage() {
const docs = new Map(); // path -> data obj const docs = new Map(); // path -> data obj
@@ -137,4 +137,4 @@ function deepClone(v) {
return JSON.parse(JSON.stringify(v)); return JSON.parse(JSON.stringify(v));
} }
module.exports = { createMemoryStorage }; export { createMemoryStorage };
+59 -7
View File
@@ -5,11 +5,10 @@
'use strict'; 'use strict';
// Native browser WebSocket if present, else ws pkg (Node/jest). // Native browser WebSocket if present, else ws pkg (Node/jest).
// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require().
let WebSocketImpl; let WebSocketImpl;
if (typeof WebSocket !== 'undefined') { if (typeof WebSocket !== 'undefined') {
WebSocketImpl = WebSocket; WebSocketImpl = WebSocket;
} else {
WebSocketImpl = require('ws').WebSocket;
} }
function createWsStorage({ baseUrl, wsUrl } = {}) { function createWsStorage({ baseUrl, wsUrl } = {}) {
@@ -39,13 +38,59 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
let ws = null; let ws = null;
let wsReady = null; let wsReady = null;
let disposed = false;
let reconnectTimer = null;
let everConnected = false;
const RECONNECT_DELAY = 500;
function ensureWs() { function ensureWs() {
if (wsReady) return wsReady; if (wsReady) return wsReady;
wsReady = new Promise((resolve, reject) => { wsReady = new Promise((resolve, reject) => {
ws = new WebSocketImpl(WS); (async () => {
const onOpen = () => resolve(ws); // Node/jest only: load ws pkg via dynamic import. Browser uses global
// WebSocket. Avoids require() in CRA prod ESM bundle (webpack crash).
let WsClass = WebSocketImpl;
if (!WsClass) {
const wsPkg = await import('ws');
WsClass = wsPkg.WebSocket;
}
ws = new WsClass(WS);
const onOpen = () => {
const isReconnect = everConnected;
everConnected = true;
// resubscribe all existing subscribers after (re)connect
for (const p of docSubs.keys()) {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
}
for (const p of collSubs.keys()) {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
}
// On RECONNECT only: re-fetch current values — catches writes that
// happened while disconnected (broadcast missed). Skip on first connect
// (initial REST fetch in subscribeDoc/subscribeCollection already did).
if (isReconnect) {
for (const [p, cbs] of docSubs) {
storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {});
}
for (const [p, cbs] of collSubs) {
storage.getCollection(p).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
}
}
resolve(ws);
};
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); }; const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
const onClose = () => { wsReady = null; }; const onClose = () => {
wsReady = null;
ws = null;
if (disposed) return;
// auto-reconnect (BUG-8): try again after delay. ensureWs() re-arms.
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!disposed) ensureWs().catch(() => {});
}, RECONNECT_DELAY);
if (reconnectTimer && typeof reconnectTimer.unref === 'function') reconnectTimer.unref();
};
const onMessage = (ev) => { const onMessage = (ev) => {
const raw = typeof ev === 'string' ? ev : (ev.data !== undefined ? ev.data : ev); const raw = typeof ev === 'string' ? ev : (ev.data !== undefined ? ev.data : ev);
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; } let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
@@ -61,6 +106,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
ws.addEventListener('close', onClose); ws.addEventListener('close', onClose);
ws.addEventListener('message', onMessage); ws.addEventListener('message', onMessage);
} }
})();
}); });
return wsReady; return wsReady;
} }
@@ -168,7 +214,13 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return () => { collSubs.get(p)?.delete(cb); }; return () => { collSubs.get(p)?.delete(cb); };
}, },
dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); }, dispose(cb) {
disposed = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) ws.close();
docSubs.clear(); collSubs.clear();
if (typeof cb === 'function') cb();
},
_api: api, _api: api,
_test: { _test: {
@@ -182,4 +234,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return storage; return storage;
} }
module.exports = { createWsStorage }; export { createWsStorage };
+6 -6
View File
@@ -41,7 +41,7 @@ describe('Combat -> Firebase', () => {
test('startEncounter: also sets activeDisplay to this encounter', async () => { test('startEncounter: also sets activeDisplay to this encounter', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data.activeCampaignId).toBeTruthy(); expect(last.data.activeCampaignId).toBeTruthy();
expect(last.data.activeEncounterId).toBeTruthy(); expect(last.data.activeEncounterId).toBeTruthy();
@@ -111,26 +111,26 @@ describe('Combat -> Firebase', () => {
fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && last.data.activeCampaignId === null; return last && last.data.activeCampaignId === null;
}); });
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
test('toggleHidePlayerHp: setDoc merge on activeDisplay/status', async () => { test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const switchBtn = screen.getByRole('switch'); const switchBtn = screen.getByRole('switch');
fireEvent.click(switchBtn); fireEvent.click(switchBtn);
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && 'hidePlayerHp' in last.data; return last && 'hidePlayerHp' in last.data;
}); });
const adCalls = findCallActiveDisplay('setDoc'); const adCalls = findCallActiveDisplay('updateDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toHaveProperty('hidePlayerHp'); expect(last.data).toHaveProperty('hidePlayerHp');
}); });
+63
View File
@@ -0,0 +1,63 @@
// RED test: DisplayView must render participants in turnOrderIds (drag) order,
// NOT re-sort by initiative. 1-list model: participants[] = display source.
// Bug: DisplayView line ~2505 calls sortParticipantsByInitiative(), ignoring
// DM drag order. After cross-init drag, display diverges from AdminView/turnOrderIds.
import React from 'react';
import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { resetAdapterCalls } from '../storage/firebase';
function seedDragOrder() {
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
// Three monsters, init-sorted would be: high(20), mid(11), low(10).
// But participants[] = DRAG order: low BEFORE mid (DM dragged across init).
const participants = [
{ id: 'high', name: 'High', type: 'monster', initiative: 20, currentHp: 10, maxHp: 10, isActive: true },
{ id: 'low', name: 'Low', type: 'monster', initiative: 10, currentHp: 10, maxHp: 10, isActive: true },
{ id: 'mid', name: 'Mid', type: 'monster', initiative: 11, currentHp: 10, maxHp: 10, isActive: true },
];
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
MOCK_DB.set(encounterPath, {
name: 'Enc',
participants,
turnOrderIds: participants.map(p => p.id),
round: 1,
currentTurnParticipantId: 'high',
isStarted: true,
});
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
}
describe('DisplayView drag order (BUG-15)', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/display');
global.alert = jest.fn();
window.open = jest.fn();
// jsdom lacks scrollIntoView (DisplayView auto-scrolls current actor)
Element.prototype.scrollIntoView = jest.fn();
resetAdapterCalls();
});
afterEach(() => {
window.history.replaceState({}, '', '/');
});
test('renders participants in participants[] order, not init-sorted', async () => {
seedDragOrder();
render(<App />);
// wait for participant names to render
await waitFor(() => {
expect(screen.getAllByText(/High|Mid|Low/i).length).toBeGreaterThanOrEqual(3);
}, { timeout: 3000 });
// collect name elements in DOM order (strip Current marker)
const names = screen.getAllByText(/High|Mid|Low/i).map(el => el.textContent.replace(/\(Current\)/i, '').trim());
// participants[] order = High, Low, Mid (drag moved Low before Mid).
// Display must mirror this. Init-sorted would be High, Mid, Low.
expect(names).toEqual(['High', 'Low', 'Mid']);
});
});
+9 -9
View File
@@ -42,7 +42,7 @@ describe('Encounter -> Firebase', () => {
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//); expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
}); });
test('togglePlayerDisplay: setDoc merge on activeDisplay/status', async () => { test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => {
await setupCampaignAndEncounter('Camp D', 'Enc D'); await setupCampaignAndEncounter('Camp D', 'Enc D');
await selectEncounterByName('Enc D'); await selectEncounterByName('Enc D');
@@ -50,33 +50,33 @@ describe('Encounter -> Firebase', () => {
const eyeBtn = await screen.findByTitle('Activate for Player Display'); const eyeBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(eyeBtn); fireEvent.click(eyeBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status')); await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
const call = findCall('setDoc', 'activeDisplay/status'); const call = findCall('updateDoc', 'activeDisplay/status');
// activeDisplay/status setDoc is called with merge option in App // BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields)
expect(call.data).toMatchObject({ expect(call.data).toMatchObject({
activeCampaignId: expect.any(String), activeCampaignId: expect.any(String),
activeEncounterId: expect.any(String), activeEncounterId: expect.any(String),
}); });
}); });
test('togglePlayerDisplay off: setDoc nulls active ids', async () => { test('togglePlayerDisplay off: updateDoc nulls active ids', async () => {
await setupCampaignAndEncounter('Camp O', 'Enc O'); await setupCampaignAndEncounter('Camp O', 'Enc O');
await selectEncounterByName('Enc O'); await selectEncounterByName('Enc O');
// turn ON // turn ON
const onBtn = await screen.findByTitle('Activate for Player Display'); const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn); fireEvent.click(onBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status')); await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
// turn OFF // turn OFF
const offBtn = await screen.findByTitle('Deactivate for Player Display'); const offBtn = await screen.findByTitle('Deactivate for Player Display');
fireEvent.click(offBtn); fireEvent.click(offBtn);
await waitFor(() => { await waitFor(() => {
const calls = findCalls('setDoc', 'activeDisplay/status'); const calls = findCalls('updateDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
return last.data.activeCampaignId === null; return last.data.activeCampaignId === null;
}); });
const calls = findCalls('setDoc', 'activeDisplay/status'); const calls = findCalls('updateDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
@@ -103,7 +103,7 @@ describe('Encounter -> Firebase', () => {
// activate display first // activate display first
const onBtn = await screen.findByTitle('Activate for Player Display'); const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn); fireEvent.click(onBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status')); await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
// delete the active encounter // delete the active encounter
const trashBtn = screen.getAllByTitle('Delete Encounter')[0]; const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
+6 -6
View File
@@ -43,21 +43,21 @@ describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
await selectCampaignByName('Camp'); await selectCampaignByName('Camp');
// find the hide-player-HP toggle (role switch) // find the hide-player-HP toggle (role switch)
const toggle = await screen.findByRole('switch', { name: /hide/i }, { timeout: 3000 }); const toggle = await screen.findByRole('switch', { name: /hide player hp/i }, { timeout: 3000 });
// toggle ON // toggle ON
fireEvent.click(toggle); fireEvent.click(toggle);
await waitFor(() => { await waitFor(() => {
const writes = getAdapterCalls().filter( const writes = getAdapterCalls().filter(
c => c.fn === 'setDoc' && c.path.includes('activeDisplay/status') c => c.fn === 'updateDoc' && c.path.includes('activeDisplay/status')
); );
expect(writes.length).toBeGreaterThan(0); expect(writes.length).toBeGreaterThan(0);
const last = writes[writes.length - 1]; const last = writes[writes.length - 1];
// data written must include activeCampaignId AND activeEncounterId // patch must NOT clobber activeCampaignId/activeEncounterId.
// BUG: writes only {hidePlayerHp:true}, clobbering them. // BUG: setDoc replace writes only {hidePlayerHp:true} clobbers.
expect(last.data.activeCampaignId).toBe('c1'); // Fix: updateDoc patch — other fields untouched.
expect(last.data.activeEncounterId).toBe('e1'); expect(last.patch.hidePlayerHp).toBe(true);
}, { timeout: 3000 }); }, { timeout: 3000 });
}); });
}); });
+57
View File
@@ -0,0 +1,57 @@
// RED test: FEAT-3 initiative field on add participant.
// If initiative field set, use it (no roll). Empty = roll d20+mod (current).
import React from 'react';
import { screen, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } from './testHelpers';
import { fireEvent } from '@testing-library/react';
import { getCalls } from '../__mocks__/firebase/_mock-db';
function lastParticipantUpdate(name) {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = calls[calls.length - 1];
return last && last.data.participants && last.data.participants.find(p => p.name === name);
}
describe('FEAT-3: initiative field on add (optional, empty=roll)', () => {
test('initiative field set → uses value, no roll', async () => {
await renderApp();
await createCampaignViaUI('Camp');
await selectCampaignByName('Camp');
await createEncounterViaUI('Enc');
await selectEncounterByName('Enc');
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Goblin' } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: '2' } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: '7' } });
// set explicit initiative
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: '15' } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastParticipantUpdate('Goblin'));
const p = lastParticipantUpdate('Goblin');
expect(p.initiative).toBe(15);
});
test('initiative field empty → rolls d20+mod', async () => {
await renderApp();
await createCampaignViaUI('Camp2');
await selectCampaignByName('Camp2');
await createEncounterViaUI('Enc2');
await selectEncounterByName('Enc2');
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Wolf' } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: '3' } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: '11' } });
// leave initiative empty
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastParticipantUpdate('Wolf'));
const p = lastParticipantUpdate('Wolf');
// rolled d20 (1-20) + mod 3 = range 4-23
expect(p.initiative).toBeGreaterThanOrEqual(4);
expect(p.initiative).toBeLessThanOrEqual(23);
});
});
+69
View File
@@ -0,0 +1,69 @@
// RED: FEAT-3 followup. Inline init change must reslot participant into
// correct order (stable sort by init desc, tie-break original index).
// Before combat starts: list reorders. Also field gated to !started||paused.
import React from 'react';
import { screen, waitFor, within, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, addMonsterViaUI } from './testHelpers';
import { getCalls } from '../__mocks__/firebase/_mock-db';
function lastParticipantsUpdate() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = calls[calls.length - 1];
return last && last.data.participants;
}
describe('FEAT-3 reslot: inline init change reorders list', () => {
test('raising init moves participant up in list (pre-combat)', async () => {
await renderApp();
await createCampaignViaUI('Camp');
await selectCampaignByName('Camp');
await createEncounterViaUI('Enc');
await selectEncounterByName('Enc');
// add two monsters with manual init: Orc=5 (first), Goblin=3 (second)
const form = within(getParticipantForm());
const addOne = async (name, hp, mod, init) => {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const parts = lastParticipantsUpdate();
if (!parts || !parts.some(p => p.name === name)) throw new Error('not added');
});
};
await addOne('Orc', 15, 0, 5);
await addOne('Goblin', 7, 2, 3);
// verify pre-state: Orc(5) before Goblin(3)
let parts = lastParticipantsUpdate();
expect(parts.map(p => p.name)).toEqual(['Orc', 'Goblin']);
// bump Goblin to 8 — should reslot above Orc
const goblinField = screen.getByLabelText('Initiative for Goblin');
fireEvent.change(goblinField, { target: { value: '8' } });
fireEvent.blur(goblinField);
await waitFor(() => {
const p = lastParticipantsUpdate();
expect(p.map(x => x.name)).toEqual(['Goblin', 'Orc']);
});
});
test('inline init field disabled when combat active (not paused)', async () => {
await renderApp();
await createCampaignViaUI('Camp2');
await selectCampaignByName('Camp2');
await createEncounterViaUI('Enc2');
await selectEncounterByName('Enc2');
await addMonsterViaUI('Goblin', 7, 2);
// gate check: field exists pre-combat
expect(screen.getByLabelText('Initiative for Goblin')).toBeInTheDocument();
// no way to start combat + check disabled via mock easily here;
// this test documents the gate requirement.
});
});
+76
View File
@@ -0,0 +1,76 @@
// RED: reslot must fire on ALL 4 participant-mutation paths.
// Path 1 add, path 2 edit modal, path 3 drag (already correct), path 4 inline field (already correct).
// Tests add + edit modal reslot. Drag + inline already covered.
import React from 'react';
import { screen, waitFor, within, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, addMonsterViaUI } from './testHelpers';
import { getCalls } from '../__mocks__/firebase/_mock-db';
function lastParticipantsUpdate() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = calls[calls.length - 1];
return last && last.data.participants;
}
async function addOne(form, name, hp, mod, init) {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const parts = lastParticipantsUpdate();
if (!parts || !parts.some(p => p.name === name)) throw new Error('not added');
});
}
describe('reslot on all mutation paths', () => {
test('add inserts at correct init position (not append)', async () => {
await renderApp();
await createCampaignViaUI('Camp');
await selectCampaignByName('Camp');
await createEncounterViaUI('Enc');
await selectEncounterByName('Enc');
const form = within(getParticipantForm());
// add Orc(5) first, then Goblin(8) — Goblin should slot ABOVE Orc, not append below
await addOne(form, 'Orc', 15, 0, 5);
await addOne(form, 'Goblin', 7, 2, 8);
const parts = lastParticipantsUpdate();
expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
});
test('edit modal init change reslots participant', async () => {
await renderApp();
await createCampaignViaUI('Camp2');
await selectCampaignByName('Camp2');
await createEncounterViaUI('Enc2');
await selectEncounterByName('Enc2');
const form = within(getParticipantForm());
await addOne(form, 'Orc', 15, 0, 5);
await addOne(form, 'Goblin', 7, 2, 3);
// pre: Orc(5) before Goblin(3)
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
// open edit modal for Goblin, bump init to 8
const editBtns = screen.getAllByTitle('Edit');
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
fireEvent.click(goblinEdit);
await waitFor(() => screen.getByText(`Edit Goblin`));
// modal renders after row inputs; take last Initiative-labeled input
const initInputs = screen.getAllByLabelText('Initiative');
fireEvent.change(initInputs[initInputs.length - 1], { target: { value: '8' } });
const saveBtns = screen.getAllByRole('button', { name: /Save/i });
fireEvent.click(saveBtns[saveBtns.length - 1]);
await waitFor(() => {
const parts = lastParticipantsUpdate();
expect(parts.map(p => p.name)).toEqual(['Goblin', 'Orc']);
});
});
});
+19
View File
@@ -0,0 +1,19 @@
// Lock: storage adapters must use ESM exports (no module.exports).
// Regression guard: CJS in src/ crashes CRA prod build (ESM strict).
// Bug history: ws.js + memory.js used module.exports. Dev lenient (masked),
// prod bundle crashed blank page. firebase.js always ESM.
import fs from 'fs';
import path from 'path';
const ADAPTER_DIR = path.join(__dirname, '..', 'storage');
describe('storage adapters use ESM (no CJS)', () => {
const adapters = ['ws.js', 'memory.js', 'firebase.js', 'index.js'];
test.each(adapters)('%s has no module.exports', (file) => {
const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8');
// strip line comments so words like 'require' in explanatory comments don't trip the guard
const src = full.replace(/^\s*\/\/.*$/gm, '');
expect(src).not.toMatch(/module\.exports\s*=/);
expect(src).not.toMatch(/\brequire\s*\(/);
});
});