Generic (non-5e) ruleset mode + UI polish + combat switch fix #7

Merged
robert merged 25 commits from feat/generic-ruleset into main 2026-07-08 22:54:04 -04:00
28 changed files with 2730 additions and 241 deletions
@@ -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); });
+20
View File
@@ -307,6 +307,26 @@ ttrpg-initiative-tracker/
* `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.418.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.
+29 -13
View File
@@ -5,6 +5,30 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
also better vert tab layout - labelt friendly
x needs AC for players dude
x and quick entry hp
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)
hp wont go over max and no temp hp support
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?
I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
npcs.....
### dm list - keep active particpant in view (scroll)
not sure good way to do this
@@ -14,6 +38,11 @@ not sure good way to do this
lots of updates
## monsters per campaign and npcs
## or/and ....copy from encoubnter?
### TEST GAP: current branch changes need focused coverage
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
@@ -50,19 +79,6 @@ lots of updates
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
### BUG: select campaign during active combat = screen flicker, no action
- In active encounter, click different campaign → flicker, no nav, no end.
- Either block (toast: end encounter first) or auto-end current + switch.
- Decide UX before fix.
### FEAT: generic/non-5e rules mode
- Campaign/encounter ruleset toggle: `5e` vs `generic`.
- Generic mode turns off death saves/state machine.
- Generic mode allows negative HP.
- 5e mode rejects negative damage/heal and clamps HP at 0.
### FEAT: clarify "Is NPC" in add-participant
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
+8
View File
@@ -63,6 +63,14 @@ git config core.hooksPath .githooks # enable pre-push test gate
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}
+108
View File
@@ -64,6 +64,15 @@ Object in `encounter.participants[]`:
| `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:
@@ -121,6 +130,101 @@ Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initia
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).
@@ -198,6 +302,8 @@ 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[])
@@ -206,6 +312,8 @@ 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.
+27
View File
@@ -0,0 +1,27 @@
{
"short_name": "TTRPG Display",
"name": "TTRPG Initiative Tracker — Player Display",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/display",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"theme_color": "#1A202C",
"background_color": "#1A202C"
}
+14 -3
View File
@@ -27,10 +27,15 @@ fi
# frontend: server storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then
echo "starting frontend :3999..."
# 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://127.0.0.1:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
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
@@ -50,5 +55,11 @@ 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)"
+35
View File
@@ -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();
});
});
+49
View File
@@ -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']);
});
});
+185
View File
@@ -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');
});
});
+60
View File
@@ -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();
});
});
+102
View File
@@ -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);
});
});
+7 -1
View File
@@ -29,7 +29,13 @@ 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));
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 () => {
+157
View File
@@ -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();
});
});
+289 -21
View File
@@ -28,6 +28,22 @@ const generateId = () =>
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
// Parse HP formula like "2d6+9" or "3d8-2" or "1d20". Roll, return total.
// Returns null on invalid input. Whitespace/case tolerant.
function rollHpFormula(str) {
if (!str || typeof str !== 'string') return null;
const m = str.trim().toLowerCase().match(/^(\d+)d(\d+)\s*([+-]\s*\d+)?$/);
if (!m) return null;
const count = parseInt(m[1], 10);
const sides = parseInt(m[2], 10);
if (count === 0 || sides === 0) return null;
let bonus = 0;
if (m[3]) bonus = parseInt(m[3].replace(/\s/g, ''), 10);
let total = 0;
for (let i = 0; i < count; i++) total += Math.floor(Math.random() * sides) + 1;
return total + bonus;
}
const formatInitMod = (mod) => {
if (mod === undefined || mod === null) return 'N/A';
return mod >= 0 ? `+${mod}` : `${mod}`;
@@ -102,6 +118,8 @@ function snapshotOf(enc) {
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
turnOrderIds: [...(enc.turnOrderIds || [])],
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
startedAt: enc.startedAt ?? null,
endedAt: enc.endedAt ?? null,
};
}
@@ -207,6 +225,7 @@ function expandUndo(entry, currentEnc) {
case 'death_save':
case 'stabilize':
case 'revive':
case 'mark_dead':
case 'deactivate_dead_monster': {
// Full death-state restore: currentHp, status, death-save counters,
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
@@ -304,12 +323,18 @@ function expandUndo(entry, currentEnc) {
redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
};
}
case 'start_encounter':
case 'start_encounter': {
return {
encounterPath: encPath,
updates: { ...u, startedAt: u.startedAt ?? null },
redo: { isStarted: true, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], startedAt: snap.startedAt ?? Date.now() },
};
}
case 'end_encounter': {
return {
encounterPath: encPath,
updates: u,
redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
updates: { ...u, endedAt: u.endedAt ?? null },
redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() },
};
}
default:
@@ -335,6 +360,9 @@ function makeParticipant(opts) {
status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
deathSaveFailures: opts.deathSaveFailures || 0,
hpFormula: opts.hpFormula || null,
ac: opts.ac !== undefined ? opts.ac : null,
tempHp: opts.tempHp !== undefined ? opts.tempHp : 0,
};
}
@@ -343,24 +371,37 @@ function buildCharacterParticipant(character) {
const modifier = character.defaultInitMod || 0;
const finalInitiative = initiativeRoll + modifier;
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
const currentHp = character.defaultCurrentHp != null ? character.defaultCurrentHp : maxHp;
return {
participant: makeParticipant({
name: character.name,
type: 'character',
type: character.isNpc ? 'npc' : 'character',
originalCharacterId: character.id,
initiative: finalInitiative,
maxHp,
currentHp: maxHp,
currentHp,
ac: character.defaultAc !== undefined ? character.defaultAc : null,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
};
}
function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula, ac }) {
const initiativeRoll = rollD20();
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
const finalInitiative = initiativeRoll + modifier;
const hp = maxHp || DEFAULT_MAX_HP;
// maxHp wins if both set; else roll formula; else default.
let hp;
let hpRoll = null;
if (maxHp) {
hp = maxHp;
} else if (hpFormula) {
const rolled = rollHpFormula(hpFormula);
hp = rolled || DEFAULT_MAX_HP;
hpRoll = rolled;
} else {
hp = DEFAULT_MAX_HP;
}
return {
participant: makeParticipant({
name,
@@ -368,8 +409,10 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
initiative: finalInitiative,
maxHp: hp,
currentHp: hp,
hpFormula: hpFormula || null,
ac: ac !== undefined ? ac : null,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
};
}
@@ -390,6 +433,8 @@ async function startEncounter(encounter, ctx) {
participants: sortedParticipants,
currentTurnParticipantId: firstActive.id,
turnOrderIds: sortedParticipants.map(p => p.id),
startedAt: Date.now(),
endedAt: null,
};
const log = {
type: 'start_encounter',
@@ -402,6 +447,8 @@ async function startEncounter(encounter, ctx) {
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
startedAt: encounter.startedAt ?? null,
endedAt: encounter.endedAt ?? null,
},
};
return commit(encounter, patch, log, ctx);
@@ -478,14 +525,20 @@ async function addParticipant(encounter, participant, ctx) {
}
async function addParticipants(encounter, newParticipants, ctx) {
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
// Slot each new participant into list by initiative (desc), preserve
// existing order + drag-established ties. Matches addParticipant semantics.
let base = [...(encounter.participants || [])];
for (const np of newParticipants) {
const idx = slotIndexForInit(base, np.initiative);
base.splice(idx, 0, np);
}
const names = newParticipants.map(p => p.name).join(', ');
const patch = { participants: updatedParticipants };
const patch = { participants: base, ...syncTurnOrder(base) };
const log = {
type: 'add_participants',
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
delta: { count: newParticipants.length },
undo: { participants: newParticipants },
undo: { participants: newParticipants, newTurnOrderIds: patch.turnOrderIds },
};
return commit(encounter, patch, log, ctx);
}
@@ -571,9 +624,29 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
return commit(encounter, patch, log, ctx);
}
async function setTempHp(encounter, participantId, tempHp, ctx) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const value = Math.max(0, tempHp || 0);
const oldValues = { tempHp: participant.tempHp || 0 };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: value } : p
);
const log = {
type: 'set_temp_hp',
participantId,
participantName: participant.name,
message: `${participant.name} temp HP set to ${value}`,
delta: { tempHp: value, oldTempHp: oldValues.tempHp },
undo: { oldValues, newValues: { tempHp: value } },
};
return commit(encounter, { participants: updatedParticipants }, log, ctx);
}
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
const ctx = maybeCtx || optionsOrCtx;
const options = maybeCtx ? (optionsOrCtx || {}) : {};
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (isNaN(amount) || amount === 0) return encounter;
@@ -584,6 +657,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
const oldValues = {
currentHp: participant.currentHp,
tempHp: participant.tempHp || 0,
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
deathSaveFailures: participant.deathSaveFailures || 0,
@@ -595,8 +669,42 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
let message = '';
let logDelta = { amount, from: participant.currentHp };
const status = oldValues.status;
const currentTemp = participant.tempHp || 0;
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
if (ruleset === 'generic') {
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
}
if (changeType === 'damage') {
// Temp HP absorbs damage first.
let remainingDmg = amount;
let tempAfter = currentTemp;
if (tempAfter > 0) {
const absorbed = Math.min(tempAfter, remainingDmg);
tempAfter -= absorbed;
remainingDmg -= absorbed;
updates.tempHp = tempAfter;
}
// If fully absorbed by temp, no regular HP change.
if (remainingDmg === 0 && participant.currentHp > 0) {
const updatedParticipants0 = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: tempAfter } : p
);
const log0 = {
type: 'hp_change',
participantId,
participantName: participant.name,
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
};
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
}
amount = remainingDmg;
const tempBefore = currentTemp;
const tempFinal = updates.tempHp !== undefined ? updates.tempHp : tempBefore;
if (participant.currentHp === 0) {
if (status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
@@ -628,23 +736,23 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
const add = options.isCriticalHit ? 2 : 1;
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
if (failures >= 3) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
} else {
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures };
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures, tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
} else if (amount >= participant.currentHp + participant.maxHp) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
message = `Massive damage! ${participant.name} instantly killed`;
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
} else {
const newHp = Math.max(0, participant.currentHp - amount);
if (newHp === 0) {
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
} else {
updates = { currentHp: newHp, status: 'conscious' };
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
@@ -683,7 +791,99 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
return commit(encounter, patch, log, ctx);
}
// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
const participantId = participant.id;
let updates, message, logDelta = { amount, from: participant.currentHp };
const currentTemp = participant.tempHp || 0;
if (changeType === 'damage') {
// Temp HP absorbs damage first.
let remainingDmg = amount;
let tempAfter = currentTemp;
if (tempAfter > 0) {
const absorbed = Math.min(tempAfter, remainingDmg);
tempAfter -= absorbed;
remainingDmg -= absorbed;
}
if (remainingDmg === 0) {
const updatedParticipants0 = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: tempAfter } : p
);
const log0 = {
type: 'hp_change', participantId, participantName: participant.name,
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
};
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
}
amount = remainingDmg;
const tempFinal = tempAfter;
if (oldValues.status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, isActive: false } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: 0, deathSaveFailures: 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
message: `${participant.name} marked inactive (dead monster)`,
delta: { status: 'dead', isActive: false },
undo: { oldValues, newValues },
}, ctx);
}
return encounter;
}
const newHp = participant.currentHp - amount;
const isMonster = participant.type === 'monster';
if (newHp <= 0 && isMonster) {
updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal };
} else if (newHp <= 0) {
updates = { currentHp: newHp, status: 'down', tempHp: tempFinal };
} else {
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
} else if (changeType === 'heal') {
if (oldValues.status === 'dead') return encounter;
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
updates = { currentHp: newHp, status: 'conscious' };
message = `${participant.name} healed for ${amount} (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
} else {
throw new Error(`Unknown HP change type: ${changeType}`);
}
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...updates } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
deathSaveFailures: newP.deathSaveFailures || 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
const undoAmount = changeType === 'damage' ? amount : -amount;
return commit(encounter, { participants: updatedParticipants }, {
type: changeType, participantId, participantName: participant.name,
message, delta: logDelta,
undo: { amount: undoAmount, oldValues, newValues },
}, ctx);
}
async function deathSave(encounter, participantId, outcome, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Death saves not available in generic ruleset.');
}
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -764,6 +964,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
}
async function stabilizeParticipant(encounter, participantId, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Stabilize not available in generic ruleset.');
}
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -806,12 +1009,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
}
async function reviveParticipant(encounter, participantId, ctx) {
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
if (status !== 'dead') return encounter;
const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
// Generic: dead→conscious (no stable state). HP unchanged (may be negative).
// 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
const updates = ruleset === 'generic'
? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
: { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
);
@@ -845,6 +1053,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
return commit(encounter, patch, log, ctx);
}
// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
async function markDead(encounter, participantId, ctx) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (participant.status === 'dead') return encounter;
const oldValues = {
currentHp: participant.currentHp,
status: participant.status || 'conscious',
isActive: participant.isActive,
conditions: [...(participant.conditions || [])],
};
const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'mark_dead', participantId, participantName: participant.name,
message: `${participant.name} marked dead`,
delta: { status: 'dead' },
undo: { oldValues, newValues },
}, ctx);
}
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId);
@@ -884,13 +1120,45 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
}
async function endEncounter(encounter, ctx) {
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] };
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [], endedAt: Date.now() };
const log = {
type: 'end_encounter',
message: `Combat ended: "${encounter.name}"`,
delta: {},
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])] },
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null },
};
// Character writeback: sync maxHp/ac/currentHp to campaign players if enabled.
const wbCampaignId = encounter.campaignId || ctx.campaignId;
if ((wbCampaignId || ctx.campaign) && ctx.storage) {
let campaign;
try {
campaign = ctx.campaign || await ctx.storage.getDoc(`campaigns/${wbCampaignId}`);
} catch { campaign = null; }
if (campaign && campaign.syncCharacters && Array.isArray(campaign.players)) {
const charParticipants = (encounter.participants || []).filter(p => p.originalCharacterId);
const oldValues = [];
let changed = false;
const updatedPlayers = campaign.players.map(player => {
const cp = charParticipants.find(p => p.originalCharacterId === player.id);
if (!cp) return player;
const oldMaxHp = player.defaultMaxHp;
const oldAc = player.defaultAc;
const oldCurrentHp = player.defaultCurrentHp;
oldValues.push({ id: player.id, defaultMaxHp: oldMaxHp, defaultAc: oldAc, defaultCurrentHp: oldCurrentHp });
const newPlayer = { ...player };
if (cp.maxHp != null && cp.maxHp !== oldMaxHp) { newPlayer.defaultMaxHp = cp.maxHp; changed = true; }
if (cp.ac != null && cp.ac !== oldAc) { newPlayer.defaultAc = cp.ac; changed = true; }
if (cp.currentHp != null && cp.currentHp !== oldCurrentHp) { newPlayer.defaultCurrentHp = cp.currentHp; changed = true; }
return newPlayer;
});
if (changed) {
await ctx.storage.updateDoc(`campaigns/${wbCampaignId}`, { players: updatedPlayers });
log.undo.characterWriteback = oldValues;
}
}
}
return commit(encounter, patch, log, ctx);
}
@@ -913,13 +1181,13 @@ async function toggleHidePlayerHp(currentValue, ctx) {
module.exports = {
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
generateId, rollD20, formatInitMod,
generateId, rollD20, rollHpFormula, formatInitMod,
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
snapshotOf, buildEntry, expandUndo,
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp,
};
+951 -198
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
// Safe default: any value other than exactly '1' = off.
// Dynamic key access so react-scripts DefinePlugin does NOT inline the value
// at build time — allows tests + runtime env changes to take effect.
const KEY = 'REACT_APP_' + 'DEV_TOOLS';
export const isDevToolsEnabled = () => process.env[KEY] === '1';
+1 -1
View File
@@ -62,7 +62,7 @@ describe('Campaign -> Firebase', () => {
// CharacterManager form
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
+30
View File
@@ -0,0 +1,30 @@
import { isDevToolsEnabled } from '../config/devTools';
describe('Bulk-delete-all dev-tools gate logic', () => {
const orig = process.env.REACT_APP_DEV_TOOLS;
afterEach(() => {
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
else process.env.REACT_APP_DEV_TOOLS = orig;
});
test('false when unset (safe default)', () => {
delete process.env.REACT_APP_DEV_TOOLS;
expect(isDevToolsEnabled()).toBe(false);
});
test('false when 0', () => {
process.env.REACT_APP_DEV_TOOLS = '0';
expect(isDevToolsEnabled()).toBe(false);
});
test('false when arbitrary string', () => {
process.env.REACT_APP_DEV_TOOLS = 'true';
expect(isDevToolsEnabled()).toBe(false);
});
test('true only when exactly 1', () => {
process.env.REACT_APP_DEV_TOOLS = '1';
expect(isDevToolsEnabled()).toBe(true);
});
});
@@ -0,0 +1,36 @@
import React from 'react';
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
import App from '../App';
describe('Bulk-delete-all button render gating', () => {
const orig = process.env.REACT_APP_DEV_TOOLS;
afterEach(() => {
cleanup();
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
else process.env.REACT_APP_DEV_TOOLS = orig;
});
async function renderAndCreateCampaign() {
window.history.replaceState({}, '', '/');
global.alert = jest.fn();
global.window.open = jest.fn();
render(<App />);
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
});
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
await act(async () => {
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
await new Promise(r => setTimeout(r, 200));
});
}
test('prod safety: button absent when DEV_TOOLS unset', async () => {
delete process.env.REACT_APP_DEV_TOOLS;
await renderAndCreateCampaign();
expect(screen.queryByText(/Delete All Campaigns/i)).not.toBeInTheDocument();
});
});
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
import App from '../App';
describe('Bulk-delete-all button dev feature', () => {
const orig = process.env.REACT_APP_DEV_TOOLS;
afterEach(() => {
cleanup();
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
else process.env.REACT_APP_DEV_TOOLS = orig;
});
async function renderAndCreateCampaign() {
window.history.replaceState({}, '', '/');
global.alert = jest.fn();
global.window.open = jest.fn();
render(<App />);
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
});
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
await act(async () => {
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
await new Promise(r => setTimeout(r, 200));
});
}
test('dev: button present when DEV_TOOLS=1 and campaigns exist', async () => {
process.env.REACT_APP_DEV_TOOLS = '1';
await renderAndCreateCampaign();
await waitFor(() => screen.getByText(/Delete All Campaigns/i), { timeout: 5000 });
});
});
+94
View File
@@ -0,0 +1,94 @@
// Display guard: starting encounter does NOT steal display from another live encounter.
// Ending encounter does NOT clear display if different encounter showing.
import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
const DISPLAY_PATH = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
function findCallActiveDisplay(fn) {
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
}
describe('Display guard', () => {
test('startEncounter: claims display when slot empty', async () => {
await setupReady('GuardCamp1', 'GuardEnc1');
await addMonsterViaUI('Goblin', 10, 5);
await startCombatViaUI();
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
expect(last).toBeTruthy();
expect(last.data.activeEncounterId).toBeTruthy();
});
test('startEncounter: does NOT steal display from another live encounter', async () => {
// Pre-seed display as busy with a DIFFERENT encounter
MOCK_DB.set(DISPLAY_PATH, {
activeCampaignId: 'other-camp',
activeEncounterId: 'other-enc',
});
await setupReady('GuardCamp2', 'GuardEnc2');
await addMonsterViaUI('Orc', 15, 8);
await startCombatViaUI();
// Combat started on encounter doc
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
expect(encCalls.some(c => c.data.isStarted === true)).toBe(true);
// But display setDoc should NOT have been called to claim this encounter
const adClaims = findCallActiveDisplay('setDoc').filter(
c => c.data && c.data.activeEncounterId && c.data.activeEncounterId !== 'other-enc'
);
expect(adClaims).toHaveLength(0);
});
test('endEncounter: clears display when ending the displayed encounter', async () => {
await setupReady('GuardCamp3', 'GuardEnc3');
await addMonsterViaUI('Wolf', 8, 3);
await startCombatViaUI();
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
return last && last.data && last.data.activeCampaignId === null;
});
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
});
test('endEncounter: does NOT clear display when different encounter is live', async () => {
await setupReady('GuardCamp4', 'GuardEnc4');
await addMonsterViaUI('Bat', 4, 2);
await startCombatViaUI();
// Now hijack display to a different encounter (simulating eyeball toggle elsewhere)
MOCK_DB.set(DISPLAY_PATH, {
activeCampaignId: 'different-camp',
activeEncounterId: 'different-enc',
});
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
// Wait for encounter to end
await waitFor(() => {
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = encCalls[encCalls.length - 1];
return last && last.data.isStarted === false;
});
// Display should NOT have been cleared
const clearCalls = findCallActiveDisplay('setDoc').filter(
c => c.data && c.data.activeCampaignId === null
);
expect(clearCalls).toHaveLength(0);
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ describe('FEAT-3 reslot: inline init change reorders list', () => {
const addOne = async (name, hp, mod, init) => {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
+39
View File
@@ -0,0 +1,39 @@
// localStorage scoping: selectedEncounter + scrollY keyed per campaign.
// Two tabs on different campaigns don't fight over selection/scroll.
import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { setupReady, addMonsterViaUI } from './testHelpers';
describe('localStorage campaign scoping', () => {
test('selectedEncounter key is scoped (not bare)', async () => {
await setupReady('ScopeCamp1', 'ScopeEnc1');
// bare key should not exist
expect(localStorage.getItem('ttrpg.selectedEncounter')).toBeNull();
// scoped key with suffix should exist
const scoped = Object.keys(localStorage).find(
k => k.startsWith('ttrpg.selectedEncounter.') && k !== 'ttrpg.selectedEncounter'
);
expect(scoped).toBeTruthy();
expect(localStorage.getItem(scoped)).toBeTruthy();
});
test('scrollY key is scoped (not bare)', async () => {
await setupReady('ScopeCamp2', 'ScopeEnc2');
await addMonsterViaUI('Goblin', 10, 5);
expect(localStorage.getItem('ttrpg.scrollY')).toBeNull();
const scoped = Object.keys(localStorage).find(
k => k.startsWith('ttrpg.scrollY.') && k !== 'ttrpg.scrollY'
);
// key may not exist until save fires — trigger by checking bare is null
// and at least scoped format is used if any scrollY key present
const scrollKeys = Object.keys(localStorage).filter(k => k.startsWith('ttrpg.scrollY'));
scrollKeys.forEach(k => expect(k).not.toBe('ttrpg.scrollY'));
});
test('global keys still exist (campaign selection, wake lock)', async () => {
await setupReady('ScopeCamp3', 'ScopeEnc3');
expect(localStorage.getItem('ttrpg.selectedCampaign')).toBeTruthy();
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ async function addCharacterToEncounter(name = 'Hero', hp = 10) {
await selectCampaignByName(`DS-${name}`);
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: String(hp) } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
+1 -1
View File
@@ -16,7 +16,7 @@ function lastParticipantsUpdate() {
async function addOne(form, name, hp, mod, init) {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
+1 -1
View File
@@ -74,7 +74,7 @@ export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(maxHp) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
const { getCalls } = require('../__mocks__/firebase/_mock-db');
await waitFor(() => {