Files
ttrpg-initiative-tracker/docs/DEVELOPMENT.md
T
david raistrick 41c1e48874 Wake lock persistence, button reposition, docs, dev LAN support
Wake lock (Prevent Sleep) toggles now persist across reloads via
localStorage in both AdminView and DisplayView. Buttons repositioned
inline in AdminView campaigns header bar (was floating overlay causing
overlap on tablets). DisplayView buttons persist localStorage too.

Wake lock acquire failure now shows toast with fix hint (HTTPS or
Chrome flag). Fullscreenchange listener re-acquires wake lock (Android
discards on screen off).

dev-start.sh: auto-detects LAN IP (en0/en1), frontend binds 0.0.0.0,
backend URL inlined as LAN IP so phones reach backend. DANGEROUSLY_DISABLE_HOST_CHECK
for LAN access. Outputs LAN URL + wake lock flag instructions.

Docs: README 'Prevent Sleep (Wake Lock)' section covering secure context
requirement, Android Chrome flag workaround for LAN testing, iOS Safari
standalone PWA bug. DEVELOPMENT.md LAN access + wake lock note.
2026-07-08 13:19:10 -04:00

9.3 KiB

Development

TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm workspaces.

Prerequisites

  • Node.js 22+
  • npm 10+

Layout

/
  package.json              # workspaces root
  src/                      # React frontend (CRA)
    App.js                  # main app (~2900 lines)
    storage/                # adapter layer (firebase/ws/memory + contract)
    __mocks__/firebase/     # firebase SDK mock (Layer 1 tests)
    tests/                  # frontend tests
  server/                   # Backend: generic KV doc store (firebase mirror)
    index.js                # REST (doc/coll/batch) + WS bootstrap
    db.js                   # SQLite docs table, KV ops, broadcast
    tests/                  # backend + adapter-vs-live tests
  shared/                   # Pure logic, no I/O (client + server + tests import)
    turn.js                 # turn-order state machine
    tests/                  # turn logic tests
  scripts/                  # manual demo tool (NOT test)
    replay-combat.js        # live backend demo
  tests/
    audit/                  # exploratory bug-finders (manual, Math.random)
      audit-rotation.js     # rotation invariant
      audit-state.js        # 9 invariant classes
  scratch/                  # gitignored: throwaway repro/exploration
  docs/
    REWORK_PLAN.md
    DEVELOPMENT.md          # this file
    GLOSSARY.md             # domain terms (turn vs round, etc)
    ENCOUNTER_BUILDER.md    # DM interface guide
    TESTING.md              # test + automation ops

Setup

git clone git@github.com:keen99/ttrpg-initiative-tracker.git
cd ttrpg-initiative-tracker
npm install
git config core.hooksPath .githooks   # enable pre-push test gate

Run

Local dev stack (one command)

./scripts/dev-start.sh   # backend :4001 + frontend :3999, server mode
./scripts/dev-stop.sh    # stop both
  • backend: npm run server:dev (:4001, sqlite data/tracker.sqlite)
  • frontend: npm start (:3999, REACT_APP_STORAGE=server)
  • logs: tmp/server.log, tmp/fe.log

Idempotent: if port busy, leaves existing proc as-is.

LAN access (phones): frontend binds 0.0.0.0, LAN IP auto-detected. Other devices hit http://<lan-ip>:3999.

Wake Lock (Prevent Sleep) on LAN IP: requires secure context. Plain HTTP on LAN IP = wake lock silently fails. Android Chrome flag workaround: chrome://flags/#unsafely-treat-insecure-origin-as-secure → add http://<lan-ip>:3999 (with port), enable, relaunch. See README for full details.

Smoke check:

curl http://127.0.0.1:4001/health   # -> {"ok":true}

Never put db in /tmp (wipe risk). Use ./data/ (gitignored) or docker volume.

Manual (fallback)

Backend:

npm run server:dev        # :4001, db: server/data/tracker.sqlite
# or direct:
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js

Frontend (server mode):

REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
npm start

Opens http://127.0.0.1:3999/. Admin view /, player view /display.

Firebase mode (default, upstream): set REACT_APP_FIREBASE_* in .env.local (copy env.example). STORAGE_MODE=firebase falls through to real SDK.

Test

Commands

npm run test:all          # shared + server (fast, no frontend)
npm run shared:test       # pure turn logic
npm run server:test       # adapter vs live backend
npm test                  # CRA frontend (src/tests/, slow with scenario)

Suites

Suite Location What Count
Unit (turn logic) shared/tests/ pure nextTurn, rotation, pause-add 50 (1 skip)
Integration (adapter vs backend) server/tests/ ws adapter through live REST/WS 23
Characterization (UI) src/tests/ locks current App.js behavior 62
Scenario src/tests/Combat.scenario.test.js 100-round full combat (240s) 289 phases

