Audit tools are test code (bug-finders), not scripts. Move to tests/audit/. scripts/ now only replay-combat (live demo tool). scratch/ = gitignored throwaway. Repro scripts, exploration, debug. Update DEVELOPMENT.md + scripts/README to match new layout.
8.4 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)
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
Backend (dev)
npm run server:dev # :4001, db: server/data/tracker.sqlite
# or direct:
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
Smoke check:
curl http://127.0.0.1:4001/health # -> {"ok":true}
Never put db in /tmp (wipe risk). Use ./data/ (gitignored) or docker volume.
Frontend (dev server, ws mode)
REACT_APP_STORAGE=ws \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
npm start
Opens http://127.0.0.1:3999/. Admin view /, player view /display.
Firebase mode (default, upstream): set REACT_APP_FIREBASE_* in .env.local (copy env.example). STORAGE_MODE=firebase falls through to real SDK.
Test
Commands
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:
- rotation integrity (skip/dupe)
- HP bounds (0 ≤ hp ≤ max, no NaN)
- isActive consistency (dead = inactive)
- turnOrder no dup ids
- turnOrder ids all active
- currentTurn valid + active
- deathSave range (0 ≤ saves ≤ 3, reset on revive)
- removeParticipant orphans
- 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 initws/memory→ stub auth + db sentinel, route viastorage.*adapter
Test layers
- Layer 1: App vs firebase mock. Proves adapter call shape. Never exercises ws adapter.
- Layer 2: ws adapter vs live backend. Proves translation + path identity.
Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers).
Local pipeline (pre-push hook)
Private repo = no free GitHub Actions. Tests run locally via git hook.
.githooks/pre-push runs npm run test:all (shared + server, fast). Frontend tests not gated (slow).
Skip:
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(offmain)
git fetch upstream # pull friend's changes
git merge upstream/main # rebase our branch onto his