Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907484fc7f | ||
|
|
9dbbb88730 | ||
|
|
1078062b53 | ||
|
|
94126567e7 | ||
|
|
f3ed971592 | ||
|
|
95db6fdfdc | ||
|
|
a5b6d61e83 | ||
|
|
76f5dc09ab | ||
|
|
c2bb5cca29 | ||
|
|
7955bed4a9 | ||
|
|
66f3cc1380 | ||
|
|
19de9fcf75 | ||
|
|
c41a8aa994 | ||
|
|
02344eb3f5 | ||
|
|
e191aa5ad2 | ||
|
|
c544bc305a | ||
|
|
835a4663f7 | ||
|
|
995347d255 | ||
|
|
9159755846 | ||
|
|
dbe018825b | ||
|
|
3b75ec9b3d | ||
|
|
3646a9cf8e | ||
|
|
6991375afc | ||
|
|
77570097f8 | ||
|
|
055895875a | ||
|
|
532af1ecc4 | ||
|
|
47056788e8 | ||
|
|
6fe4a27dd5 | ||
|
|
bf1fccfd3b | ||
|
|
2c997de0da | ||
|
|
c80ac6882f | ||
|
|
1d4ec873dc | ||
|
|
2569cc4497 | ||
|
|
8354d21fc6 | ||
|
|
9f8b09c7d9 | ||
|
|
ed1783e419 | ||
|
|
6fe2b762ab | ||
|
|
9ef3e3bd9e | ||
|
|
3423319b9b | ||
|
|
7bcf01dcf9 | ||
|
|
1ad4b660a4 | ||
|
|
9c90c1500c | ||
|
|
f2797595f4 | ||
|
|
32b8dfd8a1 | ||
|
|
ab9f8fa2e7 | ||
|
|
5a09f71d4c | ||
|
|
942545c085 | ||
|
|
0ff50561c7 | ||
|
|
8ad750c60a | ||
|
|
81a1629118 | ||
|
|
4da5ed9bcc | ||
|
|
c556860e49 | ||
|
|
27b2272ced | ||
|
|
e94e1959ff | ||
|
|
cb41d9ec8f | ||
|
|
6baecd374e | ||
|
|
79af61cb8b | ||
|
|
b12ac904a6 | ||
|
|
7f60ec140e | ||
|
|
e650cd2710 | ||
|
|
d83d1383c0 | ||
|
|
c3d89554e8 | ||
|
|
d1cc3a8b9a | ||
|
|
31d19c5912 | ||
|
|
530aaadf90 | ||
|
|
1a744e511a | ||
|
|
8cf3dad5b6 | ||
|
|
3d0dc79206 | ||
|
|
4b8b8bccfb | ||
|
|
c0998da0a7 | ||
|
|
d00cc104c9 | ||
|
|
36d7186a54 | ||
|
|
08c27c1ca5 | ||
|
|
0514939c51 | ||
|
|
ec578eeef5 | ||
|
|
c54fd88c32 | ||
|
|
e31fe15382 | ||
|
|
a2c63cc77f | ||
|
|
e22f412c52 | ||
|
|
4406fd2045 | ||
|
|
81c0b26b71 | ||
|
|
da25f46e3e | ||
|
|
c1d982b4a4 | ||
|
|
afdd72e829 | ||
|
|
58ae04b400 | ||
|
|
d73405753a | ||
|
|
3b07fc27b0 | ||
|
|
af165f4491 | ||
|
|
dbd0c75792 | ||
|
|
750ee99080 | ||
|
|
313a897e4b | ||
|
|
3ea67019d2 | ||
|
|
7c3ec105d5 | ||
|
|
d1cbe7091a | ||
|
|
5d3a0607ef | ||
|
|
94b62dc5ab | ||
|
|
fcddb58b8b | ||
|
|
7467a8d30f | ||
|
|
5521a2f6c6 | ||
|
|
494327ff17 | ||
|
|
c72b88f8bb | ||
|
|
0473eacc1d | ||
|
|
c6d3b7e1a6 | ||
|
|
e0f75cfb6c | ||
|
|
b62996dcbf | ||
|
|
260fb314bc | ||
|
|
fb7fbb2263 | ||
|
|
435e109070 | ||
|
|
b024fa08bb | ||
|
|
49ea39ea93 | ||
|
|
b2fd06ed17 | ||
|
|
e514a48d6e | ||
|
|
c90fc6ffb0 | ||
|
|
d979b03f2e | ||
|
|
be481767f0 | ||
|
|
bac94d85ff | ||
|
|
08c6146cf7 | ||
|
|
d48ecf1460 | ||
|
|
c314d1975e | ||
|
|
a8e88cf0f0 | ||
|
|
d35a730e12 | ||
|
|
912c493974 | ||
|
|
40fc4e596b | ||
|
|
80b454d087 | ||
|
|
2756b7b3eb | ||
|
|
f81308a0df | ||
|
|
33e0e52789 | ||
|
|
13490fe3de | ||
|
|
7866dec83b | ||
|
|
891fc696d9 | ||
|
|
9fd0f3ec38 | ||
|
|
6630fd9158 | ||
|
|
1d4c561c09 | ||
|
|
84a8b78acd | ||
|
|
52866784b2 | ||
|
|
35cd1581e3 | ||
|
|
ed67535b1f | ||
|
|
17245dfa1b | ||
|
|
e843acdf8a | ||
|
|
74b4c2c42d | ||
|
|
d1ee69a70a | ||
|
|
a5a4df78f0 | ||
|
|
b095e37bfe | ||
|
|
54e8df9ffa | ||
|
|
e743d40e8d | ||
|
|
3e84f28325 | ||
|
|
812298fa73 | ||
|
|
5bb9e5fc19 | ||
|
|
35b5a1d238 | ||
|
|
d581e60ba3 | ||
|
|
4158a1634d | ||
|
|
0c1196aee1 | ||
|
|
672f042b60 | ||
|
|
b6555648ee | ||
|
|
84dd17e174 | ||
|
|
12b24eb707 | ||
|
|
2ee2bba93b | ||
|
|
9457f48b23 | ||
|
|
fa19913e23 | ||
|
|
0e76fb2fc7 | ||
|
|
e06adaa081 | ||
|
|
d679c9d1e9 | ||
|
|
ad7979d8fd | ||
|
|
33b27775b4 | ||
|
|
d96d3a6cf2 | ||
|
|
439f48871e | ||
|
|
35990f588e | ||
|
|
b11fbe4715 | ||
|
|
7b52480329 | ||
|
|
58cc588726 | ||
|
|
e200f59b7e | ||
|
|
bb65709e26 | ||
|
|
33d831af54 | ||
|
|
4150267925 | ||
|
|
e23cea205a | ||
|
|
6cd25dadaa | ||
|
|
90cfb36b56 | ||
|
|
7676751a5b | ||
|
|
16118dd958 | ||
|
|
33c93ab86b | ||
|
|
451151628c | ||
|
|
1e0df31cd4 | ||
|
|
3be9b0a921 | ||
|
|
895fa06227 | ||
|
|
82f45e60f0 | ||
|
|
ceba7632f8 | ||
|
|
8cf3a49a76 | ||
|
|
dda3453d79 | ||
|
|
d754f8657c | ||
|
|
9563ce7959 | ||
|
|
5f8602cd73 | ||
|
|
788e3cd1a2 | ||
|
|
99a38bb75a | ||
|
|
9f73dedcad | ||
|
|
893fe49ccb | ||
|
|
6adcd0f8e0 | ||
|
|
d631545570 | ||
|
|
ad11bbc648 | ||
|
|
785af983da | ||
|
|
40a798514d |
@@ -1,31 +1,10 @@
|
|||||||
# Ignore Git directory
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Ignore Node.js modules (they will be installed in the Docker image)
|
|
||||||
node_modules
|
node_modules
|
||||||
|
**/node_modules
|
||||||
# Ignore build output (it will be generated in the Docker image)
|
.git
|
||||||
build
|
tmp
|
||||||
dist
|
data
|
||||||
|
*.log
|
||||||
# Ignore Docker files themselves
|
npm-debug.log*
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
|
|
||||||
# Ignore any local environment files if you have them
|
|
||||||
.env
|
|
||||||
# .env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Ignore IDE and OS-specific files
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.suo
|
|
||||||
*.user
|
|
||||||
*.userosscache
|
|
||||||
*.sln.docstates
|
|
||||||
Thumbs.db
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pre-push: run test suites before push. Skip with --no-verify.
|
||||||
|
echo "[pre-push] running tests..."
|
||||||
|
npm run test:all || { echo "[pre-push] tests failed. push aborted. (skip: git push --no-verify)"; exit 1; }
|
||||||
|
exit 0
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
# .gitignore
|
|
||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
@@ -6,4 +5,12 @@ dist
|
|||||||
.env.local
|
.env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
*.log
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.sqlite-*
|
||||||
|
server/data/*.sqlite
|
||||||
|
server/data/*.sqlite-*
|
||||||
|
/data
|
||||||
|
/scratch
|
||||||
|
tmp/
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # Dev server at http://localhost:3000
|
||||||
|
npm run build # Production build
|
||||||
|
npm test # Run tests (no test files currently exist)
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker:
|
||||||
|
```bash
|
||||||
|
docker build -t ttrpg-initiative-tracker .
|
||||||
|
docker run -p 8080:80 --rm --name ttrpg-tracker-app ttrpg-initiative-tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
The Dockerfile uses `NODE_OPTIONS=--openssl-legacy-provider npm run build` to work around an OpenSSL compatibility issue between Node 18 and react-scripts 5.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Single-page React app (Create React App) for DMs to manage TTRPG combat encounters with a real-time player display. The entire application — all components, hooks, Firebase init, and routing — lives in **`src/App.js`** (~2500 lines).
|
||||||
|
|
||||||
|
**Key dependencies:** React 18, Firebase SDK v10 (Firestore + anonymous auth), Tailwind CSS v3, lucide-react icons.
|
||||||
|
|
||||||
|
### Two App Modes (query-param routing)
|
||||||
|
|
||||||
|
- Default URL → `AdminView`: full DM interface (campaign/encounter/participant management)
|
||||||
|
- `?playerView=true` or `/display` → `DisplayView`: read-only player-facing view for a second monitor
|
||||||
|
|
||||||
|
### Firebase / Firestore
|
||||||
|
|
||||||
|
All app state lives in Firestore under `artifacts/{APP_ID}/public/data/`:
|
||||||
|
- `campaigns/` — campaign documents
|
||||||
|
- `campaigns/{id}/encounters/` — sub-collections with a `participants` array per encounter
|
||||||
|
- `activeDisplay/status` — single doc controlling what the player display shows
|
||||||
|
|
||||||
|
`APP_ID` defaults to `"ttrpg-initiative-tracker-default"` and can be overridden via `REACT_APP_TRACKER_APP_ID`.
|
||||||
|
|
||||||
|
All users authenticate anonymously (Firebase Anonymous Auth). If `window.__initial_auth_token` is set, a custom token is used instead.
|
||||||
|
|
||||||
|
Real-time updates use two custom hooks in App.js: `useFirestoreDocument(docPath)` and `useFirestoreCollection(collectionPath)`, both backed by `onSnapshot`.
|
||||||
|
|
||||||
|
### App.js Sections (in order)
|
||||||
|
|
||||||
|
1. Constants — `APP_VERSION`, `CONDITIONS` array, defaults
|
||||||
|
2. Firebase config — reads `REACT_APP_FIREBASE_*` env vars
|
||||||
|
3. Firestore path helpers — `getPath` object
|
||||||
|
4. Utility functions — `generateId()`, `rollD20()`, sort helpers
|
||||||
|
5. Custom hooks — `useFirestoreDocument`, `useFirestoreCollection`
|
||||||
|
6. Reusable UI — `Modal`, `ConfirmationModal`, `LoadingSpinner`, `ErrorDisplay`
|
||||||
|
7. Feature components — forms, managers, controls (see below)
|
||||||
|
8. `AdminView` (~line 1942) — root DM component
|
||||||
|
9. `DisplayView` (~line 2186) — root player component
|
||||||
|
10. `App` (~line 2426) — auth + routing
|
||||||
|
|
||||||
|
Major feature components: `CreateCampaignForm`, `CreateEncounterForm`, `EditParticipantModal`, `CharacterManager`, `ParticipantManager`, `InitiativeControls`, `EncounterManager`.
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
Tailwind CSS with two custom font families configured in `tailwind.config.js`:
|
||||||
|
- `font-cinzel` — Cinzel (serif, used for headings)
|
||||||
|
- `font-garamond` — Alegreya Sans (default body font)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Copy `env.example` to `.env.local` and fill in Firebase credentials:
|
||||||
|
|
||||||
|
```
|
||||||
|
REACT_APP_FIREBASE_API_KEY
|
||||||
|
REACT_APP_FIREBASE_AUTH_DOMAIN
|
||||||
|
REACT_APP_FIREBASE_PROJECT_ID
|
||||||
|
REACT_APP_FIREBASE_STORAGE_BUCKET
|
||||||
|
REACT_APP_FIREBASE_MESSAGING_SENDER_ID
|
||||||
|
REACT_APP_FIREBASE_APP_ID
|
||||||
|
REACT_APP_TRACKER_APP_ID # optional, Firestore namespace
|
||||||
|
```
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Death Saves 5e - Implementation Plan
|
||||||
|
|
||||||
|
TDD required. Tests first, implementation after.
|
||||||
|
|
||||||
|
## Gap Analysis
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
- ✅ Separate success/fail counters (deathSaves, deathFails)
|
||||||
|
- ✅ 3 success → stable, 3 fail → dead
|
||||||
|
- ✅ UI shows ✓/✕ buttons, status badges
|
||||||
|
|
||||||
|
**Missing (from DEATH_SAVES.md):**
|
||||||
|
- ❌ Nat 20: 1 HP + conscious, reset counters
|
||||||
|
- ❌ Nat 1: 2 failures
|
||||||
|
- ❌ Damage at 0 HP → add failures (normal=1, crit=2)
|
||||||
|
- ❌ Massive damage: damage >= currentHp + maxHp → instant dead
|
||||||
|
- ❌ Stabilize manual action
|
||||||
|
- ❌ Healing resets counters
|
||||||
|
- ❌ Damage breaks stable → back to dying
|
||||||
|
|
||||||
|
**Test Gap:**
|
||||||
|
- turn.deathsave.test.js: basic counters only
|
||||||
|
- turn.combat.test.js: single deathSave call, no coverage
|
||||||
|
- Combat.scenario.test.js: basic fail/death flow
|
||||||
|
- No nat20, nat1, damage-at-0, massive damage, stabilize, healing integration
|
||||||
|
|
||||||
|
## Phase 1: Extend Tests (TDD)
|
||||||
|
|
||||||
|
### File: `shared/tests/turn.deathsave.nat20.test.js`
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Nat20 → hp=1, deathSaves=0, deathFails=0, isDying=false, isStable=false, status="conscious"
|
||||||
|
- Nat20 overrides existing saves/fails
|
||||||
|
- Nat20 log entry correct
|
||||||
|
|
||||||
|
### File: `shared/tests/turn.deathsave.nat1.test.js`
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Nat1 → deathFails += 2
|
||||||
|
- Nat1 triggers instant death (3 fails total)
|
||||||
|
- Nat1 log entry correct
|
||||||
|
|
||||||
|
### File: `shared/tests/turn.deathsave.damage.test.js`
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Damage at 0 HP → deathFails += 1
|
||||||
|
- Crit damage at 0 HP → deathFails += 2
|
||||||
|
- Mixed: save success + damage → independent tracking works
|
||||||
|
- Damage log includes "while dying"
|
||||||
|
|
||||||
|
### File: `shared/tests/turn.deathsave.massive.test.js`
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Damage >= currentHp + maxHp → instant dead
|
||||||
|
- Bypasses death saves entirely
|
||||||
|
- Log entry shows "massive damage"
|
||||||
|
|
||||||
|
### File: `shared/tests/turn.deathsave.stabilize.test.js`
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Stabilize → hp=0, isStable=true, isDying=false, counters=0
|
||||||
|
- Stabilize from dying state
|
||||||
|
- Stabilize log entry correct
|
||||||
|
|
||||||
|
### File: `shared/tests/turn.deathsave.heal.test.js`
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Healing at 0 HP → reset counters, isDying=false, isStable=false
|
||||||
|
- Healing at stable → same reset
|
||||||
|
- Healing log includes "cleared death saves"
|
||||||
|
|
||||||
|
### File: `shared/tests/turn.deathsave.stable.test.js`
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Damage to stable → isStable=false, isDying=true, counters reset
|
||||||
|
- Stable takes damage → fresh death save loop
|
||||||
|
|
||||||
|
### File: `shared/tests/turn.deathsave.undo.test.js`
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- Undo deathSave restores previous state
|
||||||
|
- Undo nat20 restores counters
|
||||||
|
- Undo damage at 0 HP restores fail count
|
||||||
|
- Undo stabilize restores dying state
|
||||||
|
|
||||||
|
## Phase 2: Combat Test Integration
|
||||||
|
|
||||||
|
### Update `shared/tests/turn.combat.test.js`
|
||||||
|
|
||||||
|
Add phases:
|
||||||
|
- Nat20 phase: roll nat20, verify 1 HP + conscious
|
||||||
|
- Nat1 phase: roll nat1, verify 2 failures
|
||||||
|
- Damage-at-zero phase: normal damage + crit damage at 0 HP
|
||||||
|
- Massive damage phase: instant death bypass
|
||||||
|
- Stabilize phase: stabilize dying character
|
||||||
|
- Healing-reset phase: heal at 0 HP, verify counters reset
|
||||||
|
- Assert death save state at each phase
|
||||||
|
|
||||||
|
### Update `src/tests/Combat.scenario.test.js`
|
||||||
|
|
||||||
|
Add action functions:
|
||||||
|
- deathSaveActionNat20(name)
|
||||||
|
- deathSaveActionNat1(name)
|
||||||
|
- stabilizeAction(name)
|
||||||
|
- applyDamageAtZero(name, amount, isCrit)
|
||||||
|
- applyMassiveDamage(name)
|
||||||
|
- healingResetAction(name, amount)
|
||||||
|
|
||||||
|
Add scenario phases:
|
||||||
|
- Round 10-20: nat20/nat1 scenarios
|
||||||
|
- Round 30-40: damage-at-zero scenarios (normal + crit)
|
||||||
|
- Round 50-60: stabilize scenarios
|
||||||
|
- Round 70-80: massive damage scenarios
|
||||||
|
- Round 90-100: healing-reset scenarios
|
||||||
|
- Verify log entries include all death save types
|
||||||
|
|
||||||
|
### Update `scripts/combat.js`
|
||||||
|
|
||||||
|
Verify death save events:
|
||||||
|
- Check death_save events in log
|
||||||
|
- Verify nat20, nat1, damage_at_zero, massive_damage log types
|
||||||
|
- Verify stabilize events
|
||||||
|
- Verify healing_reset events
|
||||||
|
|
||||||
|
## Phase 3: Extend `deathSave` Function
|
||||||
|
|
||||||
|
Add roll result parameter:
|
||||||
|
```ts
|
||||||
|
async function deathSave(encounter, participantId, type, n, rollResult, ctx) {
|
||||||
|
// rollResult: number (1-20) or null for manual input
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Handle nat20:
|
||||||
|
```ts
|
||||||
|
if (rollResult === 20) {
|
||||||
|
updates.hp = 1;
|
||||||
|
updates.deathSaves = 0;
|
||||||
|
updates.deathFails = 0;
|
||||||
|
updates.isDying = false;
|
||||||
|
updates.isStable = false;
|
||||||
|
status = 'conscious';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Handle nat1:
|
||||||
|
```ts
|
||||||
|
if (rollResult === 1) {
|
||||||
|
const currentFails = participant.deathFails || 0;
|
||||||
|
updates.deathFails = Math.min(3, currentFails + 2);
|
||||||
|
// Check for death...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 4: Extend `applyHpChange` Function
|
||||||
|
|
||||||
|
Add damage-at-zero logic:
|
||||||
|
```ts
|
||||||
|
if (changeType === 'damage' && participant.currentHp === 0) {
|
||||||
|
const failuresToAdd = isCritical ? 2 : 1;
|
||||||
|
const currentFails = participant.deathFails || 0;
|
||||||
|
updates.deathFails = Math.min(3, currentFails + failuresToAdd);
|
||||||
|
updates.isStable = false;
|
||||||
|
// Check for death...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add massive damage rule:
|
||||||
|
```ts
|
||||||
|
if (changeType === 'damage' && damage >= participant.currentHp + participant.maxHp) {
|
||||||
|
updates.hp = 0;
|
||||||
|
updates.isDying = true;
|
||||||
|
updates.deathSaves = 0;
|
||||||
|
updates.deathFails = 3;
|
||||||
|
updates.isStable = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add healing reset logic:
|
||||||
|
```ts
|
||||||
|
if (changeType === 'heal' && (participant.currentHp === 0 || participant.isStable || participant.isDying)) {
|
||||||
|
updates.deathSaves = 0;
|
||||||
|
updates.deathFails = 0;
|
||||||
|
updates.isDying = false;
|
||||||
|
updates.isStable = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 5: New `stabilizeParticipant` Function
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||||
|
// hp=0, isStable=true, isDying=false, counters=0
|
||||||
|
// Log: "Stabilized"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 6: UI Extensions
|
||||||
|
|
||||||
|
### Phase 6a: Roll Result Modal
|
||||||
|
|
||||||
|
Add roll result input:
|
||||||
|
- Modal when clicking death save button
|
||||||
|
- Input: d20 result (1-20) or "manual"
|
||||||
|
- Auto-detect nat20/nat1
|
||||||
|
|
||||||
|
### Phase 6b: Nat1/Nat20/Stabilize Buttons
|
||||||
|
|
||||||
|
Death save row layout:
|
||||||
|
```
|
||||||
|
[Saves: □ □ □] [Fails: □ □ □]
|
||||||
|
[Nat1] [Nat20] [Stabilize]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nat1 button:**
|
||||||
|
- Red icon (AlertTriangle)
|
||||||
|
- Click → deathSave(participantId, 'fail', null, 1, ctx)
|
||||||
|
- Adds 2 failures immediately
|
||||||
|
- Log: "Nat 1! [name] takes 2 death save failures"
|
||||||
|
- Disable if deathFails >= 2 (would cause death anyway)
|
||||||
|
|
||||||
|
**Nat20 button:**
|
||||||
|
- Gold icon (Zap)
|
||||||
|
- Click → deathSave(participantId, 'success', null, 20, ctx)
|
||||||
|
- Sets hp=1, resets counters, conscious
|
||||||
|
- Log: "Nat 20! [name] restored to 1 HP, conscious"
|
||||||
|
|
||||||
|
**Stabilize button:**
|
||||||
|
- Emerald icon (HeartPulse)
|
||||||
|
- Only show when dying (hp=0, !isStable, !dead)
|
||||||
|
- Click → stabilizeParticipant(participantId, ctx)
|
||||||
|
- Sets isStable=true, counters=0
|
||||||
|
- Log: "[name] stabilized at 0 HP"
|
||||||
|
- Hide if already isStable
|
||||||
|
|
||||||
|
**Conditional visibility:**
|
||||||
|
- All buttons: only when hp === 0 && !dead
|
||||||
|
- Stabilize: hide if already isStable
|
||||||
|
- Nat1: disable if deathFails >= 2
|
||||||
|
|
||||||
|
### Phase 6c: Crit Checkbox for Damage
|
||||||
|
|
||||||
|
Add crit checkbox:
|
||||||
|
- For damage at 0 HP
|
||||||
|
- Pass crit to applyHpChange
|
||||||
|
- UI: checkbox "Critical hit?"
|
||||||
|
|
||||||
|
### Phase 6d: Enhanced Log Messages
|
||||||
|
|
||||||
|
- Nat20: "Nat 20! [name] restored to 1 HP, conscious"
|
||||||
|
- Nat1: "Nat 1! [name] takes 2 death save failures"
|
||||||
|
- Damage at 0: "[name] takes [n] damage while dying"
|
||||||
|
- Massive damage: "Massive damage! [name] instantly killed"
|
||||||
|
- Stabilize: "[name] stabilized at 0 HP"
|
||||||
|
- Healing reset: "[name] healed to [n] HP, death saves cleared"
|
||||||
|
|
||||||
|
## Phase 7: Integration Tests
|
||||||
|
|
||||||
|
File: `shared/tests/turn.deathsave.flow.test.js`
|
||||||
|
|
||||||
|
Test full scenarios:
|
||||||
|
- Dying → nat20 → conscious → take damage → dying
|
||||||
|
- Dying → nat1 → 2 fails → another fail → dead
|
||||||
|
- Dying → stabilize → damage → dying again
|
||||||
|
- Massive damage bypasses all
|
||||||
|
- Healing resets mid-death-save loop
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. Phase 1: Write all test files (fail expected)
|
||||||
|
2. Phase 2: Update combat tests (fail expected)
|
||||||
|
3. Phase 3: Extend deathSave, run tests
|
||||||
|
4. Phase 4: Extend applyHpChange, run tests
|
||||||
|
5. Phase 5: Add stabilizeParticipant, run tests
|
||||||
|
6. Phase 6a: Roll result modal
|
||||||
|
7. Phase 6b: Nat1/Nat20/Stabilize buttons
|
||||||
|
8. Phase 6c: Crit checkbox
|
||||||
|
9. Phase 6d: Enhanced log messages
|
||||||
|
10. Phase 7: Integration tests
|
||||||
|
11. Manual testing: full scenarios in dev server
|
||||||
|
|
||||||
|
## Risk Notes
|
||||||
|
|
||||||
|
- Roll result input: optional field (DM may not want to input every roll)
|
||||||
|
- Crit detection: manual checkbox (no way to auto-detect)
|
||||||
|
- Massive damage: optional rule (some tables ignore)
|
||||||
|
- Backward compat: existing API must still work with null rollResult
|
||||||
|
- Nat1/Nat20/Stabilize buttons: DM-optional, not required to use
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
# Dockerfile
|
# Dockerfile
|
||||||
|
|
||||||
# Stage 1: Build the React application
|
# Stage 1: Build the React application
|
||||||
FROM node:18-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
LABEL stage="build-local-testing"
|
LABEL stage="build-local-testing"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json (or yarn.lock)
|
# Copy root package.json/lockfile plus workspace manifests so npm can
|
||||||
|
# resolve the @ttrpg/shared and server workspace links during install.
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
|
||||||
# Install dependencies using the lock file for consistency
|
# Install dependencies using the lock file for consistency
|
||||||
RUN npm install
|
RUN npm install
|
||||||
@@ -38,8 +41,11 @@ LABEL stage="nginx-server"
|
|||||||
# Copy the build output from the 'build' stage to Nginx's html directory
|
# Copy the build output from the 'build' stage to Nginx's html directory
|
||||||
COPY --from=build /app/build /usr/share/nginx/html
|
COPY --from=build /app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Replace default nginx config with one that handles SPA client-side routing
|
||||||
|
COPY nginx-docker.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Expose port 80 (Nginx default)
|
# Expose port 80 (Nginx default)
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Start Nginx when the container launches
|
# Start Nginx when the container launches
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Jenkinsfile — build + publish both images to the thinkserver:5000 registry.
|
||||||
|
//
|
||||||
|
// ttrpg-tracker-firebase : root Dockerfile, nginx serving the static SPA,
|
||||||
|
// Firebase storage (config baked in at build time).
|
||||||
|
// ttrpg-tracker-sqlite : docker/Dockerfile, Caddy + Node, server/SQLite
|
||||||
|
// storage (REACT_APP_STORAGE=server).
|
||||||
|
//
|
||||||
|
// Requirements on the Jenkins agent:
|
||||||
|
// * Docker CLI available, agent user in the docker group (or DooD socket).
|
||||||
|
// * thinkserver:5000 reachable and trusted by the Docker daemon. If it is a
|
||||||
|
// plain-HTTP registry, add it to /etc/docker/daemon.json on the AGENT host:
|
||||||
|
// { "insecure-registries": ["thinkserver:5000"] } (then restart docker)
|
||||||
|
//
|
||||||
|
// Jenkins credentials this pipeline expects:
|
||||||
|
// * ttrpg-firebase-env-local (Secret file) — the .env.local contents with
|
||||||
|
// REACT_APP_FIREBASE_* values. Baked into the firebase image at build time.
|
||||||
|
// * thinkserver-registry (Username/pw) — OPTIONAL. Only needed if the
|
||||||
|
// registry requires auth; the login stage is guarded below.
|
||||||
|
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
timeout(time: 30, unit: 'MINUTES')
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
string(name: 'TRACKER_APP_ID',
|
||||||
|
defaultValue: 'ttrpg-initiative-tracker-default',
|
||||||
|
description: 'Firestore/app namespace baked into the SQLite image (REACT_APP_TRACKER_APP_ID).')
|
||||||
|
booleanParam(name: 'PUSH_LATEST', defaultValue: true,
|
||||||
|
description: 'Also tag and push :latest in addition to the commit-SHA tag.')
|
||||||
|
booleanParam(name: 'REGISTRY_AUTH', defaultValue: false,
|
||||||
|
description: 'Enable docker login using the thinkserver-registry credential.')
|
||||||
|
booleanParam(name: 'REDEPLOY_PORTAINER', defaultValue: true,
|
||||||
|
description: 'After pushing, POST to the Portainer stack webhooks to re-pull and redeploy.')
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
REGISTRY = 'thinkserver:5000'
|
||||||
|
FIREBASE_IMAGE = "${REGISTRY}/ttrpg-tracker-firebase"
|
||||||
|
SQLITE_IMAGE = "${REGISTRY}/ttrpg-tracker-sqlite"
|
||||||
|
DOCKER_BUILDKIT = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
script {
|
||||||
|
env.SHORT_SHA = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
|
||||||
|
// Only build/push on main. BRANCH_NAME is set by Multibranch jobs; it
|
||||||
|
// is unset in a plain "Pipeline from SCM" job, which already only ever
|
||||||
|
// checks out main, so treat unset as main too.
|
||||||
|
env.IS_MAIN = (env.BRANCH_NAME == null || env.BRANCH_NAME == 'main').toString()
|
||||||
|
if (env.IS_MAIN != 'true') {
|
||||||
|
echo "Branch '${env.BRANCH_NAME}' is not main — skipping image build/push."
|
||||||
|
} else {
|
||||||
|
echo "Building tag ${env.SHORT_SHA} (build #${env.BUILD_NUMBER})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Registry login') {
|
||||||
|
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REGISTRY_AUTH } } }
|
||||||
|
steps {
|
||||||
|
withCredentials([usernamePassword(credentialsId: 'thinkserver-registry',
|
||||||
|
usernameVariable: 'REG_USER',
|
||||||
|
passwordVariable: 'REG_PASS')]) {
|
||||||
|
sh 'echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build firebase image') {
|
||||||
|
when { expression { env.IS_MAIN == 'true' } }
|
||||||
|
steps {
|
||||||
|
// Firebase config is compiled into the static bundle, so it must exist
|
||||||
|
// at build time. Inject .env.local from the Jenkins secret file, then
|
||||||
|
// the root Dockerfile's `COPY .env.local .env` picks it up.
|
||||||
|
withCredentials([file(credentialsId: 'ttrpg-firebase-env-local', variable: 'FIREBASE_ENV')]) {
|
||||||
|
sh '''
|
||||||
|
cp "$FIREBASE_ENV" .env.local
|
||||||
|
docker build \
|
||||||
|
-f Dockerfile \
|
||||||
|
-t "$FIREBASE_IMAGE:$SHORT_SHA" \
|
||||||
|
.
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build sqlite image') {
|
||||||
|
when { expression { env.IS_MAIN == 'true' } }
|
||||||
|
steps {
|
||||||
|
// Server/SQLite build. BuildKit is required (syntax directive + cache
|
||||||
|
// mount in docker/Dockerfile). Context is the repo root.
|
||||||
|
sh '''
|
||||||
|
docker build \
|
||||||
|
-f docker/Dockerfile \
|
||||||
|
--build-arg REACT_APP_TRACKER_APP_ID="$TRACKER_APP_ID" \
|
||||||
|
-t "$SQLITE_IMAGE:$SHORT_SHA" \
|
||||||
|
.
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Tag latest') {
|
||||||
|
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.PUSH_LATEST } } }
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
docker tag "$FIREBASE_IMAGE:$SHORT_SHA" "$FIREBASE_IMAGE:latest"
|
||||||
|
docker tag "$SQLITE_IMAGE:$SHORT_SHA" "$SQLITE_IMAGE:latest"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Push') {
|
||||||
|
when { expression { env.IS_MAIN == 'true' } }
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
docker push "$FIREBASE_IMAGE:$SHORT_SHA"
|
||||||
|
docker push "$SQLITE_IMAGE:$SHORT_SHA"
|
||||||
|
'''
|
||||||
|
script {
|
||||||
|
if (params.PUSH_LATEST) {
|
||||||
|
sh '''
|
||||||
|
docker push "$FIREBASE_IMAGE:latest"
|
||||||
|
docker push "$SQLITE_IMAGE:latest"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Redeploy (Portainer webhooks)') {
|
||||||
|
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REDEPLOY_PORTAINER } } }
|
||||||
|
steps {
|
||||||
|
// Webhook URLs are Secret-text credentials, so they never appear in the
|
||||||
|
// repo and Jenkins masks them in the console. They are bound to shell
|
||||||
|
// env vars and referenced inside a single-quoted sh block, so Groovy
|
||||||
|
// never interpolates the value into the pipeline log.
|
||||||
|
withCredentials([
|
||||||
|
string(credentialsId: 'portainer-webhook-firebase', variable: 'WEBHOOK_FIREBASE'),
|
||||||
|
string(credentialsId: 'portainer-webhook-sqlite', variable: 'WEBHOOK_SQLITE'),
|
||||||
|
]) {
|
||||||
|
sh '''
|
||||||
|
set +x
|
||||||
|
echo "Triggering Portainer redeploy (firebase stack)"
|
||||||
|
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_FIREBASE"
|
||||||
|
echo "Triggering Portainer redeploy (sqlite stack)"
|
||||||
|
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_SQLITE"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
// Never leave Firebase secrets in the workspace or log out cleanly.
|
||||||
|
sh 'rm -f .env.local || true'
|
||||||
|
sh 'docker logout "$REGISTRY" || true'
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
echo "Pushed ${FIREBASE_IMAGE}:${SHORT_SHA} and ${SQLITE_IMAGE}:${SHORT_SHA}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
Attribution-NonCommercial-ShareAlike 4.0 International
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||||
|
does not provide legal services or legal advice. Distribution of
|
||||||
|
Creative Commons public licenses does not create a lawyer-client or
|
||||||
|
other relationship. Creative Commons makes its licenses and related
|
||||||
|
information available on an "as-is" basis. Creative Commons gives no
|
||||||
|
warranties regarding its licenses, any material licensed under their
|
||||||
|
terms and conditions, or any related information. Creative Commons
|
||||||
|
disclaims all liability for damages resulting from their use to the
|
||||||
|
fullest extent possible.
|
||||||
|
|
||||||
|
Using Creative Commons Public Licenses
|
||||||
|
|
||||||
|
Creative Commons public licenses provide a standard set of terms and
|
||||||
|
conditions that creators and other rights holders may use to share
|
||||||
|
original works of authorship and other material subject to copyright
|
||||||
|
and certain other rights specified in the public license below. The
|
||||||
|
following considerations are for informational purposes only, are not
|
||||||
|
exhaustive, and do not form part of our licenses.
|
||||||
|
|
||||||
|
Considerations for licensors: Our public licenses are
|
||||||
|
intended for use by those authorized to give the public
|
||||||
|
permission to use material in ways otherwise restricted by
|
||||||
|
copyright and certain other rights. Our licenses are
|
||||||
|
irrevocable. Licensors should read and understand the terms
|
||||||
|
and conditions of the license they choose before applying it.
|
||||||
|
Licensors should also secure all rights necessary before
|
||||||
|
applying our licenses so that the public can reuse the
|
||||||
|
material as expected. Licensors should clearly mark any
|
||||||
|
material not subject to the license. This includes other CC-
|
||||||
|
licensed material, or material used under an exception or
|
||||||
|
limitation to copyright. More considerations for licensors:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensors
|
||||||
|
|
||||||
|
Considerations for the public: By using one of our public
|
||||||
|
licenses, a licensor grants the public permission to use the
|
||||||
|
licensed material under specified terms and conditions. If
|
||||||
|
the licensor's permission is not necessary for any reason--for
|
||||||
|
example, because of any applicable exception or limitation to
|
||||||
|
copyright--then that use is not regulated by the license. Our
|
||||||
|
licenses grant only permissions under copyright and certain
|
||||||
|
other rights that a licensor has authority to grant. Use of
|
||||||
|
the licensed material may still be restricted for other
|
||||||
|
reasons, including because others have copyright or other
|
||||||
|
rights in the material. A licensor may make special requests,
|
||||||
|
such as asking that all changes be marked or described.
|
||||||
|
Although not required by our licenses, you are encouraged to
|
||||||
|
respect those requests where reasonable. More considerations
|
||||||
|
for the public:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensees
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||||
|
Public License
|
||||||
|
|
||||||
|
By exercising the Licensed Rights (defined below), You accept and agree
|
||||||
|
to be bound by the terms and conditions of this Creative Commons
|
||||||
|
Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
||||||
|
("Public License"). To the extent this Public License may be
|
||||||
|
interpreted as a contract, You are granted the Licensed Rights in
|
||||||
|
consideration of Your acceptance of these terms and conditions, and the
|
||||||
|
Licensor grants You such rights in consideration of benefits the
|
||||||
|
Licensor receives from making the Licensed Material available under
|
||||||
|
these terms and conditions.
|
||||||
|
|
||||||
|
|
||||||
|
Section 1 -- Definitions.
|
||||||
|
|
||||||
|
a. Adapted Material means material subject to Copyright and Similar
|
||||||
|
Rights that is derived from or based upon the Licensed Material
|
||||||
|
and in which the Licensed Material is translated, altered,
|
||||||
|
arranged, transformed, or otherwise modified in a manner requiring
|
||||||
|
permission under the Copyright and Similar Rights held by the
|
||||||
|
Licensor. For purposes of this Public License, where the Licensed
|
||||||
|
Material is a musical work, performance, or sound recording,
|
||||||
|
Adapted Material is always produced where the Licensed Material is
|
||||||
|
synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright
|
||||||
|
and Similar Rights in Your contributions to Adapted Material in
|
||||||
|
accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. BY-NC-SA Compatible License means a license listed at
|
||||||
|
creativecommons.org/compatiblelicenses, approved by Creative
|
||||||
|
Commons as essentially the equivalent of this Public License.
|
||||||
|
|
||||||
|
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||||
|
closely related to copyright including, without limitation,
|
||||||
|
performance, broadcast, sound recording, and Sui Generis Database
|
||||||
|
Rights, without regard to how the rights are labeled or
|
||||||
|
categorized. For purposes of this Public License, the rights
|
||||||
|
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||||
|
Rights.
|
||||||
|
|
||||||
|
e. Effective Technological Measures means those measures that, in the
|
||||||
|
absence of proper authority, may not be circumvented under laws
|
||||||
|
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||||
|
Treaty adopted on December 20, 1996, and/or similar international
|
||||||
|
agreements.
|
||||||
|
|
||||||
|
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||||
|
any other exception or limitation to Copyright and Similar Rights
|
||||||
|
that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
g. License Elements means the license attributes listed in the name
|
||||||
|
of a Creative Commons Public License. The License Elements of this
|
||||||
|
Public License are Attribution, NonCommercial, and ShareAlike.
|
||||||
|
|
||||||
|
h. Licensed Material means the artistic or literary work, database,
|
||||||
|
or other material to which the Licensor applied this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
i. Licensed Rights means the rights granted to You subject to the
|
||||||
|
terms and conditions of this Public License, which are limited to
|
||||||
|
all Copyright and Similar Rights that apply to Your use of the
|
||||||
|
Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||||
|
under this Public License.
|
||||||
|
|
||||||
|
k. NonCommercial means not primarily intended for or directed towards
|
||||||
|
commercial advantage or monetary compensation. For purposes of
|
||||||
|
this Public License, the exchange of the Licensed Material for
|
||||||
|
other material subject to Copyright and Similar Rights by digital
|
||||||
|
file-sharing or similar means is NonCommercial provided there is
|
||||||
|
no payment of monetary compensation in connection with the
|
||||||
|
exchange.
|
||||||
|
|
||||||
|
l. Share means to provide material to the public by any means or
|
||||||
|
process that requires permission under the Licensed Rights, such
|
||||||
|
as reproduction, public display, public performance, distribution,
|
||||||
|
dissemination, communication, or importation, and to make material
|
||||||
|
available to the public including in ways that members of the
|
||||||
|
public may access the material from a place and at a time
|
||||||
|
individually chosen by them.
|
||||||
|
|
||||||
|
m. Sui Generis Database Rights means rights other than copyright
|
||||||
|
resulting from Directive 96/9/EC of the European Parliament and of
|
||||||
|
the Council of 11 March 1996 on the legal protection of databases,
|
||||||
|
as amended and/or succeeded, as well as other essentially
|
||||||
|
equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
n. You means the individual or entity exercising the Licensed Rights
|
||||||
|
under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
|
||||||
|
Section 2 -- Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License,
|
||||||
|
the Licensor hereby grants You a worldwide, royalty-free,
|
||||||
|
non-sublicensable, non-exclusive, irrevocable license to
|
||||||
|
exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
a. reproduce and Share the Licensed Material, in whole or
|
||||||
|
in part, for NonCommercial purposes only; and
|
||||||
|
|
||||||
|
b. produce, reproduce, and Share Adapted Material for
|
||||||
|
NonCommercial purposes only.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||||
|
Exceptions and Limitations apply to Your use, this Public
|
||||||
|
License does not apply, and You do not need to comply with
|
||||||
|
its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section
|
||||||
|
6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The
|
||||||
|
Licensor authorizes You to exercise the Licensed Rights in
|
||||||
|
all media and formats whether now known or hereafter created,
|
||||||
|
and to make technical modifications necessary to do so. The
|
||||||
|
Licensor waives and/or agrees not to assert any right or
|
||||||
|
authority to forbid You from making technical modifications
|
||||||
|
necessary to exercise the Licensed Rights, including
|
||||||
|
technical modifications necessary to circumvent Effective
|
||||||
|
Technological Measures. For purposes of this Public License,
|
||||||
|
simply making modifications authorized by this Section 2(a)
|
||||||
|
(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
a. Offer from the Licensor -- Licensed Material. Every
|
||||||
|
recipient of the Licensed Material automatically
|
||||||
|
receives an offer from the Licensor to exercise the
|
||||||
|
Licensed Rights under the terms and conditions of this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
b. Additional offer from the Licensor -- Adapted Material.
|
||||||
|
Every recipient of Adapted Material from You
|
||||||
|
automatically receives an offer from the Licensor to
|
||||||
|
exercise the Licensed Rights in the Adapted Material
|
||||||
|
under the conditions of the Adapter's License You apply.
|
||||||
|
|
||||||
|
c. No downstream restrictions. You may not offer or impose
|
||||||
|
any additional or different terms or conditions on, or
|
||||||
|
apply any Effective Technological Measures to, the
|
||||||
|
Licensed Material if doing so restricts exercise of the
|
||||||
|
Licensed Rights by any recipient of the Licensed
|
||||||
|
Material.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or
|
||||||
|
may be construed as permission to assert or imply that You
|
||||||
|
are, or that Your use of the Licensed Material is, connected
|
||||||
|
with, or sponsored, endorsed, or granted official status by,
|
||||||
|
the Licensor or others designated to receive attribution as
|
||||||
|
provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not
|
||||||
|
licensed under this Public License, nor are publicity,
|
||||||
|
privacy, and/or other similar personality rights; however, to
|
||||||
|
the extent possible, the Licensor waives and/or agrees not to
|
||||||
|
assert any such rights held by the Licensor to the limited
|
||||||
|
extent necessary to allow You to exercise the Licensed
|
||||||
|
Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to
|
||||||
|
collect royalties from You for the exercise of the Licensed
|
||||||
|
Rights, whether directly or through a collecting society
|
||||||
|
under any voluntary or waivable statutory or compulsory
|
||||||
|
licensing scheme. In all other cases the Licensor expressly
|
||||||
|
reserves any right to collect such royalties, including when
|
||||||
|
the Licensed Material is used other than for NonCommercial
|
||||||
|
purposes.
|
||||||
|
|
||||||
|
|
||||||
|
Section 3 -- License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the
|
||||||
|
following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified
|
||||||
|
form), You must:
|
||||||
|
|
||||||
|
a. retain the following if it is supplied by the Licensor
|
||||||
|
with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed
|
||||||
|
Material and any others designated to receive
|
||||||
|
attribution, in any reasonable manner requested by
|
||||||
|
the Licensor (including by pseudonym if
|
||||||
|
designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of
|
||||||
|
warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the
|
||||||
|
extent reasonably practicable;
|
||||||
|
|
||||||
|
b. indicate if You modified the Licensed Material and
|
||||||
|
retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
c. indicate the Licensed Material is licensed under this
|
||||||
|
Public License, and include the text of, or the URI or
|
||||||
|
hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||||
|
reasonable manner based on the medium, means, and context in
|
||||||
|
which You Share the Licensed Material. For example, it may be
|
||||||
|
reasonable to satisfy the conditions by providing a URI or
|
||||||
|
hyperlink to a resource that includes the required
|
||||||
|
information.
|
||||||
|
3. If requested by the Licensor, You must remove any of the
|
||||||
|
information required by Section 3(a)(1)(A) to the extent
|
||||||
|
reasonably practicable.
|
||||||
|
|
||||||
|
b. ShareAlike.
|
||||||
|
|
||||||
|
In addition to the conditions in Section 3(a), if You Share
|
||||||
|
Adapted Material You produce, the following conditions also apply.
|
||||||
|
|
||||||
|
1. The Adapter's License You apply must be a Creative Commons
|
||||||
|
license with the same License Elements, this version or
|
||||||
|
later, or a BY-NC-SA Compatible License.
|
||||||
|
|
||||||
|
2. You must include the text of, or the URI or hyperlink to, the
|
||||||
|
Adapter's License You apply. You may satisfy this condition
|
||||||
|
in any reasonable manner based on the medium, means, and
|
||||||
|
context in which You Share Adapted Material.
|
||||||
|
|
||||||
|
3. You may not offer or impose any additional or different terms
|
||||||
|
or conditions on, or apply any Effective Technological
|
||||||
|
Measures to, Adapted Material that restrict exercise of the
|
||||||
|
rights granted under the Adapter's License You apply.
|
||||||
|
|
||||||
|
|
||||||
|
Section 4 -- Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that
|
||||||
|
apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||||
|
to extract, reuse, reproduce, and Share all or a substantial
|
||||||
|
portion of the contents of the database for NonCommercial purposes
|
||||||
|
only;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database
|
||||||
|
contents in a database in which You have Sui Generis Database
|
||||||
|
Rights, then the database in which You have Sui Generis Database
|
||||||
|
Rights (but not its individual contents) is Adapted Material,
|
||||||
|
including for purposes of Section 3(b); and
|
||||||
|
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share
|
||||||
|
all or a substantial portion of the contents of the database.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not
|
||||||
|
replace Your obligations under this Public License where the Licensed
|
||||||
|
Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
|
||||||
|
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||||
|
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||||
|
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||||
|
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||||
|
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||||
|
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||||
|
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||||
|
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||||
|
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||||
|
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||||
|
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||||
|
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||||
|
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||||
|
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||||
|
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided
|
||||||
|
above shall be interpreted in a manner that, to the extent
|
||||||
|
possible, most closely approximates an absolute disclaimer and
|
||||||
|
waiver of all liability.
|
||||||
|
|
||||||
|
|
||||||
|
Section 6 -- Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and
|
||||||
|
Similar Rights licensed here. However, if You fail to comply with
|
||||||
|
this Public License, then Your rights under this Public License
|
||||||
|
terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under
|
||||||
|
Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided
|
||||||
|
it is cured within 30 days of Your discovery of the
|
||||||
|
violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||||
|
right the Licensor may have to seek remedies for Your violations
|
||||||
|
of this Public License.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, the Licensor may also offer the
|
||||||
|
Licensed Material under separate terms or conditions or stop
|
||||||
|
distributing the Licensed Material at any time; however, doing so
|
||||||
|
will not terminate this Public License.
|
||||||
|
|
||||||
|
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 7 -- Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different
|
||||||
|
terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the
|
||||||
|
Licensed Material not stated herein are separate from and
|
||||||
|
independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 8 -- Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and
|
||||||
|
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||||
|
conditions on any use of the Licensed Material that could lawfully
|
||||||
|
be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is
|
||||||
|
deemed unenforceable, it shall be automatically reformed to the
|
||||||
|
minimum extent necessary to make it enforceable. If the provision
|
||||||
|
cannot be reformed, it shall be severed from this Public License
|
||||||
|
without affecting the enforceability of the remaining terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no
|
||||||
|
failure to comply consented to unless expressly agreed to by the
|
||||||
|
Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted
|
||||||
|
as a limitation upon, or waiver of, any privileges and immunities
|
||||||
|
that apply to the Licensor or You, including from the legal
|
||||||
|
processes of any jurisdiction or authority.
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons is not a party to its public
|
||||||
|
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||||
|
its public licenses to material it publishes and in those instances
|
||||||
|
will be considered the “Licensor.” The text of the Creative Commons
|
||||||
|
public licenses is dedicated to the public domain under the CC0 Public
|
||||||
|
Domain Dedication. Except for the limited purpose of indicating that
|
||||||
|
material is shared under a Creative Commons public license or as
|
||||||
|
otherwise permitted by the Creative Commons policies published at
|
||||||
|
creativecommons.org/policies, Creative Commons does not authorize the
|
||||||
|
use of the trademark "Creative Commons" or any other trademark or logo
|
||||||
|
of Creative Commons without its prior written consent including,
|
||||||
|
without limitation, in connection with any unauthorized modifications
|
||||||
|
to any of its public licenses or any other arrangements,
|
||||||
|
understandings, or agreements concerning use of licensed material. For
|
||||||
|
the avoidance of doubt, this paragraph does not form part of the
|
||||||
|
public licenses.
|
||||||
|
|
||||||
|
Creative Commons may be contacted at creativecommons.org.
|
||||||
@@ -1,66 +1,128 @@
|
|||||||
# TTRPG Initiative Tracker
|
# TTRPG Initiative Tracker (v0.4)
|
||||||
|
|
||||||
This application is the result of not having the exact tool I want to use, and a few sessions of [vibe-coding](https://www.youtube.com/watch?v=Tw18-4U7mts) with [Google Gemini](https://developers.google.com/gemini-code-assist/docs/overview).
|

|
||||||
|
|
||||||
**Use at your own risk.**
|
This application is the result of not having the exact tool I want to use, and after a few sessions of [vibe-coding](https://www.youtube.com/watch?v=Tw18-4U7mts) with [Google Gemini](https://developers.google.com/gemini-code-assist/docs/overview), here it is!.
|
||||||
|
|
||||||
A web-based application designed to help Dungeon Masters (DMs) manage and display combat initiative for tabletop role-playing games (TTRPGs). It features a DM admin interface for controlling encounters and a separate player display view suitable for an external monitor or for players to view on their own devices.
|
**Use at your own risk.**
|
||||||
|
|
||||||
|
A web-based application designed to help Dungeon Masters (DMs) manage and display combat initiative for tabletop role-playing games (TTRPGs). It features a DM admin interface for controlling encounters and a separate player display view suitable for an external monitor.
|
||||||
|
|
||||||
|

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

|

|
||||||
|
|
||||||
* **Campaign Management:** Create campaigns to organize your game sessions.
|
* **Campaign Management:**
|
||||||
* **Character Management:** Add and manage characters (player characters) within each campaign.
|
* Create campaigns to organize game sessions.
|
||||||
|
* Option to set a custom background image URL for the player display on a per-campaign basis.
|
||||||
|
* Campaign cards display the number of characters and encounters.
|
||||||
|
* **Character Management:**
|
||||||
|
* Add and manage characters (player characters) within each campaign.
|
||||||
|
* Set default Max HP and default Initiative Modifier for each campaign character.
|
||||||
|
* The character list is collapsible to save screen space.
|
||||||
* **Encounter Management:**
|
* **Encounter Management:**
|
||||||
* Create multiple encounters per campaign.
|
* Create multiple encounters per campaign.
|
||||||
* Add characters and monsters to encounters with initiative scores and hit points.
|
* Add characters from the campaign roster (with auto-rolled initiative based on their modifier and HP pre-filled from campaign defaults) or add custom monsters.
|
||||||
* DM controls to start, advance turns, and end encounters.
|
* Monsters have an editable default initiative modifier (pre-filled at +2) and can be marked as NPCs.
|
||||||
|
* "Add All Campaign Characters" button for quickly populating encounters with initiative rolls.
|
||||||
|
* DM controls to start, pause/resume, advance turns, and end encounters.
|
||||||
|
* Visual feedback for rolled initiative when adding individual participants.
|
||||||
|
* Participants can be added mid-combat while the encounter is paused; the turn order recalculates on resume.
|
||||||
|
* **HP Tracking:**
|
||||||
|
* Apply damage or healing to any participant during combat directly from the DM view.
|
||||||
|
* When a participant reaches 0 HP they are automatically marked inactive and shown with a skull (☠️) icon.
|
||||||
|
* Healing a dead participant above 0 HP revives them and reactivates them in the turn order.
|
||||||
|
* **Death Save Tracking:**
|
||||||
|
* When a player character reaches 0 HP, three death save checkboxes appear in the DM view.
|
||||||
|
* Marking the third death save triggers a dissolve animation on the player display, then removes the participant from the encounter.
|
||||||
|
* **Conditions:**
|
||||||
|
* Apply any of 22 conditions to a participant, including all standard D&D conditions plus several extras: Alchemist Fire 🔥, Bardic Inspiration 🎵, Blinded 🙈, Charmed 💘, Deafened 🔇, Exhaustion 😴, Frightened 😱, Grappled 🤜, Grazed 🩹, Incapacitated 💫, Invisible 👻, Paralyzed ⚡, Petrified 🗿, Poisoned 🤢, Prone ⬇️, Restrained 🕸️, Sapped 🔨, Shield 🛡️, Slowed 🐌, Stunned 💥, Unconscious 💤, Vexed 🎯.
|
||||||
|
* Active conditions are shown as emoji badges on both the DM view and the player display.
|
||||||
|
* Conditions can be toggled directly from the badge or from an expandable condition picker per participant.
|
||||||
* **Player Display:**
|
* **Player Display:**
|
||||||
* A clean interface showing the current initiative order and participant HP (monster HP totals are hidden, only the bar is shown).
|
* A clean interface showing the current initiative order, round number, participant HP, and current turn.
|
||||||
* Option to set a custom background image URL for the player display on a per-campaign basis.
|
* HP bars use color-coded thresholds: green (above half), yellow (quarter to half), red (below quarter), dark red (dead).
|
||||||
* DM can activate/deactivate encounters for the player display.
|
* Monster and NPC HP totals are hidden; only the color-coded bar is shown.
|
||||||
* Player display can be opened in a separate window.
|
* Player character HP can be hidden from the player display via a toggle in the DM's initiative controls ("Hide player HP"). When enabled, only the color-coded bar is shown for characters as well.
|
||||||
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display.
|
* Active conditions are displayed as emoji badges on each participant's card.
|
||||||
* **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts) to set a manual order.
|
* Inactive participants (and dead ones) are greyed out with a grayscale filter.
|
||||||
* **Responsive Design:** Styled with Tailwind CSS for usability across different screen sizes.
|
* Inactive monsters (e.g. pre-staged or summoned reserves) are hidden from the player display entirely; inactive player characters still appear greyed out.
|
||||||
|
* The current participant's card auto-scrolls into view when the turn advances.
|
||||||
|
* NPCs are visually distinct from hostile monsters, which are distinct from player characters.
|
||||||
|
* Displays a custom campaign background image if one is set.
|
||||||
|
* Shows a "Game Session Paused" message when no encounter is active, or "(Combat Paused)" when the DM pauses a running encounter.
|
||||||
|
* Player display is opened in a separate window via the "Open Player Window" button in the DM's header.
|
||||||
|
* A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor.
|
||||||
|
* A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active.
|
||||||
|
* **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page.
|
||||||
|
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display (or a self-hosted WebSocket backend, see below).
|
||||||
|
* **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order.
|
||||||
|
* **Responsive Design:** Styled with Tailwind CSS.
|
||||||
|
* **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
* **Frontend:** React
|
* **Frontend:** React
|
||||||
* **Styling:** Tailwind CSS
|
* **Styling:** Tailwind CSS
|
||||||
* **Backend/Database:** Firebase Firestore (for real-time data)
|
* **Backend/Database:** Firebase Firestore (default), or a self-hosted Node/Express + `ws` + SQLite (`better-sqlite3`) backend behind Caddy (optional, see [Self-Hosted Backend](#self-hosted-backend-optional))
|
||||||
* **Authentication:** Firebase Anonymous Authentication
|
* **Authentication:** Firebase Anonymous Authentication (Firebase mode only; the self-hosted backend has no auth layer — intended for trusted/local networks)
|
||||||
|
* **Shared logic:** Turn-order state machine (`shared/`) is framework-agnostic and used by both the frontend and the self-hosted backend, covered by a Jest test suite
|
||||||
|
|
||||||
## App Usage Overview
|
## App Usage Overview
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The TTRPG Initiative Tracker is designed for Dungeon Masters to manage combat encounters and display the initiative order to players. Here's a typical workflow:
|
The TTRPG Initiative Tracker is designed for Dungeon Masters to manage combat encounters and display the initiative order to players. Here's a typical workflow:
|
||||||
|
|
||||||
1. **Admin Interface (DM's View):**
|
1. **Admin Interface (DM's View):**
|
||||||
* **Create a Campaign:** Start by creating a new campaign. You can optionally provide a URL for a custom background image that will be shown on the Player Display for this campaign.
|
* **Create a Campaign:** Start by creating a new campaign. You can optionally provide a URL for a custom background image that will be shown on the Player Display for this campaign. Campaign cards will show a preview of this background.
|
||||||
* **Add Characters:** Within a campaign, add your player characters to the campaign roster.
|
* **Add Characters:** Within a campaign, add your player characters. For each, set their name, default maximum HP, and a default initiative modifier. This section is collapsible to save screen space.
|
||||||
* **Create Encounters:** For each combat scenario, create an encounter within the selected campaign.
|
* **Create Encounters:** For each combat scenario, create an encounter within the selected campaign.
|
||||||
* **Manage Participants:**
|
* **Manage Participants:**
|
||||||
* Add characters from your campaign roster and any monsters to the selected encounter.
|
* Add characters from your campaign roster. Their Max HP will pre-fill from their campaign default, and their initiative will be automatically rolled (d20 + their default modifier).
|
||||||
* Set their initiative scores and maximum hit points.
|
* Add monsters. Provide a name, Max HP, and an initiative modifier (defaults to +2 but is editable). Mark if the monster is an "NPC" for different styling. Their initiative will be automatically rolled (d20 + specified modifier).
|
||||||
* If there are ties in initiative, you can drag and drop participants (before starting the encounter) to set a specific tie-breaker order.
|
* Use the "Add All From Campaign (Roll Init)" button to quickly add all campaign characters not yet in the encounter, with their initiative rolled and HP set from defaults.
|
||||||
|
* If there are ties in initiative, you can drag and drop participants (before starting the encounter or while it's paused) to set a specific tie-breaker order.
|
||||||
* **Control Player Display:**
|
* **Control Player Display:**
|
||||||
* Use the "eyeball" icon next to an encounter to toggle it as "LIVE ON PLAYER DISPLAY". This controls what is shown on the separate Player Display window. Only one encounter can be live at a time.
|
* Click the "Open Player Window" button in the header to launch a separate, clean window for your players (ideal for an external monitor). This window's content is controlled by the DM.
|
||||||
* Click the "Open Player Window" button in the header to launch a separate, clean window for your players (ideal for an external monitor).
|
* Use the "eyeball" icon next to an encounter to toggle it as "LIVE ON PLAYER DISPLAY". This controls what is shown on the Player Display window. Only one encounter can be live at a time.
|
||||||
* **Run Combat:**
|
* **Run Combat:**
|
||||||
* Once participants are added and initiative is set (including any manual tie-breaking), click "Start Encounter". This also automatically makes the encounter live on the Player Display.
|
* Once participants are added and initiative is set, click "Start Encounter". This also automatically makes the encounter live on the Player Display.
|
||||||
* Use the "Next Turn" button to advance through the initiative order. The current combatant will be highlighted in both the Admin View and the Player Display.
|
* Use the "Pause" button to temporarily halt combat. While paused, you can adjust HP and re-order tied initiatives. Click "Resume" to continue.
|
||||||
* Apply damage or healing to participants directly in the Admin View during combat.
|
* Use the "Next Turn" button (disabled when paused) to advance through the initiative order. The current combatant will be highlighted.
|
||||||
* Mark participants as inactive (e.g., if knocked out) using the toggle next to their name.
|
* Apply damage or healing to participants directly in the Admin View. Participants at 0 HP are automatically deactivated and marked with a skull icon. Healing them above 0 HP revives them.
|
||||||
* Click "End Encounter" when combat is over. This will also deactivate it from the Player Display.
|
* For player characters at 0 HP, track death saving throws using the three checkboxes that appear. Marking the third failure triggers a death animation on the player display and removes the participant.
|
||||||
|
* Apply or remove conditions (Blinded, Charmed, Poisoned, etc.) using the ✨ button next to each participant. Active conditions appear as emoji badges on both the DM view and the player display.
|
||||||
|
* Mark participants as inactive (e.g., if knocked out) using the toggle next to their name. Inactive monsters are hidden from the player display entirely, making this useful for pre-staging summoned reserves.
|
||||||
|
* Toggle **Hide player HP** in the initiative controls to show or hide player character HP values on the player display.
|
||||||
|
* Click "End Encounter" (with confirmation) when combat is over. This also deactivates it from the Player Display.
|
||||||
|
|
||||||
2. **Player Display Window:**
|
2. **Player Display Window:**
|
||||||
* This window (opened by the DM) shows a simplified view of the active encounter.
|
* This window (opened by the DM via the "Open Player Window" button, or accessed directly at `/display` or `/?playerView=true`) shows a simplified, header-less view of the active encounter.
|
||||||
* It displays the initiative order, current turn, round number, and participant HP (monster HP values are hidden, only the bar is shown).
|
* It displays the initiative order, current turn, round number, and participant HP bars. Monster/NPC HP totals are hidden; only the color-coded bar is shown. Player character HP is shown numerically unless the DM has enabled "Hide player HP".
|
||||||
* If a custom background URL was set for the campaign, it will be displayed.
|
* HP bars are color-coded: green (above 50%), yellow (25–50%), red (below 25%), dark red (dead/0 HP).
|
||||||
* If no encounter is active on the Player Display, it will show a "Game Session Paused" message.
|
* Active conditions are shown as emoji badges on each participant's card.
|
||||||
|
* The current participant's card automatically scrolls into view when the turn advances.
|
||||||
|
* NPCs are styled with a muted gray background, distinct from hostile monsters (reddish-brown) and player characters (dark blue/indigo).
|
||||||
|
* Dead and inactive participants are greyed out with a grayscale filter. Inactive monsters are hidden entirely.
|
||||||
|
* When a player character's third death save is marked, a dissolve animation plays on the display before the participant is removed.
|
||||||
|
* If a custom background URL was set for the campaign, it will be displayed with a semi-transparent overlay behind the participant cards.
|
||||||
|
* If no encounter is active on the Player Display, it shows "Game Session Paused". If the current encounter is paused by the DM, it shows "(Combat Paused)".
|
||||||
|
* Use the **fullscreen button** (top-right) to go fullscreen — great for a dedicated second monitor. Press Escape or click again to exit.
|
||||||
|
* Use the **prevent sleep toggle** (moon icon, top-right) to keep the screen awake using the browser Wake Lock API. The icon turns amber and switches to a coffee cup when active.
|
||||||
|
|
||||||
|
3. **Combat Log (`/logs`):**
|
||||||
|
* A dedicated page that records all significant combat events: encounter starts and ends, turn advances, HP changes, condition changes, participant additions/removals, and activation toggles.
|
||||||
|
* Each entry is timestamped and tagged with the encounter name for easy reference.
|
||||||
|
* Most entries have an **↩ Undo** button. Clicking it rolls back that specific action in Firestore — restoring HP, conditions, turn order, or participant state to what it was before. The entry is then greyed out and marked "rolled back".
|
||||||
|
* The log can be cleared in bulk from the page (with confirmation).
|
||||||
|
* Accessible via the "Logs" link in the DM header or directly at `/logs`.
|
||||||
|
|
||||||
This flow allows the DM to prepare and run encounters efficiently while providing a clear, real-time view for the players.
|
This flow allows the DM to prepare and run encounters efficiently while providing a clear, real-time view for the players.
|
||||||
|
|
||||||
@@ -69,128 +131,182 @@ This flow allows the DM to prepare and run encounters efficiently while providin
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
* **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/).
|
* **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/).
|
||||||
* **Firebase Project:** You'll need a Firebase project with Firestore Database and Anonymous Authentication enabled.
|
* **Firebase Project** (only if using the default Firebase storage mode): You'll need a Firebase project with:
|
||||||
|
* Firestore Database created and initialized.
|
||||||
|
* Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab.
|
||||||
* **Git:** For cloning the repository.
|
* **Git:** For cloning the repository.
|
||||||
|
|
||||||
|
The project is an npm workspaces monorepo (`server/`, `shared/`, plus the CRA frontend at the root) — a single `npm install` at the repo root installs everything.
|
||||||
|
|
||||||
### Local Development Setup (using npm)
|
### Local Development Setup (using npm)
|
||||||
|
|
||||||
#### **Clone the Repository:**
|
1. **Clone the Repository:**
|
||||||
```bash
|
```bash
|
||||||
git clone <your-repository-url>
|
git clone https://code.draft13.com/robert/ttrpg-initiative-tracker
|
||||||
cd ttrpg-initiative-tracker
|
cd ttrpg-initiative-tracker
|
||||||
```
|
|
||||||
|
|
||||||
#### **Create Firebase Configuration File (`.env.local`):**
|
|
||||||
* In the root of the project, create a file named `.env.local`.
|
|
||||||
* Add your Firebase project configuration details to this file. You can find these in your Firebase project settings (Project settings > General > Your apps > Firebase SDK snippet > Config).
|
|
||||||
* **Important:** This `.env.local` file contains sensitive API keys and should **NOT** be committed to Git. Make sure it's listed in your `.gitignore` file.
|
|
||||||
|
|
||||||
Your `.env.local` should look like this:
|
|
||||||
```ini
|
|
||||||
REACT_APP_FIREBASE_API_KEY="YOUR_FIREBASE_API_KEY"
|
|
||||||
REACT_APP_FIREBASE_AUTH_DOMAIN="YOUR_FIREBASE_AUTH_DOMAIN"
|
|
||||||
REACT_APP_FIREBASE_PROJECT_ID="YOUR_FIREBASE_PROJECT_ID"
|
|
||||||
REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET"
|
|
||||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID"
|
|
||||||
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID"
|
|
||||||
|
|
||||||
# Used for namespacing Firestore paths, can be any unique string for your app instance
|
|
||||||
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-dev"
|
|
||||||
```
|
|
||||||
*An `.env.example` file is included in the repository as a template.*
|
|
||||||
|
|
||||||
#### **Install Dependencies:**
|
|
||||||
Navigate to the project root in your terminal and run:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
This will install all the necessary packages defined in `package.json` and create a `package-lock.json` file.
|
|
||||||
|
|
||||||
#### **Set up Firestore Security Rules:**
|
|
||||||
* Go to your Firebase project console -> Firestore Database -> Rules.
|
|
||||||
* Use the following rules for development (allows authenticated users to read/write all data). **For production, you MUST implement more restrictive rules.**
|
|
||||||
```
|
```
|
||||||
rules_version = '2';
|
|
||||||
service cloud.firestore {
|
2. **Create Firebase Configuration File (`.env.local`):**
|
||||||
match /databases/{database}/documents {
|
* In the root of the project, create a file named `.env.local`.
|
||||||
match /{document=**} {
|
* Add your Firebase project configuration details to this file. You can find these in your Firebase project settings (Project settings > General > Your apps > Firebase SDK snippet > Config).
|
||||||
allow read, write: if request.auth != null;
|
* **Important:** This `.env.local` file contains sensitive API keys and should **NOT** be committed to Git. Make sure it's listed in your `.gitignore` file.
|
||||||
}
|
|
||||||
}
|
Your `.env.local` should look like this:
|
||||||
}
|
```ini
|
||||||
|
REACT_APP_FIREBASE_API_KEY="YOUR_FIREBASE_API_KEY"
|
||||||
|
REACT_APP_FIREBASE_AUTH_DOMAIN="YOUR_FIREBASE_AUTH_DOMAIN"
|
||||||
|
REACT_APP_FIREBASE_PROJECT_ID="YOUR_FIREBASE_PROJECT_ID"
|
||||||
|
REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET"
|
||||||
|
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID"
|
||||||
|
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID"
|
||||||
|
|
||||||
|
# Used for namespacing Firestore paths, can be any unique string for your app instance
|
||||||
|
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-dev"
|
||||||
```
|
```
|
||||||
* Publish these rules.
|
*An `.env.example` file is included in the repository as a template.*
|
||||||
|
|
||||||
#### **Enable Anonymous Authentication:**
|
3. **Install Dependencies:**
|
||||||
* In your Firebase project console -> Authentication -> Sign-in method.
|
Navigate to the project root in your terminal and run:
|
||||||
* Enable "Anonymous" as a sign-in provider.
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
This will install all the necessary packages defined in `package.json` and create a `package-lock.json` file.
|
||||||
|
|
||||||
#### **Run the Development Server:**
|
4. **Set up Firestore Security Rules:**
|
||||||
```bash
|
* Go to your Firebase project console -> Firestore Database -> Rules.
|
||||||
npm start
|
* Use the following rules for development (allows authenticated users to read/write all data). **For production, you MUST implement more restrictive rules.**
|
||||||
```
|
```
|
||||||
This will start the React development server, usually on `http://localhost:3000`. The application will open in your default web browser.
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
match /{document=**} {
|
||||||
|
allow read, write: if request.auth != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* Publish these rules.
|
||||||
|
|
||||||
|
5. **Run the Development Server:**
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
This will start the React development server, usually on `http://localhost:3000`. The application will open in your default web browser.
|
||||||
|
|
||||||
### Deployment with Docker
|
### Deployment with Docker
|
||||||
|
|
||||||
This project includes a `Dockerfile` to containerize the application for deployment. It uses a multi-stage build:
|
There are two Docker paths, depending on which storage mode you want:
|
||||||
|
|
||||||
|
* **Firebase mode (default):** the root `Dockerfile` builds a static frontend-only image, described below.
|
||||||
|
* **Self-hosted mode:** the `docker/` directory builds a single container running the Node backend + SQLite + the frontend behind Caddy, with no Firebase dependency. See [Self-Hosted Backend (Optional)](#self-hosted-backend-optional).
|
||||||
|
|
||||||
|
#### Firebase-mode image (root `Dockerfile`)
|
||||||
|
|
||||||
|
This project includes a `Dockerfile` to containerize the Firebase-backed application for deployment. It uses a multi-stage build:
|
||||||
* **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`.
|
* **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`.
|
||||||
* **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage.
|
* **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage.
|
||||||
|
|
||||||
**1. Prerequisites for Docker:**
|
1. **Prerequisites for Docker:**
|
||||||
* Ensure Docker Desktop (or Docker Engine on Linux) is installed and running. Download from [docker.com](https://www.docker.com/products/docker-desktop).
|
* Ensure Docker Desktop (or Docker Engine on Linux) is installed and running. Download from [docker.com](https://www.docker.com/products/docker-desktop).
|
||||||
|
|
||||||
**2. Building the Docker Image (for Local Testing with `.env.local`):**
|
2. **Building the Docker Image (for Local Testing with `.env.local`):**
|
||||||
* Make sure your `.env.local` file is present in the project root and correctly configured with your Firebase details. The `Dockerfile` is set up to copy this file as `.env` during the build for local testing.
|
* Make sure your `.env.local` file is present in the project root and correctly configured with your Firebase details. The `Dockerfile` is set up to copy this file as `.env` during the build for local testing.
|
||||||
* **Security Warning:** The image built this way will contain your Firebase API keys from `.env.local`. **Do NOT push this specific image to a public Docker registry.** For production, environment variables should be injected by your hosting platform or CI/CD pipeline at build or runtime.
|
* **Security Warning:** The image built this way will contain your Firebase API keys from `.env.local`. **Do NOT push this specific image to a public Docker registry.** For production, environment variables should be injected by your hosting platform or CI/CD pipeline at build or runtime.
|
||||||
|
|
||||||
To build the image, navigate to the project root and run:
|
To build the image, navigate to the project root and run:
|
||||||
|
```bash
|
||||||
|
docker build -t ttrpg-initiative-tracker .
|
||||||
|
```
|
||||||
|
*(You can replace `ttrpg-initiative-tracker` with your preferred image name/tag).*
|
||||||
|
|
||||||
|
3. **Running the Docker Container Locally:**
|
||||||
|
Once the image is built, run it:
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:80 --rm --name ttrpg-tracker-app ttrpg-initiative-tracker
|
||||||
|
```
|
||||||
|
* `-p 8080:80`: Maps port 8080 on your host machine to port 80 inside the container (where Nginx is listening).
|
||||||
|
* `--rm`: Automatically removes the container when it stops.
|
||||||
|
* `--name ttrpg-tracker-app`: Assigns a name to the running container.
|
||||||
|
* `ttrpg-initiative-tracker`: The name of the image you built.
|
||||||
|
|
||||||
|
You can then access the application at `http://localhost:8080`.
|
||||||
|
|
||||||
|
4. **Production Deployment Considerations:**
|
||||||
|
* When deploying to a production environment (e.g., a cloud provider, your own server), you should **not** copy your `.env.local` file into the Docker image.
|
||||||
|
* Instead, configure the `REACT_APP_FIREBASE_...` environment variables directly in your hosting platform's settings or pass them to the Docker container at runtime (if your application is set up to read them at runtime, though Create React App bakes them in at build time).
|
||||||
|
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
|
||||||
|
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
|
||||||
|
|
||||||
|
### Self-Hosted Backend (Optional)
|
||||||
|
|
||||||
|
If you'd rather not depend on Firebase/Google, the app can run entirely self-hosted: a Node/Express + `ws` backend backed by SQLite, with the frontend talking to it instead of Firestore. Intended for trusted/local networks (no auth layer yet).
|
||||||
|
|
||||||
|
**Quickest path — Docker Compose (single container: Caddy + Node + SQLite):**
|
||||||
```bash
|
```bash
|
||||||
docker build -t ttrpg-initiative-tracker .
|
docker compose -f docker/docker-compose.yml up --build
|
||||||
```
|
```
|
||||||
*(You can replace `ttrpg-initiative-tracker` with your preferred image name/tag).*
|
This serves the app at `http://localhost:8080` (override with `PORT`), proxies `/api` and `/ws` to the backend, and persists the SQLite database in a named Docker volume. Override the Firestore-style app-id namespace with `TRACKER_APP_ID` if needed.
|
||||||
|
|
||||||
**3. Running the Docker Container Locally:**
|
**Local dev (recommended):**
|
||||||
Once the image is built, run it:
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 8080:80 --rm --name ttrpg-tracker-app ttrpg-initiative-tracker
|
npm install # installs root, server/, and shared/ workspaces
|
||||||
|
./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
|
||||||
|
./scripts/dev-stop.sh # stop both
|
||||||
```
|
```
|
||||||
* `-p 8080:80`: Maps port 8080 on your host machine to port 80 inside the container (where Nginx is listening).
|
|
||||||
* `--rm`: Automatically removes the container when it stops.
|
|
||||||
* `--name ttrpg-tracker-app`: Assigns a name to the running container.
|
|
||||||
* `ttrpg-initiative-tracker`: The name of the image you built.
|
|
||||||
|
|
||||||
You can then access the application at `http://localhost:8080`.
|
**Manual (fallback):**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run server:dev # starts backend on :4001 (SQLite at data/tracker.sqlite)
|
||||||
|
|
||||||
**4. Production Deployment Considerations:**
|
# in another terminal:
|
||||||
|
REACT_APP_STORAGE=server \
|
||||||
* When deploying to a production environment (e.g., a cloud provider, your own server), you should **not** copy your `.env.local` file into the Docker image.
|
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
||||||
* Instead, configure the `REACT_APP_FIREBASE_...` environment variables directly in your hosting platform's settings or pass them to the Docker container at runtime (if your application is set up to read them at runtime, though Create React App bakes them in at build time).
|
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
|
||||||
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
|
npm start
|
||||||
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
|
```
|
||||||
|
|
||||||
|
See `docs/DEVELOPMENT.md` for the full architecture (generic KV doc store, storage adapter interface, test layers) and `docs/REWORK_PLAN.md` for the design rationale.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
`ttrpg-initiative-tracker/`
|
<pre>
|
||||||
- ` .dockerignore` # Specifies intentionally untracked files that Docker should ignore
|
ttrpg-initiative-tracker/
|
||||||
- ` .env.example` # Example environment variables
|
├── .dockerignore # Specifies intentionally untracked files that Docker should ignore
|
||||||
- ` .env.local` # Local environment variables (ignored by Git)
|
├── .env.example # Example environment variables
|
||||||
- ` .gitignore` # Specifies intentionally untracked files that Git should ignore
|
├── .env.local # Local environment variables (ignored by Git)
|
||||||
- ` Dockerfile` # Instructions to build the Docker image
|
├── .gitignore # Specifies intentionally untracked files that Git should ignore
|
||||||
- ` package-lock.json` # Records exact versions of dependencies
|
├── Dockerfile # Firebase-mode image (frontend-only, served by nginx)
|
||||||
- ` package.json` # Project metadata and dependencies
|
├── package.json # Workspaces root: frontend + server + shared
|
||||||
- ` postcss.config.js` # PostCSS configuration (for Tailwind CSS)
|
├── package-lock.json # Records exact versions of dependencies
|
||||||
- ` tailwind.config.js` # Tailwind CSS configuration
|
├── postcss.config.js # PostCSS configuration (for Tailwind CSS)
|
||||||
- ` public/` # Static assets
|
├── tailwind.config.js # Tailwind CSS configuration
|
||||||
- ` favicon.ico`
|
├── docker/ # Self-hosted deployment: Dockerfile, docker-compose.yml, Caddyfile
|
||||||
- ` index.html` # Main HTML template
|
├── docs/ # Rework plan, dev setup, testing, encounter-builder guide, glossary
|
||||||
- ` manifest.json`
|
├── scripts/ # Manual demo/ops tooling (e.g. replay-combat.js)
|
||||||
- ` src/` # React application source code
|
├── tests/ # Exploratory audit tooling (not part of the automated suite)
|
||||||
- ` App.js` # Main application component
|
├── public/ # Static assets
|
||||||
- ` index.css` # Global styles (including Tailwind directives)
|
│ ├── favicon.ico
|
||||||
- ` index.js` # React entry point
|
│ ├── index.html # Main HTML template
|
||||||
|
│ └── manifest.json
|
||||||
|
├── src/ # React frontend (Create React App)
|
||||||
|
│ ├── App.js # Main application component
|
||||||
|
│ ├── storage/ # Storage adapter layer: firebase / ws / memory
|
||||||
|
│ ├── index.css # Global styles (including Tailwind directives)
|
||||||
|
│ └── index.js # React entry point
|
||||||
|
├── server/ # Self-hosted backend: Express + ws + SQLite (better-sqlite3)
|
||||||
|
└── shared/ # Framework-agnostic turn-order logic, used by frontend + server
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
* `docs/DEVELOPMENT.md` — setup, running the backend, test suites
|
||||||
|
* `docs/REWORK_PLAN.md` — why/how the self-hosted backend was added
|
||||||
|
* `docs/TESTING.md` — test layers and how to run them
|
||||||
|
* `docs/ENCOUNTER_BUILDER.md` — entity model and storage paths behind the DM interface
|
||||||
|
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
|
||||||
|
* `TODO.md` — known bugs and backlog
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Feel free to fork the repository and submit pull requests. For major changes, please open an issue first to discuss what you would like to change.
|
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### dm list - keep active particpant in view (scroll)
|
||||||
|
not sure good way to do this
|
||||||
|
|
||||||
|
|
||||||
|
### npm install warnings cleanup pass
|
||||||
|
lots of updates
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### TEST GAP: current branch changes need focused coverage
|
||||||
|
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
|
||||||
|
- Server SQL query test: `where + orderBy + limit` should return latest logs for one encounter only.
|
||||||
|
- Undo/redo stack order: undo A3 then A2, redo must replay A2 first, then A3.
|
||||||
|
- Combat controls should not subscribe to logs while mounted; undo/redo should query logs only on click.
|
||||||
|
- Unified CLI smoke: `node scripts/combat.js verify <fixture.json>` returns CLEAN on known-good log.
|
||||||
|
- Unified CLI replay smoke: `node scripts/combat.js replay ... --out tmp/x.json` writes JSON array and auto-verifies.
|
||||||
|
- Ctrl-C replay behavior: SIGINT during replay should end encounter, clear active display, write partial log, run verify.
|
||||||
|
- SQLite schema/index test: `idx_docs_parent_ts` and `idx_docs_parent_encounter_ts` exist for server DB.
|
||||||
|
|
||||||
|
### confirm warnings treated as error = fail in all tests, build pipeline, linters, everything. again.
|
||||||
|
|
||||||
|
### BUG: addParticipants (batch add) does not slot by initiative
|
||||||
|
- shared/turn.js addParticipants appends `[...existing, ...new]`, no slotIndexForInit.
|
||||||
|
- Violates INIT doc: "Add = insert into slot by initiative."
|
||||||
|
- Single addParticipant slots correct. Batch (add-all-chars) appends.
|
||||||
|
- Pre-start batch add = wrong order. Post-start worse.
|
||||||
|
|
||||||
|
### BUG: nextTurn throws on solo combatant
|
||||||
|
- nextActiveAfter loop `for step=1; step<n` skips when n=1 → {nextId:null}.
|
||||||
|
- nextTurn throws "Could not determine next participant."
|
||||||
|
- Solo active combat cannot pass turn.
|
||||||
|
|
||||||
|
### BUG: reorderParticipants cross-pointer drag = silent no-op
|
||||||
|
- Cross-pointer drag returns encounter unchanged, no log, no toast.
|
||||||
|
- DM drags across current turn → nothing happens, no feedback.
|
||||||
|
|
||||||
|
### BUG: addParticipant undo missing currentTurnParticipantId when started
|
||||||
|
- undo saves participants + conditional turnOrderIds, no currentTurnParticipantId.
|
||||||
|
- Pointer can misalign on undo if added near pointer region.
|
||||||
|
|
||||||
|
### BUG: computeTurnOrderAfterRemoval isActive uses find() not boolean
|
||||||
|
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
|
||||||
|
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
|
||||||
|
|
||||||
|
|
||||||
|
### FEAT: clarify "Is NPC" in add-participant
|
||||||
|
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
||||||
|
display-only? skip in turn order?). Clarify intent before UX changes.
|
||||||
|
|
||||||
|
|
||||||
|
### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later)
|
||||||
|
|
||||||
|
|
||||||
|
## FEAT - parallel campaigns
|
||||||
|
|
||||||
|
## FEAT - multi user
|
||||||
|
|
||||||
|
## FEAT - clarify what end encounter does and what initiatives reset means
|
||||||
|
|
||||||
|
## Done (history)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Ignore Git directory
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Ignore Node.js modules (they will be installed in the Docker image)
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Ignore build output (it will be generated in the Docker image)
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Ignore Docker files themselves (Caddyfile MUST stay in context for frontend build)
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.ws
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Ignore any local environment files if you have them
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Ignore IDE and OS-specific files
|
||||||
|
.vscode/
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Ignore local sqlite data + scratch diagnostics (never bake into image)
|
||||||
|
data/
|
||||||
|
scratch/
|
||||||
|
tmp/
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# docker/Dockerfile --- single container: caddy (front) + node (back).
|
||||||
|
# Build context = repo root.
|
||||||
|
# ---- build stage: frontend + install backend deps ----
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install --include-workspace-root --omit=dev --prefer-offline
|
||||||
|
|
||||||
|
# frontend code (changes often)
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY public/ ./public/
|
||||||
|
COPY tailwind.config.js postcss.config.js ./
|
||||||
|
|
||||||
|
# build frontend (server storage, same-origin /api + /ws via caddy)
|
||||||
|
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
|
||||||
|
ENV REACT_APP_STORAGE=server
|
||||||
|
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
|
||||||
|
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
|
||||||
|
|
||||||
|
# server after backend - it's fast. so changing server only doesnt invalidate slow frontend.
|
||||||
|
COPY server/ ./server/
|
||||||
|
# better-sqlite3 native build (alpine musl). Cached if server unchanged.
|
||||||
|
RUN cd server && npm rebuild better-sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
# ---- runtime stage: caddy + node ----
|
||||||
|
FROM node:22-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 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"]
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# docker/portainer-stack.yml — Portainer stack for the SQLite/server build.
|
||||||
|
# Deploys the prebuilt image from the local registry (built + pushed by Jenkins).
|
||||||
|
# Paste into Portainer's web editor (Stacks -> Add stack -> Web editor).
|
||||||
|
#
|
||||||
|
# TLS is terminated by the external nginx, which should reverse_proxy to
|
||||||
|
# http://<docker-host>:8080 (and pass the WebSocket upgrade headers for /ws).
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: thinkserver:5000/ttrpg-tracker-sqlite:latest
|
||||||
|
container_name: ttrpg-tracker
|
||||||
|
ports:
|
||||||
|
- "8080:80" # host:container — nginx upstream is :8080
|
||||||
|
volumes:
|
||||||
|
- app-data:/data # SQLite DB lives here; back this up
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/tracker.sqlite
|
||||||
|
- PORT=4001 # node backend port (internal; caddy proxies to it)
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/docker-restart.sh — rebuild + restart docker container.
|
||||||
|
# Usage: ./scripts/docker-restart.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "=== stopping existing container ==="
|
||||||
|
docker compose -f docker/docker-compose.yml down 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== rebuilding image ==="
|
||||||
|
docker compose -f docker/docker-compose.yml build
|
||||||
|
|
||||||
|
echo "=== starting container ==="
|
||||||
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
|
echo "=== status ==="
|
||||||
|
docker compose -f docker/docker-compose.yml ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "app: http://localhost:${PORT:-8080}"
|
||||||
|
echo "logs: docker compose -f docker/docker-compose.yml logs -f"
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Death Saves (D&D 5e)
|
||||||
|
|
||||||
|
Concept and rule structure for the death-save state machine. Reference for how
|
||||||
|
combat tracking should behave at 0 HP. This is the 5e death-save loop, not a
|
||||||
|
code spec.
|
||||||
|
|
||||||
|
## Scope: tracker tracks, DM decides
|
||||||
|
|
||||||
|
This tracker is not a virtual tabletop. It does not roll dice or interpret the
|
||||||
|
table. The DM rolls at the table, decides what happened, and feeds the result
|
||||||
|
into the tracker through the UI. The tracker records the result and applies the
|
||||||
|
automatic pieces.
|
||||||
|
|
||||||
|
- DM does: rolls the d20, determines crits vs normal hits, declares damage
|
||||||
|
amounts, decides when to stabilize or heal.
|
||||||
|
- Tracker does: increments success/failure counters on DM input, resolves the
|
||||||
|
terminal states (3 successes → stable, 3 failures → dead), resets counters on
|
||||||
|
heal or stabilize, records HP changes.
|
||||||
|
|
||||||
|
The transitions below describe the rules the tracker must honor once the DM
|
||||||
|
hands it a result. The DM is the source of truth for what happened; the tracker
|
||||||
|
is the source of truth for what state that produces.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
A character or NPC at 0 HP enters a death-save loop. Track per death-save participant:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
deathSaveSuccesses: 0 | 1 | 2 | 3
|
||||||
|
deathSaveFailures: 0 | 1 | 2 | 3
|
||||||
|
status: "conscious" | "dying" | "stable" | "dead"
|
||||||
|
hp: number
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transitions
|
||||||
|
|
||||||
|
**Drop to 0 HP** (damage, character/NPC):
|
||||||
|
- If damage >= currentHp + maxHp → outright dead (massive damage). Equivalently: after reducing currentHp to 0, overflow damage >= maxHp.
|
||||||
|
- Otherwise → `status = "dying"`, counters reset to 0.
|
||||||
|
|
||||||
|
**Drop to 0 HP** (monster):
|
||||||
|
- Non-NPC monsters do not use death saves.
|
||||||
|
- `status = "dead"`, counters reset, `isActive = false`.
|
||||||
|
|
||||||
|
**Death save** (d20 roll on a dying character/NPC turn):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if roll === 1: failures += 2 // natural 1 = two failures
|
||||||
|
else if roll === 20: hp = 1; successes = 0; failures = 0; status = "conscious" // nat 20 = 1 HP, conscious
|
||||||
|
else if roll >= 10: successes += 1
|
||||||
|
else: failures += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then resolve immediately on the click/action that reaches 3:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if failures >= 3: status = "dead"; successes = 0; failures = 0
|
||||||
|
if successes >= 3: status = "stable"; successes = 0; failures = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI must transition immediately. A third failure shows Dead state, not three
|
||||||
|
active failure boxes. A third success shows Stable state, not three active
|
||||||
|
success boxes.
|
||||||
|
|
||||||
|
**Damage while dying** (at 0 HP):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
failures += isCriticalHit ? 2 : 1
|
||||||
|
if failures >= 3: status = "dead"
|
||||||
|
```
|
||||||
|
|
||||||
|
Attacks from within 5 ft against an unconscious creature are auto-crits, so
|
||||||
|
melee vs a dying character/NPC adds 2 failures.
|
||||||
|
|
||||||
|
**Healing while dying or stable**:
|
||||||
|
|
||||||
|
Any +HP received while `dying` or `stable` makes the character/NPC conscious.
|
||||||
|
This usually comes from magic, or from the stable-state time-delayed recovery
|
||||||
|
(1 HP after 1d4 hours, if the DM chooses to track it here).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
hp = max(1, hp + amount)
|
||||||
|
successes = 0
|
||||||
|
failures = 0
|
||||||
|
status = "conscious"
|
||||||
|
```
|
||||||
|
|
||||||
|
Any HP regained from `dying` or `stable` clears all counters and ends the
|
||||||
|
death-save loop. This does **not** apply to `dead`; dead requires revive flow,
|
||||||
|
revival magic, or DM state override, not normal healing.
|
||||||
|
|
||||||
|
**Stabilize** (manual action, not a save):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
hp = 0
|
||||||
|
successes = 0
|
||||||
|
failures = 0
|
||||||
|
status = "stable"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stable state
|
||||||
|
|
||||||
|
- Stays at 0 HP, unconscious, no longer makes death saves.
|
||||||
|
- 3 successes = **stable (unconscious)**, NOT conscious. NOT 1 HP.
|
||||||
|
1 HP only comes from a natural 20 or healing.
|
||||||
|
- Taking any damage ends stable → back to `dying` (counters reset, then damage
|
||||||
|
applies its failure per "damage while dying").
|
||||||
|
|
||||||
|
## Tool-specific initiative behavior
|
||||||
|
|
||||||
|
Death is a combat state, not an automatic removal action.
|
||||||
|
|
||||||
|
When a character or NPC becomes `dead`:
|
||||||
|
- Do **not** remove them from initiative.
|
||||||
|
- Do **not** automatically deactivate them.
|
||||||
|
- Do **not** remove them from the encounter participants list.
|
||||||
|
- Leave them in turn order. Other table/tool events may still matter on that
|
||||||
|
initiative count.
|
||||||
|
- DM may manually mark them inactive or remove them.
|
||||||
|
|
||||||
|
When a non-NPC monster becomes `dead`:
|
||||||
|
- Set `isActive = false` automatically.
|
||||||
|
- Keep it in the encounter participants list until DM removes it.
|
||||||
|
- Player display hides it because inactive participants are hidden.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- `status === "dying"` ⟺ hp === 0 AND no terminal flag.
|
||||||
|
- `status === "stable"` ⟹ hp === 0, unconscious, counters 0.
|
||||||
|
- `status === "dead"` ⟹ hp === 0, no further saves, counters 0.
|
||||||
|
- `status === "dead"` ⟹ participant remains in initiative and participants until DM removes them.
|
||||||
|
- `status === "dead"` does not itself mean inactive; monster death sets inactive as a separate rule.
|
||||||
|
- Normal +HP applies to `dying` or `stable`, not `dead`; dead requires revive, revival, or DM override.
|
||||||
|
- Successes and failures tracked **independently**. 2 fail + 3 success = stable.
|
||||||
|
Counters do not need to be consecutive.
|
||||||
|
- A roll of 10+ is a success. 9 or lower is a failure.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [PHB 5e: Death Saving Throws](https://roll20.net/compendium/dnd5e/Rules%3ADamage%20and%20Healing)
|
||||||
|
- [Reddit: What happens on 3 successful death saves?](https://www.reddit.com/r/DnD/comments/io5js7/what_happens_if_you_succeed_in_3_death_saving/)
|
||||||
|
- [Roll20: Conditions (Unconscious)](https://roll20.net/compendium/dnd5e/Conditions)
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm workspaces.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
package.json # workspaces root
|
||||||
|
src/ # React frontend (CRA)
|
||||||
|
App.js # main app (~2900 lines)
|
||||||
|
storage/ # adapter layer (firebase/ws/memory + contract)
|
||||||
|
__mocks__/firebase/ # firebase SDK mock (Layer 1 tests)
|
||||||
|
tests/ # frontend tests
|
||||||
|
server/ # Backend: generic KV doc store (firebase mirror)
|
||||||
|
index.js # REST (doc/coll/batch) + WS bootstrap
|
||||||
|
db.js # SQLite docs table, KV ops, broadcast
|
||||||
|
tests/ # backend + adapter-vs-live tests
|
||||||
|
shared/ # Pure logic, no I/O (client + server + tests import)
|
||||||
|
turn.js # turn-order state machine
|
||||||
|
tests/ # turn logic tests
|
||||||
|
scripts/ # manual demo tool (NOT test)
|
||||||
|
replay-combat.js # live backend demo
|
||||||
|
tests/
|
||||||
|
audit/ # exploratory bug-finders (manual, Math.random)
|
||||||
|
audit-rotation.js # rotation invariant
|
||||||
|
audit-state.js # 9 invariant classes
|
||||||
|
scratch/ # gitignored: throwaway repro/exploration
|
||||||
|
docs/
|
||||||
|
REWORK_PLAN.md
|
||||||
|
DEVELOPMENT.md # this file
|
||||||
|
GLOSSARY.md # domain terms (turn vs round, etc)
|
||||||
|
ENCOUNTER_BUILDER.md # DM interface guide
|
||||||
|
TESTING.md # test + automation ops
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:keen99/ttrpg-initiative-tracker.git
|
||||||
|
cd ttrpg-initiative-tracker
|
||||||
|
npm install
|
||||||
|
git config core.hooksPath .githooks # enable pre-push test gate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
### Local dev stack (one command)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./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.
|
||||||
|
|
||||||
|
Smoke check:
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:4001/health # -> {"ok":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
|
||||||
|
|
||||||
|
### Manual (fallback)
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
```bash
|
||||||
|
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):
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:all # shared + server (fast, no frontend)
|
||||||
|
npm run shared:test # pure turn logic
|
||||||
|
npm run server:test # adapter vs live backend
|
||||||
|
npm test # CRA frontend (src/tests/, slow with scenario)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Suites
|
||||||
|
|
||||||
|
| Suite | Location | What | Count |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Unit (turn logic) | `shared/tests/` | pure nextTurn, rotation, pause-add | 50 (1 skip) |
|
||||||
|
| Integration (adapter vs backend) | `server/tests/` | ws adapter through live REST/WS | 23 |
|
||||||
|
| Characterization (UI) | `src/tests/` | locks current App.js behavior | 62 |
|
||||||
|
| Scenario | `src/tests/Combat.scenario.test.js` | 100-round full combat (240s) | 289 phases |
|
||||||
|
|
||||||
|
Total: 134 green + 1 validated RED (skipped).
|
||||||
|
|
||||||
|
### Test types
|
||||||
|
|
||||||
|
- **Unit** = pure logic, fast, no I/O. Locks behavior of single functions.
|
||||||
|
- **Integration** = real backend per test, adapter translation verified.
|
||||||
|
- **Characterization** = render App via mock, assert current (buggy or not) UI behavior. Not desired-state.
|
||||||
|
- **Scenario** = end-to-end flow through rendered App, asserts full sequence completes.
|
||||||
|
- **Contract** = same spec run against every storage impl (memory, ws, firebase). Catches adapter drift.
|
||||||
|
|
||||||
|
### Running one file / pattern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test --workspace shared -- --testPathPattern=round-rotation
|
||||||
|
CI=true npx react-scripts test --watchAll=false src/tests/App.characterization.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario test is slow
|
||||||
|
|
||||||
|
`Combat.scenario.test.js` runs 100 combat rounds through rendered App — 240s timeout by design. Skip when iterating:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CI=true npx react-scripts test --watchAll=false --testPathIgnorePatterns="Combat.scenario"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demo tool (NOT test)
|
||||||
|
|
||||||
|
`scripts/replay-combat.js` = live backend demo. Watch UI react to state changes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start backend + frontend first
|
||||||
|
node scripts/replay-combat.js [rounds] [delayMs]
|
||||||
|
# defaults: 100 rounds, 200ms/step
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage per round: damage, heal, all 22 conditions, toggleActive,
|
||||||
|
removeParticipant, addParticipant (reinforcements), updateParticipant,
|
||||||
|
pause/resume, reorderParticipants, endEncounter. Revives dead each round
|
||||||
|
to sustain full round count.
|
||||||
|
|
||||||
|
## Audit tools (NOT unit tests)
|
||||||
|
|
||||||
|
`tests/audit/` = exploratory, Math.random, non-deterministic. Manual run.
|
||||||
|
Unit tests (`{shared,server,src}/tests/`) lock known bugs deterministically.
|
||||||
|
|
||||||
|
### audit-rotation.js
|
||||||
|
|
||||||
|
Pure turn.js simulation of replay op sequence. Detects rotation violations
|
||||||
|
(skip/dupe per round). Found BUG-1 (addParticipant + pause corrupts rotation).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tests/audit/audit-rotation.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Bisect: comment/uncomment op blocks to isolate triggering combo.
|
||||||
|
|
||||||
|
### audit-state.js
|
||||||
|
|
||||||
|
Runs pure turn.js combat, audits 9 invariant classes per round:
|
||||||
|
|
||||||
|
1. rotation integrity (skip/dupe)
|
||||||
|
2. HP bounds (0 ≤ hp ≤ max, no NaN)
|
||||||
|
3. isActive consistency (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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tests/audit/audit-state.js [rounds] # default 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Current state (post BUG-1/2 fix): 0 violations / 100 rounds.
|
||||||
|
|
||||||
|
See `TODO.md` for known bugs.
|
||||||
|
|
||||||
|
## Scratch
|
||||||
|
|
||||||
|
`scratch/` = gitignored throwaway. Repro scripts, exploration, debug.
|
||||||
|
Not committed. Use freely, delete anytime.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # CRA production build -> build/
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker build (existing, frontend-only):
|
||||||
|
```bash
|
||||||
|
docker build -t ttrpg-initiative-tracker .
|
||||||
|
docker run -p 8080:80 --rm ttrpg-initiative-tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
Full-stack docker-compose arrives in M5.
|
||||||
|
|
||||||
|
## Storage architecture
|
||||||
|
|
||||||
|
### Generic KV doc store
|
||||||
|
|
||||||
|
Backend = firebase mirror. Single `docs` table: `path` (PK), `parent`, `data` (JSON), `updated_at`. Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.
|
||||||
|
|
||||||
|
```
|
||||||
|
Client (browser) Server
|
||||||
|
| |
|
||||||
|
|-- storage.setDoc(path,data) -->| REST PUT /api/doc
|
||||||
|
|<---- 200 ----------------------|
|
||||||
|
| |
|
||||||
|
|-- storage.subscribeDoc(path) -->| WS subscribe
|
||||||
|
|<---- WS {initial} --------------| immediate value
|
||||||
|
| ... |
|
||||||
|
|<---- WS {change} --------------| on any write to path
|
||||||
|
| |
|
||||||
|
Display / tablet |
|
||||||
|
|<---- WS {change} --------------| same push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path normalization
|
||||||
|
|
||||||
|
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Adapter `norm()` strips prefix → bare canonical (`campaigns/...`). All impls share identity (contract test).
|
||||||
|
|
||||||
|
### STORAGE_MODE flow
|
||||||
|
|
||||||
|
`getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`).
|
||||||
|
- `firebase` → real SDK init
|
||||||
|
- `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:
|
||||||
|
```bash
|
||||||
|
git push --no-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Already configured on this checkout after `git config core.hooksPath .githooks`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Milestone | State |
|
||||||
|
|---|---|
|
||||||
|
| 0 repo/branch | ✅ done |
|
||||||
|
| 1 backend + tests | ✅ done |
|
||||||
|
| 2 frontend WS adapter | ✅ done |
|
||||||
|
| 3 characterization tests | ✅ done (134 green) |
|
||||||
|
| 4 skip fix + manual override | ⬜ next |
|
||||||
|
| 5 docker compose | ⬜ |
|
||||||
|
| 6 undo rework | ⬜ |
|
||||||
|
| 7 playwright e2e | ⬜ deferred |
|
||||||
|
|
||||||
|
See `docs/REWORK_PLAN.md` for full plan, `TODO.md` for known bugs.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- `origin` = `github.com:keen99/ttrpg-initiative-tracker` (this fork)
|
||||||
|
- `upstream` = `code.draft13.com/robert/ttrpg-initiative-tracker` (friend's Gitea, read-only)
|
||||||
|
- work branch: `rework-backend` (off `main`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream # pull friend's changes
|
||||||
|
git merge upstream/main # rebase our branch onto his
|
||||||
|
```
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# 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'` \| `'npc'` \| `'monster'` | character/NPC = death saves, monster = dead at 0 HP |
|
||||||
|
| `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/stable by `status` |
|
||||||
|
| `conditions` | array | condition ids from `CONDITIONS` list |
|
||||||
|
| `isActive` | bool | in turn rotation? false = skipped by nextTurn |
|
||||||
|
| `status` | `'conscious'` \| `'dying'` \| `'stable'` \| `'dead'` | death-save source of truth |
|
||||||
|
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
|
||||||
|
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
|
||||||
|
|
||||||
|
## 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 (sets `type: 'npc'`, keeps monster/DM-control color, enables death saves)
|
||||||
|
- 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,
|
||||||
|
conditions:[], isActive:true, status:'conscious', deathSaveSuccesses:0, deathSaveFailures:0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Reorder before start (tie-break)
|
||||||
|
Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`.
|
||||||
|
|
||||||
|
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- No re-sort after start; 1-list order remains source of truth
|
||||||
|
- Inactive (`isActive:false`) participants are skipped, but stay in slot
|
||||||
|
|
||||||
|
### 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)` for living/dying/stable participants; normal heal does not affect dead participants
|
||||||
|
- Character/NPC at 0 HP → `status:'dying'`, death-save controls, unconscious condition
|
||||||
|
- Monster at 0 HP → `status:'dead'`, `isActive:false`
|
||||||
|
- Massive damage (`damage >= currentHp + maxHp`) → dead
|
||||||
|
|
||||||
|
### Death saves (character/NPC only, at 0 HP)
|
||||||
|
Action buttons: Success, Fail, Nat1, Nat20, Stabilize.
|
||||||
|
- Success: +1 success; 3 successes → stable/unconscious at 0 HP
|
||||||
|
- Fail: +1 failure; 3 failures → dead
|
||||||
|
- Nat1: +2 failures
|
||||||
|
- Nat20: 1 HP, conscious, counters reset
|
||||||
|
- Stabilize: stable/unconscious at 0 HP, counters reset
|
||||||
|
- Revive: dead → 0 HP, stable/unconscious, active
|
||||||
|
|
||||||
|
### 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 participants hidden (including auto-inactive dead monsters and DM-hidden 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** = slot by initiative into participants[] + sync
|
||||||
|
- **Toggle active** = flip `isActive` only, stay in slot
|
||||||
|
- **Remove** = drop from participants[] + sync, advance current if needed
|
||||||
|
|
||||||
|
No re-sort after `startEncounter`.
|
||||||
|
|
||||||
|
## 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 become inactive automatically and are skipped. Remove via trash icon to clean list.
|
||||||
|
- Dead characters/NPCs stay active until DM marks inactive, removes, or revives them.
|
||||||
|
- Player display auto-follows Start Combat. Manual control via Open Player Window.
|
||||||
|
|
||||||
|
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Glossary — TTRPG Initiative Tracker
|
||||||
|
|
||||||
|
Domain terms used throughout the app, shared turn logic, tests, and docs. Keep
|
||||||
|
these definitions stable so logs, UI labels, and code agree.
|
||||||
|
|
||||||
|
## Combat structure
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Initiative** | The ordered sequence determining who acts next. Rolled once at the start of an encounter; re-rolled only on a new encounter or explicit DM re-roll. |
|
||||||
|
| **Round** | One full pass through initiative — every active participant gets exactly one turn. N participants (PCs + NPCs + monsters + features) = N turns per round. Round counter increments when initiative wraps back to the first participant. |
|
||||||
|
| **Turn** | A single participant's initiative slot within a round. One participant acts. As many turns per round as there are participants. |
|
||||||
|
| **Initiative slot** | Synonym for turn's position in the ordered list. |
|
||||||
|
| **Top of round** | The first turn of a round (round counter increments here). |
|
||||||
|
|
||||||
|
Example: encounter with 8 participants (3 PCs + 4 monsters + 1 NPC).
|
||||||
|
|
||||||
|
```
|
||||||
|
Round 1: turn 1 (Fighter) → turn 2 (Goblin1) → ... → turn 8 (Merchant)
|
||||||
|
Round 2: turn 1 (Fighter) → ... → turn 8 (Merchant) [round counter +=1 at top]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Participants
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Participant** | Any combatant tracked in initiative. Has HP, initiative roll, conditions, and an `isActive` flag. |
|
||||||
|
| **PC** (Player Character) | Controlled by a player. On death → death saves (not removed from initiative). |
|
||||||
|
| **NPC** (Non-Player Character) | DM-controlled ally/neutral (e.g. merchant, quest-giver). May or may not roll initiative. |
|
||||||
|
| **Monster** | Hostile DM-controlled combatant. On death → typically removed from active initiative or marked dead. |
|
||||||
|
| **Feature / Lair** | Environmental or legendary effect that occupies an initiative slot (e.g. lair action at initiative 20). |
|
||||||
|
|
||||||
|
## Participant state
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **HP** (Hit Points) | Current health. `currentHp` / `maxHp`. At 0 → dying or dead (rules differ by type). |
|
||||||
|
| **Initiative mod** | Bonus added to d20 initiative roll. `defaultInitMod`. |
|
||||||
|
| **Conditions** | Temporary status effects (stunned, prone, poisoned, etc.) applied/toggled during play. Array on participant. |
|
||||||
|
| **isActive** | Whether the participant is in the active initiative rotation. Set false on death (CURRENT behavior — see M4 skip-bug fix). |
|
||||||
|
| **Death save** | PC-only mechanic. Successes/failures tracked at 0 HP. 3 fails → dead; 3 successes → stable. |
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Admin view** (`/`) | DM interface. Full create/edit/combat control. |
|
||||||
|
| **Player view** (`/display` or `?playerView=true`) | Read-only second-screen display for players. Shows current turn, HP bars, conditions, round. No DM controls. |
|
||||||
|
| **Active display** | The single `activeDisplay/status` doc controlling what the player view shows (which campaign/encounter, hide-player-HP flag). |
|
||||||
|
|
||||||
|
## Backend / storage
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Adapter** | `src/storage/{firebase,server}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
|
||||||
|
| **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. |
|
||||||
|
| **Generic KV doc store** | Backend stores opaque JSON at arbitrary path strings. No shape-specific endpoints. Backend = firebase mirror, not REST API for app entities. |
|
||||||
|
| **Layer 1 test** | App vs firebase mock. Proves adapter call shape. |
|
||||||
|
| **Layer 2 test** | server adapter vs live backend. Proves translation + path identity. |
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Initiative Ordering — Design
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
**Slot, never sort.**
|
||||||
|
|
||||||
|
Initiative order = a single list. Participants occupy slots determined by
|
||||||
|
initiative value. The list is mutated by insert/move operations — never
|
||||||
|
re-sorted wholesale.
|
||||||
|
|
||||||
|
## Single Source of Truth
|
||||||
|
|
||||||
|
One list: `participants[]`.
|
||||||
|
|
||||||
|
- Display order = `participants[]` order (both Player view + DM view).
|
||||||
|
- Turn order = `participants[]` order.
|
||||||
|
- No derived/re-sorted copy. `turnOrderIds`, if present, is always an exact
|
||||||
|
mirror (`participants.map(p => p.id)`) — never an independent ordering.
|
||||||
|
|
||||||
|
## When Ordering Changes
|
||||||
|
|
||||||
|
| Action | Effect on order |
|
||||||
|
|--------|-----------------|
|
||||||
|
| **Add** (roll or manual) | Insert participant into slot by initiative |
|
||||||
|
| **Edit initiative** | Move participant to new slot |
|
||||||
|
| **Drag** | Reorder within a tie (same initiative) only |
|
||||||
|
| **Remove** | Splice out; others keep position |
|
||||||
|
| **Start encounter** | Freeze current list. No re-sort. |
|
||||||
|
| **Round wrap** | No rebuild. Continue rotation through existing list. |
|
||||||
|
| **Damage/heal/death/save** | No order change. |
|
||||||
|
| **Toggle active** | No position change. Does **not** advance current turn. Skip on next explicit turn advance only. |
|
||||||
|
|
||||||
|
## Slotting Rules
|
||||||
|
|
||||||
|
- Insert/move positions participants so the list stays initiative-descending.
|
||||||
|
- **Tie-break = original add order.** Later additions slot *after* existing
|
||||||
|
same-init participants. Stable insertion.
|
||||||
|
- Tie order is only changed by explicit DM drag. Never auto-changed.
|
||||||
|
|
||||||
|
## Drag (DM Tie-Break Override)
|
||||||
|
|
||||||
|
- Only participants with the **same initiative** are draggable onto each other.
|
||||||
|
- Cross-initiative drag is **not allowed**. (Use edit-initiative field instead.)
|
||||||
|
- Drag persists in `participants[]`. Survives all subsequent operations.
|
||||||
|
- **Re-slotting on add/edit must preserve drag-established tie order.**
|
||||||
|
|
||||||
|
## Toggle Active Semantics
|
||||||
|
|
||||||
|
Toggle active is a roster/status edit, not a turn action.
|
||||||
|
|
||||||
|
- Deactivating the current participant leaves `currentTurnParticipantId` unchanged.
|
||||||
|
- Deactivating the current participant does **not** increment `round`.
|
||||||
|
- DM must click Next Turn to pass turn after deactivating current.
|
||||||
|
- Next Turn skips inactive participants during rotation.
|
||||||
|
- Reactivating a participant restores them to rotation at their existing slot.
|
||||||
|
- Toggle active never changes participant position or tie order.
|
||||||
|
|
||||||
|
Reason: auto-advancing from toggle active makes a status edit count like a turn pass,
|
||||||
|
can wrap the round accidentally, and causes double-act/skip drift.
|
||||||
|
|
||||||
|
## Explicitly Forbidden
|
||||||
|
|
||||||
|
- `sort()` on every mutation. Overwrites drag order. Root cause of past drift.
|
||||||
|
- Separate display order vs turn order. Causes player/DM divergence.
|
||||||
|
- Re-sort on round wrap. Replays rounds, introduces skips.
|
||||||
|
- Cross-initiative drag. Contradicts initiative as the primary key.
|
||||||
|
- Auto-changing tie order on add/edit. Silently loses DM intent.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Sorting is destructive to manual ordering. A stable slot list with drag for
|
||||||
|
ties gives deterministic, DM-controllable order that never drifts between
|
||||||
|
display surfaces or combat rounds.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Log System Redesign Plan
|
||||||
|
|
||||||
|
**Status:** DRAFT — not started
|
||||||
|
**Branch:** `feat-turn-writes-logs`
|
||||||
|
**Goal:** Lean mutation deltas. Kill bloated payload/undo duplication.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Current events = 10-16KB each. `add_participant` row = 16KB to say "Fighter added, init 22." Root cause: full participant roster stored 3x (payload + undo_payload.updates + undo_payload.redo).
|
||||||
|
|
||||||
|
DB: 2486 docs, most bloated. Replay trace 1.6-5.5MB for ~250 steps.
|
||||||
|
|
||||||
|
Old log (pre-refactor) = message string + small undo object. Proven worked for undo/redo + viewer. Just wanted MORE STRUCTURE, not MORE DATA. Overbuilt instead.
|
||||||
|
|
||||||
|
## Intention
|
||||||
|
|
||||||
|
Each event = mutation delta + lean snapshot. Structured for filter/group/analyzer. No roster arrays.
|
||||||
|
|
||||||
|
### Canonical lean event shape
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id, ts, type, // identity + filter key
|
||||||
|
message, // human text (viewer displays)
|
||||||
|
encounterId, encounterName, // which encounter
|
||||||
|
participantId, participantName, // who (nullable: pause/nextTurn have none)
|
||||||
|
// type-specific scalar delta (one of these clusters per type):
|
||||||
|
// add: { init, maxHp, type: 'character'|'monster' }
|
||||||
|
// remove: { dead: bool }
|
||||||
|
// damage/heal:{ amount }
|
||||||
|
// condition: { condition }
|
||||||
|
// toggleActive:{ revive: bool }
|
||||||
|
// reorder: { draggedId, targetId }
|
||||||
|
// update: { changedFields: ['notes', ...] }
|
||||||
|
// nextTurn: (none — snapshot carries new round/pointer)
|
||||||
|
// pause/resume/start/end: (none)
|
||||||
|
snapshot: { // lean, ALREADY GOOD — keep as-is
|
||||||
|
round, currentTurnParticipantId, turnOrderIds, activeIds
|
||||||
|
},
|
||||||
|
undo: <type-specific inverse, id-based> // minimal
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Undo inverses (id-based, minimal)
|
||||||
|
|
||||||
|
| type | undo |
|
||||||
|
|------|------|
|
||||||
|
| add_participant | `{ participantId }` → removeParticipant by id |
|
||||||
|
| remove_participant | full participant obj (must restore — only place full obj needed) |
|
||||||
|
| damage | `{ amount: -amount }` or heal back |
|
||||||
|
| heal | `{ amount: -amount }` or damage back |
|
||||||
|
| condition | `{ condition }` (toggle is its own inverse) |
|
||||||
|
| toggleActive | `{ revive: !revive }` |
|
||||||
|
| reorder | `{ draggedId, targetId }` (swap back) |
|
||||||
|
| update | `{ changedFields: [...], oldValues: {...} }` |
|
||||||
|
| nextTurn | `{ currentTurnParticipantId, round }` from snapshot delta |
|
||||||
|
| pause/resume | toggle |
|
||||||
|
| start/end | full prior scalar state |
|
||||||
|
|
||||||
|
**No `redo` field.** Redo = re-apply forward delta from event itself.
|
||||||
|
|
||||||
|
**No `undo_payload` wrapper.** Just `undo` object.
|
||||||
|
|
||||||
|
## 4 Consumers
|
||||||
|
|
||||||
|
1. **Viewer** — shows `message` + `type` + `participantName` + `undone` flag. Legacy logs (no type) display message only.
|
||||||
|
2. **Undo/redo** — apply `undo` inverse by id. Id-based, minimal.
|
||||||
|
3. **Replay** — turn.js funcs write own lean events. Trust func return. NO read-back. JSONL trace = lean snapshots + types.
|
||||||
|
4. **Analyzer** — reads `snapshot` sequence + `type`. Reconstructs roster from event stream (add/remove by id). NO payload arrays needed.
|
||||||
|
|
||||||
|
## Legacy Support
|
||||||
|
|
||||||
|
- **Viewer:** yes — show old message-only logs, no type parse
|
||||||
|
- **Undo:** no — old logs unundoable (acceptable, sit inert)
|
||||||
|
- **Analyzer:** best-effort — snapshot may be absent on legacy → skip invariant check for that run
|
||||||
|
|
||||||
|
## Why Lean
|
||||||
|
|
||||||
|
- `add_participant` 16KB → ~150 bytes
|
||||||
|
- Replay trace 5.5MB → ~250KB
|
||||||
|
- Undo still works (proven old log did same)
|
||||||
|
- Analyzer unaffected (uses snapshot+type, not payload)
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### Phase 1: Redesign event writers (shared/turn.js)
|
||||||
|
|
||||||
|
- [ ] Rewrite `buildEntry()` to produce lean shape (delta fields per type)
|
||||||
|
- [ ] Rewrite `commit()` — no patch storage, write lean event
|
||||||
|
- [ ] Per-func: compute minimal delta + inverse from enc before/after
|
||||||
|
- [ ] Drop `payload`, `undo_payload`, `undo_payload.redo`
|
||||||
|
|
||||||
|
### Phase 2: Update undo executor
|
||||||
|
|
||||||
|
- [ ] Find undo consumer (App.js `applyUndo` or similar)
|
||||||
|
- [ ] Switch to id-based inverse application
|
||||||
|
- [ ] Handle each type's undo shape
|
||||||
|
- [ ] Drop `transactionalUndo` full-patch path if exists
|
||||||
|
|
||||||
|
### Phase 3: Update replay trace
|
||||||
|
|
||||||
|
- [ ] `replay-combat.js` callStep: trust return, snapshot only (no read-back)
|
||||||
|
- [ ] Trace = `{step, ts, type, pre:leanSnap, post:leanSnap}` — no participant arrays
|
||||||
|
|
||||||
|
### Phase 4: Update analyzer
|
||||||
|
|
||||||
|
- [ ] `analyze-turns.js` already uses snapshot+type only — verify no payload dep
|
||||||
|
- [ ] Reconstruct roster from event stream for skip/double-act checks (id-level)
|
||||||
|
|
||||||
|
### Phase 5: Legacy compat
|
||||||
|
|
||||||
|
- [ ] `logEvent.js normalizeEvent` — lift legacy into lean shape (type='legacy', message only)
|
||||||
|
- [ ] Viewer handles legacy gracefully
|
||||||
|
- [ ] Download = lean events only (legacy excluded or message-only)
|
||||||
|
|
||||||
|
### Phase 6: Cleanup
|
||||||
|
|
||||||
|
- [ ] Delete old bloated docs from DB? Or leave (inert, viewer reads message)?
|
||||||
|
- [ ] Verify build CI=true passes
|
||||||
|
- [ ] Verify analyzer clean on fresh replay
|
||||||
|
- [ ] Verify undo works on new events
|
||||||
|
|
||||||
|
## NOT in scope
|
||||||
|
|
||||||
|
- Fixing 5 pre-existing initiative bugs (logged in TODO.md, separate)
|
||||||
|
- Changing snapshot shape (already lean)
|
||||||
|
- Changing viewer UI (just data source)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Undo for `remove_participant`: must restore full obj (only place full obj needed). Acceptable?
|
||||||
|
- Should legacy logs be deleted from DB or left inert?
|
||||||
|
- Download format: lean JSON array (no legacy) — confirm
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# Initiative Tracker — Rework Plan
|
||||||
|
|
||||||
|
Status: **APPROVED — executing**
|
||||||
|
Owner: draistrick (fork → `keen99/ttrpg-initiative-tracker`, private)
|
||||||
|
Upstream: `code.draft13.com/robert/ttrpg-initiative-tracker` (friend's Gitea)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Replace Firebase with self-hosted backend.** Browser cannot own a DB file (sandbox). Cross-device (DM + tablet + player view) requires a real backend. Backend is the foundation, built first.
|
||||||
|
2. **Automated test ecosystem as the baseline.** Lock current behavior before changing it.
|
||||||
|
3. **Remain mergeable upstream.** Default behavior (Firebase) preserved behind flag. Upstream `main` stays clean. Friend keeps Firebase path.
|
||||||
|
4. **Self-hostable in local Docker** (in-house network). Public exposure = future, only after auth + multiuser safety.
|
||||||
|
|
||||||
|
## Non-Goals (this plan)
|
||||||
|
|
||||||
|
- Ripping Firebase. Kept as default adapter upstream.
|
||||||
|
- Public/multiuser deployment. Deferred.
|
||||||
|
- Rewriting the entire 2935-line `App.js`. Only extract what testability demands.
|
||||||
|
- Feature/bug work. That lives in `TODO.md`. This plan = infra + backend + test harness only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
### Why Firebase is wrong here (for this fork)
|
||||||
|
- Requires Google account + network for a single-user tabletop tool.
|
||||||
|
- Realtime value (DM view ↔ player display) is real but solvable locally.
|
||||||
|
- API key baked into client bundle (CRA `REACT_APP_*` at build); security depends entirely on console rules not in repo.
|
||||||
|
- Vendor lock + quota; `onSnapshot` on collections burns reads.
|
||||||
|
- Friend keeps it; we fork off it.
|
||||||
|
|
||||||
|
### Why a backend is mandatory
|
||||||
|
Browser sandbox cannot write the filesystem. No sqlite file, no `/data/db.sqlite`, nothing. Browser JS is blocked from disk by design. Therefore cross-device storage (DM ↔ tablet ↔ player view) requires a separate Node process owning the DB file and serving the browser over HTTP/WebSocket. There is no browser-only path. **The backend is step one, not deferred.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Stack (locked)
|
||||||
|
- **Node.js** runtime
|
||||||
|
- **Express** web framework
|
||||||
|
- **ws** WebSocket lib (realtime push, replaces `onSnapshot`)
|
||||||
|
- **better-sqlite3** SQLite driver (synchronous, simple, fast)
|
||||||
|
- **SQLite** DB (single file, docker volume, trivial backup)
|
||||||
|
- **Jest** test runner (already in CRA deps)
|
||||||
|
|
||||||
|
Postgres deferred until public multiuser exposure is real. SQLite schema ports easily if that day comes.
|
||||||
|
|
||||||
|
### Backend design
|
||||||
|
- Owns SQLite file. Only writer.
|
||||||
|
- Holds authoritative state.
|
||||||
|
- Generic KV doc store (firebase mirror): single `docs` table (path PK, parent, data JSON, updated_at). Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.
|
||||||
|
- WS broadcast on every state change → all connected clients (DM view, player display, tablet) update instantly.
|
||||||
|
|
||||||
|
### Three storage impls, one interface (frontend)
|
||||||
|
|
||||||
|
The storage interface is the test seam and the upstream-compat layer.
|
||||||
|
|
||||||
|
| Impl | When used | Automated-tested? |
|
||||||
|
|---|---|---|
|
||||||
|
| `firebase.js` | default (`STORAGE=firebase`) — upstream path | No — requires live Firebase project |
|
||||||
|
| `server.js` | `STORAGE=server` — our fork, talks to backend | Yes — against running backend |
|
||||||
|
| `memory.js` | test-only, in-process | Yes — fast, deterministic |
|
||||||
|
|
||||||
|
**Frontend interface contract** (all three implement):
|
||||||
|
- `getDoc(path)`, `setDoc(path, data, opts)`, `updateDoc(path, patch)`
|
||||||
|
- `deleteDoc(path)`, `batch(ops)`
|
||||||
|
- `subscribeDoc(path, cb)` / `subscribeCollection(path, cb)` → real-time push
|
||||||
|
|
||||||
|
Firebase impl: existing `onSnapshot` + SDK calls, moved verbatim behind interface (M2).
|
||||||
|
WS impl: thin adapter; generic KV ops, receives **state updates** via WS subscribe (M2).
|
||||||
|
Memory impl: in-memory Map + EventEmitter, for tests (M3).
|
||||||
|
|
||||||
|
### Repo layout (npm workspaces)
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
package.json # workspaces root
|
||||||
|
src/ # React frontend (existing, refactored behind storage interface)
|
||||||
|
storage/
|
||||||
|
index.js # factory: pick impl from STORAGE env
|
||||||
|
firebase.js # extracted from current App.js (verbatim)
|
||||||
|
server.js # talks to backend (was ws.js)
|
||||||
|
memory.js # NEW — test only
|
||||||
|
contract.js # interface spec (runStorageContract)
|
||||||
|
tests/ # frontend tests
|
||||||
|
server/ # NEW
|
||||||
|
index.js # Express + ws bootstrap, generic KV REST
|
||||||
|
db.js # better-sqlite3, docs table (KV), broadcast
|
||||||
|
handlers.js # REST handlers
|
||||||
|
tests/ # adapter vs live backend (Layer 2 test)
|
||||||
|
shared/ # pure logic, no I/O, importable by client + server + tests
|
||||||
|
turn.js # turn logic (single source; tests import)
|
||||||
|
tests/ # turn logic unit tests (characterization + desired)
|
||||||
|
data/ # gitignored sqlite DB
|
||||||
|
docker-compose.yml # NEW — M5
|
||||||
|
docs/
|
||||||
|
REWORK_PLAN.md # this file
|
||||||
|
DEVELOPMENT.md
|
||||||
|
GLOSSARY.md
|
||||||
|
TODO.md # bugs + features (separate from this plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- **Now:** `AUTH_MODE=none`. App gated by nginx HTTP basic auth (reuse friend's existing pattern). In-house only. Risk acceptable: someone sees your initiative counter.
|
||||||
|
- **Future:** `AUTH_MODE=token` — real login, real users. Only if/when publicly exposed. Not built this plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
Each milestone = independently mergeable PR upstream (unless marked ❌).
|
||||||
|
|
||||||
|
| M | Does | Tests? |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | repo, branch, remotes | no |
|
||||||
|
| 1 | build backend (Node+Express+WebSocket+better-sqlite3) | unit tests as built |
|
||||||
|
| 2 | frontend WS adapter — app runs vs backend, cross-device works | yes |
|
||||||
|
| 3 | characterization tests lock current behavior | yes |
|
||||||
|
| 4 | resolve initiative rotation corruption (BUG-5) | yes |
|
||||||
|
| 5 | docker single container (caddy+node) | smoke ✅ |
|
||||||
|
| 6 | _moved to TODO backlog (feature work)_ | - |
|
||||||
|
| 7 | playwright multi-window e2e (deferred) | e2e |
|
||||||
|
| 8 | (future) public exposure | - |
|
||||||
|
|
||||||
|
### Milestone 0 — Repo + branch setup ✅
|
||||||
|
- Fresh branch off `main` (not `dsr-rework`). Name: `rework-backend`.
|
||||||
|
- `upstream` remote = friend's Gitea (read-only fetch).
|
||||||
|
- Push origin = `keen99/ttrpg-initiative-tracker` (private).
|
||||||
|
- npm workspaces root config.
|
||||||
|
- Commit this plan.
|
||||||
|
- **Exit criteria:** clean branch, plan committed, remotes set. ✅ DONE.
|
||||||
|
- **Upstream-PRable:** n/a (fork infra)
|
||||||
|
|
||||||
|
### Milestone 1 — Build backend ✅
|
||||||
|
- `server/`: Express + WebSocket + better-sqlite3.
|
||||||
|
- Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path.
|
||||||
|
- Server holds authoritative state. No turn logic server-side (logic stays client-side in `shared/turn.js`).
|
||||||
|
- **Exit criteria:** backend boots, serves state over WS, persists to SQLite, unit tests green. ✅ DONE.
|
||||||
|
- **Upstream-PRable:** ❌ divergence (friend stays Firebase).
|
||||||
|
|
||||||
|
### Milestone 2 — Frontend WS adapter ✅
|
||||||
|
- Define `storage/contract.js` interface spec.
|
||||||
|
- Move all Firestore call sites from `App.js` into `storage/firebase.js` behind interface (verbatim).
|
||||||
|
- Implement `storage/server.js` per interface, talking to backend. Generic KV ops, subscribes via WebSocket.
|
||||||
|
- Implement `storage/memory.js` for frontend unit tests.
|
||||||
|
- `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged).
|
||||||
|
- App runs against backend with `STORAGE=server`.
|
||||||
|
- Cross-device verified manually: DM view + player display + tablet.
|
||||||
|
- **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE.
|
||||||
|
- **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌.
|
||||||
|
|
||||||
|
### Milestone 3 — Characterization tests lock current behavior ✅
|
||||||
|
- Lock current behavior via tests.
|
||||||
|
- Cover: START, NEXT_TURN, PAUSE, RESUME, ADD_PARTICIPANT, REMOVE_PARTICIPANT, TOGGLE_ACTIVE, REORDER, APPLY_DAMAGE/HEAL, DEATH_SAVE, END.
|
||||||
|
- Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (server adapter vs live backend, proves translation).
|
||||||
|
- Iterate until confident: baseline solid, regressions impossible to silently slip.
|
||||||
|
- **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE.
|
||||||
|
- **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape).
|
||||||
|
|
||||||
|
### Milestone 4 — Resolve initiative rotation corruption (BUG-5) ✅
|
||||||
|
- **Fixed** (commit `494327f`).
|
||||||
|
- Slot-array turn order model + DRY advance core (`nextActiveAfter`).
|
||||||
|
Both `nextTurn` + `computeTurnOrderAfterRemoval` delegate → one advance
|
||||||
|
path, no drift.
|
||||||
|
- 500-round replay: 0 skips, 0 double-acts.
|
||||||
|
- Tests: `turn.skip.test.js`, `turn.dry.test.js` (advance parity lock).
|
||||||
|
- **Upstream-PRable:** ✅ bug fix.
|
||||||
|
|
||||||
|
### Milestone 5 — Docker compose ✅
|
||||||
|
- Single container: caddy (front, static + proxy) + node backend (internal :4001).
|
||||||
|
- Files in `docker/` tree (kept separate from upstream root Dockerfile):
|
||||||
|
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
|
||||||
|
- `docker/Caddyfile` — proxy /api + /ws (WebSocket path) to node, static SPA fallback
|
||||||
|
- `docker/entrypoint.sh` — node bg + caddy fg
|
||||||
|
- `docker/docker-compose.yml` — one `app` service, volume for sqlite
|
||||||
|
- Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080.
|
||||||
|
- No `image:` field => compose auto-names, never pulls service image (private).
|
||||||
|
- **Exit criteria:** `docker compose up` runs full stack in-house. ✅ DONE.
|
||||||
|
- Verified: REST roundtrip, WS subscribe+push, replay 20 rounds CLEAN (0 skips/doubles/shifts), UI styled (Tailwind compiles).
|
||||||
|
- **Upstream-PRable:** ✅ separate docker/ tree, root Dockerfile untouched, firebase default preserved.
|
||||||
|
|
||||||
|
### Milestone 6 — Undo rework — _MOVED to TODO backlog_
|
||||||
|
- Moved: feature work (transactional undo), not infra. Lives in `TODO.md` now.
|
||||||
|
- Scope: events table `(type, payload, undo_payload, undone, ts)`; undo = apply undo_payload in tx.
|
||||||
|
|
||||||
|
### Milestone 7 — Playwright E2E (deferred)
|
||||||
|
- Multi-window E2E: DM view + display + player view in separate browser contexts against running backend.
|
||||||
|
- Verify realtime sync end-to-end.
|
||||||
|
- **Only build if sync regresses or we deviate significantly.** Turn-logic unit + backend integration tests cover most regression risk cheaper.
|
||||||
|
- **Exit criteria:** e2e green for core combat flow across 3 windows.
|
||||||
|
- **Upstream-PRable:** ✅ if test infra shared.
|
||||||
|
|
||||||
|
### Milestone 8 — (Future) Public exposure
|
||||||
|
- Real auth (`AUTH_MODE=token`).
|
||||||
|
- Rate limiting, CSRF, hardening.
|
||||||
|
- Postgres migration if load warrants.
|
||||||
|
- Only if we decide to expose publicly + multiuser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Layers
|
||||||
|
1. **Turn logic unit tests** (Jest, pure functions, `shared/tests/`). Characterization + desired. Cheap, essential.
|
||||||
|
2. **Backend integration tests** (Jest, `server/tests/`) — spin server on random port, assert WS pushes + SQLite persists + transactional correctness.
|
||||||
|
3. **Frontend adapter contract tests** (Jest, `src/tests/`) — impl parity against interface (memory). Firebase mock harness for Layer 1 App tests.
|
||||||
|
|
||||||
|
### Characterization → desired
|
||||||
|
1. **Characterization** — capture current behavior exactly (bugs included). Locks extraction/port as provably identical. Lets later fix be provable.
|
||||||
|
2. **Desired-behavior (red)** — write what *should* happen. Fail today. Fix → green. Bug stays dead. (Bug fixes live in TODO.md, tracked separately.)
|
||||||
|
|
||||||
|
### Manual smoke via config flags
|
||||||
|
- `STORAGE=firebase` → current behavior (friend's path, upstream default).
|
||||||
|
- `STORAGE=server` → our path, local backend.
|
||||||
|
- docker-compose profiles mirror the above.
|
||||||
|
|
||||||
|
### Accepted test gap
|
||||||
|
- Firebase adapter untested (requires live project). Accepted cost.
|
||||||
|
- Mitigated by: interface contract; if firebase impl drifts, integration smoke only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mergeability upstream
|
||||||
|
|
||||||
|
| Milestone | Upstream-PRable? | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 repo setup | n/a | fork infra |
|
||||||
|
| 1 backend | ❌ | divergence (friend stays Firebase) |
|
||||||
|
| 2 WS adapter | ⚠️ partial | interface + firebase extract ✅, WS ❌ |
|
||||||
|
| 3 characterization tests | ✅ | if storage-agnostic |
|
||||||
|
| 4 BUG-5 rotation fix | ✅ | bug fix |
|
||||||
|
| 5 docker | ✅ | separate docker/ tree, root Dockerfile untouched, firebase preserved |
|
||||||
|
| 6 undo (moved to TODO) | - | - |
|
||||||
|
| 7 playwright | ✅ | if test infra shared |
|
||||||
|
|
||||||
|
Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally zero change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **CRA + workspaces friction.** Create React App may resist monorepo layout. Mitigation: keep `src/` as CRA root, `server/` + `shared/` as separate workspaces imported via alias. Eject/craco only if forced.
|
||||||
|
- **Firebase drift untested.** Mitigation: interface contract; friend's path his to maintain.
|
||||||
|
- **Undo history migration.** Existing log entries use old snapshot format. Mitigation: keep old undo working until cleared, new format for new entries.
|
||||||
|
- **WS reconnect/state-sync edge cases.** Transient drop mid-combat. Mitigation: client requests full state resync on (re)connect; server is source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
1. **Branch:** `rework-backend` off `main`.
|
||||||
|
2. **npm workspaces** for `server/` + `shared/` alongside CRA `src/`. Fallback alias if CRA fights.
|
||||||
|
3. **Backend = generic KV doc store** (firebase mirror), not shape-specific endpoints. Thin adapter passthrough. Opaque JSON at arbitrary path strings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
- M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
|
||||||
|
- Backend live: port 4001, db `./data/tracker.sqlite`
|
||||||
|
- Frontend: port 3999 with `REACT_APP_STORAGE=server`
|
||||||
|
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
|
||||||
|
- Next milestones: M5 docker-compose. Undo moved to TODO backlog.
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 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 (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-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 (server mode)
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
→ 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
|
||||||
|
- `server` → backend (docker/prod)
|
||||||
|
|
||||||
|
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs server/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.
|
||||||
@@ -6,4 +6,11 @@ REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET_HERE"
|
|||||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE"
|
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE"
|
||||||
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE"
|
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE"
|
||||||
|
|
||||||
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default"
|
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default"
|
||||||
|
|
||||||
|
# --- Self-hosted backend mode (optional) ---
|
||||||
|
# Default storage = firebase (SDK). Set REACT_APP_STORAGE=server to use the
|
||||||
|
# bundled Express + WebSocket backend (server/) instead.
|
||||||
|
# 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"
|
||||||
|
After Width: | Height: | Size: 658 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 483 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 159 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,25 +2,34 @@
|
|||||||
"name": "ttrpg-initiative-tracker",
|
"name": "ttrpg-initiative-tracker",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.0",
|
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
"firebase": "^10.12.2",
|
"firebase": "^10.12.2",
|
||||||
"lucide-react": "^0.395.0",
|
"lucide-react": "^0.395.0",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4",
|
"tailwindcss": "^3.4.3",
|
||||||
"autoprefixer": "^10.4.19",
|
"web-vitals": "^2.1.4"
|
||||||
"postcss": "^8.4.38",
|
|
||||||
"tailwindcss": "^3.4.3"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "CI=true react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"test:ci": "./scripts/cap.sh 60 npx react-scripts test --watchAll=false",
|
||||||
|
"test:one": "./scripts/cap.sh 60 npx react-scripts test --watchAll=false --testPathPattern",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
|
"server:dev": "npm run dev --workspace server",
|
||||||
|
"server:test": "npm test --workspace server",
|
||||||
|
"shared:test": "npm test --workspace shared",
|
||||||
|
"test:all": "./scripts/run-tests.sh"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
@@ -39,5 +48,8 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^14.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Alegreya+Sans:ital,wght@0,400;0,500;0,700;1,400&display=swap" rel="stylesheet">
|
||||||
<title>TTRPG Initiative Tracker</title>
|
<title>TTRPG Initiative Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# scripts/
|
||||||
|
|
||||||
|
Dev orchestration + manual demo tool. NOT test.
|
||||||
|
|
||||||
|
## dev-start.sh / dev-stop.sh
|
||||||
|
|
||||||
|
Local dev stack: backend (:4001, sqlite) + frontend (:3999, server mode).
|
||||||
|
One command. Writes env vars for you. Logs to `tmp/server.log`, `tmp/fe.log`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev-start.sh # start (idempotent: leaves running ports as-is)
|
||||||
|
./scripts/dev-stop.sh # stop both
|
||||||
|
```
|
||||||
|
|
||||||
|
## replay-combat.js
|
||||||
|
|
||||||
|
Live backend demo. Drives full combat via server adapter (same contract as App).
|
||||||
|
Player display live-updates. Watch UI react to state changes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start backend + frontend first (see docs/DEVELOPMENT.md)
|
||||||
|
node scripts/replay-combat.js [rounds] [delayMs]
|
||||||
|
# defaults: 100 rounds, 200ms/step
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage per round: damage, heal, all 22 conditions, toggleActive,
|
||||||
|
removeParticipant, addParticipant (reinforcements), updateParticipant,
|
||||||
|
pause/resume, reorderParticipants, endEncounter. Revives dead each round
|
||||||
|
to sustain full round count.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `tests/audit/` — exploratory bug-finders (manual run, non-deterministic)
|
||||||
|
- `{shared,server,src}/tests/` — jest unit/integration/characterization
|
||||||
|
- `scratch/` — gitignored throwaway
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
// scripts/analyze-turns.js
|
||||||
|
// Invariant checker for combat rotation. Source-agnostic.
|
||||||
|
//
|
||||||
|
// Input (autodetect):
|
||||||
|
// .jsonl file — replay-combat trace: per-step {step,ts,type,call:{fn,args},
|
||||||
|
// pre,post}. pre/post = backend read-back snapshots.
|
||||||
|
// .json array — downloaded log OR exported events. {ts,type,...,snapshot}.
|
||||||
|
// snapshot = what turn.js logged (lighter: no participants[]).
|
||||||
|
// .log file — replay stdout: extract trace path from 'trace written:' line.
|
||||||
|
// stdin — either jsonl or json.
|
||||||
|
// no arg = usage. User must specify path.
|
||||||
|
//
|
||||||
|
// INVARIANTS (define correctness; no prediction):
|
||||||
|
// 1. round monotonically ascends; +1 only on pointer wrap (last→first active).
|
||||||
|
// No backward, no double-increment, no skip.
|
||||||
|
// 2. pointer advances forward in turnOrderIds (mod wrap), skipping inactive.
|
||||||
|
// Never backward, never stationary except on pause/non-rotation mutations.
|
||||||
|
// 3. no double-act: in one rotation cycle each active participant becomes
|
||||||
|
// current ≤1 time.
|
||||||
|
// 4. no real skip: participant active for full cycle, never removed/deactivated,
|
||||||
|
// but never became current = skipped.
|
||||||
|
// 5. order stable across non-reorder mutations. turnOrderIds shift without
|
||||||
|
// add/remove/reorder = display divergence.
|
||||||
|
// 6. slot order (initiative desc, tie-break stable) maintained except after
|
||||||
|
// explicit reorder. Replay-trace only (needs participants[].initiative).
|
||||||
|
//
|
||||||
|
// Exit 0 clean, 1 issues found.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// ---------- input ----------
|
||||||
|
|
||||||
|
function readInput() {
|
||||||
|
const arg = process.argv[2];
|
||||||
|
|
||||||
|
// No arg + no stdin = usage.
|
||||||
|
if (!arg && process.stdin.isTTY) {
|
||||||
|
console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
|
||||||
|
console.error(' cat events | node scripts/analyze-turns.js');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!arg) {
|
||||||
|
const stdin = fs.readFileSync(0, 'utf8');
|
||||||
|
if (!stdin.trim()) {
|
||||||
|
console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
|
||||||
|
console.error(' cat events | node scripts/analyze-turns.js');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
return stdin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay stdout .log: extract trace path from `trace written:` line.
|
||||||
|
if (/\.log$/i.test(arg)) {
|
||||||
|
const logText = fs.readFileSync(arg, 'utf8');
|
||||||
|
const m = logText.match(/trace written: \d+ steps -> (.+)$/m);
|
||||||
|
if (m) {
|
||||||
|
const tracePath = m[1].trim();
|
||||||
|
if (fs.existsSync(tracePath)) {
|
||||||
|
console.error(`[analyze] trace: ${tracePath}`);
|
||||||
|
return fs.readFileSync(tracePath, 'utf8');
|
||||||
|
}
|
||||||
|
console.error(`[analyze] trace path from .log not found: ${tracePath}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.error(`[analyze] no 'trace written:' line in ${arg}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readFileSync(arg, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// snake_case log type → camelCase fn (invariant checks match both shapes).
|
||||||
|
// replay JSONL already camelCase; unchanged.
|
||||||
|
function normalizeFn(fn) {
|
||||||
|
if (!fn) return fn;
|
||||||
|
if (!fn.includes('_')) return fn;
|
||||||
|
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse input text → array of step-arrays (one per encounter for downloaded
|
||||||
|
// logs, one for replay JSONL trace). Each step:
|
||||||
|
// { step, ts, type, fn, args, pre, post, error }
|
||||||
|
// JSONL: pre/post from backend read-back. JSON array: post=snapshot, no pre.
|
||||||
|
function loadSteps(text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
// JSONL: one JSON obj per line (each starts '{' + parses standalone).
|
||||||
|
const looksJsonl = (() => {
|
||||||
|
const lines = trimmed.split('\n');
|
||||||
|
if (lines.length < 2) return false;
|
||||||
|
const first = lines[0].trim();
|
||||||
|
const second = lines[1].trim();
|
||||||
|
if (!first.startsWith('{') || !second.startsWith('{')) return false;
|
||||||
|
try { JSON.parse(first); JSON.parse(second); return true; }
|
||||||
|
catch { return false; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (looksJsonl) {
|
||||||
|
const steps = trimmed.split('\n').filter(l => l.trim()).map(l => {
|
||||||
|
const r = JSON.parse(l);
|
||||||
|
return {
|
||||||
|
step: r.step, ts: r.ts, type: r.type,
|
||||||
|
fn: normalizeFn(r.call ? r.call.fn : r.type),
|
||||||
|
args: r.call ? r.call.args : null,
|
||||||
|
pre: r.pre || null, post: r.post || null, error: r.error || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [steps]; // single trace
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON array. Downloaded logs may merge multiple encounters → split by id.
|
||||||
|
const raw = JSON.parse(trimmed);
|
||||||
|
const arr = Array.isArray(raw) ? raw : [raw];
|
||||||
|
const groups = new Map(); // encounterId -> []
|
||||||
|
for (const e of arr) {
|
||||||
|
const key = e.encounterId || '_none_';
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key).push(e);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
const toSteps = (evs) => evs.map((e, i) => ({
|
||||||
|
step: i + 1, ts: e.ts || 0, type: e.type, fn: normalizeFn(e.type), args: null,
|
||||||
|
pre: null,
|
||||||
|
post: e.snapshot ? {
|
||||||
|
round: e.snapshot.round,
|
||||||
|
currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
|
||||||
|
isStarted: true, isPaused: false,
|
||||||
|
turnOrderIds: e.snapshot.turnOrderIds || [],
|
||||||
|
activeIds: e.snapshot.activeIds || [],
|
||||||
|
participants: null, // downloaded logs lack full participant roster
|
||||||
|
} : null,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
for (const evs of groups.values()) {
|
||||||
|
// Same encounterId may span multiple combat runs (restart via
|
||||||
|
// start_encounter). Sub-split so each rotation cycle analyzed within one
|
||||||
|
// continuous run. start_encounter = run boundary — BUT only flush if a
|
||||||
|
// prior run already started (i.e. true restart). First start_encounter
|
||||||
|
// after pure setup (add_participant etc.) stays with its setup events.
|
||||||
|
let cur = [];
|
||||||
|
let started = false;
|
||||||
|
const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
|
||||||
|
for (const e of evs) {
|
||||||
|
if (e.type === 'start_encounter' && started) flush();
|
||||||
|
if (e.type === 'start_encounter') started = true;
|
||||||
|
cur.push(e);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
const nameMap = new Map(); // id -> name (built lazily from snapshots)
|
||||||
|
|
||||||
|
function learnNames(steps) {
|
||||||
|
for (const s of steps) {
|
||||||
|
for (const snap of [s.pre, s.post]) {
|
||||||
|
if (snap && Array.isArray(snap.participants)) {
|
||||||
|
for (const p of snap.participants) if (p.id && p.name) nameMap.set(p.id, p.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
|
||||||
|
|
||||||
|
// next active position after fromPos in order, skipping inactive. Mirrors
|
||||||
|
// turn.js nextActiveAfter so we know what SHOULD have happened — but this is
|
||||||
|
// invariant definition, not prediction: we check the ACTUAL post-current.
|
||||||
|
function expectedAdvance(order, fromPos, isActive) {
|
||||||
|
const n = order.length;
|
||||||
|
if (n === 0) return { nextId: null, wrapped: false };
|
||||||
|
for (let step = 1; step < n; step++) {
|
||||||
|
const idx = (fromPos + step) % n;
|
||||||
|
const id = order[idx];
|
||||||
|
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
|
||||||
|
}
|
||||||
|
// solo active = stays itself (turn.js would throw; treat as no-advance)
|
||||||
|
return { nextId: null, wrapped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- invariant checks ----------
|
||||||
|
|
||||||
|
// Split analysis into independent passes. Each invariant = own function.
|
||||||
|
// Entangling cycle-skip tracking with per-step mutation handling caused
|
||||||
|
// stale-set false positives (active-set rebuilt on every roster mutation
|
||||||
|
// discarded the cycle-start snapshot).
|
||||||
|
function analyze(steps) {
|
||||||
|
const issues = [];
|
||||||
|
const rounds = new Map();
|
||||||
|
function ensureRound(r) {
|
||||||
|
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, issues: [] });
|
||||||
|
return rounds.get(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-step: round monotonic, advance direction, order stability ----
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const s = steps[i];
|
||||||
|
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
|
||||||
|
const post = s.post;
|
||||||
|
if (!post) continue;
|
||||||
|
|
||||||
|
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
|
||||||
|
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
|
||||||
|
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';
|
||||||
|
|
||||||
|
if (isStart) { ensureRound(post.round || 1).turnCount++; continue; }
|
||||||
|
if (isEnd) continue;
|
||||||
|
if (isNextTurn) {
|
||||||
|
ensureRound(post.round || 0).turnCount++;
|
||||||
|
if (!pre) continue;
|
||||||
|
|
||||||
|
const order = pre.turnOrderIds || [];
|
||||||
|
const fromPos = order.indexOf(pre.currentTurnParticipantId);
|
||||||
|
const isActive = id => (pre.activeIds || []).includes(id);
|
||||||
|
const exp = expectedAdvance(order, fromPos, isActive);
|
||||||
|
const actual = post.currentTurnParticipantId;
|
||||||
|
|
||||||
|
// invariant 2: correct advance target
|
||||||
|
if (exp.nextId && actual && actual !== exp.nextId) {
|
||||||
|
issues.push({ step: s.step, round: post.round, kind: 'wrong_advance',
|
||||||
|
expected: nm(exp.nextId), actual: nm(actual),
|
||||||
|
detail: `nextTurn → ${nm(actual)}, expected ${nm(exp.nextId)}` });
|
||||||
|
}
|
||||||
|
// invariant 1: round monotonic + no phantom/skip
|
||||||
|
if (pre.round !== undefined && post.round !== undefined) {
|
||||||
|
if (post.round < pre.round)
|
||||||
|
issues.push({ step: s.step, kind: 'round_backward', from: pre.round, to: post.round,
|
||||||
|
detail: `round backward ${pre.round}→${post.round}` });
|
||||||
|
if (post.round > pre.round + 1)
|
||||||
|
issues.push({ step: s.step, kind: 'round_skip', from: pre.round, to: post.round,
|
||||||
|
detail: `round jumped ${pre.round}→${post.round}` });
|
||||||
|
if (post.round === pre.round + 1 && !exp.wrapped)
|
||||||
|
issues.push({ step: s.step, kind: 'round_phantom', from: pre.round, to: post.round,
|
||||||
|
detail: `round incremented without pointer wrap` });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-rotation mutation: invariant 5 order stability
|
||||||
|
if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
|
||||||
|
const before = JSON.stringify(pre.turnOrderIds || []);
|
||||||
|
const after = JSON.stringify(post.turnOrderIds || []);
|
||||||
|
if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
|
||||||
|
issues.push({ step: s.step, kind: 'order_shift', fn: s.fn,
|
||||||
|
detail: `turnOrderIds changed without add/remove/reorder (${s.fn})` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dedicated cycle pass: skip + double-act (invariants 3+4) ----
|
||||||
|
// Cycle = all-act-once between pointer wraps. Snapshot active-set at cycle
|
||||||
|
// start. Track removals/deactivations as legitimate disqualifications.
|
||||||
|
// Skip = in start-set, never disqualified, never acted.
|
||||||
|
issues.push(...checkCycles(steps));
|
||||||
|
|
||||||
|
return { issues, rounds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCycles: walk steps, maintain rotation cycle state. On wrap/end,
|
||||||
|
// finalize: skip = activeAtStart minus (acted ∪ disqualified). Double-act =
|
||||||
|
// current that became current >1 in cycle (excluding the legit starter).
|
||||||
|
function checkCycles(steps) {
|
||||||
|
const out = [];
|
||||||
|
let cycleActive = new Set(); // snapshot at cycle start (immutable for cycle)
|
||||||
|
let cycleActed = new Set();
|
||||||
|
let cycleRemoved = new Set(); // removed/disqualified mid-cycle (no skip flag)
|
||||||
|
let cycleStarter = null;
|
||||||
|
let cycleRound = null;
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
// disqualify by active-set delta, not fn name. Log types vary (deactivate,
|
||||||
|
// reactivate, remove_participant, add_participant...). Any step where an id
|
||||||
|
// leaves activeIds = disqualified. Any id entering = joins cycle.
|
||||||
|
function finalize(endStep) {
|
||||||
|
if (!started) return;
|
||||||
|
// disqualify: anyone removed/deactivated mid-cycle is gone (legit)
|
||||||
|
// skip = was active at start, never acted, never disqualified
|
||||||
|
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
|
||||||
|
if (skipped.length) {
|
||||||
|
out.push({ step: endStep, round: cycleRound, kind: 'real_skip',
|
||||||
|
actors: skipped.map(nm), detail: `active full cycle, never acted` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const s = steps[i];
|
||||||
|
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
|
||||||
|
const post = s.post;
|
||||||
|
if (!post) continue;
|
||||||
|
|
||||||
|
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
|
||||||
|
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
|
||||||
|
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';
|
||||||
|
|
||||||
|
if (isStart) {
|
||||||
|
finalize(s.step);
|
||||||
|
cycleRound = post.round || 1;
|
||||||
|
cycleActive = new Set(post.activeIds || []);
|
||||||
|
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
|
||||||
|
cycleStarter = post.currentTurnParticipantId;
|
||||||
|
started = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isEnd) { started = false; continue; } // end: abandon cycle, no skip verdict (incomplete)
|
||||||
|
|
||||||
|
// CRITICAL: pointer (currentTurnParticipantId) changes via BOTH nextTurn
|
||||||
|
// AND mutation-advance (toggleActive/remove of current auto-advances via
|
||||||
|
// computeTurnOrderAfterRemoval). Any new current = got the turn = acted.
|
||||||
|
// Only count this on nextTurn (normal) or when a mutation actually moved
|
||||||
|
// the pointer (pre.current != post.current).
|
||||||
|
if (started && pre && post.currentTurnParticipantId &&
|
||||||
|
pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
|
||||||
|
const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
|
||||||
|
if (wrapped && isNextTurn) {
|
||||||
|
finalize(s.step);
|
||||||
|
cycleRound = post.round;
|
||||||
|
cycleActive = new Set(post.activeIds || []);
|
||||||
|
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
|
||||||
|
cycleStarter = post.currentTurnParticipantId;
|
||||||
|
} else {
|
||||||
|
const c = post.currentTurnParticipantId;
|
||||||
|
if (cycleActed.has(c) && c !== cycleStarter) {
|
||||||
|
out.push({ step: s.step, round: post.round, kind: 'double_act', actor: nm(c),
|
||||||
|
detail: `${nm(c)} acted twice in round ${post.round} (via ${s.fn})` });
|
||||||
|
}
|
||||||
|
cycleActed.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNextTurn) continue;
|
||||||
|
|
||||||
|
// roster mutation mid-cycle: cycleActive = cycle-start snapshot (immutable).
|
||||||
|
// invariant 4 = active FULL cycle → only removals disqualify (can't have
|
||||||
|
// been full-cycle if removed). Mid-cycle additions don't qualify for skip
|
||||||
|
// check, so never enroll them. Revivals: stay out (can act, not skip-flag).
|
||||||
|
if (pre && post && pre.activeIds && post.activeIds) {
|
||||||
|
const postSet = new Set(post.activeIds);
|
||||||
|
for (const id of [...cycleActive]) {
|
||||||
|
if (!postSet.has(id)) {
|
||||||
|
cycleRemoved.add(id);
|
||||||
|
cycleActive.delete(id);
|
||||||
|
cycleActed.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no mid-cycle enrollment: new ids weren't active at cycle start.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no final finalize: incomplete cycle can't be judged for skips.
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// roster/order-affecting fns where turnOrderIds change is EXPECTED.
|
||||||
|
// Handle both camelCase (replay trace fn) + snake_case (log type).
|
||||||
|
function orderChangedByRosterOrReorder(fn) {
|
||||||
|
return [
|
||||||
|
'addParticipant','addParticipants','removeParticipant','reorderParticipants',
|
||||||
|
'startEncounter','endEncounter','setup_encounter','setup_campaign',
|
||||||
|
'add_participant','add_participants','remove_participant','reorder',
|
||||||
|
'start_encounter','end_encounter',
|
||||||
|
].includes(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// slot order (invariant 6) — replay trace only (needs participants[].initiative)
|
||||||
|
function checkSlotOrder(steps) {
|
||||||
|
const violations = [];
|
||||||
|
let prevOrder = null; // [{id,init}]
|
||||||
|
let prevStep = 0;
|
||||||
|
const orderAffecting = new Set(['addParticipant','addParticipants','removeParticipant',
|
||||||
|
'reorderParticipants','startEncounter','setup_encounter','setup_campaign']);
|
||||||
|
for (const s of steps) {
|
||||||
|
if (!s.post || !Array.isArray(s.post.participants)) continue;
|
||||||
|
const cur = s.post.participants.map(p => ({ id: p.id, init: p.initiative, name: p.name }));
|
||||||
|
if (prevOrder && prevOrder.length === cur.length) {
|
||||||
|
const sameIds = prevOrder.every((p, i) => p.id === cur[i].id);
|
||||||
|
if (sameIds) {
|
||||||
|
// same roster, same order — check initiative monotonic desc with stable ties
|
||||||
|
for (let i = 1; i < cur.length; i++) {
|
||||||
|
if (cur[i].initiative > cur[i - 1].initiative) {
|
||||||
|
// initiative ascended — only ok if a reorder happened
|
||||||
|
if (!orderAffecting.has(s.fn)) {
|
||||||
|
violations.push({ step: s.step, kind: 'slot_violation',
|
||||||
|
at: i, prev: nm(cur[i-1].id)+':'+cur[i-1].init,
|
||||||
|
cur: nm(cur[i].id)+':'+cur[i].init,
|
||||||
|
detail: `initiative ascended without reorder` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevOrder = cur;
|
||||||
|
prevStep = s.step;
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- reporting ----------
|
||||||
|
|
||||||
|
function reportOne(label, steps) {
|
||||||
|
learnNames(steps);
|
||||||
|
const { issues, rounds } = analyze(steps);
|
||||||
|
const slotViolations = checkSlotOrder(steps);
|
||||||
|
const all = [...issues, ...slotViolations].sort((a, b) => (a.step || 0) - (b.step || 0));
|
||||||
|
const byKind = {};
|
||||||
|
for (const it of all) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
|
||||||
|
|
||||||
|
console.log(`=== ${label} — ${steps.length} steps, ${rounds.size} rounds ===`);
|
||||||
|
if (all.length === 0) {
|
||||||
|
console.log('CLEAN');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
console.log(`--- ${all.length} issues ---`);
|
||||||
|
for (const k of Object.keys(byKind)) console.log(` ${k}: ${byKind[k]}`);
|
||||||
|
for (const it of all.slice(0, 30)) {
|
||||||
|
const where = it.round != null ? `R${it.round} ` : '';
|
||||||
|
console.log(` step ${it.step} ${where}${it.kind}: ${it.detail || ''}`);
|
||||||
|
}
|
||||||
|
if (all.length > 30) console.log(` ... +${all.length - 30} more`);
|
||||||
|
return all.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = readInput();
|
||||||
|
const allSteps = loadSteps(text); // array of step-arrays (one per encounter)
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < allSteps.length; i++) {
|
||||||
|
const label = allSteps.length > 1 ? `[encounter ${i + 1}/${allSteps.length}]` : 'trace';
|
||||||
|
if (i > 0) console.log('');
|
||||||
|
total += reportOne(label, allSteps[i]);
|
||||||
|
}
|
||||||
|
console.log(`\n=== ${allSteps.length} source(s), ${total} total issues ===`);
|
||||||
|
process.exit(total === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/cap.sh — wrap a command with hard timeout. Hang = FAIL.
|
||||||
|
# Picks gtimeout (mac coreutils) or timeout (linux).
|
||||||
|
# Usage: cap.sh <seconds> <cmd> [args...]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TIMEOUT_BIN=gtimeout
|
||||||
|
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
|
||||||
|
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: need 'timeout' or 'gtimeout'" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
secs="$1"; shift
|
||||||
|
exec "$TIMEOUT_BIN" --signal=KILL "$secs" "$@"
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// scripts/combat.js
|
||||||
|
// ONE tool. Replaces replay-combat.js + analyze-turns.js + replay-from-logs.js.
|
||||||
|
//
|
||||||
|
// Two modes (same binary):
|
||||||
|
//
|
||||||
|
// combat replay — drive a fresh combat through LIVE backend, write log
|
||||||
|
// combat verify — read any log file, check combat rotated correctly
|
||||||
|
//
|
||||||
|
// WHY ONE TOOL:
|
||||||
|
// Same data, same words. Old 3-tool split = format + naming nightmare.
|
||||||
|
// Replay writes the log, verify reads it. Same log shape both ends.
|
||||||
|
//
|
||||||
|
// LOG FORMAT:
|
||||||
|
// JSON array. Same as app download. One mental model. NOT jsonl.
|
||||||
|
// Each entry = canonical lean event (shared/logEvent.js shape):
|
||||||
|
// { id, ts, type, message, encounterId, encounterName, encounterPath,
|
||||||
|
// participantId, participantName, delta, undo, undone, snapshot }
|
||||||
|
// snapshot = { round, currentTurnParticipantId, turnOrderIds, activeIds }
|
||||||
|
//
|
||||||
|
// USAGE:
|
||||||
|
// combat replay [rounds] [delayMs] --out <log.json> [-v]
|
||||||
|
// combat verify <log.json>
|
||||||
|
// cat events.json | combat verify
|
||||||
|
//
|
||||||
|
// EXIT:
|
||||||
|
// replay: 0 success, 1 error, 2 bad args
|
||||||
|
// verify: 0 all checks pass, 1 bugs found, 2 bad args/no input
|
||||||
|
//
|
||||||
|
// WHAT VERIFY CHECKS (in DM words, not internals):
|
||||||
|
// - Round count go up correctly (no skip, no jump, no backward)
|
||||||
|
// - Turn pass to right person each step
|
||||||
|
// - Nobody act twice in same round
|
||||||
|
// - Nobody get skipped who was active whole round
|
||||||
|
// - Turn order stay stable (no random reshuffle)
|
||||||
|
// - Initiative slot order hold (replay logs only — need full roster)
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const shared = require('../shared');
|
||||||
|
const { normalizeEvent, serializeEvents } = shared.logEvent;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ARGS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const mode = argv[0];
|
||||||
|
const rest = argv.slice(1);
|
||||||
|
if (mode !== 'replay' && mode !== 'verify') return { mode: null };
|
||||||
|
|
||||||
|
const out = { mode, verbose: false, out: null, positional: [] };
|
||||||
|
for (let i = 0; i < rest.length; i++) {
|
||||||
|
const a = rest[i];
|
||||||
|
if (a === '-v' || a === '--verbose') { out.verbose = true; continue; }
|
||||||
|
if (a === '--out' && rest[i + 1]) { out.out = rest[i + 1]; i++; continue; }
|
||||||
|
out.positional.push(a);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageReplay() {
|
||||||
|
console.error('Usage: combat replay [rounds] [delayMs] --out <log.json> [-v]');
|
||||||
|
console.error(' --out <log.json> REQUIRED. Path you control.');
|
||||||
|
console.error(' rounds default 20, delayMs default 200');
|
||||||
|
console.error(' -v / --verbose log every action per turn');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageVerify() {
|
||||||
|
console.error('Usage: combat verify <log.json>');
|
||||||
|
console.error(' cat events.json | combat verify');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (!args.mode) {
|
||||||
|
console.error('combat — unified replay + verify for the TTRPG combat tracker');
|
||||||
|
console.error('');
|
||||||
|
console.error('Usage:');
|
||||||
|
console.error(' combat replay [rounds] [delayMs] --out <log.json> [-v]');
|
||||||
|
console.error(' combat verify <log.json>');
|
||||||
|
console.error(' cat events.json | combat verify');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REPLAY MODE
|
||||||
|
// =============================================================================
|
||||||
|
// Drive fresh combat through live backend. Each mutation = one lean log entry
|
||||||
|
// written to --out path. DM-facing stdout: round headers + per-turn actions.
|
||||||
|
// Same log shape as app download. verify reads it back.
|
||||||
|
|
||||||
|
if (args.mode === 'replay') {
|
||||||
|
if (!args.out) usageReplay();
|
||||||
|
require('./combat/replay.js')(args).catch(err => {
|
||||||
|
console.error('replay failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// VERIFY MODE
|
||||||
|
// =============================================================================
|
||||||
|
// Read a combat log (app download OR combat replay output). Check rotation
|
||||||
|
// correctness. DM-facing output: combat name, rounds, per-round turn list,
|
||||||
|
// checks pass/fail. No "mutation", "pointer", "invariant" in output.
|
||||||
|
|
||||||
|
if (args.mode === 'verify') {
|
||||||
|
const logPath = args.positional[0];
|
||||||
|
let text;
|
||||||
|
if (logPath) {
|
||||||
|
if (!fs.existsSync(logPath)) {
|
||||||
|
console.error(`combat verify: file not found: ${logPath}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
text = fs.readFileSync(logPath, 'utf8');
|
||||||
|
} else if (!process.stdin.isTTY) {
|
||||||
|
text = fs.readFileSync(0, 'utf8');
|
||||||
|
}
|
||||||
|
if (!text || !text.trim()) usageVerify();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = require('./combat/verify.js')(text, { verbose: args.verbose });
|
||||||
|
process.exit(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('verify failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// scripts/combat/replay.js
|
||||||
|
// Drive fresh combat through LIVE backend. Write lean log to --out path.
|
||||||
|
// Same log shape as app download (JSON array of canonical events).
|
||||||
|
//
|
||||||
|
// DM-facing stdout: round headers + per-turn actions. No internals leak.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const shared = require('../../shared');
|
||||||
|
const { normalizeEvent } = shared.logEvent;
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause, endEncounter,
|
||||||
|
addParticipant, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, applyHpChange, deathSave,
|
||||||
|
stabilizeParticipant, reviveParticipant,
|
||||||
|
toggleCondition, reorderParticipants,
|
||||||
|
} = shared;
|
||||||
|
const { createServerStorage } = require('../../src/storage/server');
|
||||||
|
|
||||||
|
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||||
|
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||||
|
|
||||||
|
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||||
|
const PUB = `artifacts/${APP_ID}/public/data`;
|
||||||
|
const getPath = {
|
||||||
|
campaigns: () => `${PUB}/campaigns`,
|
||||||
|
campaign: (id) => `${PUB}/campaigns/${id}`,
|
||||||
|
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
|
||||||
|
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
|
||||||
|
activeDisplay: () => `${PUB}/activeDisplay/status`,
|
||||||
|
logs: () => `${PUB}/logs`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
|
function buildRoster() {
|
||||||
|
return [
|
||||||
|
{ id: 'char-fighter', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||||
|
{ id: 'char-cleric', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||||
|
{ id: 'char-rogue', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function buildMonsters() {
|
||||||
|
return [
|
||||||
|
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
|
||||||
|
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
|
||||||
|
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
|
||||||
|
{ name: 'Wolf', maxHp: 40, initMod: 3 },
|
||||||
|
{ name: 'Merchant', maxHp: 30, initMod: 0, asNpc: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONDITIONS = [
|
||||||
|
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||||
|
'frightened','grappled','incapacitated','invisible','paralyzed',
|
||||||
|
'petrified','poisoned','prone','restrained','sapped','shield',
|
||||||
|
'slowed','stunned','unconscious','vexed',
|
||||||
|
];
|
||||||
|
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
|
||||||
|
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
|
||||||
|
let condIdx = 0;
|
||||||
|
|
||||||
|
// Lean snapshot — verify needs rotation fields only.
|
||||||
|
function snapshot(enc) {
|
||||||
|
if (!enc) return null;
|
||||||
|
return {
|
||||||
|
round: enc.round ?? 0,
|
||||||
|
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||||
|
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||||
|
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameOf(enc, id) {
|
||||||
|
if (!id || !enc) return '(none)';
|
||||||
|
const p = (enc.participants || []).find(x => x.id === id);
|
||||||
|
return p ? p.name : '(missing)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve participantId + name from call args + encounter state
|
||||||
|
let _encCache = null;
|
||||||
|
function resolveId(a) {
|
||||||
|
if (!a) return null;
|
||||||
|
const name = a.target || a.participant || a.dragged || a.name;
|
||||||
|
if (!name || !_encCache) return null;
|
||||||
|
const p = (_encCache.participants || []).find(x => x.name === name);
|
||||||
|
return p ? p.id : null;
|
||||||
|
}
|
||||||
|
function resolveName(a) {
|
||||||
|
if (!a) return null;
|
||||||
|
return a.target || a.participant || a.dragged || a.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = async function replay(args) {
|
||||||
|
// In pipelines, Ctrl-C often kills downstream (e.g. timestamper) first.
|
||||||
|
// Then any later console.log hits EPIPE and process dies before cleanup.
|
||||||
|
// Ignore broken output pipe so SIGINT cleanup can still end combat + verify.
|
||||||
|
for (const s of [process.stdout, process.stderr]) {
|
||||||
|
s.on('error', (err) => {
|
||||||
|
if (err && err.code === 'EPIPE') return;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUNDS = parseInt(args.positional[0], 10);
|
||||||
|
const DELAY = parseInt(args.positional[1], 10);
|
||||||
|
const rounds = Number.isNaN(ROUNDS) ? 20 : ROUNDS;
|
||||||
|
const delay = Number.isNaN(DELAY) ? 200 : DELAY;
|
||||||
|
// Safety only. Roster grows via reinforcements, so fixed rounds*30 was too low
|
||||||
|
// and killed long replays around 6000 events. Keep generous hard stop.
|
||||||
|
const MAX_STEPS = Math.max(rounds * 250, 10000);
|
||||||
|
const VERBOSE = args.verbose;
|
||||||
|
|
||||||
|
const runStamp = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-');
|
||||||
|
const campaignId = crypto.randomUUID();
|
||||||
|
const encounterId = crypto.randomUUID();
|
||||||
|
const encounterPath = getPath.encounter(campaignId, encounterId);
|
||||||
|
const activeDisplayPath = getPath.activeDisplay();
|
||||||
|
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
|
||||||
|
|
||||||
|
// ensure parent dir exists
|
||||||
|
const traceDir = path.dirname(args.out);
|
||||||
|
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
||||||
|
|
||||||
|
const events = []; // collected lean events, flushed to --out at end
|
||||||
|
let stepN = 0;
|
||||||
|
let prevEnc = null;
|
||||||
|
let interrupted = false;
|
||||||
|
let finishing = false;
|
||||||
|
const onSigint = () => {
|
||||||
|
interrupted = true;
|
||||||
|
console.error('\ninterrupted — ending combat and verifying partial log...');
|
||||||
|
finishAndExit().catch(err => {
|
||||||
|
console.error('interrupt cleanup failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
process.once('SIGINT', onSigint);
|
||||||
|
|
||||||
|
// callStep: trust func return (post), no per-step getDoc. Build lean event
|
||||||
|
// shape = same as app download. Pre/post snapshot = ground truth.
|
||||||
|
async function callStep(fn, argsObj, runner) {
|
||||||
|
stepN++;
|
||||||
|
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
|
||||||
|
const preSnap = snapshot(encBefore);
|
||||||
|
let result, threw = null;
|
||||||
|
try { result = await runner(encBefore); }
|
||||||
|
catch (e) { threw = e.message; }
|
||||||
|
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
|
||||||
|
prevEnc = encAfter;
|
||||||
|
_encCache = encAfter;
|
||||||
|
const postSnap = snapshot(encAfter);
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
ts: Date.now(),
|
||||||
|
type: fn,
|
||||||
|
message: describe(fn, argsObj, encAfter),
|
||||||
|
encounterId,
|
||||||
|
encounterName: `Replay ${runStamp}`,
|
||||||
|
encounterPath,
|
||||||
|
participantId: resolveId(argsObj),
|
||||||
|
participantName: resolveName(argsObj),
|
||||||
|
delta: null,
|
||||||
|
undo: null,
|
||||||
|
undone: false,
|
||||||
|
snapshot: postSnap,
|
||||||
|
_pre: preSnap,
|
||||||
|
_call: { fn, args: argsObj },
|
||||||
|
_error: threw,
|
||||||
|
});
|
||||||
|
return { enc: encAfter, result, threw };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishAndExit() {
|
||||||
|
if (finishing) return;
|
||||||
|
finishing = true;
|
||||||
|
process.removeListener('SIGINT', onSigint);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// end encounter, even on Ctrl-C, so live display/app is not left mid-combat
|
||||||
|
const latest = await storage.getDoc(encounterPath);
|
||||||
|
prevEnc = latest;
|
||||||
|
if (latest && latest.isStarted) {
|
||||||
|
await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
|
||||||
|
}
|
||||||
|
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('cleanup warning:', err.message || err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = interrupted ? console.error : console.log;
|
||||||
|
const clean = events.map(({ _pre, _call, _error, ...rest }) => rest);
|
||||||
|
fs.writeFileSync(args.out, JSON.stringify(clean, null, 2));
|
||||||
|
log(`log written: ${events.length} events -> ${args.out}`);
|
||||||
|
log('');
|
||||||
|
|
||||||
|
log('--- verifying ---');
|
||||||
|
const text = fs.readFileSync(args.out, 'utf8');
|
||||||
|
const verify = require('./verify.js');
|
||||||
|
const exit = verify(text, { verbose: VERBOSE, log });
|
||||||
|
process.exit(exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// human-readable message per action
|
||||||
|
function describe(fn, a, enc) {
|
||||||
|
switch (fn) {
|
||||||
|
case 'setup_campaign': return 'Campaign created';
|
||||||
|
case 'setup_encounter': return 'Encounter created';
|
||||||
|
case 'addParticipant': return `${a.name} joined`;
|
||||||
|
case 'startEncounter': return `Combat started — round 1, ${nameOf(enc, enc.currentTurnParticipantId)} first`;
|
||||||
|
case 'nextTurn': return `Turn passed to ${nameOf(enc, enc.currentTurnParticipantId)}`;
|
||||||
|
case 'applyHpChange':
|
||||||
|
return a.changeType === 'heal'
|
||||||
|
? `${a.target} healed +${a.amount}`
|
||||||
|
: `${a.target} took ${a.amount} damage`;
|
||||||
|
case 'toggleCondition': return `${a.participant} ${a.condition} toggled`;
|
||||||
|
case 'updateParticipant': return `${a.participant} edited`;
|
||||||
|
case 'deathSave': return `${a.participant} death save (${a.outcome})`;
|
||||||
|
case 'toggleParticipantActive':
|
||||||
|
return a.revive ? `${a.participant} reactivated` : `${a.participant} toggled`;
|
||||||
|
case 'togglePause': return a.to === 'paused' ? 'Combat paused' : 'Combat resumed';
|
||||||
|
case 'removeParticipant': return `${a.participant} removed`;
|
||||||
|
case 'reorderParticipants': return `${a.dragged} moved before ${a.target}`;
|
||||||
|
case 'endEncounter': return 'Combat ended';
|
||||||
|
default: return fn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`replay: ${rounds} rounds, ${delay}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
|
||||||
|
|
||||||
|
// --- setup ---
|
||||||
|
await callStep('setup_campaign', { campaignId }, async () =>
|
||||||
|
storage.setDoc(getPath.campaign(campaignId), {
|
||||||
|
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
|
||||||
|
players: buildRoster(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await callStep('setup_encounter', { encounterId }, async () =>
|
||||||
|
storage.setDoc(encounterPath, {
|
||||||
|
id: encounterId, name: `Replay ${runStamp}`, campaignId,
|
||||||
|
participants: [], isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const ch of buildRoster()) {
|
||||||
|
const { participant } = buildCharacterParticipant(ch);
|
||||||
|
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||||
|
addParticipant(enc, participant, ctx));
|
||||||
|
}
|
||||||
|
for (const m of buildMonsters()) {
|
||||||
|
const { participant } = buildMonsterParticipant(m);
|
||||||
|
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||||
|
addParticipant(enc, participant, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- start combat ---
|
||||||
|
let enc = await storage.getDoc(encounterPath);
|
||||||
|
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
|
||||||
|
enc = startRes.enc;
|
||||||
|
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
|
||||||
|
console.log(`--- round ${enc.round} ---`);
|
||||||
|
|
||||||
|
let totalTurns = 0;
|
||||||
|
let lastRound = enc.round;
|
||||||
|
|
||||||
|
// --- main loop: drive by real enc.round ---
|
||||||
|
while (!interrupted && enc.isStarted && enc.round <= rounds && stepN < MAX_STEPS) {
|
||||||
|
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
|
||||||
|
totalTurns++;
|
||||||
|
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
|
||||||
|
|
||||||
|
if (actor) {
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||||
|
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
|
||||||
|
enc = (await callStep('applyHpChange',
|
||||||
|
{ target: tgt.name, changeType: 'damage', amount: dmg },
|
||||||
|
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx))).enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTurns % 5 === 0) {
|
||||||
|
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
|
||||||
|
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
|
||||||
|
enc = (await callStep('toggleCondition',
|
||||||
|
{ participant: actor.name, condition: cond },
|
||||||
|
(e) => toggleCondition(e, actor.id, cond, ctx))).enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTurns % 11 === 0) {
|
||||||
|
if (VERBOSE) console.log(` update ${actor.name} notes`);
|
||||||
|
enc = (await callStep('updateParticipant',
|
||||||
|
{ participant: actor.name, fields: ['notes'] },
|
||||||
|
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor.status === 'dying' && (actor.type === 'character' || actor.type === 'npc')) {
|
||||||
|
const outcomes = ['success', 'fail', 'nat1', 'nat20'];
|
||||||
|
const outcome = outcomes[totalTurns % outcomes.length];
|
||||||
|
if (VERBOSE) console.log(` deathSave ${actor.name} ${outcome}`);
|
||||||
|
const dsRes = await callStep('deathSave',
|
||||||
|
{ participant: actor.name, outcome },
|
||||||
|
async (e) => (await deathSave(e, actor.id, outcome, ctx)).enc);
|
||||||
|
enc = dsRes.enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTurns % 9 === 0) {
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
|
||||||
|
enc = (await callStep('toggleParticipantActive',
|
||||||
|
{ participant: tgt.name },
|
||||||
|
(e) => toggleParticipantActive(e, tgt.id, ctx))).enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTurns % 20 === 0 && enc.isPaused === false) {
|
||||||
|
if (VERBOSE) console.log(` reinforcements: pause`);
|
||||||
|
enc = (await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx))).enc;
|
||||||
|
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
|
||||||
|
if (VERBOSE) console.log(` add ${r.participant.name}`);
|
||||||
|
enc = (await callStep('addParticipant',
|
||||||
|
{ name: r.participant.name, reinforcement: true },
|
||||||
|
(e) => addParticipant(e, r.participant, ctx))).enc;
|
||||||
|
if (VERBOSE) console.log(` resume`);
|
||||||
|
enc = (await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx))).enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTurns % 13 === 0) {
|
||||||
|
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
|
||||||
|
if (dead) {
|
||||||
|
if (VERBOSE) console.log(` remove ${dead.name}`);
|
||||||
|
enc = (await callStep('removeParticipant',
|
||||||
|
{ participant: dead.name, dead: true },
|
||||||
|
(e) => removeParticipant(e, dead.id, ctx))).enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTurns % 17 === 0) {
|
||||||
|
const order = enc.participants || [];
|
||||||
|
if (order.length >= 2) {
|
||||||
|
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
|
||||||
|
if (b) {
|
||||||
|
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
|
||||||
|
enc = (await callStep('reorderParticipants',
|
||||||
|
{ dragged: b.name, target: a.name },
|
||||||
|
(e) => reorderParticipants(e, b.id, a.id, ctx))).enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interrupted) break;
|
||||||
|
if (delay > 0) await sleep(delay);
|
||||||
|
if (interrupted) break;
|
||||||
|
|
||||||
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||||
|
if (VERBOSE) console.log(` -> nextTurn`);
|
||||||
|
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
|
||||||
|
enc = advRes.enc;
|
||||||
|
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
|
||||||
|
|
||||||
|
// round wrap
|
||||||
|
if (enc.isStarted && enc.round > lastRound) {
|
||||||
|
if (enc.round > rounds) {
|
||||||
|
// cap hit: the nextTurn that wrapped us here is a phantom — remove it
|
||||||
|
// so verify doesn't show an incomplete round.
|
||||||
|
events.pop();
|
||||||
|
stepN--;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
console.log(`--- round ${enc.round} ---`);
|
||||||
|
lastRound = enc.round;
|
||||||
|
const down = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||||
|
for (const d of down) {
|
||||||
|
if (interrupted) break;
|
||||||
|
if (d.status === 'dead') {
|
||||||
|
if (VERBOSE) console.log(` revive dead ${d.name}`);
|
||||||
|
enc = (await callStep('reviveParticipant',
|
||||||
|
{ participant: d.name, revive: true },
|
||||||
|
(e) => reviveParticipant(e, d.id, ctx))).enc;
|
||||||
|
}
|
||||||
|
if (d.status === 'stable') {
|
||||||
|
if (VERBOSE) console.log(` heal stable ${d.name} +${d.maxHp}`);
|
||||||
|
enc = (await callStep('applyHpChange',
|
||||||
|
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
|
||||||
|
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx))).enc;
|
||||||
|
}
|
||||||
|
const latest = (enc.participants || []).find(p => p.id === d.id);
|
||||||
|
if (latest && latest.isActive === false) {
|
||||||
|
if (VERBOSE) console.log(` reactivate ${latest.name}`);
|
||||||
|
enc = (await callStep('toggleParticipantActive',
|
||||||
|
{ participant: latest.name, revive: true },
|
||||||
|
(e) => toggleParticipantActive(e, latest.id, ctx))).enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`replay: ${totalTurns} turns, reached round ${enc.round} (cap ${rounds})`);
|
||||||
|
|
||||||
|
await finishAndExit();
|
||||||
|
};
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
// scripts/combat/verify.js
|
||||||
|
// Read combat log (JSON array of lean events). Check rotation correctness.
|
||||||
|
// DM-facing output: combat name, rounds, per-round turn list, checks pass/fail.
|
||||||
|
//
|
||||||
|
// No internal jargon in output. "Someone acted twice", "someone skipped",
|
||||||
|
// "turn passed wrong way" — not "double_act", "real_skip", "wrong_advance".
|
||||||
|
//
|
||||||
|
// CHECKS (rules combat must obey):
|
||||||
|
// 1. Round count go up correctly (no skip/jump/backward)
|
||||||
|
// 2. Turn pass to right person each step
|
||||||
|
// 3. Nobody act twice in same round
|
||||||
|
// 4. Nobody get skipped who was active whole round
|
||||||
|
// 5. Turn order stay stable (no random reshuffle)
|
||||||
|
// 6. Initiative slot order hold (replay logs only — needs full roster)
|
||||||
|
//
|
||||||
|
// Return: 0 clean, 1 bugs found.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { normalizeEvent } = require('../../shared/logEvent');
|
||||||
|
|
||||||
|
// snake_case log type → camelCase fn (checks match both shapes)
|
||||||
|
function normalizeFn(fn) {
|
||||||
|
if (!fn) return fn;
|
||||||
|
if (!fn.includes('_')) return fn;
|
||||||
|
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse text → array of step-arrays (one per combat run).
|
||||||
|
// Input: JSON array of lean events (app download OR combat replay output).
|
||||||
|
// Split: by encounterId, then sub-split on start_encounter (restart boundary).
|
||||||
|
// First start after setup stays with setup (not bogus "encounter 1").
|
||||||
|
function loadSteps(text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
const raw = JSON.parse(trimmed);
|
||||||
|
const arr = Array.isArray(raw) ? raw : [raw];
|
||||||
|
const groups = new Map();
|
||||||
|
for (const e of arr) {
|
||||||
|
const key = e.encounterId || '_none_';
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key).push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSteps = (evs) => evs.map((e, i) => ({
|
||||||
|
step: i + 1, ts: e.ts || 0, type: e.type,
|
||||||
|
fn: normalizeFn(e.type),
|
||||||
|
args: null,
|
||||||
|
pre: null,
|
||||||
|
post: e.snapshot ? {
|
||||||
|
round: e.snapshot.round,
|
||||||
|
currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
|
||||||
|
isStarted: true, isPaused: false,
|
||||||
|
turnOrderIds: e.snapshot.turnOrderIds || [],
|
||||||
|
activeIds: e.snapshot.activeIds || [],
|
||||||
|
participants: null,
|
||||||
|
} : null,
|
||||||
|
message: e.message || '',
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const evs of groups.values()) {
|
||||||
|
let cur = [];
|
||||||
|
let started = false;
|
||||||
|
const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
|
||||||
|
for (const e of evs) {
|
||||||
|
if (e.type === 'start_encounter' && started) flush();
|
||||||
|
if (e.type === 'start_encounter') started = true;
|
||||||
|
cur.push(e);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- name map ----------
|
||||||
|
|
||||||
|
const nameMap = new Map();
|
||||||
|
function learnNames(steps) {
|
||||||
|
for (const s of steps) {
|
||||||
|
// snapshot has no roster; use participantName field on events + messages
|
||||||
|
if (s.message) {
|
||||||
|
// no-op; names come from post.currentTurnParticipantId via message ctx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback: ids unknown → short. (replay logs carry encounterName; app logs
|
||||||
|
// carry participantName. We use message text + the turnOrderIds as-is.)
|
||||||
|
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
|
||||||
|
|
||||||
|
// rebuild name map from raw events (participantName field)
|
||||||
|
function learnNamesFromEvents(evs) {
|
||||||
|
for (const e of evs) {
|
||||||
|
if (e.participantId && e.participantName) nameMap.set(e.participantId, e.participantName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- expected advance ----------
|
||||||
|
|
||||||
|
function expectedAdvance(order, fromPos, isActive) {
|
||||||
|
const n = order.length;
|
||||||
|
if (n === 0) return { nextId: null, wrapped: false };
|
||||||
|
for (let step = 1; step < n; step++) {
|
||||||
|
const idx = (fromPos + step) % n;
|
||||||
|
const id = order[idx];
|
||||||
|
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
|
||||||
|
}
|
||||||
|
return { nextId: null, wrapped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- checks ----------
|
||||||
|
|
||||||
|
function analyze(steps) {
|
||||||
|
const issues = [];
|
||||||
|
const rounds = new Map();
|
||||||
|
function ensureRound(r) {
|
||||||
|
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, actors: [] });
|
||||||
|
return rounds.get(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const s = steps[i];
|
||||||
|
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
|
||||||
|
const post = s.post;
|
||||||
|
if (!post) continue;
|
||||||
|
|
||||||
|
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
|
||||||
|
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
|
||||||
|
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter';
|
||||||
|
|
||||||
|
if (isStart) {
|
||||||
|
ensureRound(post.round || 1).actors.push(post.currentTurnParticipantId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isEnd) continue;
|
||||||
|
if (isNextTurn) {
|
||||||
|
const r = ensureRound(post.round || 0);
|
||||||
|
r.turnCount++;
|
||||||
|
r.actors.push(post.currentTurnParticipantId);
|
||||||
|
if (!pre) continue;
|
||||||
|
|
||||||
|
const order = pre.turnOrderIds || [];
|
||||||
|
const fromPos = order.indexOf(pre.currentTurnParticipantId);
|
||||||
|
const isActive = id => (pre.activeIds || []).includes(id);
|
||||||
|
const exp = expectedAdvance(order, fromPos, isActive);
|
||||||
|
const actual = post.currentTurnParticipantId;
|
||||||
|
|
||||||
|
if (exp.nextId && actual && actual !== exp.nextId) {
|
||||||
|
issues.push({ step: s.step, round: post.round, kind: 'wrong_turn',
|
||||||
|
detail: `Turn passed to ${nm(actual)}, should have been ${nm(exp.nextId)}` });
|
||||||
|
}
|
||||||
|
if (pre.round !== undefined && post.round !== undefined) {
|
||||||
|
if (post.round < pre.round)
|
||||||
|
issues.push({ step: s.step, kind: 'round_backward',
|
||||||
|
detail: `Round went ${pre.round} → ${post.round}` });
|
||||||
|
if (post.round > pre.round + 1)
|
||||||
|
issues.push({ step: s.step, kind: 'round_jump',
|
||||||
|
detail: `Round jumped ${pre.round} → ${post.round}` });
|
||||||
|
if (post.round === pre.round + 1 && !exp.wrapped)
|
||||||
|
issues.push({ step: s.step, kind: 'round_phantom',
|
||||||
|
detail: `Round went ${pre.round} → ${post.round} but turn didn't wrap` });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
|
||||||
|
const before = JSON.stringify(pre.turnOrderIds || []);
|
||||||
|
const after = JSON.stringify(post.turnOrderIds || []);
|
||||||
|
if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
|
||||||
|
issues.push({ step: s.step, kind: 'order_shift',
|
||||||
|
detail: `Turn order changed during ${s.fn}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.push(...checkCycles(steps, rounds));
|
||||||
|
return { issues, rounds };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCycles(steps, rounds) {
|
||||||
|
const out = [];
|
||||||
|
let cycleActive = new Set();
|
||||||
|
let cycleActed = new Set();
|
||||||
|
let cycleStarter = null;
|
||||||
|
let cycleRound = null;
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
function finalize(endStep) {
|
||||||
|
if (!started) return;
|
||||||
|
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
|
||||||
|
if (skipped.length) {
|
||||||
|
out.push({ step: endStep, round: cycleRound, kind: 'skipped',
|
||||||
|
detail: `Never acted in round ${cycleRound}: ${skipped.map(nm).join(', ')}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const s = steps[i];
|
||||||
|
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
|
||||||
|
const post = s.post;
|
||||||
|
if (!post) continue;
|
||||||
|
|
||||||
|
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
|
||||||
|
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
|
||||||
|
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter';
|
||||||
|
|
||||||
|
if (isStart) {
|
||||||
|
finalize(s.step);
|
||||||
|
cycleRound = post.round || 1;
|
||||||
|
cycleActive = new Set(post.activeIds || []);
|
||||||
|
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
|
||||||
|
cycleStarter = post.currentTurnParticipantId;
|
||||||
|
started = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isEnd) { started = false; continue; }
|
||||||
|
|
||||||
|
if (started && pre && post.currentTurnParticipantId &&
|
||||||
|
pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
|
||||||
|
const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
|
||||||
|
if (wrapped) {
|
||||||
|
// drain removed/inactive before finalizing so removed actors
|
||||||
|
// aren't flagged as skipped.
|
||||||
|
if (pre && pre.activeIds && post.activeIds) {
|
||||||
|
const postSet = new Set(post.activeIds);
|
||||||
|
for (const id of [...cycleActive]) {
|
||||||
|
if (!postSet.has(id)) {
|
||||||
|
cycleActive.delete(id);
|
||||||
|
cycleActed.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalize(s.step);
|
||||||
|
cycleRound = post.round;
|
||||||
|
cycleActive = new Set(post.activeIds || []);
|
||||||
|
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
|
||||||
|
cycleStarter = post.currentTurnParticipantId;
|
||||||
|
} else {
|
||||||
|
const c = post.currentTurnParticipantId;
|
||||||
|
if (cycleActed.has(c) && c !== cycleStarter) {
|
||||||
|
out.push({ step: s.step, round: post.round, kind: 'acted_twice',
|
||||||
|
detail: `${nm(c)} acted twice in round ${post.round}` });
|
||||||
|
}
|
||||||
|
cycleActed.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNextTurn) continue;
|
||||||
|
|
||||||
|
if (pre && post && pre.activeIds && post.activeIds) {
|
||||||
|
const postSet = new Set(post.activeIds);
|
||||||
|
for (const id of [...cycleActive]) {
|
||||||
|
if (!postSet.has(id)) {
|
||||||
|
cycleActive.delete(id);
|
||||||
|
cycleActed.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderChangedByRosterOrReorder(fn) {
|
||||||
|
return [
|
||||||
|
'addParticipant','addParticipants','removeParticipant','reorderParticipants',
|
||||||
|
'startEncounter','endEncounter','setup_encounter','setup_campaign',
|
||||||
|
'add_participant','add_participants','remove_participant','reorder',
|
||||||
|
'start_encounter','end_encounter',
|
||||||
|
].includes(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- reporting (DM-facing) ----------
|
||||||
|
|
||||||
|
const KIND_LABEL = {
|
||||||
|
wrong_turn: 'Turn passed to wrong person',
|
||||||
|
round_backward: 'Round count went backward',
|
||||||
|
round_jump: 'Round count jumped',
|
||||||
|
round_phantom: 'Round count changed without turn wrapping',
|
||||||
|
order_shift: 'Turn order changed unexpectedly',
|
||||||
|
skipped: 'Someone got skipped',
|
||||||
|
acted_twice: 'Someone acted twice',
|
||||||
|
slot_violation: 'Initiative order violated',
|
||||||
|
};
|
||||||
|
|
||||||
|
function reportOne(label, steps, rawEventsForNames, opts = {}) {
|
||||||
|
const log = opts.log || console.log;
|
||||||
|
if (rawEventsForNames) learnNamesFromEvents(rawEventsForNames);
|
||||||
|
const { issues, rounds } = analyze(steps);
|
||||||
|
|
||||||
|
// combat name
|
||||||
|
const name = (rawEventsForNames && rawEventsForNames[0] && rawEventsForNames[0].encounterName) || 'Combat';
|
||||||
|
log(`=== ${label}: ${name} ===`);
|
||||||
|
|
||||||
|
// Per-round turn list only in verbose mode. Long replays can print thousands
|
||||||
|
// of names and swamp useful result output.
|
||||||
|
const sortedRounds = [...rounds.keys()].sort((a, b) => a - b);
|
||||||
|
if (opts.verbose) {
|
||||||
|
for (const r of sortedRounds) {
|
||||||
|
const info = rounds.get(r);
|
||||||
|
log(` Round ${r}: ${info.actors.map(nm).join(' → ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(` (${steps.length} events, ${rounds.size} rounds${opts.verbose ? '' : ', use -v for per-round turns'})`);
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
log(' ✓ PASS — combat rotated correctly');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
log(` ✗ FAIL — ${issues.length} problem(s):`);
|
||||||
|
const byKind = {};
|
||||||
|
for (const it of issues) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
|
||||||
|
for (const [k, n] of Object.entries(byKind)) {
|
||||||
|
log(` ${KIND_LABEL[k] || k}: ${n}`);
|
||||||
|
}
|
||||||
|
for (const it of issues.slice(0, 15)) {
|
||||||
|
const where = it.round != null ? `R${it.round} ` : '';
|
||||||
|
log(` [${where}event ${it.step}] ${it.detail}`);
|
||||||
|
}
|
||||||
|
if (issues.length > 15) log(` ... +${issues.length - 15} more`);
|
||||||
|
return issues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function verify(text, opts = {}) {
|
||||||
|
const log = opts.log || console.log;
|
||||||
|
const allSteps = loadSteps(text);
|
||||||
|
if (!allSteps.length) {
|
||||||
|
log('No combat events found.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw events grouped same way for name learning
|
||||||
|
const raw = JSON.parse(text.trim());
|
||||||
|
const arr = Array.isArray(raw) ? raw : [raw];
|
||||||
|
const rawGroups = new Map();
|
||||||
|
for (const e of arr) {
|
||||||
|
const key = e.encounterId || '_none_';
|
||||||
|
if (!rawGroups.has(key)) rawGroups.set(key, []);
|
||||||
|
rawGroups.get(key).push(e);
|
||||||
|
}
|
||||||
|
const rawList = [...rawGroups.values()];
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < allSteps.length; i++) {
|
||||||
|
const label = allSteps.length > 1 ? `[combat ${i + 1}/${allSteps.length}]` : 'Combat';
|
||||||
|
if (i > 0) log('');
|
||||||
|
total += reportOne(label, allSteps[i], rawList[i], opts);
|
||||||
|
}
|
||||||
|
log('');
|
||||||
|
log(total === 0 ? 'CLEAN' : `${total} problem(s) found`);
|
||||||
|
return total === 0 ? 0 : 1;
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start local dev stack: node backend (sqlite) + react frontend, server storage mode.
|
||||||
|
# Usage: ./scripts/dev-start.sh
|
||||||
|
# Stop: ./scripts/dev-stop.sh
|
||||||
|
set -euo pipefail
|
||||||
|
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 ALLOW_DEV_ENDPOINTS=1 \
|
||||||
|
nohup npm run server:dev > tmp/server.log 2>&1 &
|
||||||
|
echo $! > tmp/server.pid
|
||||||
|
else
|
||||||
|
echo "backend already on :4001"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# frontend: server storage, :3999
|
||||||
|
if ! lsof -ti :3999 >/dev/null 2>&1; then
|
||||||
|
echo "starting frontend :3999..."
|
||||||
|
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 \
|
||||||
|
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"
|
||||||
@@ -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."
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// scripts/replay-combat.js
|
||||||
|
// Drive a full combat through the LIVE backend via the server storage adapter
|
||||||
|
// (same contract boundary as the App), so the player display window
|
||||||
|
// (subscribed via WS) live-updates as combat progresses.
|
||||||
|
// Uses shared/turn.js for all turn logic (same model as the UI).
|
||||||
|
//
|
||||||
|
// BLIND CALLER: this tool does NOT know the correct turn order. It hammers
|
||||||
|
// nextTurn + mutations and trusts turn.js. An SEPARATE analyze pass inspects
|
||||||
|
// what actually happened (invariants on backend read-back). Do not encode
|
||||||
|
// expected-order logic here — that's circular.
|
||||||
|
//
|
||||||
|
// DRIVEN BY REAL enc.round (backend state), not a fake loop counter. Round
|
||||||
|
// wraps when nextTurn's pointer advances last→first active.
|
||||||
|
//
|
||||||
|
// TRACE: writes JSONL to tmp/replay-{stamp}.jsonl — one record per call:
|
||||||
|
// { step, ts, type, call:{fn,args}, pre, post }
|
||||||
|
// pre/post = backend getDoc snapshots (independent of func return value).
|
||||||
|
// This is ground truth. analyze-turns.js consumes it. The func return value
|
||||||
|
// is "what turn.js claims"; read-back is "what the backend stored". Mismatch
|
||||||
|
// = write bug.
|
||||||
|
//
|
||||||
|
// Coverage (rotates by step counter):
|
||||||
|
// applyHpChange damage, toggleCondition, updateParticipant, deathSave,
|
||||||
|
// toggleParticipantActive, pause/add/resume reinforcements,
|
||||||
|
// removeParticipant dead, reorderParticipants same-init, nextTurn.
|
||||||
|
//
|
||||||
|
// Run: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v|--verbose]
|
||||||
|
// rounds default 20, delayMs default 200
|
||||||
|
// --out <path> = REQUIRED. Trace path you control.
|
||||||
|
// -v / --verbose = log every action per turn
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const shared = require('../shared');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause, endEncounter,
|
||||||
|
addParticipant, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, applyHpChange, deathSave,
|
||||||
|
toggleCondition, reorderParticipants,
|
||||||
|
} = shared;
|
||||||
|
const { createServerStorage } = require('../src/storage/server');
|
||||||
|
|
||||||
|
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
||||||
|
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||||
|
// args: [rounds] [delayMs] --out <path> [-v|--verbose]
|
||||||
|
// --out <path> REQUIRED — trace path. You control it, not the tool.
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const VERBOSE = args.some(a => a === '-v' || a === '--verbose');
|
||||||
|
let OUT_PATH = null;
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--out' && args[i + 1]) {
|
||||||
|
OUT_PATH = args[i + 1];
|
||||||
|
args.splice(i, 2);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!OUT_PATH) {
|
||||||
|
console.error('Usage: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v]');
|
||||||
|
console.error(' --out <path> REQUIRED.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const positional = args.filter(a => !a.startsWith('-'));
|
||||||
|
const ROUNDS = parseInt(positional[0], 10) || 20;
|
||||||
|
const DELAY = parseInt(positional[1], 10) || 200;
|
||||||
|
const MAX_STEPS = ROUNDS * 30; // safety cap on total calls
|
||||||
|
|
||||||
|
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||||
|
const PUB = `artifacts/${APP_ID}/public/data`;
|
||||||
|
const getPath = {
|
||||||
|
campaigns: () => `${PUB}/campaigns`,
|
||||||
|
campaign: (id) => `${PUB}/campaigns/${id}`,
|
||||||
|
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
|
||||||
|
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
|
||||||
|
activeDisplay: () => `${PUB}/activeDisplay/status`,
|
||||||
|
logs: () => `${PUB}/logs`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
|
function buildRoster() {
|
||||||
|
return [
|
||||||
|
{ name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
|
||||||
|
{ name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
|
||||||
|
{ name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function buildMonsters() {
|
||||||
|
return [
|
||||||
|
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
|
||||||
|
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
|
||||||
|
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
|
||||||
|
{ name: 'Wolf', maxHp: 40, initMod: 3 },
|
||||||
|
{ name: 'Merchant', maxHp: 30, initMod: 0, isNpc: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONDITIONS = [
|
||||||
|
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||||
|
'frightened','grappled','incapacitated','invisible','paralyzed',
|
||||||
|
'petrified','poisoned','prone','restrained','sapped','shield',
|
||||||
|
'slowed','stunned','unconscious','vexed',
|
||||||
|
];
|
||||||
|
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
|
||||||
|
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
|
||||||
|
let condIdx = 0;
|
||||||
|
|
||||||
|
// ---------- JSONL trace ----------
|
||||||
|
|
||||||
|
// Lean snapshot — analyzer needs rotation fields only, not roster.
|
||||||
|
// participants dropped (was 80% of trace bloat). turnOrderIds + activeIds
|
||||||
|
// carry id-level info; hp/conditions stay on encounter doc, not trace.
|
||||||
|
function snapshot(enc) {
|
||||||
|
if (!enc) return null;
|
||||||
|
return {
|
||||||
|
round: enc.round ?? 0,
|
||||||
|
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||||
|
isStarted: !!enc.isStarted,
|
||||||
|
isPaused: !!enc.isPaused,
|
||||||
|
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||||
|
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameOf(enc, id) {
|
||||||
|
if (!id || !enc) return '(none)';
|
||||||
|
const p = (enc.participants || []).find(x => x.id === id);
|
||||||
|
return p ? p.name : '(missing)';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const runStamp = new Date().toISOString().slice(0,19).replace('T', '_').replace(/:/g, '-');
|
||||||
|
const campaignId = crypto.randomUUID();
|
||||||
|
const encounterId = crypto.randomUUID();
|
||||||
|
const encounterPath = getPath.encounter(campaignId, encounterId);
|
||||||
|
const activeDisplayPath = getPath.activeDisplay();
|
||||||
|
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
|
||||||
|
|
||||||
|
let tracePath = OUT_PATH;
|
||||||
|
// ensure parent dir exists
|
||||||
|
const traceDir = path.dirname(tracePath);
|
||||||
|
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
||||||
|
const jsonl = fs.createWriteStream(tracePath, { flags: 'w' });
|
||||||
|
|
||||||
|
let stepN = 0;
|
||||||
|
let prevEnc = null; // cached post = next step's pre (no backend read-back).
|
||||||
|
// callStep: pre = cached prevEnc (or fresh read on first) → run → post =
|
||||||
|
// runner return (trusted, like old fast impl) → emit JSONL. No per-step
|
||||||
|
// backend getDoc. Funcs return newEnc already.
|
||||||
|
async function callStep(fn, args, runner) {
|
||||||
|
stepN++;
|
||||||
|
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
|
||||||
|
const pre = snapshot(encBefore);
|
||||||
|
let result, threw = null;
|
||||||
|
try { result = await runner(encBefore); }
|
||||||
|
catch (e) { threw = e.message; }
|
||||||
|
// func returns newEnc (post). Trust it — matches old fast behavior.
|
||||||
|
// Setup funcs (setDoc/getDoc) return undefined → keep encBefore.
|
||||||
|
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
|
||||||
|
prevEnc = encAfter;
|
||||||
|
const post = snapshot(encAfter);
|
||||||
|
jsonl.write(JSON.stringify({
|
||||||
|
step: stepN, ts: Date.now(), type: fn, call: { fn, args },
|
||||||
|
pre, post, error: threw,
|
||||||
|
}) + '\n');
|
||||||
|
return { enc: encAfter, result, threw };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
|
||||||
|
|
||||||
|
// --- setup: campaign + encounter docs (also traced, type 'setup') ---
|
||||||
|
await callStep('setup_campaign', { campaignId }, async () =>
|
||||||
|
storage.setDoc(getPath.campaign(campaignId), {
|
||||||
|
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await callStep('setup_encounter', { encounterId }, async () =>
|
||||||
|
storage.setDoc(encounterPath, {
|
||||||
|
id: encounterId,
|
||||||
|
name: `Big Boss Replay ${runStamp}`,
|
||||||
|
campaignId,
|
||||||
|
participants: [],
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// add roster + monsters
|
||||||
|
for (const ch of buildRoster()) {
|
||||||
|
const { participant } = buildCharacterParticipant(ch);
|
||||||
|
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||||
|
addParticipant(enc, participant, ctx)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const m of buildMonsters()) {
|
||||||
|
const { participant } = buildMonsterParticipant(m);
|
||||||
|
await callStep('addParticipant', { name: participant.name }, (enc) =>
|
||||||
|
addParticipant(enc, participant, ctx)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// start combat
|
||||||
|
let enc = (await storage.getDoc(encounterPath));
|
||||||
|
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
|
||||||
|
enc = startRes.enc;
|
||||||
|
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
|
||||||
|
console.log(`combat started: round ${enc.round}, first=${nameOf(enc, enc.currentTurnParticipantId)}`);
|
||||||
|
|
||||||
|
let totalTurns = 0;
|
||||||
|
let lastRound = enc.round;
|
||||||
|
|
||||||
|
// --- main loop: drive by real enc.round ---
|
||||||
|
while (enc.isStarted && enc.round <= ROUNDS && stepN < MAX_STEPS) {
|
||||||
|
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
|
||||||
|
totalTurns++;
|
||||||
|
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
|
||||||
|
|
||||||
|
if (actor) {
|
||||||
|
// random damage from actor to random living target
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
const dmg = 1 + Math.floor(Math.random() * 5);
|
||||||
|
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
|
||||||
|
const res = await callStep('applyHpChange',
|
||||||
|
{ target: tgt.name, changeType: 'damage', amount: dmg },
|
||||||
|
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// random condition
|
||||||
|
if (totalTurns % 5 === 0) {
|
||||||
|
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
|
||||||
|
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
|
||||||
|
const res = await callStep('toggleCondition',
|
||||||
|
{ participant: actor.name, condition: cond },
|
||||||
|
(e) => toggleCondition(e, actor.id, cond, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// random edit (notes/init unchanged)
|
||||||
|
if (totalTurns % 11 === 0) {
|
||||||
|
if (VERBOSE) console.log(` update ${actor.name} notes`);
|
||||||
|
const res = await callStep('updateParticipant',
|
||||||
|
{ participant: actor.name, fields: ['notes'] },
|
||||||
|
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// deathSave: PC at 0 HP
|
||||||
|
if (actor.currentHp <= 0 && !actor.isNpc) {
|
||||||
|
if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
|
||||||
|
const res = await callStep('deathSave',
|
||||||
|
{ participant: actor.name, type: 'success', n: 1 },
|
||||||
|
(e) => deathSave(e, actor.id, 'success', 1, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleActive every 9 turns
|
||||||
|
if (totalTurns % 9 === 0) {
|
||||||
|
const living = enc.participants.filter(p => p.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
|
||||||
|
const res = await callStep('toggleParticipantActive',
|
||||||
|
{ participant: tgt.name },
|
||||||
|
(e) => toggleParticipantActive(e, tgt.id, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reinforcements every 20 turns (pause/add/resume)
|
||||||
|
if (totalTurns % 20 === 0 && enc.isPaused === false) {
|
||||||
|
if (VERBOSE) console.log(` reinforcements: pause`);
|
||||||
|
let res = await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
|
||||||
|
if (VERBOSE) console.log(` add ${r.participant.name}`);
|
||||||
|
res = await callStep('addParticipant',
|
||||||
|
{ name: r.participant.name, reinforcement: true },
|
||||||
|
(e) => addParticipant(e, r.participant, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
if (VERBOSE) console.log(` resume`);
|
||||||
|
res = await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove dead monster every 13 turns
|
||||||
|
if (totalTurns % 13 === 0) {
|
||||||
|
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
|
||||||
|
if (dead) {
|
||||||
|
if (VERBOSE) console.log(` remove ${dead.name}`);
|
||||||
|
const res = await callStep('removeParticipant',
|
||||||
|
{ participant: dead.name, dead: true },
|
||||||
|
(e) => removeParticipant(e, dead.id, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drag same-init every 17 turns
|
||||||
|
if (totalTurns % 17 === 0) {
|
||||||
|
const order = enc.participants || [];
|
||||||
|
if (order.length >= 2) {
|
||||||
|
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
|
||||||
|
if (b) {
|
||||||
|
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
|
||||||
|
const res = await callStep('reorderParticipants',
|
||||||
|
{ dragged: b.name, target: a.name },
|
||||||
|
(e) => reorderParticipants(e, b.id, a.id, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DELAY > 0) await sleep(DELAY);
|
||||||
|
|
||||||
|
// advance turn (unless combat auto-ended)
|
||||||
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||||
|
if (VERBOSE) console.log(` -> nextTurn`);
|
||||||
|
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
|
||||||
|
enc = advRes.enc;
|
||||||
|
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
|
||||||
|
|
||||||
|
// round wrap: revive dead so combat sustains to full ROUNDS count
|
||||||
|
if (enc.isStarted && enc.round > lastRound) {
|
||||||
|
if (enc.round > ROUNDS) break; // round cap reached, stop before printing/acting
|
||||||
|
console.log(`--- round ${enc.round} ---`);
|
||||||
|
lastRound = enc.round;
|
||||||
|
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
|
||||||
|
for (const d of dead) {
|
||||||
|
if (d.isActive === false) {
|
||||||
|
if (VERBOSE) console.log(` revive ${d.name}`);
|
||||||
|
const res = await callStep('toggleParticipantActive',
|
||||||
|
{ participant: d.name, revive: true },
|
||||||
|
(e) => toggleParticipantActive(e, d.id, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
if (VERBOSE) console.log(` heal ${d.name} +${d.maxHp}`);
|
||||||
|
const res = await callStep('applyHpChange',
|
||||||
|
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
|
||||||
|
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`replay: ${totalTurns} actor-turns, reached round ${enc.round} (cap ${ROUNDS})`);
|
||||||
|
|
||||||
|
// end encounter
|
||||||
|
enc = await storage.getDoc(encounterPath);
|
||||||
|
if (enc.isStarted) {
|
||||||
|
const res = await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
|
||||||
|
enc = res.enc;
|
||||||
|
}
|
||||||
|
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
|
||||||
|
|
||||||
|
jsonl.end();
|
||||||
|
await new Promise(r => jsonl.close(r));
|
||||||
|
console.log(`trace written: ${stepN} steps -> ${tracePath}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('>>> ANALYZE: node scripts/analyze-turns.js "' + tracePath + '"');
|
||||||
|
console.log('replay done');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error('replay failed:', err); process.exit(1); });
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// scripts/replay-from-logs.js
|
||||||
|
// Ingest canonical event JSON (downloaded app logs OR replay-combat --json-out)
|
||||||
|
// and replay forward patches in order. Verifies each event's snapshot matches
|
||||||
|
// the reconstructed encounter state — catches drift between logged intent and
|
||||||
|
// actual mutation.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/replay-from-logs.js <events.json>
|
||||||
|
// node scripts/replay-from-logs.js <events.json> --encounter <id> # filter
|
||||||
|
// cat logs.json | node scripts/replay-from-logs.js # stdin
|
||||||
|
//
|
||||||
|
// Modes:
|
||||||
|
// (default) in-memory replay + snapshot verification. No backend writes.
|
||||||
|
// --write apply each patch to LIVE backend (same adapter as replay-combat).
|
||||||
|
// Creates fresh encounter under new campaign. Useful for cloning
|
||||||
|
// a logged combat into a clean DB.
|
||||||
|
//
|
||||||
|
// Output: turn sequence, round progression, drift report (any snapshot mismatch).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const { normalizeEvent } = require('../shared/logEvent');
|
||||||
|
|
||||||
|
const WRITE_MODE = process.argv.includes('--write');
|
||||||
|
const ENC_FILTER = (() => {
|
||||||
|
const i = process.argv.indexOf('--encounter');
|
||||||
|
return i >= 0 ? process.argv[i + 1] : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function readInput() {
|
||||||
|
// positional .json arg, else stdin.
|
||||||
|
const pos = process.argv[2];
|
||||||
|
if (pos && !pos.startsWith('--')) return fs.readFileSync(pos, 'utf8');
|
||||||
|
// stdin: require piped data (not interactive terminal). Avoid hang.
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
console.error('Usage: node scripts/replay-from-logs.js <events.json> [--encounter <id>] [--write]');
|
||||||
|
console.error(' cat events.json | node scripts/replay-from-logs.js');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const data = fs.readFileSync(0, 'utf8');
|
||||||
|
if (!data.trim()) {
|
||||||
|
console.error('No input on stdin and no file arg.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shallow merge patch into encounter (mirrors storage updateDoc semantics).
|
||||||
|
function applyPatch(enc, patch) {
|
||||||
|
if (!patch) return enc;
|
||||||
|
const next = { ...enc };
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
next[k] = v;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare logged snapshot vs computed state. Returns list of field mismatches.
|
||||||
|
function diffSnapshot(enc, snap) {
|
||||||
|
if (!snap) return [];
|
||||||
|
const drift = [];
|
||||||
|
const curRound = enc.round ?? 0;
|
||||||
|
const curTurn = enc.currentTurnParticipantId ?? null;
|
||||||
|
const order = enc.turnOrderIds || [];
|
||||||
|
const active = (enc.participants || []).filter(p => p.isActive).map(p => p.id);
|
||||||
|
if (snap.round !== curRound) drift.push(`round: logged=${snap.round} actual=${curRound}`);
|
||||||
|
if (snap.currentTurnParticipantId !== curTurn) drift.push(`currentTurn: logged=${snap.currentTurnParticipantId} actual=${curTurn}`);
|
||||||
|
if (JSON.stringify(snap.turnOrderIds || []) !== JSON.stringify(order)) drift.push(`turnOrderIds: logged=${JSON.stringify(snap.turnOrderIds || [])} actual=${JSON.stringify(order)}`);
|
||||||
|
if (JSON.stringify(snap.activeIds || []) !== JSON.stringify(active)) drift.push(`activeIds: logged=${JSON.stringify(snap.activeIds || [])} actual=${JSON.stringify(active)}`);
|
||||||
|
return drift;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameMap(enc) {
|
||||||
|
const m = new Map();
|
||||||
|
for (const p of (enc.participants || [])) if (p.id && p.name) m.set(p.id, p.name);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const text = readInput().trim();
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
const arr = (Array.isArray(raw) ? raw : [raw]).map(normalizeEvent).filter(Boolean);
|
||||||
|
const events = ENC_FILTER ? arr.filter(e => e.encounterId === ENC_FILTER) : arr;
|
||||||
|
|
||||||
|
console.log(`replay-from-logs: ${events.length} events${ENC_FILTER ? ` (encounter ${ENC_FILTER})` : ''}`);
|
||||||
|
|
||||||
|
let enc = null;
|
||||||
|
let encName = null;
|
||||||
|
let encPath = null;
|
||||||
|
let round = 0;
|
||||||
|
let turnCount = 0;
|
||||||
|
let appliedCount = 0;
|
||||||
|
const drift = [];
|
||||||
|
const turnSequence = [];
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
// init on first event
|
||||||
|
if (!enc) {
|
||||||
|
enc = applyPatch({}, e.payload);
|
||||||
|
encName = e.encounterName;
|
||||||
|
encPath = e.encounterPath;
|
||||||
|
} else {
|
||||||
|
enc = applyPatch(enc, e.payload);
|
||||||
|
}
|
||||||
|
appliedCount++;
|
||||||
|
|
||||||
|
if (e.snapshot) {
|
||||||
|
const d = diffSnapshot(enc, e.snapshot);
|
||||||
|
if (d.length) drift.push({ event: e, fields: d });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.type === 'next_turn') {
|
||||||
|
turnCount++;
|
||||||
|
const names = nameMap(enc);
|
||||||
|
const actor = e.snapshot?.currentTurnParticipantId;
|
||||||
|
const actorName = names.get(actor) || actor || '?';
|
||||||
|
const r = e.snapshot?.round ?? enc?.round ?? 0;
|
||||||
|
if (r !== round) { round = r; }
|
||||||
|
turnSequence.push(`R${r}: ${actorName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`encounter: ${encName || '?'}`);
|
||||||
|
console.log(`events applied: ${appliedCount} / ${events.length}`);
|
||||||
|
console.log(`turns replayed: ${turnCount}`);
|
||||||
|
console.log(`final round: ${round}`);
|
||||||
|
console.log(`participants at end: ${(enc?.participants || []).length}`);
|
||||||
|
|
||||||
|
if (drift.length) {
|
||||||
|
console.log(`\n--- SNAPSHOT DRIFT (${drift.length} events) ---`);
|
||||||
|
for (const { event: e, fields } of drift.slice(0, 10)) {
|
||||||
|
console.log(` ${e.type} (${e.id || e.ts}): ${fields.join('; ')}`);
|
||||||
|
}
|
||||||
|
if (drift.length > 10) console.log(` ... +${drift.length - 10} more`);
|
||||||
|
} else {
|
||||||
|
console.log('\nsnapshots: all match');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== ${turnCount} turns, ${drift.length} drift ===`);
|
||||||
|
console.log(drift.length === 0 ? 'CLEAN — replay matches logged intent' : 'DRIFT — logged snapshots diverge from applied patches');
|
||||||
|
|
||||||
|
process.exit(drift.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error('replay-from-logs failed:', err); process.exit(1); });
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/run-tests.sh — run all 3 test suites with hard timeout.
|
||||||
|
# Design spec: tests never take >60s. Hang = failure, not silent.
|
||||||
|
# Each suite gets 60s. Total cap = 180s. Exits non-zero on timeout OR failure.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TIMEOUT_BIN=gtimeout
|
||||||
|
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
|
||||||
|
|
||||||
|
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: need 'timeout' (coreutils) or 'gtimeout' (brew coreutils)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_suite () {
|
||||||
|
local label="$1"; shift
|
||||||
|
local cmd="$1"; shift
|
||||||
|
local secs="${1:-60}"
|
||||||
|
echo "=== $label (${secs}s cap) ==="
|
||||||
|
if $TIMEOUT_BIN --signal=KILL "$secs" bash -c "$cmd"; then
|
||||||
|
echo "=== $label: PASS ==="
|
||||||
|
else
|
||||||
|
local rc=$?
|
||||||
|
if [ "$rc" -eq 137 ] || [ "$rc" -eq 124 ]; then
|
||||||
|
echo "=== $label: FAIL — timeout (${secs}s exceeded, killed) ===" >&2
|
||||||
|
else
|
||||||
|
echo "=== $label: FAIL (exit $rc) ===" >&2
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pre-flight: refuse to run if any test is skipped/disabled.
|
||||||
|
# Skipped tests rot silently. Catch them before they hide coverage gaps.
|
||||||
|
check_for_skips () {
|
||||||
|
echo "=== skip-guard ==="
|
||||||
|
local hits
|
||||||
|
hits=$(grep -rnE '\.(skip|todo)\(|xdescribe\(|xit\(' src/tests shared/tests server/tests 2>/dev/null || true)
|
||||||
|
if [ -n "$hits" ]; then
|
||||||
|
echo "FAIL: skipped/disabled tests found:" >&2
|
||||||
|
echo "$hits" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Fix or delete. Do not skip." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "=== skip-guard: PASS ==="
|
||||||
|
}
|
||||||
|
check_for_skips
|
||||||
|
|
||||||
|
run_suite "app" "CI=true npx react-scripts test --watchAll=false" 60
|
||||||
|
run_suite "shared" "npm test --workspace shared" 30
|
||||||
|
run_suite "server" "npm test --workspace server" 30
|
||||||
|
|
||||||
|
echo "=== ALL SUITES GREEN ==="
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// server/db.js — generic KV document store on SQLite.
|
||||||
|
// Mirrors Firestore doc-tree model: every doc lives at a string path.
|
||||||
|
// Collections are implicit = all docs whose parent path equals the collection path.
|
||||||
|
//
|
||||||
|
// Path examples (canonical, prefix already stripped by adapter):
|
||||||
|
// campaigns/{id} doc
|
||||||
|
// campaigns/{cid}/encounters/{eid} doc
|
||||||
|
// campaigns/{cid}/encounters collection (parent of encounter docs)
|
||||||
|
// activeDisplay/status doc
|
||||||
|
// logs/{id} doc
|
||||||
|
//
|
||||||
|
// No shape-specific tables. Data is opaque JSON. This is the firebase mirror:
|
||||||
|
// the adapter (src/storage/server.js) is a thin passthrough, app logic unchanged.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS docs (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
parent TEXT,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent);
|
||||||
|
-- Hot path: logs page subscribes to latest logs ordered by JSON ts.
|
||||||
|
-- Without this, every log write makes UI re-fetch + sort all logs.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_docs_parent_ts ON docs(parent, CAST(json_extract(data, '$.ts') AS NUMERIC));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_docs_parent_encounter_ts ON docs(parent, json_extract(data, '$.encounterPath'), CAST(json_extract(data, '$.ts') AS NUMERIC));
|
||||||
|
`;
|
||||||
|
|
||||||
|
function openDb(dbPath) {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.exec(SCHEMA);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentOf('campaigns/abc/encounters/xyz') => 'campaigns/abc/encounters'
|
||||||
|
// parentOf('campaigns') => null (root-level doc, no parent collection tracked)
|
||||||
|
function parentOf(p) {
|
||||||
|
const i = p.lastIndexOf('/');
|
||||||
|
return i === -1 ? null : p.slice(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStore(db, broadcast) {
|
||||||
|
const stmtGet = db.prepare('SELECT data FROM docs WHERE path = ?');
|
||||||
|
const stmtUpsert = db.prepare(`
|
||||||
|
INSERT INTO docs (path, parent, data, updated_at) VALUES (@path, @parent, @data, @ts)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts
|
||||||
|
`);
|
||||||
|
const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?');
|
||||||
|
|
||||||
|
function getDoc(p) {
|
||||||
|
const row = stmtGet.get(p);
|
||||||
|
return row ? JSON.parse(row.data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDoc(p, data) {
|
||||||
|
const ts = Date.now();
|
||||||
|
stmtUpsert.run({ path: p, parent: parentOf(p), data: JSON.stringify(data), ts });
|
||||||
|
if (broadcast) broadcast({ path: p, parent: parentOf(p) });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// shallow merge; if doc missing, patch becomes the doc (matches firebase updateDoc create-on-miss)
|
||||||
|
function updateDoc(p, patch) {
|
||||||
|
const existing = getDoc(p) || {};
|
||||||
|
const merged = { ...existing, ...patch };
|
||||||
|
setDoc(p, merged);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDoc(p) {
|
||||||
|
stmtDelete.run(p);
|
||||||
|
if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCollection(collPath, { where, orderBy, dir = 'asc', limit, offset } = {}) {
|
||||||
|
let sql = 'SELECT path, data FROM docs WHERE parent = ?';
|
||||||
|
const params = [collPath];
|
||||||
|
if (where) {
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`);
|
||||||
|
if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`);
|
||||||
|
sql += ` AND json_extract(data, '$.${where.field}') = ?`;
|
||||||
|
params.push(where.value);
|
||||||
|
}
|
||||||
|
if (orderBy) {
|
||||||
|
// whitelist field name to avoid injection (alphanumeric + underscore only)
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(orderBy)) throw new Error(`bad orderBy field: ${orderBy}`);
|
||||||
|
sql += ` ORDER BY CAST(json_extract(data, '$.${orderBy}') AS NUMERIC) ${dir === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else {
|
||||||
|
sql += ' ORDER BY path ASC';
|
||||||
|
}
|
||||||
|
if (limit && Number.isInteger(+limit) && +limit > 0) {
|
||||||
|
sql += ' LIMIT ?';
|
||||||
|
params.push(+limit);
|
||||||
|
if (offset && Number.isInteger(+offset) && +offset > 0) {
|
||||||
|
sql += ' OFFSET ?';
|
||||||
|
params.push(+offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db.prepare(sql).all(...params).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCollection(collPath) {
|
||||||
|
return db.prepare('SELECT COUNT(*) AS n FROM docs WHERE parent = ?').get(collPath).n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk delete whole collection or by where-filter. No fetch. SQL knows paths.
|
||||||
|
// DEV ONLY — guarded at HTTP layer; db fn itself unguarded (server-internal trust).
|
||||||
|
function deleteCollection(collPath, { where } = {}) {
|
||||||
|
let sql = 'DELETE FROM docs WHERE parent = ?';
|
||||||
|
const params = [collPath];
|
||||||
|
const changed = [];
|
||||||
|
if (where) {
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`);
|
||||||
|
if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`);
|
||||||
|
sql += ` AND json_extract(data, '$.${where.field}') = ?`;
|
||||||
|
params.push(where.value);
|
||||||
|
}
|
||||||
|
// collect paths+parents for broadcast before delete
|
||||||
|
const rows = db.prepare('SELECT path, parent FROM docs WHERE parent = ?' + (where ? ` AND json_extract(data, '$.${where.field}') = ?` : '')).all(...params);
|
||||||
|
const info = db.prepare(sql).run(...params);
|
||||||
|
if (broadcast) rows.forEach(r => broadcast({ path: r.path, parent: r.parent, deleted: true }));
|
||||||
|
return info.changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchWrite(ops) {
|
||||||
|
const run = db.transaction((items) => {
|
||||||
|
const changed = [];
|
||||||
|
for (const op of items) {
|
||||||
|
if (op.type === 'set') {
|
||||||
|
setDoc(op.path, op.data);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path) });
|
||||||
|
} else if (op.type === 'delete') {
|
||||||
|
deleteDoc(op.path);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path), deleted: true });
|
||||||
|
} else if (op.type === 'update') {
|
||||||
|
updateDoc(op.path, op.data);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
});
|
||||||
|
const changed = run(ops);
|
||||||
|
if (broadcast) changed.forEach(c => broadcast(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transactional undo: apply encounter updates + flip log `undone` in one tx.
|
||||||
|
// No stale clobber, atomic. Returns { undone: true } or throws.
|
||||||
|
function transactionalUndo({ logPath, encounterPath, updates, undoneFlag }) {
|
||||||
|
const run = db.transaction(() => {
|
||||||
|
const merged = updateDoc(encounterPath, updates);
|
||||||
|
const log = getDoc(logPath) || {};
|
||||||
|
setDoc(logPath, { ...log, undone: undoneFlag });
|
||||||
|
return { encounter: merged, undone: undoneFlag };
|
||||||
|
});
|
||||||
|
const result = run();
|
||||||
|
if (broadcast) {
|
||||||
|
broadcast({ path: encounterPath, parent: parentOf(encounterPath) });
|
||||||
|
broadcast({ path: logPath, parent: parentOf(logPath) });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, countCollection, deleteCollection, batchWrite, transactionalUndo };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { openDb, parentOf, makeStore };
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// server/index.js — generic KV document store over HTTP + WebSocket.
|
||||||
|
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
|
||||||
|
// Adapter (src/storage/server.js) = passthrough, no shape translation.
|
||||||
|
// TEST: cache layer rebuild check.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const { openDb, makeStore } = require('./db');
|
||||||
|
|
||||||
|
function createServer({ dbPath, port, corsOrigin, allowDevEndpoints = false } = {}) {
|
||||||
|
const db = openDb(dbPath || './data/tracker.sqlite');
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: corsOrigin || '*' }));
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
// WS subscribers: path -> Set<ws>.
|
||||||
|
// Subscribers register a path (doc or collection). On change, notify:
|
||||||
|
// - doc subscribers at the changed path
|
||||||
|
// - collection subscribers at the changed doc's parent path
|
||||||
|
const docSubscribers = new Map(); // path -> Set<ws>
|
||||||
|
const collSubscribers = new Map(); // collPath -> Set<ws>
|
||||||
|
|
||||||
|
function addSub(map, key, ws) {
|
||||||
|
if (!map.has(key)) map.set(key, new Set());
|
||||||
|
map.get(key).add(ws);
|
||||||
|
}
|
||||||
|
function removeSub(map, key, ws) {
|
||||||
|
const set = map.get(key);
|
||||||
|
if (set) { set.delete(ws); if (set.size === 0) map.delete(key); }
|
||||||
|
}
|
||||||
|
function dropWs(ws) {
|
||||||
|
for (const [key, set] of docSubscribers) { set.delete(ws); if (set.size === 0) docSubscribers.delete(key); }
|
||||||
|
for (const [key, set] of collSubscribers) { set.delete(ws); if (set.size === 0) collSubscribers.delete(key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(change) {
|
||||||
|
const payload = JSON.stringify({ type: 'change', change });
|
||||||
|
// doc subscriber at exact path
|
||||||
|
const docSet = docSubscribers.get(change.path);
|
||||||
|
if (docSet) [...docSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
|
||||||
|
// collection subscribers at parent path (collection contains this doc)
|
||||||
|
if (change.parent) {
|
||||||
|
const collSet = collSubscribers.get(change.parent);
|
||||||
|
if (collSet) [...collSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = makeStore(db, broadcast);
|
||||||
|
|
||||||
|
// --- generic REST ---
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
// GET /api/doc?path=campaigns/abc/encounters/xyz
|
||||||
|
app.get('/api/doc', (req, res) => {
|
||||||
|
const { path: p } = req.query;
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
res.json({ path: p, data: store.getDoc(p) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/collection?path=campaigns/abc/encounters
|
||||||
|
app.get('/api/collection', (req, res) => {
|
||||||
|
const { path: p, whereField, whereOp, whereValue, orderBy, dir, limit, offset } = req.query;
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
const opts = {};
|
||||||
|
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
|
||||||
|
if (orderBy) opts.orderBy = orderBy;
|
||||||
|
if (dir) opts.dir = dir;
|
||||||
|
if (limit) opts.limit = limit;
|
||||||
|
if (offset) opts.offset = offset;
|
||||||
|
res.json(store.getCollection(p, opts));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/collection/count', (req, res) => {
|
||||||
|
const { path: p } = req.query;
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
res.json({ count: store.countCollection(p) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/doc body: { path, data } (replace)
|
||||||
|
app.put('/api/doc', (req, res) => {
|
||||||
|
const { path: p, data } = req.body || {};
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
res.json({ path: p, data: store.setDoc(p, data) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/doc body: { path, patch } (shallow merge, create-on-miss)
|
||||||
|
app.patch('/api/doc', (req, res) => {
|
||||||
|
const { path: p, patch } = req.body || {};
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
res.json({ path: p, data: store.updateDoc(p, patch) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/doc?path=...
|
||||||
|
app.delete('/api/doc', (req, res) => {
|
||||||
|
const { path: p } = req.query;
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
store.deleteDoc(p);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/collection?path=...&whereField=...&whereValue=...
|
||||||
|
// Bulk delete whole collection or filtered. No fetch. SQL DELETE.
|
||||||
|
// DEV ONLY: requires ALLOW_DEV_ENDPOINTS=1 env on server.
|
||||||
|
app.delete('/api/collection', (req, res) => {
|
||||||
|
if (!allowDevEndpoints && process.env.ALLOW_DEV_ENDPOINTS !== '1') {
|
||||||
|
return res.status(403).json({ error: 'bulk delete disabled (dev only)' });
|
||||||
|
}
|
||||||
|
const { path: p, whereField, whereOp, whereValue } = req.query;
|
||||||
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
|
const opts = {};
|
||||||
|
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
|
||||||
|
const deleted = store.deleteCollection(p, opts);
|
||||||
|
res.json({ ok: true, deleted });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/collection body: { path, data } (addDoc: auto-id under collection)
|
||||||
|
app.post('/api/collection', (req, res) => {
|
||||||
|
const { path: collPath, data } = req.body || {};
|
||||||
|
if (!collPath) return res.status(400).json({ error: 'path required' });
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const docPath = `${collPath}/${id}`;
|
||||||
|
res.json({ id, path: docPath, data: store.setDoc(docPath, data) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/batch body: { ops: [{type:'set'|'update'|'delete', path, data?}] }
|
||||||
|
app.post('/api/batch', (req, res) => {
|
||||||
|
const { ops } = req.body || {};
|
||||||
|
if (!Array.isArray(ops)) return res.status(400).json({ error: 'ops array required' });
|
||||||
|
store.batchWrite(ops);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/undo body: { logPath, undo } where undo={encounterPath, updates, redo}
|
||||||
|
// Transactional: apply updates + flip undone in one SQLite tx.
|
||||||
|
// If `redo` true, applies redo patch instead + flips undone:false.
|
||||||
|
app.post('/api/undo', (req, res) => {
|
||||||
|
const { logPath, undo } = req.body || {};
|
||||||
|
if (!logPath || !undo || !undo.encounterPath) {
|
||||||
|
return res.status(400).json({ error: 'logPath + undo.encounterPath required' });
|
||||||
|
}
|
||||||
|
if (!store.transactionalUndo) {
|
||||||
|
return res.status(501).json({ error: 'transactionalUndo not supported by store' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const isRedo = !!req.body.redo;
|
||||||
|
const updates = isRedo ? (undo.redo || undo.updates) : undo.updates;
|
||||||
|
const result = store.transactionalUndo({
|
||||||
|
logPath,
|
||||||
|
encounterPath: undo.encounterPath,
|
||||||
|
updates,
|
||||||
|
undoneFlag: !isRedo,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- WebSocket: subscribe by path ---
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; }
|
||||||
|
if (msg.type === 'subscribe' && msg.path) {
|
||||||
|
if (msg.kind === 'collection') addSub(collSubscribers, msg.path, ws);
|
||||||
|
else addSub(docSubscribers, msg.path, ws);
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribed', path: msg.path, kind: msg.kind || 'doc' }));
|
||||||
|
} else if (msg.type === 'unsubscribe' && msg.path) {
|
||||||
|
if (msg.kind === 'collection') removeSub(collSubscribers, msg.path, ws);
|
||||||
|
else removeSub(docSubscribers, msg.path, ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on('close', () => dropWs(ws));
|
||||||
|
ws.on('error', () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
app, server, wss, store, db,
|
||||||
|
close(done) {
|
||||||
|
wss.clients.forEach(c => { try { c.terminate(); } catch {} });
|
||||||
|
wss.close();
|
||||||
|
server.close(() => { db.close(); if (done) done(); });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot standalone if run directly.
|
||||||
|
if (require.main === module) {
|
||||||
|
const port = parseInt(process.env.PORT, 10) || 4001;
|
||||||
|
const dbPath = process.env.DB_PATH || './data/tracker.sqlite';
|
||||||
|
const { server } = createServer({ dbPath, port });
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`ttrpg backend listening on :${port} (db: ${dbPath})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createServer };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
rootDir: '.',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['<rootDir>/tests/**/*.test.js'],
|
||||||
|
testTimeout: 10000,
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@ttrpg/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Self-hosted backend: Express + ws + better-sqlite3. Server-authoritative turn logic.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch index.js",
|
||||||
|
"start": "node index.js",
|
||||||
|
"test": "../scripts/cap.sh 30 jest --forceExit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ttrpg/shared": "*",
|
||||||
|
"better-sqlite3": "^12.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Layer 2 test: exercise server.js storage adapter against a LIVE backend.
|
||||||
|
// Complements Layer 1 (App + firebase mock) which proves App call shape but
|
||||||
|
// never touches ws.js. This catches translation bugs in the adapter.
|
||||||
|
//
|
||||||
|
// Runs the shared storage contract (same spec memory/firebase satisfy) against
|
||||||
|
// createServerStorage pointed at an ephemeral backend instance. A FRESH backend is
|
||||||
|
// spun up per test to guarantee isolation (backend has no reset endpoint yet).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { createServer } = require('../index');
|
||||||
|
const { createServerStorage } = require('../../src/storage/server');
|
||||||
|
const { runStorageContract } = require('../../src/storage/contract');
|
||||||
|
|
||||||
|
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
|
||||||
|
// Disposing the storage closes the backend so each test is fully isolated.
|
||||||
|
async function makeStorage({ allowDevEndpoints = false } = {}) {
|
||||||
|
const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
|
||||||
|
const handle = createServer({ dbPath, port: 0, allowDevEndpoints });
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
handle.server.on('error', reject);
|
||||||
|
handle.server.listen(0, resolve);
|
||||||
|
});
|
||||||
|
const port = handle.server.address().port;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const wsUrl = `ws://127.0.0.1:${port}/ws`;
|
||||||
|
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
|
||||||
|
storage.dispose = (done) => handle.close(done);
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
runStorageContract('server (live backend)', () => makeStorage({ allowDevEndpoints: true }));
|
||||||
|
|
||||||
|
describe('server-side query constraints', () => {
|
||||||
|
let storage;
|
||||||
|
beforeEach(async () => {
|
||||||
|
storage = await makeStorage();
|
||||||
|
});
|
||||||
|
afterEach((done) => storage.dispose(done));
|
||||||
|
|
||||||
|
test('GET /api/collection honors orderBy + limit (server-side, not client)', async () => {
|
||||||
|
// 5 docs with distinct ts
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await storage._api('GET', '/api/collection',
|
||||||
|
{ path: 'logs', orderBy: 'ts', dir: 'desc', limit: '2' });
|
||||||
|
expect(Array.isArray(res)).toBe(true);
|
||||||
|
expect(res.length).toBe(2);
|
||||||
|
// desc: highest ts first
|
||||||
|
expect(res[0].data ? res[0].data.n : res[0].n).toBe(5);
|
||||||
|
expect(res[1].data ? res[1].data.n : res[1].n).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/collection honors offset for pagination', async () => {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
|
||||||
|
}
|
||||||
|
// page 2 of size 2, desc by ts: should be n=3,2
|
||||||
|
const res = await storage._api('GET', '/api/collection',
|
||||||
|
{ path: 'logs', orderBy: 'ts', dir: 'desc', limit: '2', offset: '2' });
|
||||||
|
expect(res.length).toBe(2);
|
||||||
|
expect(res[0].data ? res[0].data.n : res[0].n).toBe(3);
|
||||||
|
expect(res[1].data ? res[1].data.n : res[1].n).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/collection/count returns total', async () => {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
|
||||||
|
}
|
||||||
|
const res = await storage._api('GET', '/api/collection/count', { path: 'logs' });
|
||||||
|
expect(res).toEqual({ count: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/collection (bulk delete)', () => {
|
||||||
|
let storage;
|
||||||
|
beforeEach(async () => {
|
||||||
|
storage = await makeStorage({ allowDevEndpoints: true });
|
||||||
|
});
|
||||||
|
afterEach((done) => storage.dispose(done));
|
||||||
|
|
||||||
|
test('deletes all docs in collection, no fetch', async () => {
|
||||||
|
for (let i = 0; i < 3; i++) await storage.addDoc('logs', { type: 'x', n: i });
|
||||||
|
const res = await storage._api('DELETE', '/api/collection', { path: 'logs' });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.deleted).toBe(3);
|
||||||
|
const after = await storage._api('GET', '/api/collection/count', { path: 'logs' });
|
||||||
|
expect(after.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors whereField filter', async () => {
|
||||||
|
await storage.addDoc('logs', { type: 'keep', n: 1 });
|
||||||
|
await storage.addDoc('logs', { type: 'drop', n: 2 });
|
||||||
|
await storage.addDoc('logs', { type: 'drop', n: 3 });
|
||||||
|
const res = await storage._api('DELETE', '/api/collection',
|
||||||
|
{ path: 'logs', whereField: 'type', whereValue: 'drop' });
|
||||||
|
expect(res.deleted).toBe(2);
|
||||||
|
const after = await storage._api('GET', '/api/collection/count', { path: 'logs' });
|
||||||
|
expect(after.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 when ALLOW_DEV_ENDPOINTS not set', async () => {
|
||||||
|
const gated = await makeStorage({ allowDevEndpoints: false });
|
||||||
|
let errMsg = null;
|
||||||
|
try {
|
||||||
|
await gated._api('DELETE', '/api/collection', { path: 'logs' });
|
||||||
|
} catch (e) { errMsg = e.message; }
|
||||||
|
expect(errMsg).toMatch(/403/);
|
||||||
|
await new Promise((resolve) => gated.dispose(resolve));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// BUG-8: ws adapter has NO reconnect. WS dies (idle/error/close) → wsReady=null,
|
||||||
|
// subscribers dead forever, no re-subscribe. Display frozen until full reload.
|
||||||
|
// Test: subscribe, write (cb fires), force-drop WS, write again (must still fire).
|
||||||
|
// RED on current.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { createServer } = require('../index');
|
||||||
|
const { createServerStorage } = require('../../src/storage/server');
|
||||||
|
|
||||||
|
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function makeStorage() {
|
||||||
|
const dbPath = path.join(os.tmpdir(), `ws-recon-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
|
||||||
|
const handle = createServer({ dbPath, port: 0 });
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
handle.server.on('error', reject);
|
||||||
|
handle.server.listen(0, resolve);
|
||||||
|
});
|
||||||
|
const port = handle.server.address().port;
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const wsUrl = `ws://127.0.0.1:${port}/ws`;
|
||||||
|
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
|
||||||
|
storage.dispose = (done) => handle.close(done);
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BUG-8: ws adapter reconnect after drop', () => {
|
||||||
|
test('subscribe fires cb after WS dropped + restored', async () => {
|
||||||
|
const storage = await makeStorage();
|
||||||
|
try {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'V1' });
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// force-drop WS (simulates idle timeout / network blip)
|
||||||
|
storage._test.forceDrop();
|
||||||
|
await flush(300);
|
||||||
|
// wsReady should be null now
|
||||||
|
expect(storage._test.getReady()).toBeNull();
|
||||||
|
|
||||||
|
// write again — subscriber must re-fire after reconnect
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'V2' });
|
||||||
|
await flush(1000);
|
||||||
|
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last).toEqual({ id: 'a', name: 'V2' });
|
||||||
|
} finally {
|
||||||
|
await new Promise(r => storage.dispose(r));
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// @ttrpg/shared — barrel export.
|
||||||
|
const turn = require('./turn.js');
|
||||||
|
module.exports = { ...turn, logEvent: require('./logEvent.js') };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
rootDir: '.',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['<rootDir>/tests/**/*.test.js'],
|
||||||
|
collectCoverageFrom: ['turn.js'],
|
||||||
|
testTimeout: 10000,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// shared/logEvent.js — canonical event shape for UI/download/replay/analyze.
|
||||||
|
// One source of truth. All four consumers import this.
|
||||||
|
//
|
||||||
|
// Canonical event:
|
||||||
|
// {
|
||||||
|
// id, ts, type, message,
|
||||||
|
// encounterId, encounterName, encounterPath,
|
||||||
|
// payload, // forward patch (null for no-op)
|
||||||
|
// undo_payload, // { encounterPath, updates, redo } or null
|
||||||
|
// undone, // bool
|
||||||
|
// snapshot // { round, currentTurnParticipantId, turnOrderIds, activeIds } or null
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Old logs (pre-refactor): { timestamp, message, encounterName, undo }.
|
||||||
|
// normalizeEvent fills defaults + lifts legacy undo into undo_payload.
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
ts: 0,
|
||||||
|
type: 'unknown',
|
||||||
|
message: '',
|
||||||
|
encounterId: null,
|
||||||
|
encounterName: null,
|
||||||
|
encounterPath: null,
|
||||||
|
payload: null,
|
||||||
|
undo_payload: null,
|
||||||
|
undone: false,
|
||||||
|
snapshot: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Canonical lean event shape:
|
||||||
|
// {
|
||||||
|
// id, ts, type, message,
|
||||||
|
// encounterId, encounterName, encounterPath,
|
||||||
|
// participantId, participantName, // nullable (pause/nextTurn have none)
|
||||||
|
// delta, // type-specific scalars (nullable)
|
||||||
|
// undo, // id-based inverse (nullable)
|
||||||
|
// undone, // bool
|
||||||
|
// snapshot // {round, currentTurnParticipantId, turnOrderIds, activeIds}
|
||||||
|
// }
|
||||||
|
// Legacy logs (pre-refactor, no type): display message only. Not undoable.
|
||||||
|
// Old bloated logs (payload/undo_payload): normalized to lean — payload dropped,
|
||||||
|
// undo_payload lifted to undo. Both display fine; undo expands via expandUndo.
|
||||||
|
function normalizeEvent(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
if (!raw.type || raw.type === 'unknown') return null;
|
||||||
|
const id = raw.id || (raw.path ? raw.path.split('/').pop() : null);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
ts: raw.ts || raw.timestamp || 0,
|
||||||
|
type: raw.type,
|
||||||
|
message: raw.message || '',
|
||||||
|
encounterId: raw.encounterId || null,
|
||||||
|
encounterName: raw.encounterName || null,
|
||||||
|
encounterPath: raw.encounterPath || null,
|
||||||
|
participantId: raw.participantId || null,
|
||||||
|
participantName: raw.participantName || null,
|
||||||
|
delta: raw.delta || null,
|
||||||
|
undo: raw.undo || raw.undo_payload || null,
|
||||||
|
undone: !!raw.undone,
|
||||||
|
snapshot: raw.snapshot || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize array of raw log entries (DB rows) → canonical JSON string.
|
||||||
|
// Legacy logs (no type) dropped by normalizeEvent. Sorted ascending by ts.
|
||||||
|
function serializeEvents(rawLogs) {
|
||||||
|
return JSON.stringify(
|
||||||
|
(rawLogs || [])
|
||||||
|
.map(normalizeEvent)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.ts - b.ts),
|
||||||
|
null, 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeEvent, serializeEvents, DEFAULTS };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@ttrpg/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Pure logic shared by client + server + tests. No I/O.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "../scripts/cap.sh 30 jest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Test Rewrite Spec — async turn.js writes own logs
|
||||||
|
|
||||||
|
## New turn.js API
|
||||||
|
|
||||||
|
ALL mutating funcs now `async`, take `ctx` last param, write encounter + log internally, return `newEnc`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// OLD
|
||||||
|
const r = nextTurn(enc);
|
||||||
|
enc = { ...enc, ...r.patch };
|
||||||
|
// caller had to write log separately
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
enc = await nextTurn(enc, ctx);
|
||||||
|
// encounter + log already written inside func
|
||||||
|
```
|
||||||
|
|
||||||
|
### Func signatures (all async unless noted)
|
||||||
|
- `startEncounter(enc, ctx)` → newEnc
|
||||||
|
- `nextTurn(enc, ctx)` → newEnc
|
||||||
|
- `togglePause(enc, ctx)` → newEnc
|
||||||
|
- `addParticipant(enc, participant, ctx)` → newEnc
|
||||||
|
- `addParticipants(enc, newParticipants[], ctx)` → newEnc
|
||||||
|
- `updateParticipant(enc, id, updatedData, ctx)` → newEnc
|
||||||
|
- `removeParticipant(enc, id, ctx)` → newEnc
|
||||||
|
- `toggleParticipantActive(enc, id, ctx)` → newEnc
|
||||||
|
- `applyHpChange(enc, id, changeType, amount, ctx)` → newEnc (no-op = returns enc unchanged, no write)
|
||||||
|
- `deathSave(enc, id, type, n, ctx)` → `{ enc, status, isDying }` (object now!)
|
||||||
|
- `toggleCondition(enc, id, conditionId, ctx)` → newEnc
|
||||||
|
- `reorderParticipants(enc, draggedId, targetId, ctx)` → newEnc (no-op = returns enc, no write)
|
||||||
|
- `endEncounter(enc, ctx)` → newEnc
|
||||||
|
|
||||||
|
### Pure helpers (sync, unchanged)
|
||||||
|
- `makeParticipant`, `buildCharacterParticipant`, `buildMonsterParticipant`
|
||||||
|
- `generateId`, `rollD20`, `formatInitMod`
|
||||||
|
- `sortParticipantsByInitiative`, `syncTurnOrder`, `computeTurnOrderAfterRemoval`
|
||||||
|
- `snapshotOf`, `buildEntry`
|
||||||
|
|
||||||
|
### Display lifecycle (async, no combat log)
|
||||||
|
- `activateDisplay({campaignId, encounterId}, ctx)` → void
|
||||||
|
- `clearDisplay(ctx)` → void
|
||||||
|
- `toggleHidePlayerHp(currentValue, ctx)` → void
|
||||||
|
|
||||||
|
## Test helper: shared/tests/_helpers.js
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { createMockStorage, mockCtx, readyEnc } = require('./_helpers');
|
||||||
|
const { storage, ctx } = mockCtx(); // ctx = {storage, encPath:'encounters/e1', logPath:'logs', displayPath:'activeDisplay/status'}
|
||||||
|
const enc = readyEnc(); // {id:'e1', name:'TestEnc', round:0, ... 2 participants p1(Bob,init10) p2(Mob,init5)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock storage methods
|
||||||
|
- `storage.calls` — array of `{fn, path, data}` ordered
|
||||||
|
- `storage.logs()` — array of log entries written (addDoc data)
|
||||||
|
- `storage.updatesFor(prefix)` — updateDoc calls matching path prefix
|
||||||
|
- `storage.docs` — Map path→data
|
||||||
|
|
||||||
|
## Transform patterns
|
||||||
|
|
||||||
|
### Pattern 1: state assertion
|
||||||
|
```js
|
||||||
|
// OLD
|
||||||
|
const r = startEncounter(enc);
|
||||||
|
expect(r.patch.round).toBe(1);
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
const newEnc = await startEncounter(enc, ctx);
|
||||||
|
expect(newEnc.round).toBe(1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: log assertion
|
||||||
|
```js
|
||||||
|
// OLD
|
||||||
|
const r = startEncounter(enc);
|
||||||
|
expect(r.log.type).toBe('start_encounter');
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
const newEnc = await startEncounter(enc, ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expect(storage.logs()[0].type).toBe('start_encounter');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: chained calls (sequence)
|
||||||
|
```js
|
||||||
|
// OLD
|
||||||
|
let enc = readyEnc();
|
||||||
|
enc = { ...enc, ...startEncounter(enc).patch };
|
||||||
|
enc = { ...enc, ...nextTurn(enc).patch };
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
let enc = readyEnc();
|
||||||
|
enc = await startEncounter(enc, ctx);
|
||||||
|
enc = await nextTurn(enc, ctx);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: no-op
|
||||||
|
```js
|
||||||
|
// OLD (returned {patch:null, log:null})
|
||||||
|
const r = applyHpChange(enc, 'p1', 'damage', 0);
|
||||||
|
expect(r.patch).toBeNull();
|
||||||
|
|
||||||
|
// NEW (returns enc unchanged, no write)
|
||||||
|
const newEnc = await applyHpChange(enc, 'p1', 'damage', 0, ctx);
|
||||||
|
expect(newEnc).toBe(enc); // same ref = no write
|
||||||
|
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: deathSave (returns object now)
|
||||||
|
```js
|
||||||
|
// OLD
|
||||||
|
const r = deathSave(enc, 'p1', 'success', 1);
|
||||||
|
expect(r.status).toBe('pending');
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
const { enc: newEnc, status, isDying } = await deathSave(enc, 'p1', 'success', 1, ctx);
|
||||||
|
expect(status).toBe('pending');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Static guards
|
||||||
|
|
||||||
|
- `static.no-sort.test.js` — KEEP. Still valid (scans turn.js for stray `.sort(`).
|
||||||
|
- `static.no-unlogged.test.js` — DELETE. Old `{patch, log}` shape gone. Every func writes log internally via `commit()`. No way to "forget".
|
||||||
|
- `static.eslint.test.js` — KEEP.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- KEEP test names (describe/it blocks) — same coverage intent.
|
||||||
|
- KEEP assertions — same values checked, just via newEnc or storage.logs().
|
||||||
|
- Fresh `mockCtx()` per test (isolation).
|
||||||
|
- For seeded-RNG combat tests: keep the LCG seed + rand helpers, just swap call pattern.
|
||||||
|
- `await` every mutating func call.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Test helper: in-memory mock storage. Records all writes (addDoc/updateDoc).
|
||||||
|
// Stores docs so getDoc/getCollection return real state. undo() actually
|
||||||
|
// applies patches + flips log `undone` flag (mirrors firebase/server contract).
|
||||||
|
function createMockStorage() {
|
||||||
|
const docs = new Map(); // path -> data
|
||||||
|
const calls = []; // ordered {fn, path, data}
|
||||||
|
|
||||||
|
const applyPatch = (path, patch) => {
|
||||||
|
if (!docs.has(path)) docs.set(path, {});
|
||||||
|
const cur = docs.get(path);
|
||||||
|
// Firestore shallow-merge: arrays replaced, not merged. Patch arrays are
|
||||||
|
// the FULL new value (participants, turnOrderIds, conditions).
|
||||||
|
docs.set(path, { ...cur, ...patch });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
docs,
|
||||||
|
async getDoc(path) { return docs.has(path) ? { ...docs.get(path) } : null; },
|
||||||
|
async setDoc(path, data) {
|
||||||
|
calls.push({ fn: 'setDoc', path, data });
|
||||||
|
docs.set(path, { ...data });
|
||||||
|
},
|
||||||
|
async addDoc(path, data) {
|
||||||
|
calls.push({ fn: 'addDoc', path, data });
|
||||||
|
// Persist log entry so getCollection + undo can read it back.
|
||||||
|
const id = data.id || `log-${calls.length}`;
|
||||||
|
const stored = { ...data, id, _path: `${path}/${id}` };
|
||||||
|
docs.set(`${path}/${id}`, stored);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
async updateDoc(path, patch) {
|
||||||
|
calls.push({ fn: 'updateDoc', path, data: patch });
|
||||||
|
applyPatch(path, patch);
|
||||||
|
},
|
||||||
|
async deleteDoc(path) {
|
||||||
|
calls.push({ fn: 'deleteDoc', path });
|
||||||
|
docs.delete(path);
|
||||||
|
},
|
||||||
|
async getCollection(path, queryConstraints = []) {
|
||||||
|
// Return docs whose _path starts with `${path}/` (Firestore collection).
|
||||||
|
const out = [];
|
||||||
|
for (const [p, d] of docs.entries()) {
|
||||||
|
if (p.startsWith(`${path}/`)) out.push({ ...d });
|
||||||
|
}
|
||||||
|
// Best-effort query support: where/orderBy/limit.
|
||||||
|
let filtered = out;
|
||||||
|
for (const qc of queryConstraints) {
|
||||||
|
if (!filtered.length) break;
|
||||||
|
const op = qc[0];
|
||||||
|
if (op === 'where') {
|
||||||
|
const [, field, , value] = qc;
|
||||||
|
filtered = filtered.filter(d => d[field] === value);
|
||||||
|
} else if (op === 'orderBy') {
|
||||||
|
const [, field, dir = 'asc'] = qc;
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const av = a[field], bv = b[field];
|
||||||
|
if (av < bv) return dir === 'asc' ? -1 : 1;
|
||||||
|
if (av > bv) return dir === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
} else if (op === 'limit') {
|
||||||
|
filtered = filtered.slice(0, qc[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
async batchWrite(ops) {
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.type === 'delete') await this.deleteDoc(op.path);
|
||||||
|
else await this.updateDoc(op.path, op.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Transactional undo — mirrors firebase.js / server.js contract:
|
||||||
|
// redo=false: apply undo.updates to encounterPath, flip log undone=true
|
||||||
|
// redo=true: apply undo.redo to encounterPath, flip log undone=false
|
||||||
|
async undo({ logPath, undo, redo = false }) {
|
||||||
|
calls.push({ fn: 'undo', path: logPath, data: { undo, redo } });
|
||||||
|
const patch = redo ? (undo.redo || undo.updates) : undo.updates;
|
||||||
|
applyPatch(undo.encounterPath, patch);
|
||||||
|
if (docs.has(logPath)) {
|
||||||
|
const log = docs.get(logPath);
|
||||||
|
docs.set(logPath, { ...log, undone: !redo });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// filters
|
||||||
|
updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); },
|
||||||
|
logs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
|
||||||
|
getLogs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo/redo harness: apply op via shared func (writes enc doc + log) then
|
||||||
|
// exercise the REAL undo mechanism (storage.undo applies patch + flips flag).
|
||||||
|
// Reads doc back so assertions reflect actual persisted state.
|
||||||
|
// applyThenUndo(ctx, encAfterOp) -> enc after undo applied
|
||||||
|
// applyThenRedo(ctx, encAfterUndo, lastLogId) -> enc after redo applied
|
||||||
|
async function undoLast(ctx, encAfterOp) {
|
||||||
|
const { storage, encPath, logPath } = ctx;
|
||||||
|
const last = storage.logs()[storage.logs().length - 1];
|
||||||
|
const { expandUndo } = require('./..');
|
||||||
|
const expanded = expandUndo(last, encAfterOp);
|
||||||
|
if (!expanded) throw new Error('expandUndo returned null');
|
||||||
|
const logEntryPath = `${logPath}/${last.id}`;
|
||||||
|
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: false });
|
||||||
|
return await storage.getDoc(encPath);
|
||||||
|
}
|
||||||
|
async function redoLast(ctx, encAfterUndo, last) {
|
||||||
|
const { storage, encPath, logPath } = ctx;
|
||||||
|
const { expandUndo } = require('./..');
|
||||||
|
const expanded = expandUndo(last, encAfterUndo);
|
||||||
|
if (!expanded) throw new Error('expandUndo returned null (redo)');
|
||||||
|
const logEntryPath = `${logPath}/${last.id}`;
|
||||||
|
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: true });
|
||||||
|
return await storage.getDoc(encPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard ctx for tests. encPath + logPath + displayPath preset.
|
||||||
|
// Returns { storage, ctx } so tests destructure both in one call:
|
||||||
|
// const { storage, ctx } = mockCtx();
|
||||||
|
function mockCtx(storage) {
|
||||||
|
storage = storage || createMockStorage();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encPath: 'encounters/e1',
|
||||||
|
logPath: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
return { storage, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard 2-participant ready encounter.
|
||||||
|
function readyEnc(opts = {}) {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
name: opts.name || 'TestEnc',
|
||||||
|
round: 0,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
turnOrderIds: [],
|
||||||
|
participants: opts.participants || [
|
||||||
|
{ id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
|
||||||
|
{ id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createMockStorage, mockCtx, readyEnc, undoLast, redoLast };
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Test logEvent normalizer — canonical shape for UI/download/replay/analyze.
|
||||||
|
// New design: legacy logs (no type) DROPPED. Not in pipeline. Kept only for
|
||||||
|
// human scroll in LogsView (raw). download/copy/replay/analyze = typed only.
|
||||||
|
const { normalizeEvent, serializeEvents } = require('../logEvent.js');
|
||||||
|
|
||||||
|
describe('logEvent normalizer', () => {
|
||||||
|
test('new schema passes through', () => {
|
||||||
|
const raw = {
|
||||||
|
id: 'l1', ts: 1000, type: 'next_turn', message: 'Bob turn',
|
||||||
|
encounterId: 'e1', encounterName: 'Enc', encounterPath: 'encounters/e1',
|
||||||
|
participantId: 'p1', participantName: 'Bob',
|
||||||
|
delta: { wrapped: true, prevRound: 1 },
|
||||||
|
undo: { currentTurnParticipantId: 'p0', round: 1, turnOrderIds: ['p0','p1'] },
|
||||||
|
undone: false, snapshot: { round: 2, currentTurnParticipantId: 'p1', turnOrderIds: ['p1'], activeIds: ['p1'] },
|
||||||
|
};
|
||||||
|
const e = normalizeEvent(raw);
|
||||||
|
expect(e.id).toBe('l1');
|
||||||
|
expect(e.type).toBe('next_turn');
|
||||||
|
expect(e.snapshot.round).toBe(2);
|
||||||
|
expect(e.delta.wrapped).toBe(true);
|
||||||
|
expect(e.undo.round).toBe(1);
|
||||||
|
expect(e.participantName).toBe('Bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old bloated schema (payload/undo_payload) lifts undo, drops payload', () => {
|
||||||
|
const raw = {
|
||||||
|
id: 'l1b', ts: 1100, type: 'damage', message: 'dmg',
|
||||||
|
encounterPath: 'encounters/e1',
|
||||||
|
payload: { participants: [{ id: 'p1', currentHp: 30 }] },
|
||||||
|
undo_payload: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
||||||
|
snapshot: { round: 2, currentTurnParticipantId: 'p1', turnOrderIds: ['p1'], activeIds: ['p1'] },
|
||||||
|
};
|
||||||
|
const e = normalizeEvent(raw);
|
||||||
|
expect(e.type).toBe('damage');
|
||||||
|
expect(e.undo).toBeTruthy();
|
||||||
|
expect(e.delta).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy (no type) dropped → null', () => {
|
||||||
|
const raw = { id: 'l2', timestamp: 500, message: 'old', encounterName: 'Enc', undo: { encounterPath: 'enc/e1', updates: {} } };
|
||||||
|
expect(normalizeEvent(raw)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit unknown type dropped → null', () => {
|
||||||
|
expect(normalizeEvent({ id: 'l3', type: 'unknown' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no type at all dropped → null', () => {
|
||||||
|
expect(normalizeEvent({ id: 'l4' })).toBeNull();
|
||||||
|
expect(normalizeEvent({ path: 'logs/abc' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null in null out', () => {
|
||||||
|
expect(normalizeEvent(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serialize drops all-legacy input → empty', () => {
|
||||||
|
const raw = [
|
||||||
|
{ id: 'c', timestamp: 300 },
|
||||||
|
{ id: 'a', timestamp: 100 },
|
||||||
|
{ id: 'b', timestamp: 200 },
|
||||||
|
];
|
||||||
|
expect(JSON.parse(serializeEvents(raw))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serialize sorts typed events ascending by ts', () => {
|
||||||
|
const raw = [
|
||||||
|
{ id: 'c', type: 'next_turn', ts: 300 },
|
||||||
|
{ id: 'a', type: 'start_encounter', ts: 100 },
|
||||||
|
{ id: 'b', type: 'damage', ts: 200 },
|
||||||
|
];
|
||||||
|
const arr = JSON.parse(serializeEvents(raw));
|
||||||
|
expect(arr.map(e => e.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serialize filters nulls + legacy, keeps typed', () => {
|
||||||
|
const arr = JSON.parse(serializeEvents([null, { id: 'x', type: 'damage' }, { id: 'legacy' }, null]));
|
||||||
|
expect(arr).toHaveLength(1);
|
||||||
|
expect(arr[0].id).toBe('x');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
|
||||||
|
// Scans App.js + storage adapters + shared modules. Catches unused imports,
|
||||||
|
// dead code, undefined vars before they hit the browser console.
|
||||||
|
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
|
||||||
|
const TARGETS = [
|
||||||
|
'src/App.js',
|
||||||
|
'src/storage/contract.js',
|
||||||
|
'src/storage/firebase.js',
|
||||||
|
'src/storage/index.js',
|
||||||
|
'src/storage/server.js',
|
||||||
|
'shared/index.js',
|
||||||
|
'shared/logEvent.js',
|
||||||
|
'shared/turn.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
function runEslint(files) {
|
||||||
|
const abs = files.map(f => `"${path.join(ROOT, f)}"`).join(' ');
|
||||||
|
// execSync throws on non-zero exit (eslint returns 1 when problems found).
|
||||||
|
// Capture stdout regardless.
|
||||||
|
let stdout;
|
||||||
|
try {
|
||||||
|
stdout = execSync(`npx eslint ${abs} --format json`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
cwd: ROOT,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
stdout = err.stdout || '';
|
||||||
|
}
|
||||||
|
return JSON.parse(stdout || '[]');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('static guard: eslint clean on prod source', () => {
|
||||||
|
test('zero eslint errors or warnings', () => {
|
||||||
|
const results = runEslint(TARGETS);
|
||||||
|
const problems = [];
|
||||||
|
let totalErrors = 0;
|
||||||
|
let totalWarnings = 0;
|
||||||
|
for (const file of results) {
|
||||||
|
totalErrors += file.errorCount;
|
||||||
|
totalWarnings += file.warningCount;
|
||||||
|
for (const msg of file.messages) {
|
||||||
|
problems.push(` ${path.relative(ROOT, file.filePath)}:${msg.line}:${msg.column} ${msg.ruleId} — ${msg.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalErrors + totalWarnings > 0) {
|
||||||
|
const summary = `eslint found ${totalErrors} error(s), ${totalWarnings} warning(s):\n${problems.join('\n')}`;
|
||||||
|
throw new Error(summary);
|
||||||
|
}
|
||||||
|
}, 30000); // eslint spawn can be slow
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// STATIC GUARD: no `.sort(` introduced in shared/turn.js outside the ONE
|
||||||
|
// allowed function (sortParticipantsByInitiative, used by startEncounter to
|
||||||
|
// freeze the list once). Slot-not-sort design (docs/INITIATIVE_ORDERING.md):
|
||||||
|
// mutations = insert/move, never wholesale re-sort. A stray `.sort(` after
|
||||||
|
// start destroys drag tie-break order.
|
||||||
|
//
|
||||||
|
// This test errs the moment someone adds `.sort(` anywhere but the allowlist.
|
||||||
|
// Maintenance: add a function to ALLOWED only if it runs at startEncounter
|
||||||
|
// (one-time freeze), NOT in add/update/reorder/nextTurn paths.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
|
||||||
|
|
||||||
|
// Functions permitted to call .sort(. Listed so intent is explicit + reviewed.
|
||||||
|
const ALLOWED_SORT_FUNCTIONS = new Set([
|
||||||
|
'sortParticipantsByInitiative',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Collect top-level function bodies by brace counting.
|
||||||
|
// Matches `function NAME(` decls AND `const NAME = ... =>` arrow fns.
|
||||||
|
// Returns [{ name, body }].
|
||||||
|
function collectBody(src, fromIdx) {
|
||||||
|
let i = fromIdx;
|
||||||
|
while (i < src.length && src[i] !== '{') i++;
|
||||||
|
let depth = 0;
|
||||||
|
const start = i;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') { depth--; if (depth === 0) break; }
|
||||||
|
}
|
||||||
|
return src.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFunctions(src) {
|
||||||
|
const out = [];
|
||||||
|
const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g;
|
||||||
|
const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g;
|
||||||
|
let m;
|
||||||
|
while ((m = fnRe.exec(src)) !== null) {
|
||||||
|
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
|
||||||
|
}
|
||||||
|
while ((m = arrowRe.exec(src)) !== null) {
|
||||||
|
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('STATIC: no .sort( outside allowlist (slot-not-sort design)', () => {
|
||||||
|
test('every .sort( call lives in an allowed function', () => {
|
||||||
|
const fns = extractFunctions(SRC);
|
||||||
|
const offenders = [];
|
||||||
|
for (const { name, body } of fns) {
|
||||||
|
if (!ALLOWED_SORT_FUNCTIONS.has(name) && /\.sort\(/.test(body)) {
|
||||||
|
offenders.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowed sort function is declared (no silent allowlist drift)', () => {
|
||||||
|
const declared = new Set(extractFunctions(SRC).map(f => f.name));
|
||||||
|
for (const name of ALLOWED_SORT_FUNCTIONS) {
|
||||||
|
expect(declared.has(name)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// BUG-10: deact+reactivate same round double-acts participant.
|
||||||
|
// OLD bug: reactivation re-inserted by initiative (front of pointer) →
|
||||||
|
// participant acted again before round end.
|
||||||
|
//
|
||||||
|
// 1-list model: toggleActive keeps slot position. Reactivation does NOT
|
||||||
|
// grant second turn. Each participant acts at most once per round.
|
||||||
|
//
|
||||||
|
// Test: walk rounds, count visits. Deactivate+reactivate mid-round must
|
||||||
|
// not cause any participant to act twice in same round.
|
||||||
|
//
|
||||||
|
// New API: mutating funcs are async, take ctx, return newEnc.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
makeParticipant,
|
||||||
|
startEncounter, nextTurn, toggleParticipantActive,
|
||||||
|
} = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function p(id, init) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100 });
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BUG-10: deact+reactivate same round', () => {
|
||||||
|
test('no participant acts twice in a round after deact+reactivate', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
// [a(10), b(7), c(3)]
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx); // a current, r1
|
||||||
|
|
||||||
|
const r1 = [];
|
||||||
|
r1.push(e.currentTurnParticipantId); // a acts
|
||||||
|
e = await nextTurn(e, ctx); r1.push(e.currentTurnParticipantId); // b acts
|
||||||
|
|
||||||
|
// b already acted and is still current. Deactivate b: status edit only,
|
||||||
|
// no turn advance. DM clicks Next Turn explicitly; nextTurn skips inactive b.
|
||||||
|
e = await toggleParticipantActive(e, 'b', ctx); // b off, current still b
|
||||||
|
expect(e.currentTurnParticipantId).toBe('b');
|
||||||
|
e = await nextTurn(e, ctx); // skips inactive b → c
|
||||||
|
r1.push(e.currentTurnParticipantId);
|
||||||
|
// reactivate b same round; b must not re-act before round wraps.
|
||||||
|
e = await toggleParticipantActive(e, 'b', ctx); // b on
|
||||||
|
|
||||||
|
// nextTurn from c → round 2 (a). b must NOT re-act in round 1.
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
expect(e.round).toBe(2);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a');
|
||||||
|
|
||||||
|
// b acted once in r1, must act once in r2
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('b');
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('c');
|
||||||
|
|
||||||
|
// per-round visit count
|
||||||
|
const bCountR1 = r1.filter(id => id === 'b').length;
|
||||||
|
expect(bCountR1).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deactivate+reactivate non-current who already acted: no re-act', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
// [a(10), b(7), c(3)]. a acts, b acts, c acts (round 1 done).
|
||||||
|
// Then deactivate a (already acted, not current since c is current).
|
||||||
|
// Reactivate a. a must not act again until round 2.
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx); // a
|
||||||
|
e = await nextTurn(e, ctx); // b
|
||||||
|
e = await nextTurn(e, ctx); // c (r1 full: a,b,c)
|
||||||
|
// c is current. deactivate a (not current, already acted).
|
||||||
|
e = await toggleParticipantActive(e, 'a', ctx); // a off, pointer stays c
|
||||||
|
expect(e.currentTurnParticipantId).toBe('c');
|
||||||
|
e = await toggleParticipantActive(e, 'a', ctx); // a on
|
||||||
|
expect(e.currentTurnParticipantId).toBe('c');
|
||||||
|
|
||||||
|
// nextTurn from c → a (round 2). a acts in r2, once.
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
expect(e.round).toBe(2);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// BUG-13: cross-pointer reorder blocked (skip/double-act otherwise).
|
||||||
|
// nextTurn walks turnOrderIds forward from current pointer. Dragging across
|
||||||
|
// pointer = ambiguous who-acts-next:
|
||||||
|
// - upcoming dragged ahead of pointer → walk wraps past → SKIPPED
|
||||||
|
// - acted dragged behind pointer → acts again → DOUBLE-ACT
|
||||||
|
// Full fix needs actedThisRound tracking. Pragmatic now: block both dirs
|
||||||
|
// during active encounter. Pre-combat: free reorder (no pointer).
|
||||||
|
//
|
||||||
|
// New API: reorderParticipants is async, takes ctx, returns newEnc.
|
||||||
|
// Blocked drag = no-op (returns same enc ref, no write).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
makeParticipant,
|
||||||
|
startEncounter, nextTurn, reorderParticipants,
|
||||||
|
} = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function p(id, init) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100 });
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BUG-13: cross-pointer reorder blocked during active encounter', () => {
|
||||||
|
test('dragging not-yet-acted participant ahead of pointer = no-op', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
// [a(10), b(10), c(10)]. a acts -> b current. c not yet acted.
|
||||||
|
let e = enc([p('a',10),p('b',10),p('c',10)]);
|
||||||
|
e = await startEncounter(e, ctx); // a (idx0, pointer)
|
||||||
|
e = await nextTurn(e, ctx); // b (idx1, pointer)
|
||||||
|
expect(e.currentTurnParticipantId).toBe('b');
|
||||||
|
|
||||||
|
// Drag c (idx2, behind pointer) before b (idx1, pointer). Crosses pointer.
|
||||||
|
// Would let c act by initiative-reinsert but nextTurn forward-walk skips.
|
||||||
|
// Blocked instead.
|
||||||
|
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||||
|
expect(newEnc).toBe(e); // no-op: same ref, no write
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dragging already-acted participant behind pointer = no-op', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
// [a(10), b(10), c(10)]. a acts (idx0, ahead of pointer).
|
||||||
|
let e = enc([p('a',10),p('b',10),p('c',10)]);
|
||||||
|
e = await startEncounter(e, ctx); // a (pointer idx0)
|
||||||
|
e = await nextTurn(e, ctx); // b (pointer idx1)
|
||||||
|
// Drag a (already acted, ahead of pointer) after c (behind pointer).
|
||||||
|
// Crosses pointer. Would let a act again. Blocked.
|
||||||
|
const newEnc = await reorderParticipants(e, 'a', 'c', ctx);
|
||||||
|
expect(newEnc).toBe(e); // no-op: same ref, no write
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorder within same side of pointer = allowed', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
// [a(10), b(10), c(10), d(10)]. a current (pointer idx0).
|
||||||
|
// b,c,d all behind pointer (upcoming). Reorder among them = safe.
|
||||||
|
let e = enc([p('a',10),p('b',10),p('c',10),p('d',10)]);
|
||||||
|
e = await startEncounter(e, ctx); // a (idx0)
|
||||||
|
// swap b,c (both idx1,2 — behind pointer). No cross.
|
||||||
|
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||||
|
// startEncounter logs too; assert the reorder write specifically.
|
||||||
|
expect(storage.logs().filter(l => l.type === 'reorder')).toHaveLength(1);
|
||||||
|
expect(newEnc.participants.map(p => p.id)).toEqual(['a','c','b','d']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pre-combat: free reorder (no pointer)', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
// isStarted=false. No pointer. Reorder allowed freely.
|
||||||
|
let e = enc([p('a',10),p('b',10),p('c',10)]);
|
||||||
|
const newEnc = await reorderParticipants(e, 'c', 'a', ctx);
|
||||||
|
expect(storage.logs().filter(l => l.type === 'reorder')).toHaveLength(1);
|
||||||
|
expect(newEnc.participants.map(p => p.id)).toEqual(['c','a','b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// BUG-7: reorderParticipants not logged.
|
||||||
|
// Every mutating func is now async, takes ctx last, and writes encounter +
|
||||||
|
// log internally via commit(). reorderParticipants must emit a log entry on a
|
||||||
|
// real move (so the drag shows up in the combat log + carries an undo payload)
|
||||||
|
// and stay a silent no-op (no write) when blocked.
|
||||||
|
//
|
||||||
|
// RED was: returned { patch, log } with log:null → handler skipped logAction.
|
||||||
|
// Now: a successful reorder writes exactly one log entry; no-ops write none.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { makeParticipant, reorderParticipants, expandUndo } = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function p(id, init) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100 });
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BUG-7: reorderParticipants logged', () => {
|
||||||
|
test('returns log object (not null) with message + undo', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
|
||||||
|
await reorderParticipants(e, 'c', 'b', ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
const entry = storage.logs()[0];
|
||||||
|
expect(typeof entry.message).toBe('string');
|
||||||
|
expect(entry.message.length).toBeGreaterThan(0);
|
||||||
|
expect(entry.undo).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undo restores original participants order', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
|
||||||
|
const orig = e.participants.map(p => p.id);
|
||||||
|
await reorderParticipants(e, 'c', 'b', ctx);
|
||||||
|
const entry = storage.logs()[0];
|
||||||
|
const restored = { ...e, ...expandUndo(entry, e).updates };
|
||||||
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no-op (same id) returns null log', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10), p('b', 10)]);
|
||||||
|
const newEnc = await reorderParticipants(e, 'a', 'a', ctx);
|
||||||
|
expect(newEnc).toBe(e); // same ref = no write
|
||||||
|
expect(storage.logs()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no-op (cross-init blocked) returns null log', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10), p('b', 5)]);
|
||||||
|
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||||
|
expect(newEnc).toBe(e); // same ref = no write
|
||||||
|
expect(storage.logs()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
// Characterization tests for shared/turn.js.
|
||||||
|
// Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix.
|
||||||
|
// These tests assert what the code does NOW, not what it SHOULD do.
|
||||||
|
//
|
||||||
|
// New API: every mutating func is async, takes ctx last, writes encounter +
|
||||||
|
// log internally, returns newEnc. We assert against newEnc (merged state) or
|
||||||
|
// the raw persisted patch (storage.calls) for "field not written" checks.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const {
|
||||||
|
sortParticipantsByInitiative,
|
||||||
|
computeTurnOrderAfterRemoval,
|
||||||
|
startEncounter,
|
||||||
|
nextTurn,
|
||||||
|
togglePause,
|
||||||
|
addParticipant,
|
||||||
|
removeParticipant,
|
||||||
|
toggleParticipantActive,
|
||||||
|
applyHpChange,
|
||||||
|
deathSave,
|
||||||
|
toggleCondition,
|
||||||
|
reorderParticipants,
|
||||||
|
endEncounter,
|
||||||
|
makeParticipant,
|
||||||
|
} = shared;
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
// Helper: minimal encounter with given participants.
|
||||||
|
function enc(participants = [], extra = {}) {
|
||||||
|
return {
|
||||||
|
name: 'Test Encounter',
|
||||||
|
participants,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
turnOrderIds: [],
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function p(id, initiative, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative, maxHp: 20, currentHp: 20,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last updateDoc patch written to storage — for "field absent from patch"
|
||||||
|
// assertions (field === undefined means func didn't write it).
|
||||||
|
const lastPatch = (storage) => {
|
||||||
|
const u = storage.calls.filter(c => c.fn === 'updateDoc').pop();
|
||||||
|
return u ? u.data : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('sortParticipantsByInitiative', () => {
|
||||||
|
test('higher initiative first', () => {
|
||||||
|
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||||
|
const sorted = sortParticipantsByInitiative(ps, ps);
|
||||||
|
expect(sorted.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ties broken by original order', () => {
|
||||||
|
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
||||||
|
const sorted = sortParticipantsByInitiative(ps, ps);
|
||||||
|
expect(sorted.map(x => x.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startEncounter', () => {
|
||||||
|
test('throws if no participants', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
await expect(startEncounter(enc([]), ctx)).rejects.toThrow('participants');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws if no active participants', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10, { isActive: false })]);
|
||||||
|
await expect(startEncounter(e, ctx)).rejects.toThrow('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets round 1, turn order sorted, current = highest init', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||||
|
const e = enc(ps);
|
||||||
|
const newEnc = await startEncounter(e, ctx);
|
||||||
|
expect(newEnc.isStarted).toBe(true);
|
||||||
|
expect(newEnc.round).toBe(1);
|
||||||
|
expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||||
|
expect(newEnc.currentTurnParticipantId).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inactive stays in turn order slot (1-list model)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)];
|
||||||
|
const newEnc = await startEncounter(enc(ps), ctx);
|
||||||
|
// 1-list: all participants sorted by init (active+inactive), inactive stays in slot
|
||||||
|
expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||||
|
expect(newEnc.currentTurnParticipantId).toBe('c'); // b inactive, skipped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextTurn', () => {
|
||||||
|
test('throws if not started', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
await expect(nextTurn(enc([p('a', 10)], { isStarted: false }), ctx)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws if paused', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
await expect(nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }), ctx)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('advances to next in order, no round bump', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||||
|
const e = enc(ps, {
|
||||||
|
isStarted: true,
|
||||||
|
round: 1,
|
||||||
|
currentTurnParticipantId: 'b',
|
||||||
|
turnOrderIds: ['b', 'c', 'a'],
|
||||||
|
});
|
||||||
|
const newEnc = await nextTurn(e, ctx);
|
||||||
|
expect(newEnc.currentTurnParticipantId).toBe('c');
|
||||||
|
expect(newEnc.round).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wraps round when last in order', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||||
|
const e = enc(ps, {
|
||||||
|
isStarted: true,
|
||||||
|
round: 1,
|
||||||
|
currentTurnParticipantId: 'a',
|
||||||
|
turnOrderIds: ['b', 'c', 'a'],
|
||||||
|
});
|
||||||
|
const newEnc = await nextTurn(e, ctx);
|
||||||
|
expect(newEnc.currentTurnParticipantId).toBe('b');
|
||||||
|
expect(newEnc.round).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ends encounter if no active participants', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { isActive: false })];
|
||||||
|
const e = enc(ps, {
|
||||||
|
isStarted: true,
|
||||||
|
round: 1,
|
||||||
|
currentTurnParticipantId: 'a',
|
||||||
|
turnOrderIds: ['a'],
|
||||||
|
});
|
||||||
|
const newEnc = await nextTurn(e, ctx);
|
||||||
|
expect(newEnc.isStarted).toBe(false);
|
||||||
|
expect(newEnc.currentTurnParticipantId).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('togglePause', () => {
|
||||||
|
test('pauses started encounter', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
|
||||||
|
const newEnc = await togglePause(e, ctx);
|
||||||
|
expect(newEnc.isPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume preserves turn order (no re-sort)', async () => {
|
||||||
|
// BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer
|
||||||
|
// and caused skips. Order frozen at startEncounter, patched incrementally.
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 5), p('b', 15)];
|
||||||
|
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
|
||||||
|
const newEnc = await togglePause(e, ctx);
|
||||||
|
expect(newEnc.isPaused).toBe(false);
|
||||||
|
expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeParticipant', () => {
|
||||||
|
test('removes from participants array', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
const newEnc = await removeParticipant(enc(ps), 'a', ctx);
|
||||||
|
expect(newEnc.participants.map(x => x.id)).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not started: no turn order mutation', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
await removeParticipant(enc(ps), 'a', ctx);
|
||||||
|
expect(lastPatch(storage).turnOrderIds).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('started: removes from turnOrderIds', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
|
||||||
|
const newEnc = await removeParticipant(e, 'a', ctx);
|
||||||
|
expect(newEnc.turnOrderIds).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('started: removing current picks next active', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 5), p('c', 3)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' });
|
||||||
|
const newEnc = await removeParticipant(e, 'a', ctx);
|
||||||
|
expect(newEnc.currentTurnParticipantId).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleParticipantActive', () => {
|
||||||
|
test('deactivates participant', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { isActive: true })];
|
||||||
|
const newEnc = await toggleParticipantActive(enc(ps), 'a', ctx);
|
||||||
|
expect(newEnc.participants[0].isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('started: deactivating current does not advance turn or round', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 5)];
|
||||||
|
const e = enc(ps, { isStarted: true, round: 1, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||||
|
const newEnc = await toggleParticipantActive(e, 'a', ctx);
|
||||||
|
expect(newEnc.currentTurnParticipantId).toBe('a');
|
||||||
|
expect(newEnc.round).toBe(1);
|
||||||
|
expect(newEnc.participants.find(p => p.id === 'a').isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('started: reactivating inserts by initiative', async () => {
|
||||||
|
// BUG-5 fix: reactivated participant slots by initiative (not appended
|
||||||
|
// to end). Preserves correct rotation order.
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { isActive: false }), p('b', 5)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
|
||||||
|
const newEnc = await toggleParticipantActive(e, 'a', ctx);
|
||||||
|
// a init=10 > b init=5 → a slots before b
|
||||||
|
expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyHpChange', () => {
|
||||||
|
test('damage reduces hp, clamps 0', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
|
||||||
|
const newEnc = await applyHpChange(enc(ps), 'a', 'damage', 5, ctx);
|
||||||
|
expect(newEnc.participants[0].currentHp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage to 0 marks dying, keeps active, keeps turn order', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { type: 'character', currentHp: 3 }), p('b', 5)];
|
||||||
|
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
|
||||||
|
expect(newEnc.participants[0].currentHp).toBe(0);
|
||||||
|
expect(newEnc.participants[0].status).toBe('dying');
|
||||||
|
expect(newEnc.participants[0].isActive).not.toBe(false);
|
||||||
|
expect(lastPatch(storage).turnOrderIds).toBeUndefined();
|
||||||
|
expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal from dying makes conscious and resets death-save counters', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveSuccesses: 2, deathSaveFailures: 1 })];
|
||||||
|
const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 5, ctx);
|
||||||
|
expect(newEnc.participants[0].currentHp).toBe(5);
|
||||||
|
expect(newEnc.participants[0].status).toBe('conscious');
|
||||||
|
expect(newEnc.participants[0].deathSaveSuccesses).toBe(0);
|
||||||
|
expect(newEnc.participants[0].deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal clamps to maxHp', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
|
||||||
|
const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 10, ctx);
|
||||||
|
expect(newEnc.participants[0].currentHp).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero amount = no-op', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10, { currentHp: 10 })]);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 0, ctx);
|
||||||
|
expect(newEnc).toBe(e); // same ref = no write
|
||||||
|
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deathSave', () => {
|
||||||
|
test('fail increments failure counter while dying', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 0, deathSaveSuccesses: 0 })];
|
||||||
|
const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
|
||||||
|
expect(status).toBe('dying');
|
||||||
|
expect(newEnc.participants[0].deathSaveFailures).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('third fail makes dead and resets counters', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 2, deathSaveSuccesses: 0 })];
|
||||||
|
const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
|
||||||
|
expect(status).toBe('dead');
|
||||||
|
expect(newEnc.participants[0].status).toBe('dead');
|
||||||
|
expect(newEnc.participants[0].deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleCondition', () => {
|
||||||
|
test('adds condition', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { conditions: [] })];
|
||||||
|
const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
|
||||||
|
expect(newEnc.participants[0].conditions).toEqual(['poisoned']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes condition', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
|
||||||
|
const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
|
||||||
|
expect(newEnc.participants[0].conditions).toEqual(['blinded']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reorderParticipants', () => {
|
||||||
|
test('drag downward onto target moves after target (same-init tie)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
||||||
|
const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx);
|
||||||
|
// drag a downward onto c: remove a → [b,c], insert after c → [b,c,a]
|
||||||
|
expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-init drag blocked (no-op)', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10), p('b', 5)]);
|
||||||
|
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||||
|
expect(newEnc).toBe(e); // same ref = no write
|
||||||
|
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('endEncounter', () => {
|
||||||
|
test('resets all combat state', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const e = enc([p('a', 10)], {
|
||||||
|
isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'],
|
||||||
|
});
|
||||||
|
const newEnc = await endEncounter(e, ctx);
|
||||||
|
expect(newEnc.isStarted).toBe(false);
|
||||||
|
expect(newEnc.round).toBe(0);
|
||||||
|
expect(newEnc.currentTurnParticipantId).toBe(null);
|
||||||
|
expect(newEnc.turnOrderIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeTurnOrderAfterRemoval', () => {
|
||||||
|
test('not started = empty', () => {
|
||||||
|
const out = computeTurnOrderAfterRemoval(enc([]), 'a', []);
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing non-current: no turnOrderIds patch (1-list syncs at call site)', () => {
|
||||||
|
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
|
||||||
|
const out = computeTurnOrderAfterRemoval(e, 'a', []);
|
||||||
|
// 1-list: removal syncs turnOrderIds via participants[] at call site.
|
||||||
|
// Helper only handles current-advance. Non-current = empty patch.
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addParticipant', () => {
|
||||||
|
test('appends participant', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const np = p('z', 7);
|
||||||
|
const newEnc = await addParticipant(enc([p('a', 10)]), np, ctx);
|
||||||
|
expect(newEnc.participants.map(x => x.id)).toEqual(['a', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects duplicate id (skip-bug root cause)', async () => {
|
||||||
|
// Two participants with same id → togglePause resume rebuilds order with
|
||||||
|
// dup id twice → nextTurn gets stuck repeating that id forever.
|
||||||
|
// Audit found this in 100-round replay (addParticipant fired while paused
|
||||||
|
// because nextTurn threw, loop spun, same totalTurns %10 → re-added).
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const existing = p('x', 5);
|
||||||
|
const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 });
|
||||||
|
await expect(addParticipant(enc([p('a', 10), existing]), dup, ctx)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// Combat integrity test: replay exact op sequence through async turn.js,
|
||||||
|
// assert rotation + state invariants per round. This IS the test the audit
|
||||||
|
// was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5.
|
||||||
|
//
|
||||||
|
// Mirrors scripts/replay-combat.js op order:
|
||||||
|
// damage, heal (cleric), conditions, toggleActive, deathSave,
|
||||||
|
// removeParticipant, addParticipant, updateParticipant, pause/resume,
|
||||||
|
// reorderParticipants, revive-between-rounds.
|
||||||
|
//
|
||||||
|
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
const {
|
||||||
|
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause,
|
||||||
|
addParticipant, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, applyHpChange, deathSave,
|
||||||
|
toggleCondition, reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
// ---- seeded RNG (deterministic, reproducible) ----
|
||||||
|
let _seed = 12345;
|
||||||
|
function rand() {
|
||||||
|
// LCG
|
||||||
|
_seed = (_seed * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return _seed / 0x7fffffff;
|
||||||
|
}
|
||||||
|
const rnd = (n) => Math.floor(rand() * n);
|
||||||
|
const pick = (arr) => arr[rnd(arr.length)];
|
||||||
|
|
||||||
|
const CONDITIONS = [
|
||||||
|
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
|
||||||
|
'exhaustion','frightened','grappled','grazed','incapacitated',
|
||||||
|
'invisible','paralyzed','petrified','poisoned','prone','restrained',
|
||||||
|
'sapped','shield','slowed','stunned','unconscious','vexed',
|
||||||
|
];
|
||||||
|
// Custom (non-built-in) condition ids: DM-added freeform strings.
|
||||||
|
// toggleCondition must accept ANY string (UI custom-condition contract).
|
||||||
|
const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed'];
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 200, currentHp: 200,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupEncounter() {
|
||||||
|
const ps = [
|
||||||
|
buildCharacterParticipant({ id:'c1', name:'Fighter', defaultMaxHp:200, defaultInitMod:2 }).participant,
|
||||||
|
buildCharacterParticipant({ id:'c2', name:'Cleric', defaultMaxHp:180, defaultInitMod:1 }).participant,
|
||||||
|
buildCharacterParticipant({ id:'c3', name:'Rogue', defaultMaxHp:160, defaultInitMod:3 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'Goblin1', maxHp:100, initMod:2 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'Goblin2', maxHp:100, initMod:2 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).participant,
|
||||||
|
buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, asNpc:true }).participant,
|
||||||
|
];
|
||||||
|
// give deterministic ids to monsters for assertions
|
||||||
|
const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' };
|
||||||
|
ps.forEach((part) => { if (idMap[part.name]) part.id = idMap[part.name]; });
|
||||||
|
return {
|
||||||
|
name: 'combat-test', participants: ps,
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentParticipant(e) {
|
||||||
|
if (!e.currentTurnParticipantId) return null;
|
||||||
|
return (e.participants || []).find(x => x.id === e.currentTurnParticipantId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||||
|
jest.setTimeout(30000);
|
||||||
|
|
||||||
|
const ROUNDS = 100;
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
test('every round visits each active participant exactly once', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
_seed = 12345; // reset for reproducibility
|
||||||
|
let e = setupEncounter();
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
let totalTurns = 0;
|
||||||
|
let lastPaused = false;
|
||||||
|
let lastReorder = 0;
|
||||||
|
let reinforcementsAdded = 0;
|
||||||
|
// built-ins first, then custom (proves arbitrary string accepted)
|
||||||
|
const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS];
|
||||||
|
|
||||||
|
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
|
||||||
|
const startRound = e.round;
|
||||||
|
const seenThisRound = [];
|
||||||
|
const cap = (e.participants.length + 2) * 2;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (e.round === startRound && guard < cap) {
|
||||||
|
// resume if paused (must precede nextTurn)
|
||||||
|
if (lastPaused) { e = await togglePause(e, ctx); lastPaused = false; }
|
||||||
|
|
||||||
|
// advance
|
||||||
|
try {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
totalTurns++;
|
||||||
|
// only count if turn belongs to THIS round (no wrap)
|
||||||
|
if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId);
|
||||||
|
|
||||||
|
const actor = currentParticipant(e);
|
||||||
|
|
||||||
|
// 1. damage
|
||||||
|
if (actor) {
|
||||||
|
const foes = e.participants.filter(
|
||||||
|
part => part.id !== actor.id && part.currentHp > 0 && part.isActive !== false
|
||||||
|
);
|
||||||
|
if (foes.length > 0) {
|
||||||
|
const tgt = pick(foes);
|
||||||
|
const dmg = 1 + rnd(5);
|
||||||
|
e = await applyHpChange(e, tgt.id, 'damage', dmg, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. heal (cleric)
|
||||||
|
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
|
||||||
|
const wounded = e.participants
|
||||||
|
.filter(part => part.currentHp > 0 && part.currentHp < part.maxHp && part.isActive !== false)
|
||||||
|
.sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
|
||||||
|
if (wounded.length > 0) {
|
||||||
|
const tgt = wounded[0];
|
||||||
|
const amt = 2 + rnd(5);
|
||||||
|
e = await applyHpChange(e, tgt.id, 'heal', amt, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. conditions
|
||||||
|
if (condQueue.length > 0) {
|
||||||
|
const cond = condQueue[0];
|
||||||
|
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try { e = await toggleCondition(e, tgt.id, cond, ctx); condQueue.shift(); }
|
||||||
|
catch (err) { condQueue.shift(); }
|
||||||
|
}
|
||||||
|
} else if (totalTurns % 6 === 0) {
|
||||||
|
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
|
||||||
|
try { e = await toggleCondition(e, tgt.id, cond, ctx); } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. toggleParticipantActive
|
||||||
|
if (totalTurns % 9 === 0) {
|
||||||
|
const living = e.participants.filter(part => part.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try { e = await toggleParticipantActive(e, tgt.id, ctx); } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5. deathSave
|
||||||
|
if (actor && actor.status === 'dying' && actor.type !== 'npc') {
|
||||||
|
try { const r = await deathSave(e, actor.id, 'fail', ctx); e = r.enc; } catch (err) {}
|
||||||
|
}
|
||||||
|
// 6. removeParticipant (monsters/NPC only; dead PCs stay until DM removes manually)
|
||||||
|
if (totalTurns % 5 === 0) {
|
||||||
|
const dead = e.participants.find(part => (part.status === 'dead' || part.currentHp <= 0) && (part.type === 'npc' || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf'));
|
||||||
|
if (dead) { try { e = await removeParticipant(e, dead.id, ctx); } catch (err) {} }
|
||||||
|
}
|
||||||
|
// 7. addParticipant
|
||||||
|
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
|
||||||
|
const spec = pick([
|
||||||
|
{ name:`Reinforce${reinforcementsAdded+1}`, maxHp:120, initMod:1 },
|
||||||
|
{ name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 },
|
||||||
|
]);
|
||||||
|
const built = buildMonsterParticipant(spec).participant;
|
||||||
|
try { e = await addParticipant(e, built, ctx); reinforcementsAdded++; } catch (err) {}
|
||||||
|
}
|
||||||
|
// 8. updateParticipant
|
||||||
|
if (totalTurns % 7 === 0) {
|
||||||
|
const living = e.participants.filter(part => part.currentHp > 0);
|
||||||
|
if (living.length > 0) {
|
||||||
|
const tgt = pick(living);
|
||||||
|
try { e = await updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` }, ctx); } catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 9. pause
|
||||||
|
if (totalTurns % 12 === 0 && !lastPaused) { e = await togglePause(e, ctx); lastPaused = true; }
|
||||||
|
// 10. reorderParticipants (mirror replay's buggy signature usage — swallowed no-op)
|
||||||
|
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
||||||
|
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
|
||||||
|
if (living.length >= 2) {
|
||||||
|
const tgt = living[0];
|
||||||
|
const newInit = (tgt.initiative || 0) + 1;
|
||||||
|
try {
|
||||||
|
const reordered = [...e.participants].map(part => part.id === tgt.id ? { ...part, initiative: newInit } : part);
|
||||||
|
e = await reorderParticipants(e, reordered); // array as draggedId throws → swallowed
|
||||||
|
lastReorder = totalTurns;
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard++;
|
||||||
|
if (!e.isStarted) break;
|
||||||
|
}
|
||||||
|
if (!e.isStarted) break;
|
||||||
|
|
||||||
|
// === per-round invariants ===
|
||||||
|
const uniq = new Set(seenThisRound);
|
||||||
|
if (uniq.size !== seenThisRound.length) {
|
||||||
|
violations.push({ round: roundN, type: 'rotation-dupe',
|
||||||
|
seen: seenThisRound.map(id => e.participants.find(part=>part.id===id)?.name||id) });
|
||||||
|
}
|
||||||
|
// turnOrderIds no dup
|
||||||
|
const orderUniq = new Set(e.turnOrderIds);
|
||||||
|
if (orderUniq.size !== e.turnOrderIds.length) {
|
||||||
|
violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds });
|
||||||
|
}
|
||||||
|
// currentTurn valid. It may be inactive immediately after DM toggles
|
||||||
|
// current actor inactive; toggle-active is status edit, not turn advance.
|
||||||
|
// Next Turn is responsible for skipping inactive current.
|
||||||
|
if (e.currentTurnParticipantId) {
|
||||||
|
const ct = e.participants.find(part => part.id === e.currentTurnParticipantId);
|
||||||
|
if (!ct) violations.push({ round: roundN, type: 'currentTurn-missing' });
|
||||||
|
}
|
||||||
|
// HP bounds
|
||||||
|
for (const part of e.participants) {
|
||||||
|
if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) {
|
||||||
|
violations.push({ round: roundN, type: 'hp-invalid', id: part.id, hp: part.currentHp, max: part.maxHp });
|
||||||
|
}
|
||||||
|
// custom conditions persisted as raw strings (not dropped)
|
||||||
|
for (const c of (part.conditions || [])) {
|
||||||
|
if (typeof c !== 'string' || !c) {
|
||||||
|
violations.push({ round: roundN, type: 'condition-not-string', id: part.id, c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// custom (freeform) conditions must survive toggle round-trip
|
||||||
|
// (presence verified via condition-not-string invariant above;
|
||||||
|
// queue drains them through toggleCondition which proves acceptance)
|
||||||
|
|
||||||
|
// revive dead between rounds
|
||||||
|
const dead = e.participants.filter(part => part.currentHp <= 0 || part.isActive === false);
|
||||||
|
for (const d of dead) {
|
||||||
|
try {
|
||||||
|
if (d.isActive === false) e = await toggleParticipantActive(e, d.id, ctx);
|
||||||
|
e = await applyHpChange(e, d.id, 'heal', d.maxHp, ctx);
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report
|
||||||
|
if (violations.length > 0) {
|
||||||
|
const byType = {};
|
||||||
|
violations.forEach(v => { byType[v.type] = (byType[v.type]||0) + 1; });
|
||||||
|
const summary = Object.entries(byType).sort((a,b)=>b[1]-a[1]).map(([k,n])=>`${n}x ${k}`).join(', ');
|
||||||
|
const first5 = violations.slice(0,5).map(v => `r${v.round} ${v.type}${v.seen?': '+JSON.stringify(v.seen):''}${v.order?': '+JSON.stringify(v.order):''}${v.msg?': '+v.msg:''}`).join('\n ');
|
||||||
|
// dump full state for first dupe for triage
|
||||||
|
throw new Error(`combat integrity violations: ${violations.length}\n ${summary}\n first 5:\n ${first5}`);
|
||||||
|
}
|
||||||
|
expect(violations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom (freeform) condition strings: UI contract.
|
||||||
|
// toggleCondition must accept ANY string, not just built-ins.
|
||||||
|
test('custom condition strings survive add/remove round-trip', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = setupEncounter();
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const tgt = e.participants[0].id;
|
||||||
|
|
||||||
|
// add custom (not in built-ins)
|
||||||
|
e = await toggleCondition(e, tgt, 'hexed', ctx);
|
||||||
|
let part = e.participants.find(x => x.id === tgt);
|
||||||
|
expect(part.conditions).toContain('hexed');
|
||||||
|
|
||||||
|
// emoji-bearing custom id
|
||||||
|
e = await toggleCondition(e, tgt, '🛡️blessed', ctx);
|
||||||
|
part = e.participants.find(x => x.id === tgt);
|
||||||
|
expect(part.conditions).toContain('🛡️blessed');
|
||||||
|
|
||||||
|
// remove
|
||||||
|
e = await toggleCondition(e, tgt, 'hexed', ctx);
|
||||||
|
part = e.participants.find(x => x.id === tgt);
|
||||||
|
expect(part.conditions).not.toContain('hexed');
|
||||||
|
expect(part.conditions).toContain('🛡️blessed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function pc(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
type: 'character',
|
||||||
|
initiative: init,
|
||||||
|
maxHp: 100,
|
||||||
|
currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
const byId = (e, id) => e.participants.find(p => p.id === id);
|
||||||
|
|
||||||
|
describe('death state: participant remains in initiative and encounter', () => {
|
||||||
|
test('dropping to 0 keeps PC active, in participants, and in turnOrderIds', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const orderBefore = e.turnOrderIds.slice();
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||||
|
|
||||||
|
expect(byId(e, 'b').status).toBe('dying');
|
||||||
|
expect(byId(e, 'b').isActive).not.toBe(false);
|
||||||
|
expect(e.participants.map(p => p.id)).toContain('b');
|
||||||
|
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dead PC remains in participants and turnOrderIds after three failures', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const orderBefore = e.turnOrderIds.slice();
|
||||||
|
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
|
||||||
|
expect(byId(e, 'b').status).toBe('dead');
|
||||||
|
expect(byId(e, 'b').isActive).not.toBe(false);
|
||||||
|
expect(e.participants.map(p => p.id)).toContain('b');
|
||||||
|
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn may still land on dead PC because DM/tool events may matter', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
|
||||||
|
expect(e.currentTurnParticipantId).toBe('b');
|
||||||
|
expect(byId(e, 'b').status).toBe('dead');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deathSave rejected/no-op once dead', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([pc('a', 20), pc('b', 15)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
|
||||||
|
const before = byId(e, 'b');
|
||||||
|
|
||||||
|
try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {}
|
||||||
|
|
||||||
|
expect(byId(e, 'b')).toMatchObject(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
makeParticipant,
|
||||||
|
startEncounter,
|
||||||
|
applyHpChange,
|
||||||
|
deathSave,
|
||||||
|
stabilizeParticipant,
|
||||||
|
reviveParticipant,
|
||||||
|
} = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function pc(id, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
type: 'character',
|
||||||
|
initiative: 10,
|
||||||
|
maxHp: 100,
|
||||||
|
currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function monster(id, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
type: 'monster',
|
||||||
|
initiative: 10,
|
||||||
|
maxHp: 100,
|
||||||
|
currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(participants, extra = {}) {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
name: 'death-save-test',
|
||||||
|
participants,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
turnOrderIds: [],
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startedAtZero() {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([pc('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const originalTurnOrderIds = [...e.turnOrderIds];
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||||
|
return { e, ctx, originalTurnOrderIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
const part = e => e.participants.find(p => p.id === 'a');
|
||||||
|
|
||||||
|
describe('death saves: D&D 5e status state machine', () => {
|
||||||
|
test('damage drops character to dying without removal or deactivation', async () => {
|
||||||
|
const { e, originalTurnOrderIds } = await startedAtZero();
|
||||||
|
const p = part(e);
|
||||||
|
|
||||||
|
expect(p.currentHp).toBe(0);
|
||||||
|
expect(p.status).toBe('dying');
|
||||||
|
expect(p.deathSaveSuccesses).toBe(0);
|
||||||
|
expect(p.deathSaveFailures).toBe(0);
|
||||||
|
expect(p.isActive).not.toBe(false);
|
||||||
|
expect(e.participants.map(x => x.id)).toContain('a');
|
||||||
|
expect(e.turnOrderIds).toEqual(originalTurnOrderIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage drops non-NPC monster to dead/inactive, not dying', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([monster('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const orderBefore = [...e.turnOrderIds];
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('dead');
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
expect(part(e).conditions).not.toContain('unconscious');
|
||||||
|
expect(part(e).isActive).toBe(false);
|
||||||
|
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dead non-NPC monster with stale active flag repairs to inactive', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([monster('a', { currentHp: 0, status: 'dead', isActive: true })]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('dead');
|
||||||
|
expect(part(e).isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage drops NPC to dying', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([monster('a', { type: 'npc' })]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||||
|
|
||||||
|
expect(part(e).type).toBe('npc');
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
expect(part(e).conditions).toContain('unconscious');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three failures immediately makes dead, resets counters, keeps participant and turn order', async () => {
|
||||||
|
let { e, ctx, originalTurnOrderIds } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(1);
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(2);
|
||||||
|
|
||||||
|
const res = await deathSave(e, 'a', 'fail', ctx);
|
||||||
|
e = res.enc;
|
||||||
|
expect(res.status).toBe('dead');
|
||||||
|
expect(part(e).status).toBe('dead');
|
||||||
|
expect(part(e).currentHp).toBe(0);
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
expect(part(e).isActive).not.toBe(false);
|
||||||
|
expect(e.participants.map(x => x.id)).toContain('a');
|
||||||
|
expect(e.turnOrderIds).toEqual(originalTurnOrderIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three successes immediately makes stable and resets counters', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
const res = await deathSave(e, 'a', 'success', ctx);
|
||||||
|
e = res.enc;
|
||||||
|
|
||||||
|
expect(res.status).toBe('stable');
|
||||||
|
expect(part(e).status).toBe('stable');
|
||||||
|
expect(part(e).currentHp).toBe(0);
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successes and failures are independent before terminal transition', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(2);
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(2);
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('stable');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nat1 adds two failures and can kill', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(2);
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('dead');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nat1 at one failure immediately kills', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('dead');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nat20 restores to 1 HP conscious and resets counters', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'nat20', ctx)).enc;
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('conscious');
|
||||||
|
expect(part(e).currentHp).toBe(1);
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage while dying adds one failure', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('critical damage while dying adds two failures', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 1, { isCriticalHit: true }, ctx);
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage while stable returns to dying and applies failure', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('stable');
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('healing while dying or stable makes conscious and clears counters', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||||
|
expect(part(e).status).toBe('conscious');
|
||||||
|
expect(part(e).currentHp).toBe(10);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'success', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('stable');
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'heal', 5, ctx);
|
||||||
|
expect(part(e).status).toBe('conscious');
|
||||||
|
expect(part(e).currentHp).toBe(5);
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal healing does not revive dead', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('dead');
|
||||||
|
|
||||||
|
try { e = await applyHpChange(e, 'a', 'heal', 10, ctx); } catch (err) {}
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('dead');
|
||||||
|
expect(part(e).currentHp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stabilize sets stable, hp zero, counters zero', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = await stabilizeParticipant(e, 'a', ctx);
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('stable');
|
||||||
|
expect(part(e).currentHp).toBe(0);
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative heal acts as damage and can drop to dying', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([pc('a', { currentHp: 1 })]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'heal', -1, ctx);
|
||||||
|
|
||||||
|
expect(part(e).currentHp).toBe(0);
|
||||||
|
expect(part(e).status).toBe('dying');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative damage acts as healing', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', -5, ctx);
|
||||||
|
|
||||||
|
expect(part(e).currentHp).toBe(5);
|
||||||
|
expect(part(e).status).toBe('conscious');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revive moves dead participant to 0 HP stable/unconscious', async () => {
|
||||||
|
let { e, ctx } = await startedAtZero();
|
||||||
|
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||||
|
expect(part(e).status).toBe('dead');
|
||||||
|
|
||||||
|
e = await reviveParticipant(e, 'a', ctx);
|
||||||
|
|
||||||
|
expect(part(e).currentHp).toBe(0);
|
||||||
|
expect(part(e).status).toBe('stable');
|
||||||
|
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
expect(part(e).conditions).toContain('unconscious');
|
||||||
|
expect(part(e).isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('death save outside dying state is rejected or no-op', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([pc('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
try { e = (await deathSave(e, 'a', 'fail', ctx)).enc; } catch (err) {}
|
||||||
|
|
||||||
|
expect(part(e).status).toBe('conscious');
|
||||||
|
expect(part(e).deathSaveFailures).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Undo roundtrip for DEATH paths: damage to 0 HP (dying/dead), deathSave,
|
||||||
|
// stabilize, revive, dead-monster auto-deactivate. Each must restore full
|
||||||
|
// prior state including status, death-save counters, conditions (unconscious),
|
||||||
|
// and isActive.
|
||||||
|
//
|
||||||
|
// Pattern: apply op (writes encounter + log) -> read last log -> expandUndo
|
||||||
|
// against newEnc -> merge -> deepEqual original snapshot.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
const { expandUndo } = shared;
|
||||||
|
const {
|
||||||
|
makeParticipant, startEncounter,
|
||||||
|
applyHpChange, deathSave, stabilizeParticipant, reviveParticipant,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
function char(id, hp = 100) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'character',
|
||||||
|
initiative: 10, maxHp: hp, currentHp: hp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function mon(id, hp = 100) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: 10, maxHp: hp, currentHp: hp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||||
|
|
||||||
|
function lastUndo(storage, enc) {
|
||||||
|
const logs = storage.logs();
|
||||||
|
const last = logs[logs.length - 1];
|
||||||
|
expect(last.undo).toBeTruthy();
|
||||||
|
const expanded = expandUndo(last, enc);
|
||||||
|
expect(expanded).toBeTruthy();
|
||||||
|
return expanded.updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('death-path undo roundtrip', () => {
|
||||||
|
test('damage to 0 HP (dying) undo restores status + unconscious condition', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('dying');
|
||||||
|
expect(p.conditions).toContain('unconscious');
|
||||||
|
const undo = lastUndo(storage, newEnc);
|
||||||
|
const after = { ...newEnc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage while dying undo restores status + failures + conditions', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); // +1 fail
|
||||||
|
expect(newEnc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
|
||||||
|
const undo = lastUndo(storage, newEnc);
|
||||||
|
const after = { ...newEnc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage while stable undo restores stable + reset failures', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||||
|
e = await stabilizeParticipant(e, 'a', ctx); // -> stable
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx); // -> dying 1 fail
|
||||||
|
const undo = lastUndo(storage, newEnc);
|
||||||
|
const after = { ...newEnc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('massive damage (dead) undo restores conscious', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 50)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 200, ctx);
|
||||||
|
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('dead');
|
||||||
|
const undo = lastUndo(storage, newEnc);
|
||||||
|
const after = { ...newEnc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dead monster auto-deactivate undo restores active', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([mon('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
// first make it dead+inactive
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dead + inactive
|
||||||
|
// second damage at 0 while dead: no-op (returns same enc) but if it had
|
||||||
|
// repaired a stale active-dead monster it writes deactivate_dead_monster.
|
||||||
|
// Force the repair path: set stale isActive=true then damage.
|
||||||
|
e = { ...e, participants: e.participants.map(p => p.id === 'a' ? { ...p, isActive: true } : p) };
|
||||||
|
const before = snap(e); // stale active-dead = state right before the op
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||||
|
expect(newEnc.participants.find(x => x.id === 'a').isActive).toBe(false);
|
||||||
|
const undo = lastUndo(storage, newEnc);
|
||||||
|
const after = { ...newEnc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deathSave fail undo restores dying + counters + conditions', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||||
|
const before = snap(e);
|
||||||
|
const r = await deathSave(e, 'a', 'fail', ctx);
|
||||||
|
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
|
||||||
|
const undo = lastUndo(storage, r.enc);
|
||||||
|
const after = { ...r.enc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deathSave nat1 (2 fails) undo restores prior counters', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||||
|
const before = snap(e);
|
||||||
|
const r = await deathSave(e, 'a', 'nat1', ctx);
|
||||||
|
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(2);
|
||||||
|
const undo = lastUndo(storage, r.enc);
|
||||||
|
const after = { ...r.enc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deathSave nat20 (conscious) undo restores dying + unconscious', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||||
|
const before = snap(e);
|
||||||
|
const r = await deathSave(e, 'a', 'nat20', ctx);
|
||||||
|
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('conscious');
|
||||||
|
expect(r.enc.participants.find(x => x.id === 'a').currentHp).toBe(1);
|
||||||
|
const undo = lastUndo(storage, r.enc);
|
||||||
|
const after = { ...r.enc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deathSave to stable undo restores dying + unconscious', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||||
|
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
|
||||||
|
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
|
||||||
|
const before = snap(e); // 2 successes, dying
|
||||||
|
const r = await deathSave(e, 'a', 'success', ctx); // -> stable
|
||||||
|
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('stable');
|
||||||
|
const undo = lastUndo(storage, r.enc);
|
||||||
|
const after = { ...r.enc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stabilize undo restores dying + unconscious + counters', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
|
||||||
|
e = await deathSave(e, 'a', 'fail', ctx).then(x => x.enc); // 1 fail
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await stabilizeParticipant(e, 'a', ctx);
|
||||||
|
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
|
||||||
|
const undo = lastUndo(storage, newEnc);
|
||||||
|
const after = { ...newEnc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revive undo restores dead + isActive + counters', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([char('a')]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 200, ctx); // -> dead (active)
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await reviveParticipant(e, 'a', ctx);
|
||||||
|
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
|
||||||
|
const undo = lastUndo(storage, newEnc);
|
||||||
|
const after = { ...newEnc, ...undo };
|
||||||
|
expect(snap(after)).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Toggle-active semantics guard.
|
||||||
|
// Design: toggle active is a status edit, NOT a turn action.
|
||||||
|
// Deactivating current does not advance turn or increment round. DM clicks
|
||||||
|
// Next Turn explicitly; Next Turn skips inactive participants.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { makeParticipant, nextTurn, toggleParticipantActive } = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||||
|
}
|
||||||
|
function enc(ps, extra = {}) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toggle-active is not a turn advance', () => {
|
||||||
|
test('mid-round: deactivating current leaves current unchanged', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1,
|
||||||
|
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
|
||||||
|
const deact = await toggleParticipantActive(e, 'a', ctx);
|
||||||
|
expect(deact.currentTurnParticipantId).toBe('a');
|
||||||
|
expect(deact.round).toBe(1);
|
||||||
|
expect(deact.participants.find(p => p.id === 'a').isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn after deactivating current skips inactive participant', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1,
|
||||||
|
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
|
||||||
|
const deact = await toggleParticipantActive(e, 'a', ctx);
|
||||||
|
const next = await nextTurn(deact, ctx);
|
||||||
|
expect(next.currentTurnParticipantId).toBe('b');
|
||||||
|
expect(next.round).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrap: deactivating current does not increment round; nextTurn does', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:2,
|
||||||
|
turnOrderIds:['a','b','c'], currentTurnParticipantId:'c' });
|
||||||
|
const deact = await toggleParticipantActive(e, 'c', ctx);
|
||||||
|
expect(deact.currentTurnParticipantId).toBe('c');
|
||||||
|
expect(deact.round).toBe(2);
|
||||||
|
const next = await nextTurn(deact, ctx);
|
||||||
|
expect(next.currentTurnParticipantId).toBe('a');
|
||||||
|
expect(next.round).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, applyHpChange, deathSave, markDead, reviveParticipant,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
function char(id, hp = 100) {
|
||||||
|
const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 });
|
||||||
|
participant.id = id;
|
||||||
|
return participant;
|
||||||
|
}
|
||||||
|
function mon(id, hp = 100) {
|
||||||
|
const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 });
|
||||||
|
participant.id = id;
|
||||||
|
return participant;
|
||||||
|
}
|
||||||
|
function enc(ps, ruleset = 'generic') {
|
||||||
|
return {
|
||||||
|
name: 't', participants: ps, isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
ruleset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||||
|
|
||||||
|
describe('generic ruleset', () => {
|
||||||
|
test('damage past 0 = negative HP, status down (character)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(-5);
|
||||||
|
expect(p.status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage exactly to 0 = status down', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(0);
|
||||||
|
expect(p.status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal from negative = conscious', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(5);
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal caps at maxHp', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(10);
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monster death = auto-inactive (generic)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([mon('m', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id.startsWith('m'));
|
||||||
|
expect(p.currentHp).toBe(-10);
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
expect(p.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monster at exactly 0 = dead + inactive (generic)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([mon('m', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id.startsWith('m'));
|
||||||
|
expect(p.currentHp).toBe(0);
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
expect(p.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deathSave throws in generic mode', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||||
|
await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revive in generic mode: dead→conscious (no stable)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||||
|
e = await markDead(e, 'a', ctx); // dead
|
||||||
|
const newEnc = await reviveParticipant(e, 'a', ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
expect(p.currentHp).toBe(0); // HP unchanged from down
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revive generic reactivates if inactive', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([mon('m', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await markDead(e, e.participants[0].id, ctx); // dead+inactive
|
||||||
|
const newEnc = await reviveParticipant(e, e.participants[0].id, ctx);
|
||||||
|
const p = newEnc.participants[0];
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
expect(p.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markDead sets status dead (character, stays active)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||||
|
const newEnc = await markDead(e, 'a', ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
expect(p.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markDead on monster = dead + inactive', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([mon('m', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp)
|
||||||
|
const newEnc = await markDead(e, e.participants[0].id, ctx);
|
||||||
|
const p = newEnc.participants[0];
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
expect(p.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markDead on conscious = dead', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await markDead(e, 'a', ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage while down = more negative (no death saves)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(-10);
|
||||||
|
expect(p.status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no unconscious condition auto-added (generic)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.conditions).not.toContain('unconscious');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('down→conscious clears dead? no, dead is separate', async () => {
|
||||||
|
// sanity: down heal to conscious, dead untouched
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// INVARIANT test: ONE list. turnOrderIds === participants.map(id) always.
|
||||||
|
// No re-sort after startEncounter. nextTurn follows list order, skipping inactive.
|
||||||
|
// Drag (reorder) overrides initiative — cross-init drag allowed + reflected.
|
||||||
|
// Display === rotation by construction (same array).
|
||||||
|
//
|
||||||
|
// RED now: current code has two lists (sort on display, frozen turnOrderIds),
|
||||||
|
// reorder throws on cross-init. Refactor (1-list model) greens these.
|
||||||
|
//
|
||||||
|
// New API: mutating funcs are async, take ctx last, write encounter + log
|
||||||
|
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
|
||||||
|
// No-ops (reorder blocked) return the same enc ref.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const {
|
||||||
|
makeParticipant,
|
||||||
|
startEncounter, nextTurn, addParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, togglePause, applyHpChange,
|
||||||
|
reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||||
|
}
|
||||||
|
function enc(ps, extra = {}) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => {
|
||||||
|
test('startEncounter: list = sorted-active participants order', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',3),p('b',10),p('c',5)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startEncounter: inactive stays in list slot (skipped by nextTurn)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
// 1-list: inactive b stays in slot, nextTurn skips it
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant mid-encounter: inserted by init, list synced', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx); // [a,c]
|
||||||
|
e = await addParticipant(e, p('b',7), ctx); // insert between a,c
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant: list === participants.map(id) after add', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await addParticipant(e, p('b',5), ctx);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeParticipant: list synced, order preserved', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await removeParticipant(e, 'b', ctx);
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','c']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorder cross-init: blocked (no-op)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx); // [a,b,c]
|
||||||
|
const newEnc = await reorderParticipants(e, 'c', 'a', ctx); // cross-init
|
||||||
|
expect(newEnc).toBe(e); // same ref = no write
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorder same-init: within upcoming side of pointer follows new order', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',10),p('c',10),p('d',3)]); // a,b,c tie
|
||||||
|
e = await startEncounter(e, ctx); // [a,b,c,d], cur=a (idx0)
|
||||||
|
// Reorder c before b (both upcoming idx1,2). Within same side = allowed.
|
||||||
|
e = await reorderParticipants(e, 'c', 'b', ctx); // [a,c,b,d], cur=a
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','c','b','d']);
|
||||||
|
// walk: a current, next c, next b, next d, wrap a
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle inactive: list unchanged (stays in rotation slot)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx); // [a,b,c]
|
||||||
|
e = await toggleParticipantActive(e, 'b', ctx); // b off
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle inactive: nextTurn skips b, visits a→c', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx); // cur=a
|
||||||
|
e = await toggleParticipantActive(e, 'b', ctx); // b inactive
|
||||||
|
e = await nextTurn(e, ctx); // skip b → c
|
||||||
|
expect(e.currentTurnParticipantId).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hp death + revive: list unchanged', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dies
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||||
|
e = await applyHpChange(e, 'b', 'heal', 50, ctx); // b revive
|
||||||
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
||||||
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Logging contract: every mutating combat op writes a log entry to ctx.logPath
|
||||||
|
// via ctx.storage.addDoc. No-op (no state change) performs NO writes and
|
||||||
|
// returns the same encounter reference. Display lifecycle ops write the display
|
||||||
|
// doc, no combat log.
|
||||||
|
//
|
||||||
|
// Contract = every combat mutation produces a log entry.
|
||||||
|
// New shape: mutating funcs are async, take ctx, write encounter (updateDoc) +
|
||||||
|
// log (addDoc) internally, return newEnc. Logs are read back via storage.logs().
|
||||||
|
//
|
||||||
|
// undo payloads now live on the written log entry's `undo_payload.updates`.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
const {
|
||||||
|
makeParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause,
|
||||||
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
|
||||||
|
reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||||
|
}
|
||||||
|
function enc(ps, extra = {}) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert last written log entry is a well-formed mutation log.
|
||||||
|
function expectLastLogged(storage) {
|
||||||
|
const logs = storage.logs();
|
||||||
|
expect(logs.length).toBeGreaterThan(0);
|
||||||
|
const last = logs[logs.length - 1];
|
||||||
|
expect(typeof last.message).toBe('string');
|
||||||
|
expect(last.message.length).toBeGreaterThan(0);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Logging contract: mutating ops', () => {
|
||||||
|
let storage, ctx;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ storage, ctx } = mockCtx());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startEncounter logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
await startEncounter(e, ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
const started = await startEncounter(e, ctx); // 1 log
|
||||||
|
await nextTurn(started, ctx); // 2 logs
|
||||||
|
expect(storage.logs()).toHaveLength(2);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('togglePause does NOT log (lifecycle excluded from undo)', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
const started = await startEncounter(e, ctx);
|
||||||
|
await togglePause(started, ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1); // start only
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
await addParticipant(e, p('d', 5), ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeParticipant logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
await removeParticipant(e, 'b', ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleParticipantActive logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
await toggleParticipantActive(e, 'b', ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyHpChange (damage) logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
await applyHpChange(e, 'b', 'damage', 10, ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyHpChange (heal) logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
await applyHpChange(e, 'b', 'heal', 5, ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deathSave logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7, { type: 'character' }), p('c', 3)]);
|
||||||
|
const started = await startEncounter(e, ctx); // 1 log
|
||||||
|
const dying = await applyHpChange(started, 'b', 'damage', 100, ctx); // 2 logs
|
||||||
|
const r = await deathSave(dying, 'b', 'fail', ctx); // 3 logs
|
||||||
|
expect(r.status).toBe('dying');
|
||||||
|
expect(storage.logs()).toHaveLength(3);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleCondition logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
await toggleCondition(e, 'b', 'poisoned', ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorderParticipants logs (BUG-7)', async () => {
|
||||||
|
// same-init tie (both 10) for valid reorder (unstarted: no pointer check)
|
||||||
|
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
|
||||||
|
await reorderParticipants(e2, 'x', 'a', ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
const started = await startEncounter(e, ctx);
|
||||||
|
await endEncounter(started, ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(2);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logging contract: no-ops', () => {
|
||||||
|
let storage, ctx;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ storage, ctx } = mockCtx());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorder same-id = no-op', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
const newEnc = await reorderParticipants(e, 'a', 'a', ctx);
|
||||||
|
expect(newEnc).toBe(e); // same ref = no write
|
||||||
|
expect(storage.logs()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorder cross-init = no-op', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||||
|
expect(newEnc).toBe(e); // same ref = no write
|
||||||
|
expect(storage.logs()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logging undo payloads', () => {
|
||||||
|
let storage, ctx;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ storage, ctx } = mockCtx());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startEncounter undo restores pre-combat state', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
await startEncounter(e, ctx);
|
||||||
|
const log = expectLastLogged(storage);
|
||||||
|
expect(log.undo).toBeDefined();
|
||||||
|
expect(log.undo.isStarted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter undo restores combat state', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
const started = await startEncounter(e, ctx);
|
||||||
|
await endEncounter(started, ctx);
|
||||||
|
const log = expectLastLogged(storage);
|
||||||
|
expect(log.undo).toBeDefined();
|
||||||
|
expect(log.undo.isStarted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyHpChange undo restores prior hp', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||||
|
const newEnc = await applyHpChange(e, 'b', 'damage', 10, ctx);
|
||||||
|
const log = expectLastLogged(storage);
|
||||||
|
expect(log.undo).toBeDefined();
|
||||||
|
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
|
||||||
|
expect(restored.participants.find(x => x.id === 'b').currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorder undo restores prior order (BUG-7)', async () => {
|
||||||
|
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
|
||||||
|
const orig = e2.participants.map(p => p.id);
|
||||||
|
const newEnc = await reorderParticipants(e2, 'x', 'a', ctx);
|
||||||
|
const log = expectLastLogged(storage);
|
||||||
|
expect(log.undo).toBeDefined();
|
||||||
|
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
|
||||||
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logging: addParticipants + updateParticipant', () => {
|
||||||
|
let storage, ctx;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ storage, ctx } = mockCtx());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipants logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7)]);
|
||||||
|
await addParticipants(e, [p('c', 3)], ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateParticipant (same slot) logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7)]);
|
||||||
|
await updateParticipant(e, 'b', { name: 'B' }, ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateParticipant (init change) logs', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7)]);
|
||||||
|
await updateParticipant(e, 'b', { initiative: 5 }, ctx);
|
||||||
|
expect(storage.logs()).toHaveLength(1);
|
||||||
|
expectLastLogged(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipants undo restores prior list', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7)]);
|
||||||
|
const orig = e.participants.map(p => p.id);
|
||||||
|
const newEnc = await addParticipants(e, [p('c', 3)], ctx);
|
||||||
|
const log = expectLastLogged(storage);
|
||||||
|
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
|
||||||
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateParticipant undo restores prior participant', async () => {
|
||||||
|
const e = enc([p('a', 10), p('b', 7)]);
|
||||||
|
const orig = e.participants.map(p => p.id);
|
||||||
|
const newEnc = await updateParticipant(e, 'b', { name: 'B' }, ctx);
|
||||||
|
const log = expectLastLogged(storage);
|
||||||
|
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
|
||||||
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Characterization test: addParticipant + pause/resume corrupts turn rotation.
|
||||||
|
// Audit found 56-77 violations/100 rounds starting round 20 in pure turn.js
|
||||||
|
// simulation. Visible in live replay (round 10: 17 turns, 6 duped actors,
|
||||||
|
// R-series stuck repeating forever).
|
||||||
|
//
|
||||||
|
// This test uses FRESH ids (crypto.randomUUID equivalent) — NOT the audit's
|
||||||
|
// self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`).
|
||||||
|
// Validates real bug reachable via normal UI flow (DM adds monster while paused,
|
||||||
|
// resumes).
|
||||||
|
//
|
||||||
|
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared;
|
||||||
|
|
||||||
|
function p(id, initiative, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(ps) {
|
||||||
|
return {
|
||||||
|
name: 'T', participants: ps,
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('addParticipant + pause/resume rotation corruption', () => {
|
||||||
|
test('add fresh participant while paused, resume, rotation completes full cycle', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const baseOrder = e.turnOrderIds.slice(); // [a,b,c]
|
||||||
|
|
||||||
|
e = await nextTurn(e, ctx); // current=b
|
||||||
|
e = await togglePause(e, ctx); // pause
|
||||||
|
|
||||||
|
// add fresh participant x (initiative 25, would sort first)
|
||||||
|
const x = p('x', 25);
|
||||||
|
e = await addParticipant(e, x, ctx);
|
||||||
|
e = await togglePause(e, ctx); // resume (rebuilds order)
|
||||||
|
|
||||||
|
// after resume, complete one full round: visit each active participant once
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length - 1; i++) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
// EXPECT: 4 unique (a,b,c,x). BUG: rotation may not visit all.
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple adds while paused, resume, rotation visits all', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
e = await nextTurn(e, ctx); // current=b
|
||||||
|
e = await togglePause(e, ctx); // pause
|
||||||
|
|
||||||
|
// add 3 fresh participants
|
||||||
|
for (const id of ['x', 'y', 'z']) {
|
||||||
|
const np = p(id, 5 + Math.floor(Math.random() * 30));
|
||||||
|
e = await addParticipant(e, np, ctx);
|
||||||
|
}
|
||||||
|
e = await togglePause(e, ctx); // resume
|
||||||
|
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
// EXPECT: all 6 participants reachable. BUG: some stuck/repeated.
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add while running, then pause+resume, rotation stays valid', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
e = await nextTurn(e, ctx); // current=b
|
||||||
|
const x = p('x', 25);
|
||||||
|
e = await addParticipant(e, x, ctx); // add while running
|
||||||
|
e = await togglePause(e, ctx); // pause
|
||||||
|
e = await togglePause(e, ctx); // resume
|
||||||
|
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// removeParticipant + computeTurnOrderAfterRemoval edge cases.
|
||||||
|
//
|
||||||
|
// New API: mutating funcs are async, take ctx last, write encounter + log
|
||||||
|
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, startEncounter, nextTurn, removeParticipant, toggleParticipantActive, applyHpChange } = shared;
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('removeParticipant turn-order edges', () => {
|
||||||
|
test('removing current picks next active as current', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await removeParticipant(e, 'a', ctx); // a was current
|
||||||
|
expect(e.currentTurnParticipantId).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing last in order wraps current to first', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await nextTurn(e, ctx); // b
|
||||||
|
e = await nextTurn(e, ctx); // c (current)
|
||||||
|
e = await removeParticipant(e, 'c', ctx);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = await startEncounter(e, ctx); // [a,b,c], cur=a
|
||||||
|
// deactivate b + c (stay in slot, inactive)
|
||||||
|
e = await toggleParticipantActive(e, 'b', ctx);
|
||||||
|
e = await toggleParticipantActive(e, 'c', ctx);
|
||||||
|
// remove current a
|
||||||
|
e = await removeParticipant(e, 'a', ctx);
|
||||||
|
// 1-list: turnOrderIds=[b,c], no active → current null, isStarted stays true
|
||||||
|
expect(e.turnOrderIds).toEqual(['b', 'c']);
|
||||||
|
expect(e.currentTurnParticipantId).toBeNull();
|
||||||
|
// isStarted still true but no turn → nextTurn throws (stale state)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing non-current keeps currentTurn', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await removeParticipant(e, 'b', ctx);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a');
|
||||||
|
expect(e.turnOrderIds).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing current that is dead (HP=0) - BUG-3 overlap', async () => {
|
||||||
|
// Dead participant removed mid-combat. Desired (M4): they STAY in order.
|
||||||
|
// removeParticipant is explicit DM action, distinct from auto-skip.
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',20),p('b',15),p('c',10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dead
|
||||||
|
e = await removeParticipant(e, 'b', ctx);
|
||||||
|
expect(e.turnOrderIds).not.toContain('b');
|
||||||
|
expect(e.participants.find(x => x.id === 'b')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Characterization for reorderParticipants correct usage.
|
||||||
|
// replay-combat.js calls it with wrong signature (swallowed by try/catch),
|
||||||
|
// so real behavior untested. Lock what it actually does.
|
||||||
|
//
|
||||||
|
// New API: reorderParticipants is async, takes ctx last, writes encounter +
|
||||||
|
// log internally, returns newEnc. A blocked move (cross-init / same id /
|
||||||
|
// cross-pointer) returns the SAME enc ref (no write). Missing id rejects.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, startEncounter, nextTurn, reorderParticipants } = shared;
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reorderParticipants', () => {
|
||||||
|
test('drag upward inserts before target (1-list model, pre-combat)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie
|
||||||
|
let e = enc(ps); // pre-combat, no pointer
|
||||||
|
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||||
|
// drag c upward onto b: insert before b → [a,c,b]
|
||||||
|
expect(newEnc.participants.map(p => p.id)).toEqual(['a', 'c', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drag downward inserts after target (1-list model, pre-combat)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 20), p('c', 10)]; // a,b tie
|
||||||
|
let e = enc(ps); // pre-combat, no pointer
|
||||||
|
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||||
|
// drag a downward onto b: insert after b → [b,a,c]
|
||||||
|
expect(newEnc.participants.map(p => p.id)).toEqual(['b', 'a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-init drag blocked (no-op)', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 20)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx); // [b,a]
|
||||||
|
const before = storage.calls.filter(c => c.fn === 'updateDoc').length;
|
||||||
|
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
|
||||||
|
expect(newEnc).toBe(e); // same ref = no write
|
||||||
|
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(before); // no new write
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws if id not found', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 20)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await expect(reorderParticipants(e, 'a', 'zzz', ctx)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paused combat allows reorder across current-turn pointer', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 20), p('c', 20), p('d', 20)];
|
||||||
|
let e = await startEncounter(enc(ps), ctx); // current=a
|
||||||
|
e = { ...e, isPaused: true };
|
||||||
|
|
||||||
|
const newEnc = await reorderParticipants(e, 'd', 'a', ctx);
|
||||||
|
|
||||||
|
expect(newEnc).not.toBe(e);
|
||||||
|
expect(newEnc.participants.map(p => p.id)).toEqual(['d', 'a', 'b', 'c']);
|
||||||
|
expect(newEnc.turnOrderIds).toEqual(['d', 'a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 20), p('c', 20)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx); // started, but reorder pre-pointer advance
|
||||||
|
// startEncounter sets current=b (idx0). reorder c before b crosses pointer.
|
||||||
|
// Use pre-combat to test syncTurnOrder.
|
||||||
|
let pre = enc(ps);
|
||||||
|
pre = await reorderParticipants(pre, 'c', 'b', ctx);
|
||||||
|
expect(pre.turnOrderIds).toEqual(['a','c','b']);
|
||||||
|
expect(pre.turnOrderIds).toEqual(pre.participants.map(p => p.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// BUG-6 candidate: reorder should affect turnOrderIds so mid-combat
|
||||||
|
// drag-drop changes who goes next within same-initiative tie.
|
||||||
|
// Currently RED (turnOrderIds not in patch).
|
||||||
|
test('reorder updates turnOrderIds to reflect new participant order (pre-combat)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 10), p('b', 20), p('c', 20)];
|
||||||
|
let e = enc(ps); // pre-combat, no pointer
|
||||||
|
e = await reorderParticipants(e, 'c', 'b', ctx);
|
||||||
|
expect(e.turnOrderIds).toEqual(['a', 'c', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Regression test: full round must rotate through ALL active participants exactly once.
|
||||||
|
// Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter
|
||||||
|
// before any coverage action). nextTurn has core bug, not just coverage-path issue.
|
||||||
|
//
|
||||||
|
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
const { startEncounter, nextTurn, makeParticipant } = shared;
|
||||||
|
|
||||||
|
function p(id, initiative, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative, maxHp: 20, currentHp: 20,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(ps) {
|
||||||
|
return {
|
||||||
|
name: 'T', participants: ps,
|
||||||
|
isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('round rotation integrity', () => {
|
||||||
|
test('3 participants: one full round visits each exactly once', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
|
||||||
|
// advance (len-1) turns: visits remaining participants, round NOT yet wrapped.
|
||||||
|
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(e.round).toBe(1); // still round 1
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(startOrder.length); // each exactly once
|
||||||
|
expect(visited.length).toBe(startOrder.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('8 participants (replay shape): one full round visits each exactly once', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [
|
||||||
|
p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11),
|
||||||
|
p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10),
|
||||||
|
];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(e.round).toBe(1);
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(startOrder.length);
|
||||||
|
expect(visited.length).toBe(startOrder.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple rounds: each round visits each participant exactly once', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
const expectedRound = e.round;
|
||||||
|
|
||||||
|
// capture exactly one full round (current + len-1 advances), no wrap yet.
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < startOrder.length - 1; i++) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBe(startOrder.length);
|
||||||
|
expect(e.round).toBe(expectedRound);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round rotation with mid-round state changes', () => {
|
||||||
|
const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared;
|
||||||
|
|
||||||
|
test('toggle a participant inactive mid-round, others still each visited once', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
|
||||||
|
// now mark 'a' inactive (already took its turn)
|
||||||
|
e = await toggleParticipantActive(e, 'a', ctx);
|
||||||
|
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
|
||||||
|
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
|
||||||
|
// round should wrap, but 'a' inactive so only b,c,d visited
|
||||||
|
const visitedActive = visited.filter(id => id !== 'a');
|
||||||
|
const uniq = new Set(visitedActive);
|
||||||
|
expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reactivate inactive participant mid-round, it gets a turn this round', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
|
||||||
|
let e = enc(ps);
|
||||||
|
// start with 'c' inactive
|
||||||
|
e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
// 1-list: c stays in slot (inactive), skipped by nextTurn
|
||||||
|
expect(e.turnOrderIds).toEqual(['a', 'b', 'c', 'd']);
|
||||||
|
expect(e.currentTurnParticipantId).toBe('a'); // c inactive, a first
|
||||||
|
|
||||||
|
// advance one turn, then reactivate c
|
||||||
|
e = await nextTurn(e, ctx); // b
|
||||||
|
e = await toggleParticipantActive(e, 'c', ctx);
|
||||||
|
|
||||||
|
// continue rotation - c should now be reachable
|
||||||
|
const visited = [e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < e.turnOrderIds.length; i++) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
expect(visited).toContain('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant mid-round: new participant gets turn this round or next', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
|
||||||
|
e = await nextTurn(e, ctx); // advance one
|
||||||
|
// add new participant
|
||||||
|
const newP = p('x', 25);
|
||||||
|
e = await addParticipant(e, newP, ctx);
|
||||||
|
|
||||||
|
// finish round - original 3 should still each get exactly one turn
|
||||||
|
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||||
|
while (e.round === 1) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
if (visited.length > 20) break; // safety
|
||||||
|
}
|
||||||
|
const originals = visited.filter(id => ['a','b','c'].includes(id));
|
||||||
|
const uniq = new Set(originals);
|
||||||
|
expect(uniq.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorderParticipants mid-round keeps rotation valid', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15)
|
||||||
|
let e = enc(ps);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const startOrder = e.turnOrderIds.slice();
|
||||||
|
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
// reorder: swap b,c (same initiative)
|
||||||
|
e = await reorderParticipants(e, 'b', 'c', ctx);
|
||||||
|
|
||||||
|
const visited = [startOrder[0], e.currentTurnParticipantId];
|
||||||
|
for (let i = 0; i < startOrder.length; i++) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
visited.push(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
const uniq = new Set(visited);
|
||||||
|
expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Invariant: no real skip. Every active participant at round start (still
|
||||||
|
// active at round end) gets a turn. Tracks per ACTUAL round (e.round), so
|
||||||
|
// rounds spanning pause/resume across loop iterations count correctly.
|
||||||
|
//
|
||||||
|
// Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume).
|
||||||
|
// If this goes RED, turn order rotation is skipping participants again.
|
||||||
|
//
|
||||||
|
// New API: mutating funcs are async, take ctx last, write encounter + log
|
||||||
|
// internally, return newEnc. setup(ctx) returns the started encounter;
|
||||||
|
// loop bodies await each mutating call.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause, addParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive,
|
||||||
|
} = shared;
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
const nm = (enc) => (id) => {
|
||||||
|
const f = enc.participants.find(p => p.id === id);
|
||||||
|
return f ? f.name : id;
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(ctx) {
|
||||||
|
const ps = [
|
||||||
|
buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant,
|
||||||
|
buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant,
|
||||||
|
buildCharacterParticipant({ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'Goblin1', maxHp: 100, initMod: 2 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant,
|
||||||
|
buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, asNpc: true }).participant,
|
||||||
|
];
|
||||||
|
let e = {
|
||||||
|
name: 't', participants: ps, isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
return startEncounter(e, ctx); // async → Promise<newEnc>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
|
||||||
|
jest.setTimeout(15000);
|
||||||
|
|
||||||
|
test('pure nextTurn: 0 skips across 100 rounds', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = await setup(ctx);
|
||||||
|
let totalSkips = 0;
|
||||||
|
for (let roundN = 1; roundN <= 100; roundN++) {
|
||||||
|
const startRound = e.round;
|
||||||
|
const activeAtStart = new Set(e.participants.filter(p => p.isActive).map(p => p.id));
|
||||||
|
const acted = new Set();
|
||||||
|
acted.add(e.currentTurnParticipantId);
|
||||||
|
let guard = 0;
|
||||||
|
const cap = e.participants.length + 1;
|
||||||
|
while (e.round === startRound && guard < cap) {
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
if (e.round === startRound) acted.add(e.currentTurnParticipantId);
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
const skipped = [...activeAtStart].filter(id => {
|
||||||
|
const p = e.participants.find(x => x.id === id);
|
||||||
|
return p && p.isActive && !acted.has(id);
|
||||||
|
});
|
||||||
|
totalSkips += skipped.length;
|
||||||
|
}
|
||||||
|
expect(totalSkips).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = await setup(ctx);
|
||||||
|
const N = nm(e);
|
||||||
|
let curRound = null;
|
||||||
|
let activeAtRoundStart = new Set();
|
||||||
|
let actedThisRound = new Set();
|
||||||
|
const onRoundStart = (enc) => {
|
||||||
|
curRound = enc.round;
|
||||||
|
activeAtRoundStart = new Set(enc.participants.filter(p => p.isActive).map(p => p.id));
|
||||||
|
actedThisRound = new Set();
|
||||||
|
if (enc.currentTurnParticipantId) actedThisRound.add(enc.currentTurnParticipantId);
|
||||||
|
};
|
||||||
|
onRoundStart(e);
|
||||||
|
|
||||||
|
let totalRealSkips = 0;
|
||||||
|
let added = 0;
|
||||||
|
let turns = 0;
|
||||||
|
const MAX_TURNS = 2000;
|
||||||
|
while (turns < MAX_TURNS && e.isStarted) {
|
||||||
|
turns++;
|
||||||
|
if (e.isPaused) e = await togglePause(e, ctx);
|
||||||
|
if (turns % 7 === 0 && !e.isPaused) { e = await togglePause(e, ctx); continue; }
|
||||||
|
const prevRound = e.round;
|
||||||
|
e = await nextTurn(e, ctx);
|
||||||
|
if (e.round !== prevRound) {
|
||||||
|
const skipped = [...activeAtRoundStart].filter(id => {
|
||||||
|
const p = e.participants.find(x => x.id === id);
|
||||||
|
return p && p.isActive && !actedThisRound.has(id);
|
||||||
|
});
|
||||||
|
totalRealSkips += skipped.length;
|
||||||
|
onRoundStart(e);
|
||||||
|
} else {
|
||||||
|
actedThisRound.add(e.currentTurnParticipantId);
|
||||||
|
}
|
||||||
|
if (turns % 9 === 0 && added < 8) {
|
||||||
|
const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant;
|
||||||
|
b.id = `reinforce${added + 1}`;
|
||||||
|
e = await addParticipant(e, b, ctx); added++;
|
||||||
|
}
|
||||||
|
if (turns % 13 === 0) {
|
||||||
|
const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId);
|
||||||
|
if (cand.length) e = await removeParticipant(e, cand[0].id, ctx);
|
||||||
|
}
|
||||||
|
if (turns % 17 === 0) {
|
||||||
|
const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId);
|
||||||
|
if (cand.length) {
|
||||||
|
const t = cand[0];
|
||||||
|
e = await toggleParticipantActive(e, t.id, ctx);
|
||||||
|
e = await toggleParticipantActive(e, t.id, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(totalRealSkips).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// SLOT-NOT-SORT invariant: addParticipant + updateParticipant must PRESERVE
|
||||||
|
// manually-dragged tie order (same-initiative participants).
|
||||||
|
//
|
||||||
|
// Design doc (docs/INITIATIVE_ORDERING.md):
|
||||||
|
// "Re-slotting on add/edit must preserve drag-established tie order."
|
||||||
|
// "Tie-break = original add order. Later additions slot AFTER existing
|
||||||
|
// same-init participants. Stable insertion."
|
||||||
|
//
|
||||||
|
// startEncounter re-sorts once to freeze the list; after that add/update use
|
||||||
|
// stable slot insertion (slotIndexForInit), never wholesale re-sort — so drag
|
||||||
|
// tie order survives.
|
||||||
|
//
|
||||||
|
// New API: addParticipant / updateParticipant / reorderParticipants are async,
|
||||||
|
// take ctx, return newEnc.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
makeParticipant,
|
||||||
|
startEncounter, addParticipant, updateParticipant, reorderParticipants,
|
||||||
|
} = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||||
|
}
|
||||||
|
function enc(ps, extra = {}) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SLOT-NOT-SORT: drag tie order survives add/edit', () => {
|
||||||
|
test('addParticipant preserves existing drag tie order', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
// Two same-init participants. DM drags b BEFORE a (tie override).
|
||||||
|
let e = enc([p('a', 10), p('b', 10)]);
|
||||||
|
e = await reorderParticipants(e, 'b', 'a', ctx); // drag b ahead of a → [b,a]
|
||||||
|
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
|
||||||
|
|
||||||
|
// Add unrelated participant (different init). Tie order [b,a] MUST survive.
|
||||||
|
e = await addParticipant(e, p('c', 5), ctx);
|
||||||
|
const ids = e.participants.map(x => x.id);
|
||||||
|
// c slots last (init 5 < 10). b,a tie order preserved.
|
||||||
|
expect(ids).toEqual(['b', 'a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant with same init as dragged pair slots AFTER tie group', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
// DM drags b before a (both init 10). Then add d also init 10.
|
||||||
|
// d must slot AFTER existing same-init pair (stable insertion), not re-sort.
|
||||||
|
let e = enc([p('a', 10), p('b', 10)]);
|
||||||
|
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a]
|
||||||
|
e = await addParticipant(e, p('d', 10), ctx); // init 10, after tie group
|
||||||
|
const ids = e.participants.map(x => x.id);
|
||||||
|
expect(ids).toEqual(['b', 'a', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateParticipant preserves drag tie order on unrelated field edit', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a', 10), p('b', 10)]);
|
||||||
|
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a]
|
||||||
|
// Edit a's name (not initiative). Tie order MUST survive.
|
||||||
|
e = await updateParticipant(e, 'a', { name: 'A2' }, ctx);
|
||||||
|
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateParticipant init change slots, preserves OTHER tie group order', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
// Two tie groups: [a,b] init 10, [c,d] init 5. DM drags b before a.
|
||||||
|
let e = enc([p('a', 10), p('b', 10), p('c', 5), p('d', 5)]);
|
||||||
|
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a,c,d]
|
||||||
|
// Edit c's initiative to 10 — slots into top group. [c] order within its
|
||||||
|
// own old group irrelevant (it leaves that group). But [b,a] tie MUST stay.
|
||||||
|
e = await updateParticipant(e, 'c', { initiative: 10 }, ctx);
|
||||||
|
const topTwo = e.participants.filter(x => x.initiative === 10).map(x => x.id);
|
||||||
|
// b before a preserved. c joins the 10-group (exact slot less critical
|
||||||
|
// than b,a order surviving).
|
||||||
|
expect(topTwo.indexOf('b')).toBeLessThan(topTwo.indexOf('a'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Undo + redo roundtrip: every op that writes a log entry must restore prior
|
||||||
|
// state on undo AND re-apply forward state on redo. Uses REAL mock storage
|
||||||
|
// undo() (applies patch + flips undone flag), not just expandUndo output.
|
||||||
|
//
|
||||||
|
// Pattern per case:
|
||||||
|
// before = snap(enc) // state before op
|
||||||
|
// newEnc = await op(enc) // apply op (writes enc doc + log entry)
|
||||||
|
// afterUndo = undoLast() // real undo via storage.undo
|
||||||
|
// expect(afterUndo) === before
|
||||||
|
// afterRedo = redoLast() // real redo via storage.undo({redo:true})
|
||||||
|
// expect(afterRedo) === snap(newEnc)
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx, undoLast, redoLast } = require('./_helpers');
|
||||||
|
const { expandUndo } = shared;
|
||||||
|
const {
|
||||||
|
makeParticipant, startEncounter, nextTurn, togglePause,
|
||||||
|
addParticipant, removeParticipant, toggleParticipantActive,
|
||||||
|
applyHpChange, toggleCondition, reorderParticipants, endEncounter,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
function p(id, init, extra = {}) {
|
||||||
|
return makeParticipant({
|
||||||
|
id, name: id, type: 'monster',
|
||||||
|
initiative: init, maxHp: 100, currentHp: 100,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enc(ps) {
|
||||||
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
|
}
|
||||||
|
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||||
|
|
||||||
|
describe('undo + redo roundtrip', () => {
|
||||||
|
test('startEncounter', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const before = enc([p('a',10),p('b',20)]);
|
||||||
|
await storage.setDoc(ctx.encPath, before);
|
||||||
|
const newEnc = await startEncounter(before, ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
// participants[] order may differ (start sorts); check turn-state fields.
|
||||||
|
expect(afterUndo.isStarted).toBe(before.isStarted);
|
||||||
|
expect(afterUndo.isPaused).toBe(before.isPaused);
|
||||||
|
expect(afterUndo.round).toBe(before.round);
|
||||||
|
expect(afterUndo.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
|
||||||
|
expect(afterUndo.turnOrderIds).toEqual(before.turnOrderIds);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(afterRedo.isStarted).toBe(newEnc.isStarted);
|
||||||
|
expect(afterRedo.round).toBe(newEnc.round);
|
||||||
|
expect(afterRedo.currentTurnParticipantId).toBe(newEnc.currentTurnParticipantId);
|
||||||
|
expect(afterRedo.turnOrderIds).toEqual(newEnc.turnOrderIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await nextTurn(e, ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('togglePause excluded from undo (no log entry)', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await togglePause(e, ctx);
|
||||||
|
// state flips (isPaused), but no log written
|
||||||
|
expect(newEnc.isPaused).toBe(true);
|
||||||
|
expect(storage.logs()).toHaveLength(1); // start only
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyHpChange damage restores HP on undo, re-applies on redo', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 20, ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyHpChange heal restores HP on undo, re-applies on redo', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10,{maxHp:100,currentHp:50})]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleCondition', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await toggleCondition(e, 'a', 'stunned', ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleParticipantActive restores participants + turn order, redo re-applies', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await toggleParticipantActive(e, 'b', ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addParticipant', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 });
|
||||||
|
const newEnc = await addParticipant(e, np, ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeParticipant restores prior participants + turn order, redo re-applies', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await removeParticipant(e, 'b', ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await endEncounter(e, ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorderParticipants (BUG-7 fixed)', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
const ps = [p('a',10),p('b',20),p('c',20)]; // b,c tie
|
||||||
|
let e = enc(ps);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateParticipant', async () => {
|
||||||
|
const { storage, ctx } = mockCtx();
|
||||||
|
let e = enc([p('a',10),p('b',20)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
await storage.setDoc(ctx.encPath, e);
|
||||||
|
const before = snap(e);
|
||||||
|
const newEnc = await shared.updateParticipant(e, 'a', { initiative: 99 }, ctx);
|
||||||
|
const last = storage.logs().at(-1);
|
||||||
|
const afterUndo = await undoLast(ctx, newEnc);
|
||||||
|
expect(snap(afterUndo)).toEqual(before);
|
||||||
|
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||||
|
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Mock in-memory Firestore for jest tests.
|
||||||
|
// Reset via resetMockDb() in setupTests.js beforeEach.
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
docs: new Map(), // path -> data
|
||||||
|
subscribers: new Map(), // path -> Set<cb>
|
||||||
|
counter: 0,
|
||||||
|
calls: [], // recorded SDK calls
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_DB = {
|
||||||
|
get(path) { return state.docs.has(path) ? clone(state.docs.get(path)) : null; },
|
||||||
|
set(path, data) {
|
||||||
|
state.docs.set(path, clone(data));
|
||||||
|
this._notify(path);
|
||||||
|
},
|
||||||
|
merge(path, patch) {
|
||||||
|
const cur = state.docs.has(path) ? state.docs.get(path) : {};
|
||||||
|
const next = { ...cur, ...clone(patch) };
|
||||||
|
state.docs.set(path, next);
|
||||||
|
this._notify(path);
|
||||||
|
},
|
||||||
|
delete(path) {
|
||||||
|
state.docs.delete(path);
|
||||||
|
this._notify(path);
|
||||||
|
},
|
||||||
|
collection(collPath) {
|
||||||
|
const out = [];
|
||||||
|
for (const [p, data] of state.docs) {
|
||||||
|
const parent = p.split('/').slice(0, -1).join('/');
|
||||||
|
if (parent === collPath) out.push({ id: p.split('/').pop(), data: clone(data) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
subscribe(path, cb) {
|
||||||
|
if (!state.subscribers.has(path)) state.subscribers.set(path, new Set());
|
||||||
|
state.subscribers.get(path).add(cb);
|
||||||
|
return () => state.subscribers.get(path)?.delete(cb);
|
||||||
|
},
|
||||||
|
_notify(path) {
|
||||||
|
// notify exact doc path subscribers (wrapped in act for test isolation).
|
||||||
|
// Set flag true around cb: testing-library asyncWrapper (waitFor) flips it
|
||||||
|
// false during poll, which makes raw react act() warn 'not configured'.
|
||||||
|
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => {
|
||||||
|
try {
|
||||||
|
const { act } = require('react');
|
||||||
|
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
act(() => cb());
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
|
||||||
|
} catch {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// notify parent collection subscribers
|
||||||
|
const parent = path.split('/').slice(0, -1).join('/');
|
||||||
|
if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => {
|
||||||
|
try {
|
||||||
|
const { act } = require('react');
|
||||||
|
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
act(() => cb());
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
|
||||||
|
} catch {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); },
|
||||||
|
_state: state,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function recordCall(entry) {
|
||||||
|
state.calls.push({ ...entry, ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMockDb() {
|
||||||
|
state.docs.clear();
|
||||||
|
state.subscribers.clear();
|
||||||
|
state.calls.length = 0;
|
||||||
|
state.counter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalls() {
|
||||||
|
return [...state.calls];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(v) {
|
||||||
|
if (v === null || v === undefined) return v;
|
||||||
|
return JSON.parse(JSON.stringify(v));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// jest manual mock: firebase/app
|
||||||
|
const fakeApp = { name: '[fake-firebase-app]', options: {} };
|
||||||
|
export function initializeApp(config) { return fakeApp; }
|
||||||
|
export const getApp = () => fakeApp;
|
||||||
|
export const getApps = () => [fakeApp];
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// jest manual mock: firebase/auth
|
||||||
|
const fakeUser = { uid: 'test-user-123', isAnonymous: true };
|
||||||
|
const fakeAuth = { currentUser: fakeUser };
|
||||||
|
|
||||||
|
export function getAuth() { return fakeAuth; }
|
||||||
|
export function signInAnonymously(auth) { return Promise.resolve({ user: fakeUser }); }
|
||||||
|
export function signInWithCustomToken(auth, token) { return Promise.resolve({ user: fakeUser }); }
|
||||||
|
export function onAuthStateChanged(auth, cb) {
|
||||||
|
cb(fakeUser);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// jest manual mock: firebase/firestore
|
||||||
|
// Records all calls so tests can assert path/payload/semantics.
|
||||||
|
// Global __firestoreCalls reset per test (see setupTests.js).
|
||||||
|
|
||||||
|
import { MOCK_DB, recordCall } from './_mock-db.js';
|
||||||
|
|
||||||
|
const ref = (path) => ({ __ref: true, path, id: path.split('/').pop() });
|
||||||
|
|
||||||
|
export function getFirestore() { return { __db: true }; }
|
||||||
|
export function doc(db, path, extra) {
|
||||||
|
const p = extra ? `${path}/${extra}` : path;
|
||||||
|
return ref(p);
|
||||||
|
}
|
||||||
|
export function collection(db, path) { return ref(path); }
|
||||||
|
export function query(refOrColl, ...constraints) { return { ref: refOrColl, constraints }; }
|
||||||
|
export function where(field, op, value) { return { __type: 'where', field, op, value }; }
|
||||||
|
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
|
||||||
|
export function limit(n) { return { __type: 'limit', n }; }
|
||||||
|
|
||||||
|
// writes
|
||||||
|
export async function setDoc(docRef, data, opts) {
|
||||||
|
recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null });
|
||||||
|
if (opts && opts.merge) {
|
||||||
|
MOCK_DB.merge(docRef.path, clone(data));
|
||||||
|
} else {
|
||||||
|
MOCK_DB.set(docRef.path, clone(data));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export async function updateDoc(docRef, patch) {
|
||||||
|
recordCall({ fn: 'updateDoc', path: docRef.path, data: clone(patch) });
|
||||||
|
MOCK_DB.merge(docRef.path, clone(patch));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export async function deleteDoc(docRef) {
|
||||||
|
recordCall({ fn: 'deleteDoc', path: docRef.path });
|
||||||
|
MOCK_DB.delete(docRef.path);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export async function addDoc(collRef, data) {
|
||||||
|
const id = `auto_${MOCK_DB.nextId()}`;
|
||||||
|
const path = `${collRef.path}/${id}`;
|
||||||
|
recordCall({ fn: 'addDoc', path, data: clone(data) });
|
||||||
|
MOCK_DB.set(path, clone(data));
|
||||||
|
return { id, path };
|
||||||
|
}
|
||||||
|
export function writeBatch(db) {
|
||||||
|
const ops = [];
|
||||||
|
return {
|
||||||
|
set: (r, d) => ops.push({ op: 'set', path: r.path, data: clone(d) }),
|
||||||
|
update: (r, d) => ops.push({ op: 'update', path: r.path, data: clone(d) }),
|
||||||
|
delete: (r) => ops.push({ op: 'delete', path: r.path }),
|
||||||
|
commit: async () => {
|
||||||
|
ops.forEach(o => {
|
||||||
|
recordCall({ fn: `batch.${o.op}`, path: o.path, data: o.data });
|
||||||
|
if (o.op === 'set') MOCK_DB.set(o.path, o.data);
|
||||||
|
else if (o.op === 'update') MOCK_DB.merge(o.path, o.data);
|
||||||
|
else if (o.op === 'delete') MOCK_DB.delete(o.path);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// reads (return from in-memory mock DB)
|
||||||
|
export async function getDoc(docRef) {
|
||||||
|
const data = MOCK_DB.get(docRef.path);
|
||||||
|
return { exists: () => data !== null, id: docRef.id, data: () => data };
|
||||||
|
}
|
||||||
|
export async function getDocs(collRefOrQuery) {
|
||||||
|
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
||||||
|
let docs = MOCK_DB.collection(collPath);
|
||||||
|
const constraints = collRefOrQuery.constraints || [];
|
||||||
|
docs = applyConstraints(docs, constraints);
|
||||||
|
return { docs: docs.map(d => ({ id: d.id, data: () => d.data, ref: { path: `${collPath}/${d.id}` } })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCountFromServer(collRefOrQuery) {
|
||||||
|
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
||||||
|
const docs = MOCK_DB.collection(collPath);
|
||||||
|
return { data: () => ({ count: docs.length }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// realtime — emit from mock DB, capture unsub
|
||||||
|
export function onSnapshot(refOrQuery, onSuccess, onError) {
|
||||||
|
const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
|
||||||
|
const constraints = refOrQuery.constraints || [];
|
||||||
|
// fire immediately with current state
|
||||||
|
const emit = () => {
|
||||||
|
if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) {
|
||||||
|
const data = MOCK_DB.get(path);
|
||||||
|
onSuccess({
|
||||||
|
exists: () => data !== null,
|
||||||
|
id: path.split('/').pop(),
|
||||||
|
data: () => data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let docs = MOCK_DB.collection(path);
|
||||||
|
docs = applyConstraints(docs, constraints);
|
||||||
|
onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emit();
|
||||||
|
// register for future changes on this path
|
||||||
|
const unsub = MOCK_DB.subscribe(path, emit);
|
||||||
|
return unsub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Firestore-style query constraints (orderBy desc/asc, limit, where)
|
||||||
|
// to mock docs. Mirrors real SDK semantics for contract tests. Offset handled
|
||||||
|
// by adapter (firebase.js slices) — mock never sees it.
|
||||||
|
function applyConstraints(docs, constraints) {
|
||||||
|
let out = [...docs];
|
||||||
|
for (const c of constraints) {
|
||||||
|
if (c.__type === 'orderBy') {
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const av = a.data[c.field];
|
||||||
|
const bv = b.data[c.field];
|
||||||
|
if (av === bv) return 0;
|
||||||
|
const cmp = av > bv ? 1 : -1;
|
||||||
|
return c.dir === 'desc' ? -cmp : cmp;
|
||||||
|
});
|
||||||
|
} else if (c.__type === 'limit') {
|
||||||
|
out = out.slice(0, c.n);
|
||||||
|
} else if (c.__type === 'where') {
|
||||||
|
out = out.filter(d => d.data[c.field] === c.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(v) {
|
||||||
|
if (v === null || v === undefined) return v;
|
||||||
|
return JSON.parse(JSON.stringify(v));
|
||||||
|
}
|
||||||
@@ -7,14 +7,9 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: 'Alegreya Sans', system-ui, sans-serif;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
/* background-color: #1A202C; /* Tailwind Slate 900 */
|
|
||||||
/* color: #E2E8F0; /* Tailwind Slate 200 */
|
|
||||||
/* These will likely be overridden by the App component's Tailwind classes */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// jest setup: RTL jest-dom + mock DB reset per test.
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { resetMockDb } from './__mocks__/firebase/_mock-db';
|
||||||
|
|
||||||
|
// RTL v14: tell React this is an act environment. Without it every
|
||||||
|
// async update warns "current testing environment is not configured to
|
||||||
|
// support act(...)". RTL reads getGlobalThis(), so set on globalThis.
|
||||||
|
// Must be set before any component renders.
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
// Test timeout guard. CRA blocks `testTimeout` in package.json jest config
|
||||||
|
// (not in allowlist). Set via jest global here instead. Fails fast on hang.
|
||||||
|
jest.setTimeout(10000);
|
||||||
|
|
||||||
|
// WARNING = FAILURE. Fail test on any console.error/warn (React act warnings,
|
||||||
|
// deprecations, prop errors). App code's own error logs allowed only inside
|
||||||
|
// try/catch failure paths — those tests should assert the error was handled,
|
||||||
|
// not silently log it. Override the spies to throw.
|
||||||
|
const originalError = console.error;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.error = (...args) => {
|
||||||
|
originalError(...args);
|
||||||
|
throw new Error(`console.error in test: ${args.map(String).join(' ').slice(0, 500)}`);
|
||||||
|
};
|
||||||
|
console.warn = (...args) => {
|
||||||
|
originalWarn(...args);
|
||||||
|
throw new Error(`console.warn in test: ${args.map(String).join(' ').slice(0, 500)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// polyfill crypto.randomUUID for jsdom (used by generateId in App.js).
|
||||||
|
if (!global.crypto) global.crypto = {};
|
||||||
|
if (!global.crypto.randomUUID) {
|
||||||
|
global.crypto.randomUUID = () => 'test-uuid-' + Math.random().toString(36).slice(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub Firebase env vars so initializeFirebase() succeeds under test.
|
||||||
|
// Real SDK calls are mocked via __mocks__/firebase/*.
|
||||||
|
process.env.REACT_APP_FIREBASE_API_KEY = 'test-api-key';
|
||||||
|
process.env.REACT_APP_FIREBASE_AUTH_DOMAIN = 'test.firebaseapp.com';
|
||||||
|
process.env.REACT_APP_FIREBASE_PROJECT_ID = 'test-project';
|
||||||
|
process.env.REACT_APP_FIREBASE_STORAGE_BUCKET = 'test.appspot.com';
|
||||||
|
process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID = '1234567890';
|
||||||
|
process.env.REACT_APP_FIREBASE_APP_ID = '1:1234567890:web:abcdef';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMockDb();
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
// Storage interface contract.
|
||||||
|
// This is the SPEC. Runs against any storage impl (memory, ws, firebase).
|
||||||
|
// TDD: written first (RED), impl built to satisfy (GREEN).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const { runStorageContract } = require('./contract.test');
|
||||||
|
// runStorageContract('memory', () => createMemoryStorage());
|
||||||
|
|
||||||
|
// Each impl factory returns a fresh storage instance (async-creatable is fine).
|
||||||
|
// Interface every impl MUST provide:
|
||||||
|
// getDoc(path) -> Promise<obj|null>
|
||||||
|
// setDoc(path, data) -> Promise<void> (replace)
|
||||||
|
// updateDoc(path, patch) -> Promise<void> (shallow merge)
|
||||||
|
// deleteDoc(path) -> Promise<void>
|
||||||
|
// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id)
|
||||||
|
// getCollection(path) -> Promise<arr> (immediate child docs)
|
||||||
|
// batchWrite(ops) -> Promise<void> ops: [{type, path, data?}]
|
||||||
|
// undo({logPath, undo, redo}) -> Promise<void> tx: apply updates + flip undone
|
||||||
|
// subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null)
|
||||||
|
// subscribeCollection(path, cb) -> unsubscribe fn cb(arr)
|
||||||
|
|
||||||
|
function runStorageContract(name, factory) {
|
||||||
|
describe(`storage contract: ${name}`, () => {
|
||||||
|
let storage;
|
||||||
|
beforeEach(async () => { storage = await factory(); });
|
||||||
|
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
|
||||||
|
|
||||||
|
describe('getDoc / setDoc', () => {
|
||||||
|
test('setDoc then getDoc returns the doc (with id)', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDoc on missing path returns null', async () => {
|
||||||
|
const doc = await storage.getDoc('campaigns/missing');
|
||||||
|
expect(doc).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setDoc overwrites entirely (not merge)', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Beta' });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ id: 'a', name: 'Beta' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// main L1624: setDoc(path, data, {merge:true}) — merge flag.
|
||||||
|
test('setDoc with {merge:true} merges into existing doc', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
|
||||||
|
await storage.setDoc('campaigns/a', { players: [1] }, { merge: true });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateDoc (shallow merge)', () => {
|
||||||
|
test('merges patch into existing doc', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] });
|
||||||
|
await storage.updateDoc('campaigns/a', { players: [1, 2] });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1, 2] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateDoc on missing doc creates it', async () => {
|
||||||
|
await storage.updateDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteDoc', () => {
|
||||||
|
test('removes doc', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
await storage.deleteDoc('campaigns/a');
|
||||||
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete missing doc is no-op (no throw)', async () => {
|
||||||
|
await expect(storage.deleteDoc('campaigns/none')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addDoc', () => {
|
||||||
|
test('auto-generates id and stores doc at collection/id', async () => {
|
||||||
|
const { id, path } = await storage.addDoc('campaigns/a/encounters', { name: 'E1' });
|
||||||
|
expect(id).toBeTruthy();
|
||||||
|
expect(path).toBe(`campaigns/a/encounters/${id}`);
|
||||||
|
const doc = await storage.getDoc(path);
|
||||||
|
expect(doc).toEqual({ id, name: 'E1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two addDocs produce distinct ids', async () => {
|
||||||
|
const r1 = await storage.addDoc('logs', { m: 'one' });
|
||||||
|
const r2 = await storage.addDoc('logs', { m: 'two' });
|
||||||
|
expect(r1.id).not.toBe(r2.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('firebase-prefixed path identity', () => {
|
||||||
|
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
|
||||||
|
// main prod truth: ALWAYS prefixed. Write+read same prefixed round-trips.
|
||||||
|
// Cross bare<->prefixed lookup is NOT required by main (App never does it).
|
||||||
|
// Kept as same-prefix roundtrip only — matches prod.
|
||||||
|
const PREFIX = 'artifacts/test-app/public/data';
|
||||||
|
|
||||||
|
test('setDoc prefixed then getDoc same prefixed path returns it (id)', async () => {
|
||||||
|
await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
|
||||||
|
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
|
||||||
|
expect(doc).toEqual({ id: 'c2', name: 'P2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCollection', () => {
|
||||||
|
test('returns immediate child docs only (not nested)', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
await storage.setDoc('campaigns/b', { name: 'B' });
|
||||||
|
await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
|
||||||
|
const docs = await storage.getCollection('campaigns');
|
||||||
|
expect(docs).toHaveLength(2);
|
||||||
|
const names = docs.map(d => d.name).sort();
|
||||||
|
expect(names).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty collection returns []', async () => {
|
||||||
|
const docs = await storage.getCollection('campaigns');
|
||||||
|
expect(docs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subcollection returns only its direct children', async () => {
|
||||||
|
await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
|
||||||
|
await storage.setDoc('campaigns/a/encounters/e2', { name: 'E2' });
|
||||||
|
await storage.setDoc('campaigns/a/encounters/e1/participants/p1', { name: 'P1' });
|
||||||
|
const docs = await storage.getCollection('campaigns/a/encounters');
|
||||||
|
expect(docs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batchWrite', () => {
|
||||||
|
test('applies multiple deletes atomically', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
await storage.setDoc('campaigns/b', { name: 'B' });
|
||||||
|
await storage.batchWrite([
|
||||||
|
{ type: 'delete', path: 'campaigns/a' },
|
||||||
|
{ type: 'delete', path: 'campaigns/b' },
|
||||||
|
]);
|
||||||
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||||
|
expect(await storage.getDoc('campaigns/b')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies set + delete mixed', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
await storage.batchWrite([
|
||||||
|
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
|
||||||
|
{ type: 'delete', path: 'campaigns/a' },
|
||||||
|
]);
|
||||||
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||||
|
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// set-only batch (not used in main currently, but interface allows).
|
||||||
|
test('applies set-only batch', async () => {
|
||||||
|
await storage.batchWrite([
|
||||||
|
{ type: 'set', path: 'campaigns/a', data: { name: 'A' } },
|
||||||
|
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
|
||||||
|
]);
|
||||||
|
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A' });
|
||||||
|
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// update-only batch (interface allows).
|
||||||
|
test('applies update-only batch', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A', hp: 10 });
|
||||||
|
await storage.setDoc('campaigns/b', { name: 'B', hp: 5 });
|
||||||
|
await storage.batchWrite([
|
||||||
|
{ type: 'update', path: 'campaigns/a', data: { hp: 8 } },
|
||||||
|
{ type: 'update', path: 'campaigns/b', data: { hp: 2 } },
|
||||||
|
]);
|
||||||
|
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A', hp: 8 });
|
||||||
|
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B', hp: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undo', () => {
|
||||||
|
test('undo applies updates + flips undone true', async () => {
|
||||||
|
await storage.setDoc('encounters/e1', { round: 2, name: 'Enc' });
|
||||||
|
await storage.setDoc('logs/l1', { message: 'x', undone: false });
|
||||||
|
await storage.undo({
|
||||||
|
logPath: 'logs/l1',
|
||||||
|
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
||||||
|
});
|
||||||
|
const enc = await storage.getDoc('encounters/e1');
|
||||||
|
const log = await storage.getDoc('logs/l1');
|
||||||
|
expect(enc.round).toBe(1);
|
||||||
|
expect(log.undone).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redo applies redo patch + flips undone false', async () => {
|
||||||
|
await storage.setDoc('encounters/e1', { round: 1 });
|
||||||
|
await storage.setDoc('logs/l1', { message: 'x', undone: true });
|
||||||
|
await storage.undo({
|
||||||
|
logPath: 'logs/l1',
|
||||||
|
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
||||||
|
redo: true,
|
||||||
|
});
|
||||||
|
const enc = await storage.getDoc('encounters/e1');
|
||||||
|
const log = await storage.getDoc('logs/l1');
|
||||||
|
expect(enc.round).toBe(2);
|
||||||
|
expect(log.undone).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteCollection', () => {
|
||||||
|
test('deletes all docs in collection', async () => {
|
||||||
|
await storage.addDoc('logs', { type: 'a' });
|
||||||
|
await storage.addDoc('logs', { type: 'b' });
|
||||||
|
const deleted = await storage.deleteCollection('logs');
|
||||||
|
expect(deleted).toBe(2);
|
||||||
|
const remaining = await storage.getCollection('logs');
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors whereField filter', async () => {
|
||||||
|
await storage.addDoc('logs', { type: 'keep', n: 1 });
|
||||||
|
await storage.addDoc('logs', { type: 'drop', n: 2 });
|
||||||
|
await storage.addDoc('logs', { type: 'drop', n: 3 });
|
||||||
|
const deleted = await storage.deleteCollection('logs', 'type', 'drop');
|
||||||
|
expect(deleted).toBe(2);
|
||||||
|
const remaining = await storage.getCollection('logs');
|
||||||
|
expect(remaining).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty collection returns 0', async () => {
|
||||||
|
const deleted = await storage.deleteCollection('logs');
|
||||||
|
expect(deleted).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeDoc', () => {
|
||||||
|
test('fires cb immediately with current value', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||||
|
await flush();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires cb on subsequent change', async () => {
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||||
|
await flush();
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
await flush();
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last).toEqual({ id: 'a', name: 'Alpha' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsubscribe stops callbacks', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const unsub = storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||||
|
await flush();
|
||||||
|
unsub();
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'X' });
|
||||||
|
await flush();
|
||||||
|
expect(calls.filter(Boolean)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeCollection', () => {
|
||||||
|
test('fires cb with current docs', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
|
||||||
|
await flush();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires on add to collection', async () => {
|
||||||
|
const calls = [];
|
||||||
|
storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
|
||||||
|
await flush();
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
await flush();
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// queryConstraints: orderBy + limit. App's combat log uses
|
||||||
|
// [orderBy('timestamp','desc'), limit(500)] — newest 500 entries.
|
||||||
|
// Adapter MUST honor these. Constraint shape = neutral {__type} objects
|
||||||
|
// (what firebase mock produces; what App passes via shared builders).
|
||||||
|
describe('subscribeCollection queryConstraints', () => {
|
||||||
|
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
|
||||||
|
const limitC = (n) => ({ __type: 'limit', n });
|
||||||
|
const offsetC = (n) => ({ __type: 'offset', offset: n });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// seed 5 log docs, timestamps out of order
|
||||||
|
await storage.setDoc('logs/l1', { msg: 'one', timestamp: 1 });
|
||||||
|
await storage.setDoc('logs/l2', { msg: 'two', timestamp: 3 });
|
||||||
|
await storage.setDoc('logs/l3', { msg: 'three', timestamp: 5 });
|
||||||
|
await storage.setDoc('logs/l4', { msg: 'four', timestamp: 2 });
|
||||||
|
await storage.setDoc('logs/l5', { msg: 'five', timestamp: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orderBy desc sorts newest-first', async () => {
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc')]);
|
||||||
|
});
|
||||||
|
expect(result.map(d => d.msg)).toEqual(['three', 'five', 'two', 'four', 'one']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limit returns only first N after ordering', async () => {
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2)]);
|
||||||
|
});
|
||||||
|
expect(result.map(d => d.msg)).toEqual(['three', 'five']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no constraints returns all (insertion/id order)', async () => {
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
storage.subscribeCollection('logs', resolve, []);
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log page pagination (LogsView pageQuery): orderBy + limit + offset.
|
||||||
|
// Server pushes offset to SQL; firebase adapter emulates (widen limit,
|
||||||
|
// slice). Both MUST return the same page.
|
||||||
|
test('offset skips first N after ordering (pagination)', async () => {
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(2)]);
|
||||||
|
});
|
||||||
|
expect(result.map(d => d.msg)).toEqual(['two', 'four']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offset past end returns empty page', async () => {
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(10)]);
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush so async subscribers settle. WS roundtrip needs real delay (network),
|
||||||
|
// memory fires near-instant. 50ms covers localhost WS comfortably.
|
||||||
|
function flush() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runStorageContract, flush };
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
|
||||||
|
// Matches interface of memory.js / ws.js so App.js calls stay identical.
|
||||||
|
//
|
||||||
|
// App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
|
||||||
|
// the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
|
||||||
|
|
||||||
|
import { initializeApp } from 'firebase/app';
|
||||||
|
import { getAuth } from 'firebase/auth';
|
||||||
|
import {
|
||||||
|
getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection,
|
||||||
|
onSnapshot, updateDoc, deleteDoc, query, where, orderBy, limit, writeBatch, getCountFromServer,
|
||||||
|
} from 'firebase/firestore';
|
||||||
|
|
||||||
|
// Adapter call recorder (instrumentation, no behavior change).
|
||||||
|
// Tests assert adapter.subscribeDoc called (catches raw-SDK bypass like DisplayView).
|
||||||
|
const ADAPTER_CALLS = [];
|
||||||
|
function recordAdapterCall(entry) { ADAPTER_CALLS.push({ ...entry, ts: Date.now() }); }
|
||||||
|
export function getAdapterCalls() { return [...ADAPTER_CALLS]; }
|
||||||
|
export function resetAdapterCalls() { ADAPTER_CALLS.length = 0; }
|
||||||
|
|
||||||
|
// Path helpers mirror App.js getPath object.
|
||||||
|
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||||
|
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
|
||||||
|
|
||||||
|
export const getPath = {
|
||||||
|
campaigns: () => `${PUBLIC_DATA_PATH}/campaigns`,
|
||||||
|
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
|
||||||
|
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
|
||||||
|
encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`,
|
||||||
|
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
|
||||||
|
logs: () => `${PUBLIC_DATA_PATH}/logs`
|
||||||
|
};
|
||||||
|
|
||||||
|
let firebaseApp = null;
|
||||||
|
let dbInstance = null;
|
||||||
|
let authInstance = null;
|
||||||
|
|
||||||
|
export function initFirebase() {
|
||||||
|
const config = {
|
||||||
|
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
||||||
|
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
|
||||||
|
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
|
||||||
|
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
|
||||||
|
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
|
||||||
|
appId: process.env.REACT_APP_FIREBASE_APP_ID
|
||||||
|
};
|
||||||
|
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
|
||||||
|
const missing = requiredKeys.filter(k => !config[k]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(`CRITICAL: Missing Firebase config: ${missing.join(', ')}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
firebaseApp = initializeApp(config);
|
||||||
|
dbInstance = getFirestore(firebaseApp);
|
||||||
|
authInstance = getAuth(firebaseApp);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Firebase init failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb() { return dbInstance; }
|
||||||
|
export function getAuthInstance() { return authInstance; }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STORAGE ADAPTER
|
||||||
|
// ============================================================================
|
||||||
|
// Wraps SDK in the storage interface (getDoc/setDoc/etc).
|
||||||
|
// App.js can now import { storage } and call storage.setDoc(path, data).
|
||||||
|
// Hooks (useFirestoreDocument etc) still use SDK directly for now.
|
||||||
|
|
||||||
|
// Translate neutral {__type} constraints (src/storage/index.js builders) to
|
||||||
|
// SDK builders. The client SDK has no offset, so emulate it: widen limit to
|
||||||
|
// offset+limit and report offsetN for the caller to slice off the leading
|
||||||
|
// docs. Read cost matches native offset (admin SDK also bills skipped docs);
|
||||||
|
// the server adapter pushes offset into SQL instead. Unknown constraint types
|
||||||
|
// are dropped — a raw object handed to query() throws and white-screens.
|
||||||
|
function toSdkConstraints(queryConstraints) {
|
||||||
|
let offsetN = 0;
|
||||||
|
let limitN = null;
|
||||||
|
const fbConstraints = [];
|
||||||
|
for (const c of queryConstraints || []) {
|
||||||
|
if (!c || !c.__type) continue;
|
||||||
|
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
|
||||||
|
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
|
||||||
|
else if (c.__type === 'limit') limitN = c.n;
|
||||||
|
else if (c.__type === 'offset') offsetN = c.offset || 0;
|
||||||
|
}
|
||||||
|
if (limitN !== null) fbConstraints.push(limit(limitN + offsetN));
|
||||||
|
return { fbConstraints, offsetN };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFirebaseStorage() {
|
||||||
|
const db = dbInstance;
|
||||||
|
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getDoc(path) {
|
||||||
|
const snap = await getDocReal(doc(db, path));
|
||||||
|
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setDoc(path, data, opts = {}) {
|
||||||
|
recordAdapterCall({ fn: 'setDoc', path, data, opts });
|
||||||
|
await setDoc(doc(db, path), data, opts.merge ? { merge: true } : undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDoc(path, patch) {
|
||||||
|
recordAdapterCall({ fn: 'updateDoc', path, patch });
|
||||||
|
await updateDoc(doc(db, path), patch);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDoc(path) {
|
||||||
|
await deleteDoc(doc(db, path));
|
||||||
|
},
|
||||||
|
|
||||||
|
async addDoc(collectionPath, data) {
|
||||||
|
const ref = await addDoc(collection(db, collectionPath), data);
|
||||||
|
return { id: ref.id, path: `${collectionPath}/${ref.id}` };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCollection(collectionPath, queryConstraints = []) {
|
||||||
|
const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
|
||||||
|
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
|
||||||
|
const snapshot = await getDocsReal(q);
|
||||||
|
return snapshot.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async countCollection(collectionPath) {
|
||||||
|
const snapshot = await getCountFromServer(collection(db, collectionPath));
|
||||||
|
return snapshot.data().count;
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchWrite(ops) {
|
||||||
|
const batch = writeBatch(db);
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.type === 'set') batch.set(doc(db, op.path), op.data);
|
||||||
|
else if (op.type === 'delete') batch.delete(doc(db, op.path));
|
||||||
|
else if (op.type === 'update') batch.update(doc(db, op.path), op.data);
|
||||||
|
}
|
||||||
|
await batch.commit();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bulk delete collection (optionally where-filtered). Firestore has no
|
||||||
|
// single bulk-by-path op: fetch matching docs, batch-delete in chunks
|
||||||
|
// of 500 (firestore batch limit). DEV ONLY.
|
||||||
|
async deleteCollection(path, whereField, whereValue) {
|
||||||
|
if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') {
|
||||||
|
throw new Error('deleteCollection: dev only');
|
||||||
|
}
|
||||||
|
let q = collection(db, path);
|
||||||
|
if (whereField) q = query(q, where(whereField, '==', whereValue));
|
||||||
|
const snap = await getDocsReal(q);
|
||||||
|
const docs = snap.docs;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < docs.length; i += 500) {
|
||||||
|
const batch = writeBatch(db);
|
||||||
|
for (const d of docs.slice(i, i + 500)) batch.delete(d.ref);
|
||||||
|
await batch.commit();
|
||||||
|
count += Math.min(500, docs.length - i);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Transactional undo via batch (atomic in firestore). Apply updates +
|
||||||
|
// flip undone flag in single commit.
|
||||||
|
async undo({ logPath, undo, redo = false }) {
|
||||||
|
const batch = writeBatch(db);
|
||||||
|
const updates = redo ? (undo.redo || undo.updates) : undo.updates;
|
||||||
|
batch.update(doc(db, undo.encounterPath), updates);
|
||||||
|
batch.update(doc(db, logPath), { undone: !redo });
|
||||||
|
await batch.commit();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
|
||||||
|
subscribeDoc(path, cb, errCb) {
|
||||||
|
recordAdapterCall({ fn: 'subscribeDoc', path });
|
||||||
|
return onSnapshot(doc(db, path), (snap) => {
|
||||||
|
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
|
||||||
|
}, (err) => {
|
||||||
|
console.error(`subscribeDoc ${path}:`, err);
|
||||||
|
if (typeof errCb === 'function') errCb(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
|
||||||
|
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
||||||
|
const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
|
||||||
|
const q = fbConstraints.length > 0
|
||||||
|
? query(collection(db, collectionPath), ...fbConstraints)
|
||||||
|
: collection(db, collectionPath);
|
||||||
|
return onSnapshot(q, (snap) => {
|
||||||
|
cb(snap.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() })));
|
||||||
|
}, (err) => {
|
||||||
|
console.error(`subscribeCollection ${collectionPath}:`, err);
|
||||||
|
if (typeof errCb === 'function') errCb(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() { /* SDK managed; no-op */ },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export SDK pieces App.js uses directly (until full refactor).
|
||||||
|
// orderBy/limit NOT re-exported: App uses neutral builders from index.js.
|
||||||
|
export {
|
||||||
|
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
|
||||||
|
query, writeBatch,
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// src/storage/index.js — storage factory + SDK re-exports.
|
||||||
|
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=server: backend.
|
||||||
|
// orderBy/limit below = NEUTRAL builders (not SDK passthrough).
|
||||||
|
// App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending).
|
||||||
|
|
||||||
|
import { initializeApp } from 'firebase/app';
|
||||||
|
import {
|
||||||
|
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
|
||||||
|
} from 'firebase/auth';
|
||||||
|
import {
|
||||||
|
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
|
||||||
|
onSnapshot, updateDoc, deleteDoc, query, writeBatch,
|
||||||
|
} from 'firebase/firestore';
|
||||||
|
import { initFirebase, createFirebaseStorage } from './firebase';
|
||||||
|
import { createServerStorage } from './server';
|
||||||
|
|
||||||
|
let storageInstance = null;
|
||||||
|
|
||||||
|
// Returns adapter instance implementing interface (getDoc/setDoc/subscribeDoc/etc).
|
||||||
|
export function getStorage() {
|
||||||
|
if (storageInstance) return storageInstance;
|
||||||
|
const mode = process.env.REACT_APP_STORAGE || 'firebase';
|
||||||
|
if (mode === 'firebase') {
|
||||||
|
const ok = initFirebase();
|
||||||
|
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
|
||||||
|
storageInstance = createFirebaseStorage();
|
||||||
|
} else if (mode === 'server') {
|
||||||
|
storageInstance = createServerStorage({
|
||||||
|
baseUrl: process.env.REACT_APP_BACKEND_URL || '',
|
||||||
|
realtimeUrl: process.env.REACT_APP_BACKEND_REALTIME_URL || '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown REACT_APP_STORAGE mode: ${mode}. Use 'firebase' or 'server'.`);
|
||||||
|
}
|
||||||
|
return storageInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageMode() {
|
||||||
|
return process.env.REACT_APP_STORAGE || 'firebase';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neutral query-constraint builders. App builds constraints with these (not
|
||||||
|
// raw SDK), so both adapters read the same shape. firebase adapter translates
|
||||||
|
// neutral -> SDK; server adapter applies client-side. Combat log uses these:
|
||||||
|
// [orderBy('timestamp','desc'), limit(500)]
|
||||||
|
export function where(field, op, value) { return { __type: 'where', field, op, value }; }
|
||||||
|
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
|
||||||
|
export function limit(n) { return { __type: 'limit', n }; }
|
||||||
|
export function offset(n) { return { __type: 'offset', offset: n }; }
|
||||||
|
|
||||||
|
export {
|
||||||
|
initializeApp,
|
||||||
|
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
|
||||||
|
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
|
||||||
|
onSnapshot, updateDoc, deleteDoc, query, writeBatch,
|
||||||
|
};
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// ws.js — thin storage adapter over generic KV backend (HTTP + WebSocket).
|
||||||
|
// Passthrough: no shape translation. Backend = firebase mirror.
|
||||||
|
// Implements same interface as memory.js. Tested by storage contract vs running server.
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
if (typeof WebSocket !== 'undefined') {
|
||||||
|
WebSocketImpl = WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServerStorage({ baseUrl, realtimeUrl } = {}) {
|
||||||
|
// Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy).
|
||||||
|
// Fallback to localhost for bare `npm start` dev without proxy.
|
||||||
|
const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, '');
|
||||||
|
let WS;
|
||||||
|
if (realtimeUrl) {
|
||||||
|
WS = realtimeUrl;
|
||||||
|
} else if (typeof window !== 'undefined' && window.location) {
|
||||||
|
// derive from current origin (http→ws, https→wss), same host/port.
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
WS = `${proto}//${window.location.host}/ws`;
|
||||||
|
} else {
|
||||||
|
WS = 'ws://127.0.0.1:4001/ws';
|
||||||
|
}
|
||||||
|
|
||||||
|
// App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
|
||||||
|
// Backend uses canonical paths. Strip prefix.
|
||||||
|
function norm(p) {
|
||||||
|
if (!p) return p;
|
||||||
|
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject id (last path segment) into doc — matches main firebase truth:
|
||||||
|
// { id: docSnap.id, ...snap.data() }
|
||||||
|
// Backend stores docs WITHOUT id; client adds it so all adapters share shape.
|
||||||
|
function withId(rawPath, data) {
|
||||||
|
if (data === null || data === undefined) return data;
|
||||||
|
const id = norm(rawPath).split('/').pop();
|
||||||
|
return { id, ...data };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docSubs = new Map(); // path -> Set<cb>
|
||||||
|
const collSubs = new Map(); // collPath -> Set<cb>
|
||||||
|
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
|
||||||
|
// Last data emitted per path. Dedupe identical re-emits so WS-open
|
||||||
|
// re-fetch (authoritative catch-up) doesn't double-fire when REST already
|
||||||
|
// delivered the same data. Race: REST empty → WS-open populated fires.
|
||||||
|
const lastEmitted = new Map(); // path -> serialized data (or null for empty)
|
||||||
|
let ws = null;
|
||||||
|
let wsReady = null;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let everConnected = false;
|
||||||
|
const RECONNECT_DELAY = 500;
|
||||||
|
|
||||||
|
// Emit helper: dedupe identical re-emits per path. WS-open re-fetch
|
||||||
|
// (authoritative catch-up) + initial REST both may fire; skip dup.
|
||||||
|
function emit(kind, path, data, cbs) {
|
||||||
|
const key = `${kind}:${path}`;
|
||||||
|
const ser = data === undefined ? 'undef' : JSON.stringify(data);
|
||||||
|
if (lastEmitted.get(key) === ser) return; // identical, skip
|
||||||
|
lastEmitted.set(key, ser);
|
||||||
|
cbs.forEach(cb => cb(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWs() {
|
||||||
|
if (wsReady) return wsReady;
|
||||||
|
wsReady = new Promise((resolve, reject) => {
|
||||||
|
(async () => {
|
||||||
|
// 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 }));
|
||||||
|
}
|
||||||
|
// 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) {
|
||||||
|
const cstr = collConstraints.get(p) || [];
|
||||||
|
storage.getCollection(p, cstr).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 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 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; }
|
||||||
|
handleMessage(msg);
|
||||||
|
};
|
||||||
|
// Bind ONCE. Browser WS + ws pkg are EventTargets: assigning onX AND
|
||||||
|
// addEventListener('x') registers the handler twice -> every event
|
||||||
|
// fires 2x (double onOpen -> spurious reconnect re-fetch, double
|
||||||
|
// onMessage -> emit dedupe races under write load -> UI looks stale).
|
||||||
|
ws.onopen = onOpen;
|
||||||
|
ws.onerror = onError;
|
||||||
|
ws.onclose = onClose;
|
||||||
|
ws.onmessage = onMessage;
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
return wsReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend pushes change notices keyed by path. Re-fetch affected subscribers.
|
||||||
|
async function handleMessage(msg) {
|
||||||
|
if (msg.type !== 'change' || !msg.change) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = msg.change;
|
||||||
|
// doc subscriber at exact changed path
|
||||||
|
const docCbs = docSubs.get(c.path);
|
||||||
|
if (docCbs) {
|
||||||
|
const doc = await storage.getDoc(c.path);
|
||||||
|
docCbs.forEach(cb => cb(doc));
|
||||||
|
}
|
||||||
|
// collection subscribers at parent path (apply their stored constraints)
|
||||||
|
if (c.parent) {
|
||||||
|
const collCbs = collSubs.get(c.parent);
|
||||||
|
if (collCbs) {
|
||||||
|
const constraints = collConstraints.get(c.parent) || [];
|
||||||
|
// Server honors orderBy + limit in SQL now.
|
||||||
|
const docs = await storage.getCollection(c.parent, constraints);
|
||||||
|
collCbs.forEach(cb => cb(docs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, query, body) {
|
||||||
|
let url = `${API}${path}`;
|
||||||
|
if (query) {
|
||||||
|
const qs = new URLSearchParams(query).toString();
|
||||||
|
url += `?${qs}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text().catch(() => '');
|
||||||
|
throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
async getDoc(rawPath) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
const res = await api('GET', '/api/doc', { path: p });
|
||||||
|
const data = res && res.data !== undefined ? res.data : null;
|
||||||
|
return withId(rawPath, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setDoc(rawPath, data, opts = {}) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
// merge flag -> PATCH (shallow merge, create-on-miss). Matches firebase
|
||||||
|
// setDoc(path, data, {merge:true}) semantics.
|
||||||
|
if (opts.merge) {
|
||||||
|
await api('PATCH', '/api/doc', null, { path: p, patch: data });
|
||||||
|
} else {
|
||||||
|
await api('PUT', '/api/doc', null, { path: p, data });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDoc(rawPath, patch) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
await api('PATCH', '/api/doc', null, { path: p, patch });
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDoc(rawPath) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
await api('DELETE', '/api/doc', { path: p });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bulk delete collection (optionally where-filtered). No fetch. SQL DELETE.
|
||||||
|
// DEV ONLY. Method ships in prod build but throws if not dev.
|
||||||
|
async deleteCollection(rawCollPath, whereField, whereValue) {
|
||||||
|
if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') {
|
||||||
|
throw new Error('deleteCollection: dev only');
|
||||||
|
}
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
const query = { path: p };
|
||||||
|
if (whereField) { query.whereField = whereField; query.whereValue = whereValue; }
|
||||||
|
const res = await api('DELETE', '/api/collection', query);
|
||||||
|
return res.deleted;
|
||||||
|
},
|
||||||
|
|
||||||
|
async addDoc(rawCollPath, data) {
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
const res = await api('POST', '/api/collection', null, { path: p, data });
|
||||||
|
return { id: res.id, path: res.path };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCollection(rawCollPath, queryConstraints = []) {
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
const query = { path: p };
|
||||||
|
// Push where + orderBy + limit + offset to server SQL when present.
|
||||||
|
// Avoids client-side full-scan + re-sort on every WS change notification.
|
||||||
|
for (const c of queryConstraints || []) {
|
||||||
|
if (!c || !c.__type) continue;
|
||||||
|
if (c.__type === 'where') {
|
||||||
|
query.whereField = c.field;
|
||||||
|
query.whereOp = c.op;
|
||||||
|
query.whereValue = c.value;
|
||||||
|
} else if (c.__type === 'orderBy') {
|
||||||
|
query.orderBy = c.field;
|
||||||
|
query.dir = c.dir || 'asc';
|
||||||
|
} else if (c.__type === 'limit') {
|
||||||
|
query.limit = c.n;
|
||||||
|
} else if (c.__type === 'offset') {
|
||||||
|
query.offset = c.offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const docs = await api('GET', '/api/collection', query);
|
||||||
|
if (!Array.isArray(docs)) return [];
|
||||||
|
return docs.map(d => {
|
||||||
|
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
|
||||||
|
return { id: d.id, ...d.data };
|
||||||
|
}
|
||||||
|
return { ...d };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async countCollection(rawCollPath) {
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
const res = await api('GET', '/api/collection/count', { path: p });
|
||||||
|
return (res && typeof res.count === 'number') ? res.count : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchWrite(ops) {
|
||||||
|
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
|
||||||
|
await api('POST', '/api/batch', null, { ops: normOps });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Transactional undo (server mode only). Firebase falls back to 2-write
|
||||||
|
// in App.js (via store.txUndo check). undo = { encounterPath, updates, redo }.
|
||||||
|
// redo=true applies redo patch + flips undone:false.
|
||||||
|
async undo({ logPath, undo, redo = false }) {
|
||||||
|
const normUndo = { ...undo, encounterPath: norm(undo.encounterPath) };
|
||||||
|
const normLog = norm(logPath);
|
||||||
|
await api('POST', '/api/undo', null, { logPath: normLog, undo: normUndo, redo });
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeDoc(rawPath, cb, errCb) {
|
||||||
|
const p = norm(rawPath);
|
||||||
|
lastEmitted.delete('doc:'+p); // fresh sub → allow first emit
|
||||||
|
// Initial value via REST (independent of WS connect). getDoc injects id.
|
||||||
|
storage.getDoc(p).then(doc => emit('doc', p, doc, docSubs.get(p) || new Set([cb]))).catch(() => {});
|
||||||
|
// WS only for subsequent change notifications.
|
||||||
|
ensureWs().then(() => {
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
||||||
|
}).catch(() => {});
|
||||||
|
if (!docSubs.has(p)) docSubs.set(p, new Set());
|
||||||
|
docSubs.get(p).add(cb);
|
||||||
|
return () => { docSubs.get(p)?.delete(cb); };
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
collConstraints.set(p, queryConstraints || []);
|
||||||
|
lastEmitted.delete('coll:'+p); // fresh sub → allow first emit
|
||||||
|
// Server honors orderBy + limit in SQL now. No client re-sort.
|
||||||
|
storage.getCollection(p, queryConstraints || [])
|
||||||
|
.then(docs => emit('coll', p, docs, collSubs.get(p) || new Set([cb])))
|
||||||
|
.catch(err => { if (errCb) errCb(err); });
|
||||||
|
// WS only for subsequent change notifications.
|
||||||
|
ensureWs().then(() => {
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||||
|
}).catch(() => {});
|
||||||
|
if (!collSubs.has(p)) collSubs.set(p, new Set());
|
||||||
|
collSubs.get(p).add(cb);
|
||||||
|
return () => { collSubs.get(p)?.delete(cb); };
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose(cb) {
|
||||||
|
disposed = true;
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
if (ws) ws.close();
|
||||||
|
docSubs.clear(); collSubs.clear(); collConstraints.clear();
|
||||||
|
if (typeof cb === 'function') cb();
|
||||||
|
},
|
||||||
|
|
||||||
|
_api: api,
|
||||||
|
_test: {
|
||||||
|
getWs: () => ws,
|
||||||
|
forceDrop: () => { if (ws) ws.close(); },
|
||||||
|
getReady: () => wsReady,
|
||||||
|
docSubs, collSubs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createServerStorage };
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// App.characterization.test.js
|
||||||
|
// Characterize App -> Firebase calls. Lock path + payload shape per action.
|
||||||
|
// Mock SDK, render AdminView, fire action, assert recorded calls.
|
||||||
|
// Purpose: refactor (path-shape rewrite) must not change these calls.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { renderApp, createCampaignViaUI, selectCampaignByName, setupReady, startCombatViaUI, addMonsterViaUI } from './testHelpers';
|
||||||
|
|
||||||
|
function findCall(fn, pathSub) {
|
||||||
|
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||||
|
}
|
||||||
|
function findCalls(fn, pathSub) {
|
||||||
|
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CAMPAIGN GROUP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('Campaign -> Firebase', () => {
|
||||||
|
test('createCampaign: setDoc with campaign path + payload', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const id = await createCampaignViaUI('Alpha');
|
||||||
|
const call = findCall('setDoc', '/campaigns/');
|
||||||
|
expect(call.path).toMatch(/campaigns\/.+$/);
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
name: 'Alpha',
|
||||||
|
playerDisplayBackgroundUrl: '',
|
||||||
|
players: [],
|
||||||
|
});
|
||||||
|
expect(call.data).toHaveProperty('ownerId');
|
||||||
|
expect(call.data).toHaveProperty('createdAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createCampaign: path includes APP_ID namespace', async () => {
|
||||||
|
await renderApp();
|
||||||
|
await createCampaignViaUI('NS Test');
|
||||||
|
const call = findCall('setDoc', '/campaigns/');
|
||||||
|
expect(call.path).toContain('artifacts/');
|
||||||
|
expect(call.path).toContain('/public/data/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createCampaign: optional background URL stored', async () => {
|
||||||
|
await renderApp();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
|
||||||
|
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
|
||||||
|
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'With BG' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/Background URL/i), { target: { value: 'https://img.test/bg.png' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
|
||||||
|
await waitFor(() => findCall('setDoc', '/campaigns/'));
|
||||||
|
const call = findCall('setDoc', '/campaigns/');
|
||||||
|
expect(call.data.playerDisplayBackgroundUrl).toBe('https://img.test/bg.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCharacter: updateDoc on campaign doc, players array grows', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const cid = await createCampaignViaUI('Roster');
|
||||||
|
await selectCampaignByName('Roster');
|
||||||
|
|
||||||
|
// CharacterManager form
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
|
||||||
|
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||||
|
const call = findCall('updateDoc', `/campaigns/${cid}`);
|
||||||
|
expect(call.data.players).toHaveLength(1);
|
||||||
|
expect(call.data.players[0]).toMatchObject({
|
||||||
|
name: 'Brog',
|
||||||
|
defaultMaxHp: 25,
|
||||||
|
defaultInitMod: 3,
|
||||||
|
});
|
||||||
|
expect(call.data.players[0]).toHaveProperty('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateCharacter: updateDoc with updated players array', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const cid = await createCampaignViaUI('EditRoster');
|
||||||
|
await selectCampaignByName('EditRoster');
|
||||||
|
|
||||||
|
// add one first
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Old Name' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||||
|
|
||||||
|
// click edit
|
||||||
|
const editBtn = await screen.findByRole('button', { name: /Edit character/i });
|
||||||
|
fireEvent.click(editBtn);
|
||||||
|
await waitFor(() => screen.getByDisplayValue('Old Name'));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } });
|
||||||
|
// Save button is icon-only (no text); submit its form.
|
||||||
|
const form = screen.getByDisplayValue('New Name').closest('form');
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = findCalls('updateDoc', `/campaigns/${cid}`);
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last.data.players[0].name).toBe('New Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteCharacter: updateDoc with character removed', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const cid = await createCampaignViaUI('DeleteRoster');
|
||||||
|
await selectCampaignByName('DeleteRoster');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Gone' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
await waitFor(() => findCall('updateDoc', '/campaigns/'));
|
||||||
|
|
||||||
|
const delBtn = await screen.findByRole('button', { name: /Delete character/i });
|
||||||
|
fireEvent.click(delBtn);
|
||||||
|
// confirmation modal
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = findCalls('updateDoc', `/campaigns/${cid}`);
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last.data.players).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteCampaign: deletes encounters batch + campaign doc + activeDisplay null', async () => {
|
||||||
|
await renderApp();
|
||||||
|
const cid = await createCampaignViaUI('Doomed');
|
||||||
|
await selectCampaignByName('Doomed');
|
||||||
|
|
||||||
|
// campaign card delete button has no aria-label; find trash by text via grid
|
||||||
|
const allDeletes = screen.getAllByText(/Delete/i);
|
||||||
|
// campaign card Delete is in card grid, last one rendered
|
||||||
|
fireEvent.click(allDeletes[allDeletes.length - 1]);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`));
|
||||||
|
const delCall = findCall('deleteDoc', `/campaigns/${cid}`);
|
||||||
|
expect(delCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteCampaign: cascade-deletes logs for campaign encounters (BUG)', async () => {
|
||||||
|
const { setupReady, startCombatViaUI } = require('./testHelpers');
|
||||||
|
await setupReady('Doomed', 'Enc');
|
||||||
|
await addMonsterViaUI('Gob', 5, 2);
|
||||||
|
await startCombatViaUI();
|
||||||
|
// log written on start
|
||||||
|
await waitFor(() => {
|
||||||
|
const logWrites = findCalls('addDoc').filter(c => c.path.includes('/logs'));
|
||||||
|
expect(logWrites.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
const cid = Object.keys(MOCK_DB.collection('campaigns').reduce((m,c)=>(m[c.id]=c,m),{}))[0];
|
||||||
|
|
||||||
|
// delete campaign
|
||||||
|
const allDeletes = screen.getAllByText(/Delete/i);
|
||||||
|
fireEvent.click(allDeletes[allDeletes.length - 1]);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`));
|
||||||
|
|
||||||
|
// logs MUST be deleted via batch or deleteDoc on each log path
|
||||||
|
const logDeletes = getCalls().filter(c =>
|
||||||
|
(c.fn === 'deleteDoc' && c.path.includes('/logs/')) ||
|
||||||
|
(c.fn === 'batch.delete' && c.path.includes('/logs/'))
|
||||||
|
);
|
||||||
|
expect(logDeletes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteEncounter: cascade-deletes logs for encounter (BUG)', async () => {
|
||||||
|
const { setupReady, startCombatViaUI } = require('./testHelpers');
|
||||||
|
await setupReady('Camp', 'DoomedEnc');
|
||||||
|
await addMonsterViaUI('Gob', 5, 2);
|
||||||
|
await startCombatViaUI();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findCalls('addDoc').filter(c => c.path.includes('/logs')).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete encounter
|
||||||
|
const allDeletes = screen.getAllByText(/Delete/i);
|
||||||
|
fireEvent.click(allDeletes[0]);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const logDeletes = getCalls().filter(c =>
|
||||||
|
(c.fn === 'deleteDoc' && c.path.includes('/logs/')) ||
|
||||||
|
(c.fn === 'batch.delete' && c.path.includes('/logs/'))
|
||||||
|
);
|
||||||
|
expect(logDeletes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Combat characterization. Lock updateDoc/setDoc patch for combat controls.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||||
|
|
||||||
|
function findCallsEnc() {
|
||||||
|
return getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
}
|
||||||
|
function lastEncCall() {
|
||||||
|
const calls = findCallsEnc();
|
||||||
|
return calls[calls.length - 1];
|
||||||
|
}
|
||||||
|
function findCallActiveDisplay(fn) {
|
||||||
|
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupWithMonsters(names = ['A', 'B', 'C']) {
|
||||||
|
await setupReady('CombatCamp', 'CombatEnc');
|
||||||
|
for (const n of names) {
|
||||||
|
await addMonsterViaUI(n, 20, Number(n.charCodeAt(0) % 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Combat -> Firebase', () => {
|
||||||
|
test('startEncounter: updateDoc sets isStarted/round/turn/current', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
const call = lastEncCall();
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
isStarted: true,
|
||||||
|
isPaused: false,
|
||||||
|
round: 1,
|
||||||
|
});
|
||||||
|
expect(call.data.currentTurnParticipantId).toBeTruthy();
|
||||||
|
expect(call.data.turnOrderIds).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startEncounter: also sets activeDisplay to this encounter', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data.activeCampaignId).toBeTruthy();
|
||||||
|
expect(last.data.activeEncounterId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn: advances currentTurnParticipantId', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
const beforeId = lastEncCall().data.currentTurnParticipantId;
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId !== beforeId);
|
||||||
|
expect(lastEncCall().data.currentTurnParticipantId).not.toBe(beforeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextTurn wrapping to round 1->2 increments round', async () => {
|
||||||
|
await setupWithMonsters(['A', 'B']);
|
||||||
|
await startCombatViaUI();
|
||||||
|
|
||||||
|
// advance through all participants to wrap
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // A->B (or 2nd)
|
||||||
|
await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // wrap
|
||||||
|
await waitFor(() => lastEncCall()?.data?.round === 2);
|
||||||
|
expect(lastEncCall().data.round).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause: updateDoc sets isPaused true', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.isPaused === true);
|
||||||
|
expect(lastEncCall().data.isPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume: updateDoc sets isPaused false + recomputes turnOrder', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.isPaused === true);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.isPaused === false);
|
||||||
|
const call = lastEncCall();
|
||||||
|
expect(call.data.isPaused).toBe(false);
|
||||||
|
expect(call.data.turnOrderIds).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter: updateDoc resets all combat state', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
await waitFor(() => lastEncCall()?.data?.isStarted === false);
|
||||||
|
const call = lastEncCall();
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
turnOrderIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter: clears activeDisplay', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
return last && last.data.activeCampaignId === null;
|
||||||
|
});
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleHidePlayerHp: setDoc{merge} patch on activeDisplay/status', async () => {
|
||||||
|
await setupWithMonsters();
|
||||||
|
await startCombatViaUI();
|
||||||
|
// Two switches now (Hide player HP + Hide NPC/monster HP). Scope to player one.
|
||||||
|
const playerHpLabel = screen.getByText('Hide player HP');
|
||||||
|
const switchBtn = playerHpLabel.parentElement.querySelector('[role="switch"]');
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
return last && 'hidePlayerHp' in last.data;
|
||||||
|
});
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data).toHaveProperty('hidePlayerHp');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
// Combat.scenario.test.js
|
||||||
|
// Full combat scenario driven through shared/turn.js directly --- NO React.
|
||||||
|
//
|
||||||
|
// Does 100 ROUNDS (not turns). A round = one full pass through initiative
|
||||||
|
// (every active participant acts once); round counter increments at wrap.
|
||||||
|
//
|
||||||
|
// Exercises ALL combat paths deterministically:
|
||||||
|
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
|
||||||
|
// updateParticipant, removeParticipant, toggleParticipantActive,
|
||||||
|
// applyHpChange (damage/heal), deathSave, toggleCondition,
|
||||||
|
// reorderParticipants, endEncounter.
|
||||||
|
//
|
||||||
|
// New API: funcs async, take ctx {storage, encPath, logPath, displayPath},
|
||||||
|
// write encounter + log internally, return newEnc. Mock storage captures writes.
|
||||||
|
// Display lifecycle now async too — but test keeps inline display writes (mirrors App.js).
|
||||||
|
//
|
||||||
|
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
|
||||||
|
// failures collected, final expect reports all.
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { createMockStorage, mockCtx, readyEnc } = require('../../shared/tests/_helpers');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant,
|
||||||
|
buildMonsterParticipant,
|
||||||
|
startEncounter, nextTurn, togglePause, endEncounter,
|
||||||
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||||
|
toggleParticipantActive: combatToggleActive,
|
||||||
|
applyHpChange: combatApplyHpChange,
|
||||||
|
deathSave: combatDeathSave,
|
||||||
|
stabilizeParticipant,
|
||||||
|
reviveParticipant,
|
||||||
|
toggleCondition: combatToggleCondition,
|
||||||
|
reorderParticipants,
|
||||||
|
} = require('../../shared');
|
||||||
|
|
||||||
|
// ---------- scenario state ----------
|
||||||
|
|
||||||
|
const ROUNDS = 100;
|
||||||
|
const ENCOUNTER_ID = 'enc-1';
|
||||||
|
|
||||||
|
let enc = {
|
||||||
|
id: ENCOUNTER_ID,
|
||||||
|
name: 'BigBoss',
|
||||||
|
participants: [],
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
turnOrderIds: [],
|
||||||
|
};
|
||||||
|
// display tracked via mock storage displayPath doc; simple local mirror.
|
||||||
|
let display = {
|
||||||
|
activeCampaignId: null,
|
||||||
|
activeEncounterId: null,
|
||||||
|
hidePlayerHp: true,
|
||||||
|
hideNpcHp: false,
|
||||||
|
};
|
||||||
|
const CAMPAIGN_ID = 'camp-1';
|
||||||
|
const CAMPAIGN_PATH = `campaigns/${CAMPAIGN_ID}`;const roster = [];
|
||||||
|
|
||||||
|
let storage, ctx;
|
||||||
|
const DISPLAY_PATH = 'activeDisplay/status';
|
||||||
|
|
||||||
|
const RESULTS = [];
|
||||||
|
async function record(phase, fn) {
|
||||||
|
try { await fn(); RESULTS.push({ phase, ok: true }); }
|
||||||
|
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
|
||||||
|
const any = (name) => enc.participants.find(p => p.name === name);
|
||||||
|
const anyById = (id) => enc.participants.find(p => p.id === id);
|
||||||
|
|
||||||
|
function addCharacterViaUI(name, maxHp, initMod) {
|
||||||
|
const character = { id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod };
|
||||||
|
roster.push(character);
|
||||||
|
// persist to campaign doc (mirrors app: players array)
|
||||||
|
if (storage) {
|
||||||
|
const existing = (storage.docs.get(CAMPAIGN_PATH) || {}).players || [];
|
||||||
|
storage.setDoc(CAMPAIGN_PATH, {
|
||||||
|
id: CAMPAIGN_ID, name: 'Scenario Campaign', createdAt: Date.now(),
|
||||||
|
players: [...existing, character],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) {
|
||||||
|
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc });
|
||||||
|
enc = await addParticipant(enc, participant, ctx);
|
||||||
|
}
|
||||||
|
async function addCharacterParticipant(charName) {
|
||||||
|
const character = roster.find(c => c.name === charName);
|
||||||
|
if (!character) throw new Error(`char not found: ${charName}`);
|
||||||
|
if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
|
||||||
|
throw new Error(`${charName} already in encounter`);
|
||||||
|
}
|
||||||
|
const { participant } = buildCharacterParticipant(character);
|
||||||
|
enc = await addParticipant(enc, participant, ctx);
|
||||||
|
}
|
||||||
|
async function addAllCharacters() {
|
||||||
|
const toAdd = roster
|
||||||
|
.filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
|
||||||
|
.map(c => buildCharacterParticipant(c).participant);
|
||||||
|
enc = await addParticipants(enc, toAdd, ctx);
|
||||||
|
}
|
||||||
|
async function startCombat() {
|
||||||
|
enc = await startEncounter(enc, ctx);
|
||||||
|
display.activeCampaignId = CAMPAIGN_ID;
|
||||||
|
display.activeEncounterId = ENCOUNTER_ID;
|
||||||
|
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: CAMPAIGN_ID, activeEncounterId: ENCOUNTER_ID });
|
||||||
|
}
|
||||||
|
async function endCombat() {
|
||||||
|
enc = await endEncounter(enc, ctx);
|
||||||
|
display.activeCampaignId = null;
|
||||||
|
display.activeEncounterId = null;
|
||||||
|
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: null, activeEncounterId: null });
|
||||||
|
}
|
||||||
|
async function nextTurnAction() { enc = await nextTurn(enc, ctx); }
|
||||||
|
async function pauseCombat() { enc = await togglePause(enc, ctx); if (!enc.isPaused) throw new Error('not paused'); }
|
||||||
|
async function resumeCombat() { enc = await togglePause(enc, ctx); if (enc.isPaused) throw new Error('not resumed'); }
|
||||||
|
|
||||||
|
async function applyDamage(name, amount, options = null) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p || (p.currentHp <= 0 && p.status === 'dead')) return;
|
||||||
|
enc = options
|
||||||
|
? await combatApplyHpChange(enc, p.id, 'damage', amount, options, ctx)
|
||||||
|
: await combatApplyHpChange(enc, p.id, 'damage', amount, ctx);
|
||||||
|
}
|
||||||
|
async function applyHeal(name, amount) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p) return;
|
||||||
|
enc = await combatApplyHpChange(enc, p.id, 'heal', amount, ctx);
|
||||||
|
}
|
||||||
|
async function toggleActive(name) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p) throw new Error(`no active target: ${name}`);
|
||||||
|
enc = await combatToggleActive(enc, p.id, ctx);
|
||||||
|
}
|
||||||
|
async function toggleConditionAction(name, label) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p) throw new Error(`no condition target: ${name}`);
|
||||||
|
enc = await combatToggleCondition(enc, p.id, label, ctx);
|
||||||
|
}
|
||||||
|
async function editParticipant(name, patch) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p) throw new Error(`no edit target: ${name}`);
|
||||||
|
enc = await updateParticipant(enc, p.id, patch, ctx);
|
||||||
|
}
|
||||||
|
async function removeParticipantByName(name) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p) throw new Error(`no remove target: ${name}`);
|
||||||
|
enc = await removeParticipant(enc, p.id, ctx);
|
||||||
|
}
|
||||||
|
async function deathSaveAction(name, outcome) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p) throw new Error(`no deathsave target: ${name}`);
|
||||||
|
const r = await combatDeathSave(enc, p.id, outcome, ctx);
|
||||||
|
enc = r.enc;
|
||||||
|
}
|
||||||
|
async function stabilizeAction(name) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p) throw new Error(`no stabilize target: ${name}`);
|
||||||
|
enc = await stabilizeParticipant(enc, p.id, ctx);
|
||||||
|
}
|
||||||
|
async function reviveAction(name) {
|
||||||
|
const p = any(name);
|
||||||
|
if (!p) throw new Error(`no revive target: ${name}`);
|
||||||
|
enc = await reviveParticipant(enc, p.id, ctx);
|
||||||
|
}
|
||||||
|
async function dragTie(draggedName, targetName) {
|
||||||
|
const d = any(draggedName), t = any(targetName);
|
||||||
|
if (!d || !t) throw new Error('drag target missing');
|
||||||
|
enc = await reorderParticipants(enc, d.id, t.id, ctx);
|
||||||
|
}
|
||||||
|
async function toggleHidePlayerHpAction() {
|
||||||
|
display.hidePlayerHp = !display.hidePlayerHp;
|
||||||
|
await storage.updateDoc(DISPLAY_PATH, { hidePlayerHp: display.hidePlayerHp });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scenario ----------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ storage, ctx } = mockCtx());
|
||||||
|
enc = {
|
||||||
|
id: ENCOUNTER_ID, name: 'BigBoss', participants: [],
|
||||||
|
isStarted: false, isPaused: false, round: 0,
|
||||||
|
currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
};
|
||||||
|
display = { activeCampaignId: null, activeEncounterId: null, hidePlayerHp: true, hideNpcHp: false };
|
||||||
|
roster.length = 0;
|
||||||
|
RESULTS.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('death-save edge cases in combat scenario helpers', async () => {
|
||||||
|
await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||||
|
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||||
|
await record('addAllChars', () => addAllCharacters());
|
||||||
|
await record('addMonster Goblin', () => addMonsterParticipant('Goblin', 8, 2));
|
||||||
|
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
||||||
|
await record('startCombat', () => startCombat());
|
||||||
|
|
||||||
|
await record('monster drops dead inactive', async () => {
|
||||||
|
await applyDamage('Goblin', any('Goblin').currentHp);
|
||||||
|
const g = any('Goblin');
|
||||||
|
expect(g.status).toBe('dead');
|
||||||
|
expect(g.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await record('npc nat20 returns conscious', async () => {
|
||||||
|
await applyDamage('Merchant', any('Merchant').currentHp);
|
||||||
|
expect(any('Merchant').status).toBe('dying');
|
||||||
|
await deathSaveAction('Merchant', 'nat20');
|
||||||
|
expect(any('Merchant').status).toBe('conscious');
|
||||||
|
expect(any('Merchant').currentHp).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await record('npc nat1 plus damage kills, revive/heal restores', async () => {
|
||||||
|
await applyDamage('Merchant', any('Merchant').currentHp);
|
||||||
|
await deathSaveAction('Merchant', 'nat1');
|
||||||
|
expect(any('Merchant').deathSaveFailures).toBe(2);
|
||||||
|
await applyDamage('Merchant', 1);
|
||||||
|
expect(any('Merchant').status).toBe('dead');
|
||||||
|
expect(any('Merchant').isActive).toBe(true);
|
||||||
|
await reviveAction('Merchant');
|
||||||
|
expect(any('Merchant').status).toBe('stable');
|
||||||
|
await applyHeal('Merchant', 5);
|
||||||
|
expect(any('Merchant').status).toBe('conscious');
|
||||||
|
expect(any('Merchant').currentHp).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
await record('character stable then crit damage dies', async () => {
|
||||||
|
await applyDamage('Fighter', any('Fighter').currentHp);
|
||||||
|
await deathSaveAction('Fighter', 'success');
|
||||||
|
await deathSaveAction('Fighter', 'success');
|
||||||
|
await deathSaveAction('Fighter', 'success');
|
||||||
|
expect(any('Fighter').status).toBe('stable');
|
||||||
|
await applyDamage('Fighter', 1);
|
||||||
|
expect(any('Fighter').status).toBe('dying');
|
||||||
|
expect(any('Fighter').deathSaveFailures).toBe(1);
|
||||||
|
await applyDamage('Fighter', 1, { isCriticalHit: true });
|
||||||
|
expect(any('Fighter').status).toBe('dead');
|
||||||
|
expect(any('Fighter').isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await record('massive damage kills character but does not deactivate', async () => {
|
||||||
|
await applyDamage('Cleric', any('Cleric').currentHp + any('Cleric').maxHp);
|
||||||
|
expect(any('Cleric').status).toBe('dead');
|
||||||
|
expect(any('Cleric').isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = RESULTS.filter(x => !x.ok);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
|
||||||
|
}
|
||||||
|
expect(failed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full 100-round combat scenario (shared, no React)', async () => {
|
||||||
|
await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||||
|
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
||||||
|
await record('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
|
||||||
|
|
||||||
|
await record('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
|
||||||
|
await record('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
|
||||||
|
await record('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
|
||||||
|
await record('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
|
||||||
|
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
|
||||||
|
|
||||||
|
await record('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
|
||||||
|
await record('addAllChars Cleric/Rogue', () => addAllCharacters());
|
||||||
|
|
||||||
|
await record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
||||||
|
await record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
||||||
|
|
||||||
|
await record('startCombat', () => startCombat());
|
||||||
|
|
||||||
|
// ---------- 100 ROUNDS ----------
|
||||||
|
let roundsDone = 0;
|
||||||
|
let prevRound = enc.round;
|
||||||
|
let turnInRound = 0;
|
||||||
|
|
||||||
|
while (roundsDone < ROUNDS) {
|
||||||
|
const r = enc.round;
|
||||||
|
|
||||||
|
if (r % 2 === 0 && turnInRound === 0) await record(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
||||||
|
if (r % 3 === 0 && turnInRound === 1) await record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
|
||||||
|
if (r % 5 === 0 && turnInRound === 2) await record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
|
||||||
|
if (r % 7 === 0 && turnInRound === 3) await record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
|
||||||
|
if (r % 13 === 0 && turnInRound === 4) await record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
||||||
|
|
||||||
|
if (r % 10 === 0 && turnInRound === 0) {
|
||||||
|
await record(`r${r} pause`, () => pauseCombat());
|
||||||
|
await record(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
|
||||||
|
await record(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
|
||||||
|
await record(`r${r} resume`, () => resumeCombat());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r % 15 === 0 && turnInRound === 5) {
|
||||||
|
const g1 = any('Goblin1');
|
||||||
|
if (g1 && alive('Goblin2')) {
|
||||||
|
await record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
|
||||||
|
await record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r === 25 && turnInRound === 0) {
|
||||||
|
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp));
|
||||||
|
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail'));
|
||||||
|
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||||
|
}
|
||||||
|
if (r === 50 && turnInRound === 0) {
|
||||||
|
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp));
|
||||||
|
await record(`r${r} deathSave Cleric x3 (dead)`, async () => {
|
||||||
|
await deathSaveAction('Cleric', 'fail');
|
||||||
|
await deathSaveAction('Cleric', 'fail');
|
||||||
|
await deathSaveAction('Cleric', 'fail');
|
||||||
|
});
|
||||||
|
const cl = any('Cleric');
|
||||||
|
if (cl) {
|
||||||
|
expect(cl.status).toBe('dead');
|
||||||
|
expect(enc.participants.map(p => p.name)).toContain('Cleric');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r % 30 === 0 && turnInRound === 1) {
|
||||||
|
const rein = any(`Reinforce${r - 10}`);
|
||||||
|
if (rein) await record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r % 20 === 0 && turnInRound === 0) {
|
||||||
|
await record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
|
||||||
|
await record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turnInRound === 0 && r % 10 === 0) {
|
||||||
|
await record(`r${r} rotation-check`, () => {
|
||||||
|
const order = enc.turnOrderIds || [];
|
||||||
|
const uniq = new Set(order);
|
||||||
|
if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
|
||||||
|
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
|
||||||
|
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
|
||||||
|
}
|
||||||
|
const ids = enc.participants.map(p => p.id);
|
||||||
|
if (JSON.stringify(order) !== JSON.stringify(ids)) {
|
||||||
|
throw new Error(`turnOrderIds drift: order=${JSON.stringify(order)} ids=${JSON.stringify(ids)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await record(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
|
||||||
|
turnInRound++;
|
||||||
|
|
||||||
|
if (enc.round > prevRound) {
|
||||||
|
roundsDone++;
|
||||||
|
prevRound = enc.round;
|
||||||
|
turnInRound = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await record('endCombat', () => endCombat());
|
||||||
|
|
||||||
|
const failed = RESULTS.filter(x => !x.ok);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
|
||||||
|
}
|
||||||
|
expect(failed).toEqual([]);
|
||||||
|
expect(roundsDone).toBe(ROUNDS);
|
||||||
|
|
||||||
|
expect(enc.isStarted).toBe(false);
|
||||||
|
expect(enc.round).toBe(0);
|
||||||
|
expect(enc.currentTurnParticipantId).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// DisplayView.characterization.test.js
|
||||||
|
// Lock DisplayView uses storage adapter (subscribeDoc), NOT raw SDK onSnapshot(doc(db)).
|
||||||
|
// Blind spot caught: M2 refactor missed DisplayView; raw SDK + ws stub db = crash.
|
||||||
|
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
|
||||||
|
|
||||||
|
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 { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
|
||||||
|
|
||||||
|
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
|
||||||
|
function seedActiveDisplay(participants = []) {
|
||||||
|
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';
|
||||||
|
|
||||||
|
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
|
||||||
|
MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
|
||||||
|
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function participant(status) {
|
||||||
|
return {
|
||||||
|
id: status,
|
||||||
|
name: status === 'dying' ? 'Rogue' : status,
|
||||||
|
type: 'character',
|
||||||
|
initiative: 18,
|
||||||
|
maxHp: 160,
|
||||||
|
currentHp: status === 'conscious' ? 10 : 0,
|
||||||
|
isActive: true,
|
||||||
|
status,
|
||||||
|
deathSaveSuccesses: 0,
|
||||||
|
deathSaveFailures: 0,
|
||||||
|
conditions: status === 'stable' || status === 'dying' ? ['unconscious'] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DisplayView characterization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/display');
|
||||||
|
global.alert = jest.fn();
|
||||||
|
window.open = jest.fn();
|
||||||
|
resetAdapterCalls();
|
||||||
|
Element.prototype.scrollIntoView = jest.fn();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
|
||||||
|
seedActiveDisplay();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// wait for DisplayView to mount and attempt subscriptions
|
||||||
|
await waitFor(() => {
|
||||||
|
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc');
|
||||||
|
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
// must subscribe to campaign doc (for background url) and encounter doc
|
||||||
|
const docSubs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc').map(c => c.path);
|
||||||
|
expect(docSubs.some(p => p.includes('/campaigns/c1'))).toBe(true);
|
||||||
|
expect(docSubs.some(p => p.includes('/encounters/e1'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DisplayView also subscribes to activeDisplay status doc via adapter', async () => {
|
||||||
|
seedActiveDisplay();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc' && c.path.includes('activeDisplay'));
|
||||||
|
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DisplayView shows Dying for dying character with Unconscious condition', async () => {
|
||||||
|
seedActiveDisplay([participant('dying')]);
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Rogue')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/Dying/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DisplayView shows Stable as Unconscious, and Dead as Dead', async () => {
|
||||||
|
seedActiveDisplay([participant('stable'), participant('dead')]);
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('stable')).toBeInTheDocument());
|
||||||
|
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText('(Stable)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DisplayView hides inactive participants for all types after fade-out', async () => {
|
||||||
|
seedActiveDisplay([
|
||||||
|
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
|
||||||
|
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
|
||||||
|
{ id: 'inactive-monster', name: 'Inactive Monster', type: 'monster', initiative: 10, maxHp: 10, currentHp: 10, isActive: false, status: 'conscious', conditions: [] },
|
||||||
|
]);
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
|
||||||
|
// inactive held during exit animation, removed after transition
|
||||||
|
await waitFor(() => expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(), { timeout: 1500 });
|
||||||
|
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Encounter characterization. Lock setDoc path + payload on encounter actions.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName } from './testHelpers';
|
||||||
|
|
||||||
|
function findCall(fn, pathSub) {
|
||||||
|
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||||
|
}
|
||||||
|
function findCalls(fn, pathSub) {
|
||||||
|
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupCampaignAndEncounter(campName, encName) {
|
||||||
|
await renderApp();
|
||||||
|
await createCampaignViaUI(campName);
|
||||||
|
await selectCampaignByName(campName);
|
||||||
|
await createEncounterViaUI(encName);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Encounter -> Firebase', () => {
|
||||||
|
test('createEncounter: setDoc with encounter path + payload', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp E', 'Boss Fight');
|
||||||
|
const call = findCall('setDoc', '/encounters/');
|
||||||
|
expect(call.path).toMatch(/encounters\/.+$/);
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
name: 'Boss Fight',
|
||||||
|
participants: [],
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
});
|
||||||
|
expect(call.data).toHaveProperty('createdAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createEncounter: path nested under campaign', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp N', 'Enc N');
|
||||||
|
const call = findCall('setDoc', '/encounters/');
|
||||||
|
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('togglePlayerDisplay: setDoc{merge} patch on activeDisplay/status', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp D', 'Enc D');
|
||||||
|
await selectEncounterByName('Enc D');
|
||||||
|
|
||||||
|
// Eye button (icon-only, title attr)
|
||||||
|
const eyeBtn = await screen.findByTitle('Activate for Player Display');
|
||||||
|
fireEvent.click(eyeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||||
|
const call = findCall('setDoc', 'activeDisplay/status');
|
||||||
|
// main truth: setDoc{merge} patch, not replace (would clobber fields)
|
||||||
|
expect(call.data).toMatchObject({
|
||||||
|
activeCampaignId: expect.any(String),
|
||||||
|
activeEncounterId: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('togglePlayerDisplay off: setDoc{merge} nulls active ids', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp O', 'Enc O');
|
||||||
|
await selectEncounterByName('Enc O');
|
||||||
|
|
||||||
|
// turn ON
|
||||||
|
const onBtn = await screen.findByTitle('Activate for Player Display');
|
||||||
|
fireEvent.click(onBtn);
|
||||||
|
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||||
|
|
||||||
|
// turn OFF
|
||||||
|
const offBtn = await screen.findByTitle('Deactivate for Player Display');
|
||||||
|
fireEvent.click(offBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = findCalls('setDoc', 'activeDisplay/status');
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
return last.data.activeCampaignId === null;
|
||||||
|
});
|
||||||
|
const calls = findCalls('setDoc', 'activeDisplay/status');
|
||||||
|
const last = calls[calls.length - 1];
|
||||||
|
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteEncounter: deleteDoc on encounter path', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp X', 'Enc X');
|
||||||
|
await selectEncounterByName('Enc X');
|
||||||
|
|
||||||
|
// trash icon on encounter card
|
||||||
|
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
|
||||||
|
fireEvent.click(trashBtn);
|
||||||
|
// confirm modal
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => findCall('deleteDoc', '/encounters/'));
|
||||||
|
const del = findCall('deleteDoc', '/encounters/');
|
||||||
|
expect(del.path).toMatch(/campaigns\/[^/]+\/encounters\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteEncounter clears activeDisplay if it was active', async () => {
|
||||||
|
await setupCampaignAndEncounter('Camp A', 'Enc A');
|
||||||
|
await selectEncounterByName('Enc A');
|
||||||
|
|
||||||
|
// activate display first
|
||||||
|
const onBtn = await screen.findByTitle('Activate for Player Display');
|
||||||
|
fireEvent.click(onBtn);
|
||||||
|
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
|
||||||
|
|
||||||
|
// delete the active encounter
|
||||||
|
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
|
||||||
|
fireEvent.click(trashBtn);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
return last.data.activeEncounterId === null;
|
||||||
|
});
|
||||||
|
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||