Total: 134 green + 1 validated RED (skipped).

Test types

  • Unit = pure logic, fast, no I/O. Locks behavior of single functions.
  • Integration = real backend per test, adapter translation verified.
  • Characterization = render App via mock, assert current (buggy or not) UI behavior. Not desired-state.
  • Scenario = end-to-end flow through rendered App, asserts full sequence completes.
  • Contract = same spec run against every storage impl (memory, ws, firebase). Catches adapter drift.

Running one file / pattern

npm test --workspace shared -- --testPathPattern=round-rotation
CI=true npx react-scripts test --watchAll=false src/tests/App.characterization.test.js

Scenario test is slow

Combat.scenario.test.js runs 100 combat rounds through rendered App — 240s timeout by design. Skip when iterating:

CI=true npx react-scripts test --watchAll=false --testPathIgnorePatterns="Combat.scenario"

Demo tool (NOT test)

scripts/replay-combat.js = live backend demo. Watch UI react to state changes.

# start backend + frontend first
node scripts/replay-combat.js [rounds] [delayMs]
# defaults: 100 rounds, 200ms/step

Coverage per round: damage, heal, all 22 conditions, toggleActive, removeParticipant, addParticipant (reinforcements), updateParticipant, pause/resume, reorderParticipants, endEncounter. Revives dead each round to sustain full round count.

Audit tools (NOT unit tests)

tests/audit/ = exploratory, Math.random, non-deterministic. Manual run. Unit tests ({shared,server,src}/tests/) lock known bugs deterministically.

audit-rotation.js

Pure turn.js simulation of replay op sequence. Detects rotation violations (skip/dupe per round). Found BUG-1 (addParticipant + pause corrupts rotation).

node tests/audit/audit-rotation.js

Bisect: comment/uncomment op blocks to isolate triggering combo.

audit-state.js

Runs pure turn.js combat, audits 9 invariant classes per round:

  1. rotation integrity (skip/dupe)
  2. HP bounds (0 ≤ hp ≤ max, no NaN)
  3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
  4. turnOrder no dup ids
  5. turnOrder ids all active
  6. currentTurn valid + active
  7. deathSave range (0 ≤ saves ≤ 3, reset on revive)
  8. removeParticipant orphans
  9. undo support
node tests/audit/audit-state.js [rounds]   # default 100

Current state (post BUG-1/2 fix): 0 violations / 100 rounds.

See TODO.md for known bugs.

Scratch

scratch/ = gitignored throwaway. Repro scripts, exploration, debug. Not committed. Use freely, delete anytime.

Build

npm run build             # CRA production build -> build/

Docker build (existing, frontend-only):

docker build -t ttrpg-initiative-tracker .
docker run -p 8080:80 --rm ttrpg-initiative-tracker

Full-stack docker-compose arrives in M5.

Storage architecture

Generic KV doc store

Backend = firebase mirror. Single docs table: path (PK), parent, data (JSON), updated_at. Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.

Client (browser)                  Server
  |                                 |
  |-- storage.setDoc(path,data) -->|  REST PUT /api/doc
  |<---- 200 ----------------------|
  |                                 |
  |-- storage.subscribeDoc(path) -->|  WS subscribe
  |<---- WS {initial} --------------|  immediate value
  |       ...                       |
  |<---- WS {change} --------------|  on any write to path
  |                                 |
  Display / tablet                 |
  |<---- WS {change} --------------|  same push

Path normalization

App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/campaigns/...). Adapter norm() strips prefix → bare canonical (campaigns/...). All impls share identity (contract test).

STORAGE_MODE flow

getStorageMode() reads REACT_APP_STORAGE env (default firebase).

  • firebase → real SDK init
  • server → stub auth + db sentinel, route via storage.* adapter

Test layers

  • Layer 1: App vs firebase mock. Proves adapter call shape. Never exercises server adapter.
  • Layer 2: server adapter vs live backend. Proves translation + path identity.

Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers).

Local pipeline (pre-push hook)

Private repo = no free GitHub Actions. Tests run locally via git hook.

.githooks/pre-push runs npm run test:all (shared + server, fast). Frontend tests not gated (slow).

Skip:

git push --no-verify

Already configured on this checkout after git config core.hooksPath .githooks.

Status

Milestone State
0 repo/branch done
1 backend + tests done
2 frontend WS adapter done
3 characterization tests done (134 green)
4 skip fix + manual override next
5 docker compose
6 undo rework
7 playwright e2e deferred

See docs/REWORK_PLAN.md for full plan, TODO.md for known bugs.

Git

  • origin = github.com:keen99/ttrpg-initiative-tracker (this fork)
  • upstream = code.draft13.com/robert/ttrpg-initiative-tracker (friend's Gitea, read-only)
  • work branch: rework-backend (off main)
git fetch upstream         # pull friend's changes
git merge upstream/main    # rebase our branch onto his