Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc0a1f1633 | ||
|
|
119d252d02 | ||
|
|
30712f0e1e | ||
|
|
7d0ea883b0 | ||
|
|
5c062bf944 | ||
|
|
b5b339a5dd | ||
|
|
eef11c3b6e | ||
|
|
2c6dfdafc8 | ||
|
|
c05a283cf0 | ||
|
|
69ab0c35c0 | ||
|
|
91e23856b4 | ||
|
|
fd03ae48c8 | ||
|
|
96770099f6 | ||
|
|
ebd91ca42a | ||
|
|
41c1e48874 | ||
|
|
863e8b3719 | ||
|
|
4a92c667c5 | ||
|
|
6f11edab94 | ||
|
|
2dfa155469 | ||
|
|
0fa3928bf8 | ||
|
|
9870d4df0d | ||
|
|
43b1f6ce41 | ||
|
|
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 |
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: ttrpg-encounter-builder
|
||||
description: |
|
||||
Seeds D&D encounter rosters into the TTRPG Initiative Tracker via its REST API
|
||||
(no UI clicking). Use when the user asks to "set up encounters", "build
|
||||
encounters", "add participants to a campaign", "seed this encounter list",
|
||||
or provides a statblock list (name + HP + init) for a campaign running on a
|
||||
self-hosted tracker instance running in SERVER MODE (bundled Express
|
||||
backend). NOT for Firebase SDK mode — that has no HTTP backend; write path
|
||||
differs entirely. Harness-agnostic: pi, Claude Code, and Codex all run the
|
||||
same script + curl recipes.
|
||||
---
|
||||
|
||||
# TTRPG Encounter Builder
|
||||
|
||||
Build encounter rosters directly via the tracker REST API. No UI clicking, no
|
||||
browser automation. One batch write per session, then verify.
|
||||
|
||||
## When to use
|
||||
|
||||
User provides a campaign name + a list of encounters, each with named
|
||||
participants carrying HP and initiative modifiers (or rolled initiative).
|
||||
Examples:
|
||||
|
||||
- "set up these encounters in the 2026 D&D campaign on bee.icantclick.org:8080"
|
||||
- "build encounter: Barrowdeep 1 - Ankheg HP 45 Init +0"
|
||||
- "seed these statblocks into my tracker"
|
||||
|
||||
Do NOT use for: starting combat, rolling HP mid-fight, editing live combat
|
||||
state, or driving the player display. Those are in-combat ops, not build ops.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running tracker instance in **SERVER MODE** (`REACT_APP_STORAGE=server`,
|
||||
bundled Express backend mounted). Reachable over HTTP.
|
||||
- **Firebase SDK mode NOT supported.** No `/api/*` endpoints exist; data
|
||||
lives in Firestore via the in-browser SDK with different auth + write
|
||||
paths. This skill cannot reach it. If the user is on firebase mode, tell
|
||||
them: switch to server mode, or build encounters via the UI / a
|
||||
firebase-admin script (out of scope here).
|
||||
- Node.js 18+ (for `fetch` + `crypto.randomUUID`). Helper script uses only
|
||||
builtins — no `npm install`.
|
||||
- Campaign must already exist (created via UI or `POST /api/collection`).
|
||||
This skill adds encounters to an existing campaign.
|
||||
|
||||
Confirm host reachable + server mode before building:
|
||||
|
||||
```bash
|
||||
curl -s <HOST>/api/collection?path=campaigns | head -c 200
|
||||
```
|
||||
|
||||
Returns JSON array of campaign docs = server mode confirmed. HTML (SPA shell)
|
||||
= firebase mode OR backend not mounted. Either way, this skill won't work —
|
||||
tell the user.
|
||||
|
||||
## Data model (read this before writing)
|
||||
|
||||
Three nested entities, all opaque JSON docs in a path-keyed KV store:
|
||||
|
||||
```
|
||||
Campaign campaigns/{campaignId}
|
||||
└─ Encounter campaigns/{campaignId}/encounters/{encounterId}
|
||||
└─ Participant (inline array item on the encounter doc)
|
||||
```
|
||||
|
||||
**Participants are inline.** There is NO `participants` sub-collection. A
|
||||
`participants` path returns empty. Each encounter doc carries its full roster
|
||||
in a `participants[]` array.
|
||||
|
||||
### Path normalization (gotcha)
|
||||
|
||||
App code uses firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`).
|
||||
The REST API uses **bare canonical** paths (`campaigns/...`). The adapter
|
||||
strips the prefix server-side.
|
||||
|
||||
- REST / scripts / direct DB → bare: `campaigns/{cid}/encounters/{eid}`
|
||||
- If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used
|
||||
the prefixed form. Drop the prefix.
|
||||
|
||||
### Encounter doc shape
|
||||
|
||||
```js
|
||||
{
|
||||
name: 'Barrowdeep 1 - Antechamber',
|
||||
createdAt: new Date().toISOString(),
|
||||
participants: [], // inline roster
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
ruleset: '5e',
|
||||
}
|
||||
```
|
||||
|
||||
Build encounters with `round: 0`, `isStarted: false`. Starting combat is a
|
||||
separate in-combat op (UI Start button or a later skill).
|
||||
|
||||
### Participant object shape
|
||||
|
||||
```js
|
||||
{
|
||||
id: <uuid>, // crypto.randomUUID(), client-generated
|
||||
name: 'Ankheg',
|
||||
type: 'monster', // 'monster' | 'npc' | 'character'
|
||||
originalCharacterId: null, // set only if type === 'character'
|
||||
initiative: <int>, // rolled ONCE at add; stored, not re-derived
|
||||
maxHp: 45,
|
||||
currentHp: 45, // start at full
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0, // character/npc only
|
||||
deathSaveFailures: 0, // character/npc only
|
||||
}
|
||||
```
|
||||
|
||||
### type field
|
||||
|
||||
- `monster` — DM-controlled, dead at 0 HP (auto-inactive). Default.
|
||||
- `npc` — DM-controlled but keeps death saves (revivable). Use for named
|
||||
allies/villains the DM runs.
|
||||
- `character` — player character. Requires `originalCharacterId` linking to a
|
||||
campaign roster entry. For build-from-statblock, use `monster` or `npc`,
|
||||
not `character`.
|
||||
|
||||
### Initiative semantics
|
||||
|
||||
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], rolled fresh per
|
||||
participant).
|
||||
- Manual override: set `initiative` directly (pre-rolled values), skip the
|
||||
roll. `initMod` is NOT stored on the participant — only the final
|
||||
`initiative` value persists.
|
||||
- Fixed at add time. Never re-derived after.
|
||||
|
||||
### id format
|
||||
|
||||
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`).
|
||||
Generate participant ids client-side so they're stable across batch writes.
|
||||
Server `POST /api/collection` auto-generates a UUID for the doc id — fine for
|
||||
campaigns/encounters, but for inline participants you generate them yourself.
|
||||
|
||||
## REST endpoints
|
||||
|
||||
All paths bare canonical.
|
||||
|
||||
| Method | Path | Body / Query | Action |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/doc` | `?path=` | read one doc |
|
||||
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
|
||||
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
|
||||
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
|
||||
| `GET` | `/api/collection` | `?path=` | list docs under collection |
|
||||
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
|
||||
| `POST` | `/api/batch` | `{ops:[{type, path, data?}]}` | atomic multi-write |
|
||||
|
||||
`POST /api/batch` is the workhorse for seeding. One batch, all encounters,
|
||||
atomic.
|
||||
|
||||
## Build flow
|
||||
|
||||
### 1. Parse the user's request
|
||||
|
||||
Extract:
|
||||
- **Host** — the running tracker URL (e.g. `http://bee.icantclick.org:8080`).
|
||||
If omitted, ask. Do not guess.
|
||||
- **Campaign name** — must match an existing campaign doc's `name` field.
|
||||
- **Encounters** — list of `{name, participants:[{name, type?, maxHp, initMod?|initiative?}]}`.
|
||||
|
||||
A statblock line like `Ankheg - HP 45 - Init +0` parses to:
|
||||
`{name:"Ankheg", type:"monster", maxHp:45, initMod:0}`.
|
||||
|
||||
### 2. Resolve the campaign id
|
||||
|
||||
Campaigns are keyed by id, not name. Look up by name:
|
||||
|
||||
```bash
|
||||
curl -s "<HOST>/api/collection?path=campaigns" \
|
||||
| jq -r '.[] | select(.name=="<CAMPAIGN NAME>") | .id'
|
||||
```
|
||||
|
||||
Exactly one match expected. Zero → campaign doesn't exist (tell user to create
|
||||
it first). Multiple → ambiguous name, ask user which id.
|
||||
|
||||
### 3. Write encounters (two options)
|
||||
|
||||
#### Option A — helper script (recommended)
|
||||
|
||||
`scripts/build-encounters.js` handles id gen, initiative rolling, batch write,
|
||||
and verification. Reads a spec file or stdin.
|
||||
|
||||
Spec format (JSON array):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Optional Road/Trail Ankheg",
|
||||
"participants": [
|
||||
{ "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Barrowdeep 2 - Minor Burial Chamber",
|
||||
"participants": [
|
||||
{ "name": "Cult Fanatic", "type": "monster", "maxHp": 39, "initMod": 2 },
|
||||
{ "name": "Cultist 1", "type": "monster", "maxHp": 9, "initMod": 2 },
|
||||
{ "name": "Cultist 2", "type": "monster", "maxHp": 9, "initMod": 2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Run (resolve script path relative to this skill dir):
|
||||
```bash
|
||||
node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" spec.json
|
||||
# or stdin:
|
||||
cat spec.json | node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" -
|
||||
```
|
||||
|
||||
Script prints each encounter id + participant init rolls, then verifies.
|
||||
|
||||
#### Option B — raw curl (any harness, no node)
|
||||
|
||||
Build the batch payload inline. Generate UUIDs and roll initiative yourself.
|
||||
One `POST /api/batch` writes everything:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "<HOST>/api/batch" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"ops": [
|
||||
{
|
||||
"type": "set",
|
||||
"path": "campaigns/CID/encounters/EID1",
|
||||
"data": {
|
||||
"name": "Optional Road/Trail Ankheg",
|
||||
"createdAt": "2026-07-07T00:00:00.000Z",
|
||||
"participants": [
|
||||
{ "id": "UUID1", "name": "Ankheg", "type": "monster", "originalCharacterId": null, "initiative": 15, "maxHp": 45, "currentHp": 45, "conditions": [], "isActive": true, "status": "conscious", "deathSaveSuccesses": 0, "deathSaveFailures": 0 }
|
||||
],
|
||||
"round": 0, "currentTurnParticipantId": null, "isStarted": false, "isPaused": false, "ruleset": "5e"
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
Response: `{"ok":true}`. If you get an error, check path is bare (no
|
||||
`artifacts/` prefix) and JSON is valid.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
Always verify after write. Don't trust the batch `ok` alone.
|
||||
|
||||
```bash
|
||||
curl -s "<HOST>/api/collection?path=campaigns/CID/encounters" \
|
||||
| jq '.[] | {name, participants: (.participants|length), started: .isStarted}'
|
||||
```
|
||||
|
||||
Expect one object per encounter with correct participant count and
|
||||
`started: false`.
|
||||
|
||||
### 5. Report back to user
|
||||
|
||||
Tell the user:
|
||||
- Host + campaign name resolved
|
||||
- Per encounter: name, id, participant count, rolled initiatives
|
||||
- Verification result (counts matched)
|
||||
- That combat is NOT started (encounters sit at `round: 0, isStarted: false`).
|
||||
User starts combat via the UI Start button when ready.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `GET /api/collection?path=artifacts/.../campaigns` returns `[]` | used firebase-prefixed path | drop `artifacts/{APP_ID}/public/data/` prefix, use bare `campaigns` |
|
||||
| `GET /health` or collection returns HTML (SPA shell) | server in firebase mode, or backend not mounted | confirm `REACT_APP_STORAGE=server` + backend running on the instance |
|
||||
| Batch returns `ok` but encounter missing | wrong campaign id, or path typo | re-resolve campaign id by name; check path segments |
|
||||
| Participant count mismatch on verify | participant objects malformed / dropped in JSON | validate each has all required fields; use script to avoid hand-JSON errors |
|
||||
| Initiative looks wrong | forgot `initiative` is stored value, not re-derived | roll `d20 + initMod` once at build, store the int. No `initMod` field on participant. |
|
||||
| Duplicate cultist names across encounters | fine — names need only be unique WITHIN an encounter, not across | no fix needed |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Creating campaigns (use UI or `POST /api/collection` with a campaign doc)
|
||||
- Starting combat / advancing turns / HP damage / death saves (in-combat ops)
|
||||
- Player display control (`activeDisplay/status`)
|
||||
- Firebase SDK mode (this skill is server-mode REST only)
|
||||
- Campaign roster characters (`players[]` on campaign doc) — those are
|
||||
templates; this skill adds inline combatants, not roster entries
|
||||
|
||||
## Reference
|
||||
|
||||
Full entity model, combat flow, and storage paths: `docs/ENCOUNTER_BUILDER.md`
|
||||
in the repo root. REST + DB internals: `docs/DEVELOPMENT.md` and
|
||||
`server/index.js`.
|
||||
@@ -0,0 +1,149 @@
|
||||
// build-encounters.js — seed TTRPG encounters via REST API.
|
||||
// Cross-harness: pi, claude, codex all call same script.
|
||||
// No deps beyond node builtins. Reads spec JSON, writes batch, verifies.
|
||||
//
|
||||
// Usage:
|
||||
// node build-encounters.js <host> <campaignName> <specFile>
|
||||
// cat spec.json | node build-encounters.js <host> <campaignName> -
|
||||
//
|
||||
// specFile = JSON array of encounters:
|
||||
// [
|
||||
// {
|
||||
// "name": "Barrowdeep 1 - Antechamber",
|
||||
// "participants": [
|
||||
// { "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 },
|
||||
// { "name": "Boss", "type": "npc", "maxHp": 39, "initMod": 2, "initiative": 15 }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
//
|
||||
// type: 'monster' | 'npc' | 'character'. Default 'monster'.
|
||||
// initiative: optional manual override. If omitted, rolled d20 + initMod.
|
||||
// initMod default 0. maxHp default 10.
|
||||
//
|
||||
// Output: written encounter ids + rolled initiatives. Exit 0 on success.
|
||||
|
||||
'use strict';
|
||||
const crypto = require('crypto');
|
||||
|
||||
const genId = () =>
|
||||
(typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
||||
|
||||
function makeParticipant(spec) {
|
||||
const type = spec.type || 'monster';
|
||||
const maxHp = spec.maxHp || 10;
|
||||
const initMod = spec.initMod || 0;
|
||||
const manual = spec.initiative !== undefined && spec.initiative !== null;
|
||||
return {
|
||||
id: genId(),
|
||||
name: spec.name,
|
||||
type,
|
||||
originalCharacterId: spec.originalCharacterId || null,
|
||||
initiative: manual ? Number(spec.initiative) : rollD20() + initMod,
|
||||
maxHp,
|
||||
currentHp: maxHp,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function getJson(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`GET ${url} -> ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
async function postJson(url, body) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`POST ${url} -> ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function findCampaignId(host, name) {
|
||||
const list = await getJson(`${host}/api/collection?path=campaigns`);
|
||||
const matches = list.filter(c => c.name === name);
|
||||
if (matches.length === 0) {
|
||||
console.error(`No campaign named "${name}". Existing:`);
|
||||
list.forEach(c => console.error(` ${c.name}`));
|
||||
process.exit(2);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
console.error(`Multiple campaigns named "${name}". Use unique name or pass id.`);
|
||||
matches.forEach(c => console.error(` ${c.id} ${c.name}`));
|
||||
process.exit(2);
|
||||
}
|
||||
return matches[0].id;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [host, campaignName, specArg] = process.argv.slice(2);
|
||||
if (!host || !campaignName || !specArg) {
|
||||
console.error('Usage: node build-encounters.js <host> <campaignName> <specFile|->');
|
||||
process.exit(1);
|
||||
}
|
||||
const specRaw = specArg === '-'
|
||||
? require('fs').readFileSync(0, 'utf8')
|
||||
: require('fs').readFileSync(specArg, 'utf8');
|
||||
const encounters = JSON.parse(specRaw);
|
||||
if (!Array.isArray(encounters)) throw new Error('spec must be a JSON array of encounters');
|
||||
|
||||
const cid = await findCampaignId(host, campaignName);
|
||||
console.log(`campaign: "${campaignName}" -> ${cid}`);
|
||||
|
||||
const encPath = `campaigns/${cid}/encounters`;
|
||||
const now = new Date().toISOString();
|
||||
const ops = [];
|
||||
const summary = [];
|
||||
for (const enc of encounters) {
|
||||
const id = genId();
|
||||
const path = `${encPath}/${id}`;
|
||||
const parts = (enc.participants || []).map(makeParticipant);
|
||||
ops.push({
|
||||
type: 'set',
|
||||
path,
|
||||
data: {
|
||||
name: enc.name,
|
||||
createdAt: now,
|
||||
participants: parts,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
ruleset: enc.ruleset || '5e',
|
||||
},
|
||||
});
|
||||
summary.push({ name: enc.name, id, parts });
|
||||
}
|
||||
|
||||
const res = await postJson(`${host}/api/batch`, { ops });
|
||||
if (!res.ok) throw new Error(`batch failed: ${JSON.stringify(res)}`);
|
||||
console.log(`batch: ${ops.length} encounter(s) written\n`);
|
||||
|
||||
// Verify
|
||||
const written = await getJson(`${host}/api/collection?path=${encPath}`);
|
||||
const byId = new Map(written.map(e => [e.id, e]));
|
||||
|
||||
for (const s of summary) {
|
||||
const got = byId.get(s.id);
|
||||
const ok = got && got.name === s.name && (got.participants || []).length === s.parts.length;
|
||||
console.log(`${ok ? '✓' : '✗'} ${s.name}`);
|
||||
console.log(` id: ${s.id}`);
|
||||
if (!ok) { console.log(` VERIFY FAILED: got ${JSON.stringify(got && { name: got.name, n: (got.participants || []).length })}`); continue; }
|
||||
s.parts.forEach(p => {
|
||||
const i = p.initiative >= 0 ? `+${p.initiative}` : `${p.initiative}`;
|
||||
console.log(` - ${p.name} (${p.type}, hp ${p.maxHp}, init ${i})`);
|
||||
});
|
||||
}
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
|
||||
@@ -1,31 +1,11 @@
|
||||
# Ignore Git directory
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore Node.js modules (they will be installed in the Docker image)
|
||||
TODO.md
|
||||
node_modules
|
||||
|
||||
# Ignore build output (it will be generated in the Docker image)
|
||||
build
|
||||
dist
|
||||
|
||||
# Ignore Docker files themselves
|
||||
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
|
||||
**/node_modules
|
||||
.git
|
||||
tmp
|
||||
data
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.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
|
||||
build
|
||||
dist
|
||||
@@ -6,4 +5,12 @@ dist
|
||||
.env.local
|
||||
.env.development.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
|
||||
|
||||
# Stage 1: Build the React application
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
LABEL stage="build-local-testing"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (or yarn.lock)
|
||||
# Copy root package.json/lockfile plus workspace manifests so npm can
|
||||
# resolve the @ttrpg/shared and server workspace links during install.
|
||||
COPY package*.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# Install dependencies using the lock file for consistency
|
||||
RUN npm install
|
||||
@@ -38,8 +41,11 @@ LABEL stage="nginx-server"
|
||||
# Copy the build output from the 'build' stage to Nginx's html directory
|
||||
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 80
|
||||
|
||||
# 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,15 +1,23 @@
|
||||
# TTRPG Initiative Tracker (v0.2.1)
|
||||
# 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).
|
||||

|
||||
|
||||
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!.
|
||||
|
||||
**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
|
||||
|
||||

|
||||
|
||||
* **Campaign Management:**
|
||||
* Create campaigns to organize game sessions.
|
||||
* Option to set a custom background image URL for the player display on a per-campaign basis.
|
||||
@@ -17,6 +25,7 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
|
||||
* **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:**
|
||||
* Create multiple encounters per campaign.
|
||||
* 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.
|
||||
@@ -24,28 +33,50 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
|
||||
* "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:**
|
||||
* A clean interface showing the current initiative order, participant HP (monster HP totals are hidden, only the bar is shown), and current turn.
|
||||
* NPCs (monster-type) are visually distinct from hostile monsters.
|
||||
* Displays custom campaign background if set.
|
||||
* Shows a "Game Session Paused" message when no encounter is active or if the current encounter is paused by the DM.
|
||||
* Player display is opened in a separate window via a button in the DM's header.
|
||||
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display.
|
||||
* A clean interface showing the current initiative order, round number, participant HP, and current turn.
|
||||
* HP bars use color-coded thresholds: green (above half), yellow (quarter to half), red (below quarter), dark red (dead).
|
||||
* Monster and NPC HP totals are hidden; only the color-coded bar is shown.
|
||||
* 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.
|
||||
* Active conditions are displayed as emoji badges on each participant's card.
|
||||
* Inactive participants (and dead ones) are greyed out with a grayscale filter.
|
||||
* 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:** Implemented for destructive actions like deleting campaigns, characters, encounters, or ending combat.
|
||||
* **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
* **Frontend:** React
|
||||
* **Styling:** Tailwind CSS
|
||||
* **Backend/Database:** Firebase Firestore (for real-time data)
|
||||
* **Authentication:** Firebase Anonymous Authentication
|
||||
* **Backend/Database:** Firebase Firestore (default), or a self-hosted Node/Express + `ws` + SQLite (`better-sqlite3`) backend behind Caddy (optional, see [Self-Hosted Backend](#self-hosted-backend-optional))
|
||||
* **Authentication:** Firebase Anonymous Authentication (Firebase mode only; the self-hosted backend has no auth layer — intended for trusted/local networks)
|
||||
* **Shared logic:** Turn-order state machine (`shared/`) is framework-agnostic and used by both the frontend and the self-hosted backend, covered by a Jest test suite
|
||||
|
||||
## App Usage Overview
|
||||
|
||||

|
||||
*(Replace with an actual screenshot of your DM view)*
|
||||

|
||||
|
||||
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:
|
||||
|
||||
@@ -65,16 +96,33 @@ The TTRPG Initiative Tracker is designed for Dungeon Masters to manage combat en
|
||||
* Once participants are added and initiative is set, click "Start Encounter". This also automatically makes the encounter live on 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.
|
||||
* Use the "Next Turn" button (disabled when paused) to advance through the initiative order. The current combatant will be highlighted.
|
||||
* Apply damage or healing to participants directly in the Admin View.
|
||||
* 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.
|
||||
* 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:**
|
||||
* This window (opened by the DM via `/?playerView=true` URL) shows a simplified, header-less view of the active encounter.
|
||||
* It displays the initiative order, current turn, round number, and participant HP (monster/NPC HP values are hidden, only the bar is shown).
|
||||
* NPCs are styled with a muted gray background, distinct from hostile monsters (custom reddish-brown) and player characters (blueish).
|
||||
* If a custom background URL was set for the campaign, it will be displayed.
|
||||
* If no encounter is active on the Player Display, or if the current encounter is paused by the DM, it will show an appropriate message ("Game Session Paused" or "Combat Paused").
|
||||
* 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 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".
|
||||
* HP bars are color-coded: green (above 50%), yellow (25–50%), red (below 25%), dark red (dead/0 HP).
|
||||
* 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.
|
||||
|
||||
@@ -83,16 +131,18 @@ This flow allows the DM to prepare and run encounters efficiently while providin
|
||||
### Prerequisites
|
||||
|
||||
* **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/).
|
||||
* **Firebase Project:** You'll need a Firebase project with:
|
||||
* **Firebase Project** (only if using the default Firebase storage mode): You'll need a Firebase project with:
|
||||
* Firestore Database created and initialized.
|
||||
* Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab.
|
||||
* **Git:** For cloning the repository.
|
||||
|
||||
The project is an npm workspaces monorepo (`server/`, `shared/`, plus the CRA frontend at the root) — a single `npm install` at the repo root installs everything.
|
||||
|
||||
### Local Development Setup (using npm)
|
||||
|
||||
1. **Clone the Repository:**
|
||||
```bash
|
||||
git clone <your-repository-url>
|
||||
git clone https://code.draft13.com/robert/ttrpg-initiative-tracker
|
||||
cd ttrpg-initiative-tracker
|
||||
```
|
||||
|
||||
@@ -145,7 +195,14 @@ This flow allows the DM to prepare and run encounters efficiently while providin
|
||||
|
||||
### Deployment with Docker
|
||||
|
||||
This project includes a `Dockerfile` to containerize the application for deployment. It uses a multi-stage build:
|
||||
There are two Docker paths, depending on which storage mode you want:
|
||||
|
||||
* **Firebase mode (default):** the root `Dockerfile` builds a static frontend-only image, described below.
|
||||
* **Self-hosted mode:** the `docker/` directory builds a single container running the Node backend + SQLite + the frontend behind Caddy, with no Firebase dependency. See [Self-Hosted Backend (Optional)](#self-hosted-backend-optional).
|
||||
|
||||
#### Firebase-mode image (root `Dockerfile`)
|
||||
|
||||
This project includes a `Dockerfile` to containerize the Firebase-backed application for deployment. It uses a multi-stage build:
|
||||
* **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`.
|
||||
* **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage.
|
||||
|
||||
@@ -180,31 +237,96 @@ This project includes a `Dockerfile` to containerize the application for deploym
|
||||
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
|
||||
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
|
||||
|
||||
### Self-Hosted Backend (Optional)
|
||||
|
||||
If you'd rather not depend on Firebase/Google, the app can run entirely self-hosted: a Node/Express + `ws` backend backed by SQLite, with the frontend talking to it instead of Firestore. Intended for trusted/local networks (no auth layer yet).
|
||||
|
||||
**Quickest path — Docker Compose (single container: Caddy + Node + SQLite):**
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
This serves the app at `http://localhost:8080` (override with `PORT`), proxies `/api` and `/ws` to the backend, and persists the SQLite database in a named Docker volume. Override the Firestore-style app-id namespace with `TRACKER_APP_ID` if needed.
|
||||
|
||||
**Local dev (recommended):**
|
||||
```bash
|
||||
npm install # installs root, server/, and shared/ workspaces
|
||||
./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
|
||||
./scripts/dev-stop.sh # stop both
|
||||
```
|
||||
|
||||
**Manual (fallback):**
|
||||
```bash
|
||||
npm install
|
||||
npm run server:dev # starts backend on :4001 (SQLite at data/tracker.sqlite)
|
||||
|
||||
# in another terminal:
|
||||
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 \
|
||||
npm start
|
||||
```
|
||||
|
||||
See `docs/DEVELOPMENT.md` for the full architecture (generic KV doc store, storage adapter interface, test layers) and `docs/REWORK_PLAN.md` for the design rationale.
|
||||
|
||||
## Project Structure
|
||||
|
||||
<pre>
|
||||
|
||||
ttrpg-initiative-tracker/
|
||||
├── .dockerignore # Specifies intentionally untracked files that Docker should ignore
|
||||
├── .env.example # Example environment variables
|
||||
├── .env.local # Local environment variables (ignored by Git)
|
||||
├── .gitignore # Specifies intentionally untracked files that Git should ignore
|
||||
├── Dockerfile # Instructions to build the Docker image
|
||||
├── Dockerfile # Firebase-mode image (frontend-only, served by nginx)
|
||||
├── package.json # Workspaces root: frontend + server + shared
|
||||
├── package-lock.json # Records exact versions of dependencies
|
||||
├── package.json # Project metadata and dependencies
|
||||
├── postcss.config.js # PostCSS configuration (for Tailwind CSS)
|
||||
├── tailwind.config.js # Tailwind CSS configuration
|
||||
├── docker/ # Self-hosted deployment: Dockerfile, docker-compose.yml, Caddyfile
|
||||
├── docs/ # Rework plan, dev setup, testing, encounter-builder guide, glossary
|
||||
├── scripts/ # Manual demo/ops tooling (e.g. replay-combat.js)
|
||||
├── tests/ # Exploratory audit tooling (not part of the automated suite)
|
||||
├── public/ # Static assets
|
||||
│ ├── favicon.ico
|
||||
│ ├── index.html # Main HTML template
|
||||
│ └── manifest.json
|
||||
└── src/ # React application source code
|
||||
├── App.js # Main application component
|
||||
├── index.css # Global styles (including Tailwind directives)
|
||||
└── index.js # React entry point
|
||||
|
||||
├── src/ # React frontend (Create React App)
|
||||
│ ├── App.js # Main application component
|
||||
│ ├── storage/ # Storage adapter layer: firebase / ws / memory
|
||||
│ ├── index.css # Global styles (including Tailwind directives)
|
||||
│ └── index.js # React entry point
|
||||
├── server/ # Self-hosted backend: Express + ws + SQLite (better-sqlite3)
|
||||
└── shared/ # Framework-agnostic turn-order logic, used by frontend + server
|
||||
</pre>
|
||||
|
||||
## Further Reading
|
||||
|
||||
* `docs/DEVELOPMENT.md` — setup, running the backend, test suites
|
||||
* `docs/REWORK_PLAN.md` — why/how the self-hosted backend was added
|
||||
* `docs/TESTING.md` — test layers and how to run them
|
||||
* `docs/ENCOUNTER_BUILDER.md` — entity model and storage paths behind the DM interface
|
||||
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
|
||||
* `TODO.md` — known bugs and backlog
|
||||
|
||||
## Prevent Sleep (Wake Lock)
|
||||
|
||||
DM and Player views have a Coffee/Moon toggle to keep screen awake during combat.
|
||||
|
||||
**Requires secure context** (HTTPS or localhost). The Screen Wake Lock API refuses
|
||||
on plain HTTP.
|
||||
|
||||
- **Prod (HTTPS):** works automatically on all devices.
|
||||
- **Local dev (localhost):** works automatically — localhost is a trusted origin.
|
||||
- **LAN IP (phones, plain HTTP):** wake lock fails silently. Two options:
|
||||
1. **Android Chrome flag (dev testing):** `chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add full URL with port, e.g. `http://10.0.0.5:3999`. Enable flag, relaunch.
|
||||
2. **mkcert local CA:** generate trusted cert for LAN IP. Heavier setup.
|
||||
|
||||
**iOS Safari:** Wake Lock API broken in standalone PWA mode (iOS 16.4–18.4, WebKit bug 254545). Fixed in 18.4+. Works in browser tab (not installed PWA).
|
||||
|
||||
**Troubleshooting:** if toggle shows active but screen still sleeps, check:
|
||||
- Battery saver / power save mode (browser refuses)
|
||||
- Chrome devtools console for `WakeLockError`
|
||||
- Secure context: `window.isSecureContext` must be `true`
|
||||
|
||||
## Contributing
|
||||
|
||||
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# TODO
|
||||
|
||||
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
|
||||
|
||||
## Open
|
||||
|
||||
x caff doent work on fron tpage or big tablet
|
||||
|
||||
x max/caff need to float or at least be availabe in combagt popout
|
||||
|
||||
x save needs bigger save button on char editor
|
||||
|
||||
|
||||
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
|
||||
|
||||
x also better vert tab layout - labelt friendly
|
||||
|
||||
x needs AC for players dude
|
||||
|
||||
x and quick entry hp
|
||||
|
||||
x hp do not carry from encounter to ecnounter!!!
|
||||
^feature to add back to campaign character after encounter?
|
||||
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
|
||||
|
||||
x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
|
||||
|
||||
x encounters cards need clear "in progress"...
|
||||
|
||||
|
||||
|
||||
x I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
|
||||
npcs.....
|
||||
|
||||
x hp wont go over max and no temp hp support
|
||||
|
||||
|
||||
## monsters per campaign and npcs
|
||||
## or/and ....copy from encoubnter?
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### 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,295 @@
|
||||
# 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.
|
||||
|
||||
**LAN access (phones):** frontend binds 0.0.0.0, LAN IP auto-detected.
|
||||
Other devices hit `http://<lan-ip>:3999`.
|
||||
|
||||
**Wake Lock (Prevent Sleep) on LAN IP:** requires secure context.
|
||||
Plain HTTP on LAN IP = wake lock silently fails. Android Chrome flag workaround:
|
||||
`chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add
|
||||
`http://<lan-ip>:3999` (with port), enable, relaunch. See README for full details.
|
||||
|
||||
Smoke check:
|
||||
```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,326 @@
|
||||
# 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 |
|
||||
|
||||
## Path normalization
|
||||
|
||||
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Server-mode adapter `norm()` strips prefix → bare canonical (`campaigns/...`).
|
||||
|
||||
- **UI / app code** → prefixed paths (`getPath.*` helpers)
|
||||
- **REST API / direct DB / scripts** → bare canonical paths (`campaigns/...`)
|
||||
|
||||
Mixing = empty results. If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used the prefixed form. Drop the prefix.
|
||||
|
||||
## 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.
|
||||
|
||||
## Build flow (API / scripts)
|
||||
|
||||
Same logical steps as UI, via REST. For LLM agents or seed scripts.
|
||||
|
||||
**Server mode only.** REST endpoints (`/api/*`) exist only when tracker runs
|
||||
with `REACT_APP_STORAGE=server` (bundled Express backend mounted). Firebase
|
||||
SDK mode has no HTTP backend — data lives in Firestore via in-browser SDK,
|
||||
different auth + write paths. This section does not apply to firebase mode.
|
||||
|
||||
Confirm server mode before scripting:
|
||||
```bash
|
||||
curl -s <HOST>/api/collection?path=campaigns | head -c 200
|
||||
```
|
||||
JSON array = server mode. HTML (SPA shell) = firebase mode or backend not
|
||||
mounted — API path won't work.
|
||||
|
||||
### REST endpoints
|
||||
|
||||
| Method | Path | Body / Query | Action |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/doc` | `?path=` | read one doc |
|
||||
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
|
||||
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
|
||||
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
|
||||
| `GET` | `/api/collection` | `?path=` | list docs under collection |
|
||||
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
|
||||
| `POST` | `/api/batch` | `{ops:[{type:'set'\|'update'\|'delete', path, data?}]}` | atomic multi-write |
|
||||
|
||||
All paths bare canonical (no `artifacts/...` prefix).
|
||||
|
||||
### id format
|
||||
|
||||
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`). Use either. Server `POST /api/collection` generates its own UUID — fine for campaigns/encounters. Participants are inline array items; generate their ids client-side so they're stable across batch writes.
|
||||
|
||||
### Object templates
|
||||
|
||||
Encounter doc (full, for `PUT` / batch `set`):
|
||||
```js
|
||||
{
|
||||
name: 'Barrowdeep 1 - Antechamber',
|
||||
createdAt: new Date().toISOString(),
|
||||
participants: [], // inline, NOT a sub-collection
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
ruleset: '5e',
|
||||
}
|
||||
```
|
||||
|
||||
Participant object (item in `encounter.participants[]`):
|
||||
```js
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'Ankheg',
|
||||
type: 'monster', // 'character' | 'npc' | 'monster'
|
||||
originalCharacterId: null, // set only if type==='character'
|
||||
initiative: rollD20() + initMod, // rolled ONCE at add; stored, not re-derived
|
||||
maxHp: 45,
|
||||
currentHp: 45, // start at full
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0, // character/npc only
|
||||
deathSaveFailures: 0, // character/npc only
|
||||
}
|
||||
```
|
||||
|
||||
### Initiative semantics
|
||||
|
||||
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], roll fresh per participant).
|
||||
- Manual override (e.g. pre-rolled values): set `initiative` directly, skip the roll.
|
||||
- Fixed at add time. NOT re-derived from `initMod` after — `initMod` is not stored on participant.
|
||||
|
||||
### Recipe: build encounters for a campaign
|
||||
|
||||
1. **Find campaign id by name:**
|
||||
```bash
|
||||
curl -s "$HOST/api/collection?path=campaigns" \
|
||||
| jq -r '.[] | select(.name=="2026 D&D") | .id'
|
||||
```
|
||||
2. **Build encounter docs + participants client-side** (object templates above), roll initiative per participant.
|
||||
3. **Write all encounters in one batch:**
|
||||
```bash
|
||||
curl -s -X POST "$HOST/api/batch" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ops":[{"type":"set","path":"campaigns/CID/encounters/EID1","data":{...}},{"type":"set","path":"campaigns/CID/encounters/EID2","data":{...}}]}'
|
||||
```
|
||||
4. **Verify:**
|
||||
```bash
|
||||
curl -s "$HOST/api/collection?path=campaigns/CID/encounters" | jq '.[] | {name, count: (.participants|length)}'
|
||||
```
|
||||
|
||||
Participants go inline in the encounter doc — there is no `participants` sub-collection. A single batch `set` per encounter writes the whole roster atomically.
|
||||
|
||||
## 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
|
||||
|
||||
Bare canonical (server mode). App code prefixes these with `artifacts/{APP_ID}/public/data/` — adapter strips it.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
REST mapping: `campaigns/{cid}` doc → `GET/PUT/PATCH/DELETE /api/doc?path=campaigns/{cid}`. Collection parent (`campaigns`, `campaigns/{cid}/encounters`) → `GET/POST /api/collection?path=...`.
|
||||
|
||||
## 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_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",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
"shared"
|
||||
],
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"firebase": "^10.12.2",
|
||||
"lucide-react": "^0.395.0",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3"
|
||||
"tailwindcss": "^3.4.3",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"build": "CI=true react-scripts build",
|
||||
"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": {
|
||||
"extends": [
|
||||
@@ -39,5 +48,8 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^14.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "/display",
|
||||
"short_name": "TTRPG Display",
|
||||
"name": "TTRPG Initiative Tracker - Player Display",
|
||||
"icons": [
|
||||
{
|
||||
"src": "player-favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "player-logo192.png",
|
||||
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
],
|
||||
"start_url": "/display",
|
||||
"scope": "/display",
|
||||
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
|
||||
"display": "fullscreen",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#1A202C",
|
||||
"background_color": "#1A202C"
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -10,10 +10,13 @@
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"id": "/",
|
||||
"short_name": "TTRPG Tracker",
|
||||
"name": "TTRPG Initiative Tracker",
|
||||
"icons": [
|
||||
@@ -9,17 +10,16 @@
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
|
||||
"display": "fullscreen",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#2D3748",
|
||||
"background_color": "#1A202C"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -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,65 @@
|
||||
#!/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
|
||||
# detect LAN ip so other devices (phones) can reach backend
|
||||
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1)
|
||||
echo "starting frontend :3999 (lan ip: $LAN_IP)..."
|
||||
NODE_ENV=development REACT_APP_DEV_TOOLS=1 \
|
||||
REACT_APP_STORAGE=server \
|
||||
REACT_APP_BACKEND_URL=http://${LAN_IP}:4001 \
|
||||
REACT_APP_BACKEND_REALTIME_URL=ws://${LAN_IP}:4001/ws \
|
||||
DANGEROUSLY_DISABLE_HOST_CHECK=true \
|
||||
HOST=0.0.0.0 \
|
||||
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 "lan url : http://${LAN_IP}:3999 (phones/other devices on network)"
|
||||
echo "logs : tmp/server.log tmp/fe.log"
|
||||
echo "stop : ./scripts/dev-stop.sh"
|
||||
echo ""
|
||||
echo "NOTE: Prevent Sleep (wake lock) requires secure context (HTTPS/localhost)."
|
||||
echo " LAN IP (http) won't work unless Android Chrome flag set:"
|
||||
echo " chrome://flags/#unsafely-treat-insecure-origin-as-secure"
|
||||
echo " Add: http://${LAN_IP}:3999 (include port)"
|
||||
@@ -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,65 @@
|
||||
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
|
||||
// Scans ALL .js/.jsx in src/ + shared/ (excl tests/mocks). 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 fs = require('fs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
function walkJs(dir) {
|
||||
let out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'tests' || entry.name === '__mocks__') continue;
|
||||
out = out.concat(walkJs(full));
|
||||
} else if (/\.(js|jsx)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const TARGETS = [
|
||||
...walkJs(path.join(ROOT, 'src')),
|
||||
...walkJs(path.join(ROOT, 'shared')),
|
||||
];
|
||||
|
||||
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,35 @@
|
||||
// AC field on participant + builders.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, buildMonsterParticipant, buildCharacterParticipant, rollHpFormula } = shared;
|
||||
|
||||
describe('AC field', () => {
|
||||
test('makeParticipant accepts ac', () => {
|
||||
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15, ac: 13 });
|
||||
expect(p.ac).toBe(13);
|
||||
});
|
||||
|
||||
test('makeParticipant ac defaults null', () => {
|
||||
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15 });
|
||||
expect(p.ac).toBeNull();
|
||||
});
|
||||
|
||||
test('buildMonsterParticipant accepts ac', () => {
|
||||
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7, initMod: 2, ac: 15 });
|
||||
expect(participant.ac).toBe(15);
|
||||
});
|
||||
|
||||
test('buildMonsterParticipant ac defaults null', () => {
|
||||
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7 });
|
||||
expect(participant.ac).toBeNull();
|
||||
});
|
||||
|
||||
test('buildCharacterParticipant accepts character ac', () => {
|
||||
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2, defaultAc: 18 });
|
||||
expect(participant.ac).toBe(18);
|
||||
});
|
||||
|
||||
test('buildCharacterParticipant ac defaults null when unset', () => {
|
||||
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30 });
|
||||
expect(participant.ac).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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,49 @@
|
||||
// addParticipants (bulk) must slot by initiative, not append.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const { buildCharacterParticipant, buildMonsterParticipant, addParticipants, makeParticipant } = shared;
|
||||
|
||||
function enc(ps = []) {
|
||||
return { name:'t', participants: ps, isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [] };
|
||||
}
|
||||
function p(id, init) {
|
||||
return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 10, currentHp: 10 });
|
||||
}
|
||||
|
||||
describe('addParticipants slot order', () => {
|
||||
test('bulk add slots by initiative desc', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([]);
|
||||
e = await addParticipants(e, [p('a', 5), p('b', 20), p('c', 10)], ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('bulk add preserves existing order + slots new', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a', 20), p('b', 5)]);
|
||||
e = await addParticipants(e, [p('c', 10)], ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
test('bulk add same-init appends after existing (stable tie)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a', 10)]);
|
||||
e = await addParticipants(e, [p('b', 10), p('c', 10)], ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('bulk add mixed inits into existing list', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a', 15), p('b', 5)]);
|
||||
e = await addParticipants(e, [p('c', 20), p('d', 10), p('e', 15)], ctx);
|
||||
expect(e.participants.map(x => x.id)).toEqual(['c', 'a', 'e', 'd', 'b']);
|
||||
});
|
||||
|
||||
test('bulk add updates turnOrderIds to match', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([]);
|
||||
e = await addParticipants(e, [p('a', 5), p('b', 20)], ctx);
|
||||
expect(e.turnOrderIds).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
@@ -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,60 @@
|
||||
// HP formula roll: parse "2d6+9" style, roll, return total.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { rollHpFormula } = shared;
|
||||
|
||||
describe('rollHpFormula', () => {
|
||||
test('basic NdM', () => {
|
||||
const r = rollHpFormula('2d6');
|
||||
expect(r).toBeGreaterThanOrEqual(2);
|
||||
expect(r).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
test('NdM+bonus', () => {
|
||||
const r = rollHpFormula('2d6+9');
|
||||
expect(r).toBeGreaterThanOrEqual(11);
|
||||
expect(r).toBeLessThanOrEqual(21);
|
||||
});
|
||||
|
||||
test('NdM-bonus', () => {
|
||||
const r = rollHpFormula('3d8-2');
|
||||
expect(r).toBeGreaterThanOrEqual(1);
|
||||
expect(r).toBeLessThanOrEqual(22);
|
||||
});
|
||||
|
||||
test('single die', () => {
|
||||
const r = rollHpFormula('1d20');
|
||||
expect(r).toBeGreaterThanOrEqual(1);
|
||||
expect(r).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('case insensitive', () => {
|
||||
const r = rollHpFormula('2D6+5');
|
||||
expect(r).toBeGreaterThanOrEqual(7);
|
||||
expect(r).toBeLessThanOrEqual(17);
|
||||
});
|
||||
|
||||
test('whitespace tolerant', () => {
|
||||
const r = rollHpFormula(' 2d6 + 9 ');
|
||||
expect(r).toBeGreaterThanOrEqual(11);
|
||||
expect(r).toBeLessThanOrEqual(21);
|
||||
});
|
||||
|
||||
test('large die', () => {
|
||||
const r = rollHpFormula('4d12+10');
|
||||
expect(r).toBeGreaterThanOrEqual(14);
|
||||
expect(r).toBeLessThanOrEqual(58);
|
||||
});
|
||||
|
||||
test('invalid returns null', () => {
|
||||
expect(rollHpFormula('abc')).toBeNull();
|
||||
expect(rollHpFormula('')).toBeNull();
|
||||
expect(rollHpFormula(null)).toBeNull();
|
||||
expect(rollHpFormula(undefined)).toBeNull();
|
||||
expect(rollHpFormula('2d')).toBeNull();
|
||||
expect(rollHpFormula('d6')).toBeNull();
|
||||
});
|
||||
|
||||
test('zero dice invalid', () => {
|
||||
expect(rollHpFormula('0d6')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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,102 @@
|
||||
// Temp HP: damage hits temp first, then regular.
|
||||
// setTempHp replaces (no stacking). Healing regular HP ignores temp.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { makeParticipant, applyHpChange, setTempHp, addParticipant } = shared;
|
||||
const { mockCtx } = require('./_helpers');
|
||||
|
||||
function setupEnc() {
|
||||
const { ctx } = mockCtx();
|
||||
return {
|
||||
enc: { id: 'enc1', name: 'TempEnc', participants: [], isStarted: false, isPaused: false, round: 0 },
|
||||
ctx,
|
||||
};
|
||||
}
|
||||
|
||||
describe('temp HP', () => {
|
||||
test('setTempHp sets tempHp on participant', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(5);
|
||||
});
|
||||
|
||||
test('setTempHp replaces existing temp (no stacking)', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await setTempHp(e, p.id, 3, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(3);
|
||||
});
|
||||
|
||||
test('setTempHp to 0 clears temp', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await setTempHp(e, p.id, 0, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
});
|
||||
|
||||
test('damage: temp absorbs before regular HP', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 8, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
expect(e.participants[0].currentHp).toBe(17);
|
||||
});
|
||||
|
||||
test('damage less than temp: only temp reduced', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 10, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 4, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(6);
|
||||
expect(e.participants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
test('heal: does not affect temp HP', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 10,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'heal', 8, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(5);
|
||||
expect(e.participants[0].currentHp).toBe(18);
|
||||
});
|
||||
|
||||
test('damage exactly equals temp: temp gone, regular intact', async () => {
|
||||
const { enc, ctx } = setupEnc();
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
let e = await addParticipant(enc, p, ctx);
|
||||
e = await setTempHp(e, p.id, 5, ctx);
|
||||
e = await applyHpChange(e, p.id, 'damage', 5, ctx);
|
||||
expect(e.participants[0].tempHp).toBe(0);
|
||||
expect(e.participants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
test('makeParticipant: tempHp defaults to 0', async () => {
|
||||
const p = makeParticipant({
|
||||
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
|
||||
});
|
||||
expect(p.tempHp).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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) => {
|
||||
const o = JSON.parse(JSON.stringify(e));
|
||||
// normalize nullable timestamp fields: null = absent (undo clears via null)
|
||||
if (o.startedAt === null) delete o.startedAt;
|
||||
if (o.endedAt === null) delete o.endedAt;
|
||||
return o;
|
||||
};
|
||||
|
||||
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,157 @@
|
||||
// Character writeback on endEncounter: sync maxHp/ac to campaign players.
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { endEncounter } = shared;
|
||||
|
||||
function makeMockStorage() {
|
||||
const docs = new Map();
|
||||
const writes = [];
|
||||
return {
|
||||
getDoc: jest.fn(async (p) => docs.get(p) || null),
|
||||
setDoc: jest.fn(async (p, d) => { docs.set(p, d); }),
|
||||
updateDoc: jest.fn(async (p, patch) => {
|
||||
writes.push({ path: p, patch });
|
||||
const cur = docs.get(p) || {};
|
||||
docs.set(p, { ...cur, ...patch });
|
||||
}),
|
||||
addDoc: jest.fn(async (p, data) => {
|
||||
writes.push({ path: p, patch: data });
|
||||
docs.set(p, data);
|
||||
}),
|
||||
deleteDoc: jest.fn(async () => {}),
|
||||
getCollection: jest.fn(async () => []),
|
||||
_docs: docs,
|
||||
_writes: writes,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEncounter(overrides = {}) {
|
||||
return {
|
||||
id: 'enc1',
|
||||
name: 'Test',
|
||||
campaignId: 'camp1',
|
||||
isStarted: true,
|
||||
isPaused: false,
|
||||
currentTurnParticipantId: 'p1',
|
||||
round: 3,
|
||||
turnOrderIds: ['p1', 'p2'],
|
||||
participants: [
|
||||
{ id: 'p1', name: 'Fighter', type: 'character', originalCharacterId: 'char1', initiative: 15, maxHp: 28, currentHp: 20, ac: 18 },
|
||||
{ id: 'p2', name: 'Goblin', type: 'monster', originalCharacterId: null, initiative: 12, maxHp: 7, currentHp: 7, ac: 13 },
|
||||
{ id: 'p3', name: 'Cleric', type: 'character', originalCharacterId: 'char2', initiative: 10, maxHp: 22, currentHp: 22, ac: 16 },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('character writeback on endEncounter', () => {
|
||||
test('no writeback when syncCharacters false/missing', async () => {
|
||||
const storage = makeMockStorage();
|
||||
storage._docs.set('campaigns/camp1', {
|
||||
id: 'camp1', name: 'Camp', players: [
|
||||
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 },
|
||||
],
|
||||
});
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
// no campaign write
|
||||
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('writeback updates char maxHp + ac + currentHp when syncCharacters true', async () => {
|
||||
const storage = makeMockStorage();
|
||||
const players = [
|
||||
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
|
||||
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
|
||||
];
|
||||
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
|
||||
expect(campWrite).toBeDefined();
|
||||
const updated = campWrite.patch.players;
|
||||
const fighter = updated.find(p => p.id === 'char1');
|
||||
expect(fighter.defaultMaxHp).toBe(28);
|
||||
expect(fighter.defaultAc).toBe(18);
|
||||
expect(fighter.defaultCurrentHp).toBe(20);
|
||||
const cleric = updated.find(p => p.id === 'char2');
|
||||
expect(cleric.defaultMaxHp).toBe(22);
|
||||
expect(cleric.defaultAc).toBe(16);
|
||||
expect(cleric.defaultCurrentHp).toBe(22);
|
||||
});
|
||||
|
||||
test('writeback skips monsters (no originalCharacterId)', async () => {
|
||||
const storage = makeMockStorage();
|
||||
const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }];
|
||||
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
|
||||
// players array still 1 entry, goblin not added
|
||||
expect(campWrite.patch.players.length).toBe(1);
|
||||
});
|
||||
|
||||
test('undo payload includes old char values for restore', async () => {
|
||||
const storage = makeMockStorage();
|
||||
const players = [
|
||||
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
|
||||
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
|
||||
];
|
||||
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||
storage._docs.set('logs/log1', null);
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
const logWrite = storage._writes.find(w => w.path === 'logs/log1');
|
||||
expect(logWrite).toBeDefined();
|
||||
const log = logWrite.patch;
|
||||
expect(log.undo.characterWriteback).toBeDefined();
|
||||
expect(log.undo.characterWriteback).toHaveLength(2);
|
||||
const fighterOld = log.undo.characterWriteback.find(c => c.id === 'char1');
|
||||
expect(fighterOld.defaultMaxHp).toBe(30);
|
||||
expect(fighterOld.defaultAc).toBe(17);
|
||||
expect(fighterOld.defaultCurrentHp).toBe(30);
|
||||
});
|
||||
|
||||
test('writeback no-op if character missing from campaign', async () => {
|
||||
const storage = makeMockStorage();
|
||||
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [], syncCharacters: true });
|
||||
const enc = makeEncounter();
|
||||
const ctx = {
|
||||
storage,
|
||||
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||
logPath: 'logs/log1',
|
||||
logCollection: 'logs',
|
||||
displayPath: 'activeDisplay/status',
|
||||
};
|
||||
await endEncounter(enc, ctx);
|
||||
// no writeback (no matching chars = no change)
|
||||
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||