diff --git a/.agents/skills/ttrpg-encounter-builder/SKILL.md b/.agents/skills/ttrpg-encounter-builder/SKILL.md new file mode 100644 index 0000000..89ff02c --- /dev/null +++ b/.agents/skills/ttrpg-encounter-builder/SKILL.md @@ -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 /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: , // crypto.randomUUID(), client-generated + name: 'Ankheg', + type: 'monster', // 'monster' | 'npc' | 'character' + originalCharacterId: null, // set only if type === 'character' + initiative: , // 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 "/api/collection?path=campaigns" \ + | jq -r '.[] | select(.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 "" spec.json +# or stdin: +cat spec.json | node scripts/build-encounters.js "" - +``` + +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 "/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 "/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`. diff --git a/.agents/skills/ttrpg-encounter-builder/scripts/build-encounters.js b/.agents/skills/ttrpg-encounter-builder/scripts/build-encounters.js new file mode 100644 index 0000000..66c1ddb --- /dev/null +++ b/.agents/skills/ttrpg-encounter-builder/scripts/build-encounters.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 +// cat spec.json | node build-encounters.js - +// +// 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 '); + 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); }); diff --git a/README.md b/README.md index 4382857..fb24170 100644 --- a/README.md +++ b/README.md @@ -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.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. diff --git a/TODO.md b/TODO.md index 529c687..60252ca 100644 --- a/TODO.md +++ b/TODO.md @@ -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? diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b26deef..4c9ad6f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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://: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://:3999` (with port), enable, relaunch. See README for full details. + Smoke check: ```bash curl http://127.0.0.1:4001/health # -> {"ok":true} diff --git a/docs/ENCOUNTER_BUILDER.md b/docs/ENCOUNTER_BUILDER.md index 701e228..7659c37 100644 --- a/docs/ENCOUNTER_BUILDER.md +++ b/docs/ENCOUNTER_BUILDER.md @@ -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 /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. diff --git a/public/display-manifest.json b/public/display-manifest.json new file mode 100644 index 0000000..369bc38 --- /dev/null +++ b/public/display-manifest.json @@ -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" +} diff --git a/scripts/dev-start.sh b/scripts/dev-start.sh index a4d2577..7e1a47c 100755 --- a/scripts/dev-start.sh +++ b/scripts/dev-start.sh @@ -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)" diff --git a/shared/tests/turn.ac.test.js b/shared/tests/turn.ac.test.js new file mode 100644 index 0000000..4ae1ab8 --- /dev/null +++ b/shared/tests/turn.ac.test.js @@ -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(); + }); +}); diff --git a/shared/tests/turn.bulkadd.test.js b/shared/tests/turn.bulkadd.test.js new file mode 100644 index 0000000..9ed4a3d --- /dev/null +++ b/shared/tests/turn.bulkadd.test.js @@ -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']); + }); +}); diff --git a/shared/tests/turn.generic.test.js b/shared/tests/turn.generic.test.js new file mode 100644 index 0000000..e4f537e --- /dev/null +++ b/shared/tests/turn.generic.test.js @@ -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'); + }); +}); diff --git a/shared/tests/turn.hpformula.test.js b/shared/tests/turn.hpformula.test.js new file mode 100644 index 0000000..9da04bc --- /dev/null +++ b/shared/tests/turn.hpformula.test.js @@ -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(); + }); +}); diff --git a/shared/tests/turn.temphp.test.js b/shared/tests/turn.temphp.test.js new file mode 100644 index 0000000..e4fedbd --- /dev/null +++ b/shared/tests/turn.temphp.test.js @@ -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); + }); +}); diff --git a/shared/tests/turn.undo.test.js b/shared/tests/turn.undo.test.js index ecc84f3..6e3f306 100644 --- a/shared/tests/turn.undo.test.js +++ b/shared/tests/turn.undo.test.js @@ -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 () => { diff --git a/shared/tests/turn.writeback.test.js b/shared/tests/turn.writeback.test.js new file mode 100644 index 0000000..8fd099f --- /dev/null +++ b/shared/tests/turn.writeback.test.js @@ -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(); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 5ad7bf2..0c41dec 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -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, }; diff --git a/src/App.js b/src/App.js index b52f429..2d682cf 100644 --- a/src/App.js +++ b/src/App.js @@ -1,11 +1,13 @@ import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import * as shared from '@ttrpg/shared'; import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage'; +import { isDevToolsEnabled } from './config/devTools'; import { PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, - StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, + StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText, Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X, Undo2, Redo2, Crosshair } from 'lucide-react'; @@ -34,7 +36,7 @@ function ToastStack({ toasts, onDismiss }) { function InfoModal({ message, onClose }) { if (!message) return null; return ( -
+
@@ -125,6 +127,7 @@ const { deathSave: combatDeathSave, stabilizeParticipant, reviveParticipant, + markDead, toggleCondition: combatToggleCondition, reorderParticipants, } = shared; @@ -344,8 +347,8 @@ function Modal({ onClose, title, children }) { return () => window.removeEventListener('keydown', handleEsc); }, [onClose]); - return ( -
+ return createPortal( +

{title}

@@ -355,7 +358,8 @@ function Modal({ onClose, title, children }) {
{children}
-
+
, + document.body ); } @@ -389,8 +393,8 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) { onClose(); }; - return ( -
+ return createPortal( +
@@ -417,7 +421,8 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {
-
+
, + document.body ); } @@ -448,11 +453,12 @@ function ErrorDisplay({ message, critical = false }) { function CreateCampaignForm({ onCreate, onCancel }) { const [name, setName] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); + const [ruleset, setRuleset] = useState('5e'); const handleSubmit = (e) => { e.preventDefault(); if (name.trim()) { - onCreate(name, backgroundUrl); + onCreate(name, backgroundUrl, ruleset); } }; @@ -484,6 +490,20 @@ function CreateCampaignForm({ onCreate, onCancel }) { className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
+
+ +
+ + +
+
+
+
+ +
+ + +
+
-
- - setInitiative(e.target.value)} - className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" - /> +
+
+ + setInitiative(e.target.value)} + className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" + /> +
+
+ + setAc(e.target.value)} + placeholder="optional" + className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" + /> +
@@ -607,6 +668,46 @@ function EditParticipantModal({ participant, onClose, onSave }) { />
+
+
+ + setTempHp(e.target.value)} + min="0" + placeholder="0" + className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-cyan-600 focus:border-cyan-600 sm:text-sm text-white" + /> +
+
+
+ {(participant.type === 'monster' || participant.type === 'npc') && ( +
+ +
+ setHpFormula(e.target.value)} + placeholder="e.g. 2d6+9" + className="flex-1 px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" + /> + +
+
+ )} {(participant.type === 'monster' || participant.type === 'npc') && (
{ setDraggedCharId(id); e.dataTransfer.effectAllowed = 'move'; }; + const handleCharDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; + const handleCharDrop = async (e, targetId) => { + e.preventDefault(); + if (!campaignId || !draggedCharId || draggedCharId === targetId) { setDraggedCharId(null); return; } + const reordered = [...campaignCharacters]; + const fromIdx = reordered.findIndex(c => c.id === draggedCharId); + const toIdx = reordered.findIndex(c => c.id === targetId); + if (fromIdx === -1 || toIdx === -1) { setDraggedCharId(null); return; } + const [moved] = reordered.splice(fromIdx, 1); + reordered.splice(toIdx, 0, moved); + try { await storage.updateDoc(getPath.campaign(campaignId), { players: reordered }); } catch (err) {} + setDraggedCharId(null); + }; + const [isOpen, setIsOpen] = useState(() => { + try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; } + catch { return true; } + }); + useEffect(() => { + try { localStorage.setItem('ttrpg.charactersCollapsed', String(!isOpen)); } + catch {} + }, [isOpen]); const handleAddCharacter = async () => { if (!db || !characterName.trim() || !campaignId) return; @@ -672,7 +798,9 @@ function CharacterManager({ campaignId, campaignCharacters }) { id: generateId(), name: characterName.trim(), defaultMaxHp: hp, - defaultInitMod: initMod + defaultInitMod: initMod, + defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null, + isNpc: isCharacterNpc || false, }; try { @@ -682,12 +810,14 @@ function CharacterManager({ campaignId, campaignCharacters }) { setCharacterName(''); setDefaultMaxHp(DEFAULT_MAX_HP); setDefaultInitMod(DEFAULT_INIT_MOD); + setDefaultAc(''); + setIsCharacterNpc(false); } catch (err) { console.error("Error adding character:", err); showToast("Failed to add character. Please try again."); } }; - const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod) => { + const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => { if (!db || !newName.trim() || !campaignId) return; const hp = parseInt(newDefaultMaxHp, 10); @@ -704,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters }) { const updatedCharacters = campaignCharacters.map(c => c.id === characterId - ? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod } + ? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null, isNpc: newIsNpc != null ? newIsNpc : (c.isNpc || false) } : c ); @@ -738,24 +868,33 @@ function CharacterManager({ campaignId, campaignCharacters }) { return ( <> -
+
-

- Campaign Characters -

{isOpen && ( <> -
{ e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 items-end"> -
+ + { e.preventDefault(); handleAddCharacter(); }} className="grid grid-cols-2 sm:grid-cols-12 gap-2 mb-4 items-end"> +
@@ -768,19 +907,7 @@ function CharacterManager({ campaignId, campaignCharacters }) { className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
-
- - setDefaultMaxHp(e.target.value)} - className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" - /> -
-
+
@@ -792,9 +919,42 @@ function CharacterManager({ campaignId, campaignCharacters }) { className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
+
+ + setDefaultAc(e.target.value)} + className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" + /> +
+
+ + setDefaultMaxHp(e.target.value)} + className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" + /> +
+ @@ -806,7 +966,17 @@ function CharacterManager({ campaignId, campaignCharacters }) {
    {campaignCharacters.map(character => ( -
  • +
  • handleCharDragStart(e, character.id)} + onDragOver={handleCharDragOver} + onDrop={(e) => handleCharDrop(e, character.id)} + onDragEnd={() => setDraggedCharId(null)} + className={`flex justify-between items-center p-3 bg-stone-800 rounded-md cursor-grab ${draggedCharId === character.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`} + > +
    + {editingCharacter && editingCharacter.id === character.id ? ( { @@ -815,31 +985,72 @@ function CharacterManager({ campaignId, campaignCharacters }) { character.id, editingCharacter.name, editingCharacter.defaultMaxHp, - editingCharacter.defaultInitMod + editingCharacter.defaultInitMod, + editingCharacter.defaultAc, + editingCharacter.defaultCurrentHp, + editingCharacter.isNpc ); }} className="flex-grow flex flex-wrap gap-2 items-center" > - setEditingCharacter({ ...editingCharacter, name: e.target.value })} - className="flex-grow min-w-[100px] px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - /> - setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })} - className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - title="Default Max HP" - /> - setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })} - className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - title="Default Init Mod" - /> +
    + + setEditingCharacter({ ...editingCharacter, name: e.target.value })} + className="w-full px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + /> +
    +
    + + setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })} + className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="Default Max HP" + /> +
    +
    + + setEditingCharacter({ ...editingCharacter, defaultCurrentHp: e.target.value })} + className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="Default Current HP" + /> +
    +
    + + setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })} + className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="Default Init Mod" + /> +
    +
    + + setEditingCharacter({ ...editingCharacter, defaultAc: e.target.value })} + className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="AC" + /> +
    + @@ -853,19 +1064,31 @@ function CharacterManager({ campaignId, campaignCharacters }) { ) : ( <> - - {character.name}{' '} - - (HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}) - - -
    +
    + {character.name} + {character.isNpc && ( + NPC + )} +
    + HP {character.defaultMaxHp || '?'} + {character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && ( + Current HP {character.defaultCurrentHp} + )} + {character.defaultAc != null && ( + AC {character.defaultAc} + )} + Init {formatInitMod(character.defaultInitMod)} +
    +
    +
    )} +
  • ))}
@@ -910,9 +1134,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp const [participantType, setParticipantType] = useState('monster'); const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD); - const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP); + const [maxHp, setMaxHp] = useState(''); + const [hpFormula, setHpFormula] = useState(''); const [manualInitiative, setManualInitiative] = useState(''); + const [monsterAc, setMonsterAc] = useState(''); const [asNpc, setAsNpc] = useState(false); + const combatActive = encounter.isStarted && !encounter.isPaused; + const [addSectionOpen, setAddSectionOpen] = useState(!combatActive); const [editingParticipant, setEditingParticipant] = useState(null); const [hpChangeValues, setHpChangeValues] = useState({}); const [draggedItemId, setDraggedItemId] = useState(null); @@ -933,6 +1161,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp ]; const participants = encounter.participants || []; + const [editingField, setEditingField] = useState(null); + const [editingId, setEditingId] = useState(null); + const blurTimeoutRef = useRef(null); + const handleFieldFocus = (id, label) => { + if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = null; } + setEditingId(id); + setEditingField(label); + }; + const handleFieldBlur = () => { + blurTimeoutRef.current = setTimeout(() => { setEditingField(null); setEditingId(null); }, 150); + }; useEffect(() => { if (participantType === 'character' && selectedCharacterId) { @@ -940,11 +1179,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp if (selectedChar && selectedChar.defaultMaxHp) { setMaxHp(selectedChar.defaultMaxHp); } else { - setMaxHp(DEFAULT_MAX_HP); + setMaxHp(''); } setAsNpc(false); } else if (participantType === 'monster') { - setMaxHp(DEFAULT_MAX_HP); + setMaxHp(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); } }, [selectedCharacterId, participantType, campaignCharacters]); @@ -977,6 +1216,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp } else { modifier = parseInt(monsterInitMod, 10) || 0; participantAsNpc = asNpc; + // maxHp wins if both set; else roll formula + if (!maxHp && hpFormula.trim()) { + const rolled = shared.rollHpFormula(hpFormula); + if (rolled) currentMaxHp = rolled; + } } const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier); @@ -988,6 +1232,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp initiative: computedInitiative, maxHp: currentMaxHp, currentHp: currentMaxHp, + hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null, + ac: participantType === 'monster' && monsterAc !== '' ? parseInt(monsterAc, 10) : (participantType === 'character' ? (campaignCharacters.find(c => c.id === selectedCharacterId)?.defaultAc ?? null) : null), conditions: [], isActive: true, status: 'conscious', @@ -1009,9 +1255,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); setParticipantName(''); - setMaxHp(DEFAULT_MAX_HP); + setMaxHp(''); + setHpFormula(''); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); + setMonsterAc(''); setAsNpc(false); setManualInitiative(''); } catch (err) { @@ -1122,6 +1370,62 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp } }; + const handleInlineCurrentHp = async (participantId, value) => { + let n = parseInt(value, 10); + if (isNaN(n)) return; + const p = participants.find(pp => pp.id === participantId); + if (!p || n === p.currentHp) return; + try { + const isGeneric = (encounter.ruleset === 'generic'); + let patch; + if (!isGeneric && n < 0) n = 0; + if (isGeneric) { + patch = { currentHp: n, status: n > 0 ? 'conscious' : 'down' }; + } else { + patch = { currentHp: n, deathSaveSuccesses: 0, deathSaveFailures: 0 }; + if (n > 0) { patch.status = 'conscious'; } + else if (p.type === 'monster') { patch.status = 'dead'; patch.isActive = false; } + else { patch.status = 'dying'; } + } + await updateParticipant(encounter, participantId, patch, buildCtx(encounterPath)); + } catch (err) { + showToast('Failed to update HP.'); + } + }; + + const handleInlineMaxHp = async (participantId, value) => { + const n = parseInt(value, 10); + if (isNaN(n)) return; + const p = participants.find(pp => pp.id === participantId); + if (!p || n === p.maxHp || n <= 0) return; + try { + await updateParticipant(encounter, participantId, { maxHp: n }, buildCtx(encounterPath)); + } catch (err) { + showToast('Failed to update Max HP.'); + } + }; + + const handleInlineAc = async (participantId, value) => { + const n = parseInt(value, 10); + if (isNaN(n)) return; + const p = participants.find(pp => pp.id === participantId); + if (!p || n === p.ac) return; + try { + await updateParticipant(encounter, participantId, { ac: n }, buildCtx(encounterPath)); + } catch (err) { + showToast('Failed to update AC.'); + } + }; + + const handleInlineTempHp = async (participantId, value) => { + const n = Math.max(0, parseInt(value, 10) || 0); + try { + await updateParticipant(encounter, participantId, { tempHp: n }, buildCtx(encounterPath)); + } catch (err) { + showToast('Failed to update Temp HP.'); + } + }; + const handleDeathSaveChange = async (participantId, outcome) => { if (!db) return; try { @@ -1153,6 +1457,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp } }; + const handleMarkDead = async (participantId) => { + if (!db) return; + try { + await markDead(encounter, participantId, buildCtx(encounterPath)); + } catch (err) { + showToast(err.message); + } + }; + const handleCritDamage = async (participantId) => { if (!db) return; try { @@ -1231,17 +1544,30 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp return ( <>
-
-

Add Participants

- -
+ {(() => { + const open = addSectionOpen && !combatActive; + return ( +
+ { if (!combatActive) { e.preventDefault(); setAddSectionOpen(v => !v); } else { e.preventDefault(); } }} + className="cursor-pointer text-lg font-medium text-amber-200 font-cinzel tracking-wide flex items-center gap-2 select-none" + > + {open ? : } + Add Participants + {combatActive && (pause to add)} + + {open && ( + <> +
+ +
{/* Warning when combat is active */} {encounter.isStarted && !encounter.isPaused && ( @@ -1261,7 +1587,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp e.preventDefault(); handleAddParticipant(); }} - className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end" + className="grid grid-cols-1 md:grid-cols-12 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end" >
@@ -1281,7 +1607,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp {participantType === 'monster' ? ( <> -
+
@@ -1319,6 +1645,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" />
+
+ + setMonsterAc(e.target.value)} + className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" + /> +
-
+
+ + setHpFormula(e.target.value)} + placeholder="e.g. 2d6+9" + className="mt-1 w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" + /> +
+
)} -
+
- - {lastRollDetails && ( -

- {lastRollDetails.manual - ? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}` - : `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative` - } -

- )} + + )} +
+ ); + })()} {participants.length === 0 &&

No participants added yet.

} +
    {sortedParticipants.map((p) => { const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId; const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative)); const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character'); - const hasDeathSaves = p.type === 'character' || p.type === 'npc'; + const isGeneric = (encounter.ruleset || '5e') === 'generic'; + const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc'); let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]'; if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600'; - const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'); - const isZeroHp = p.currentHp === 0; + const participantStatus = p.status || (isGeneric + ? (p.currentHp <= 0 ? 'down' : 'conscious') + : (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious')); + const isZeroHp = p.currentHp <= 0; const isDead = participantStatus === 'dead'; + const isDown = participantStatus === 'down'; return (
  • handleDrop(e, p.id) : undefined} onDragEnd={() => setDraggedItemId(null)} - className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`} + className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`} > + {editingId === p.id && ( + + + ✎ Editing {editingField} + + + )}
    {isDraggable && ( {isZeroHp && ☠️} {p.name} ({participantDisplayType}) + {p.ac != null && ( + + AC + { e.target.select(); handleFieldFocus(p.id, 'AC'); }} + onBlur={(e) => { if (e.target.value !== String(p.ac)) handleInlineAc(p.id, e.target.value); handleFieldBlur(); }} + onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.ac; }} + className="w-6 bg-transparent text-sky-100 text-lg font-bold leading-none text-center focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + aria-label={`AC for ${p.name}`} + /> + + )} {isCurrentTurn && !encounter.isPaused && ( CURRENT @@ -1452,36 +1824,79 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp )} {hasDeathSaves && participantStatus === 'dying' && (Dying)} {hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && (Unconscious)} + {isDown && (Down)} {isDead && DEAD}

    - + { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }} - onFocus={(e) => e.target.select()} - onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }} + onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }} + onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }} - className="w-10 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed" + className="w-7 bg-transparent text-amber-300 text-lg font-bold text-center focus:outline-none disabled:text-amber-300 disabled:opacity-100 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`Initiative for ${p.name}`} /> - HP: {p.currentHp}/{p.maxHp} + + { e.target.select(); handleFieldFocus(p.id, 'HP'); }} + onBlur={(e) => { if (e.target.value !== String(p.currentHp)) handleInlineCurrentHp(p.id, e.target.value); handleFieldBlur(); }} + onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.currentHp; }} + className="w-10 bg-transparent text-white text-base text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + aria-label={`Current HP for ${p.name}`} + /> + / + { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }} + onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }} + onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }} + className="w-10 bg-transparent text-stone-200 text-base text-center border-b border-transparent hover:border-stone-400 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + aria-label={`Max HP for ${p.name}`} + /> + + + + + { e.target.select(); handleFieldFocus(p.id, 'Temp HP'); }} + onBlur={(e) => { if (e.target.value !== String(p.tempHp || 0)) handleInlineTempHp(p.id, e.target.value); handleFieldBlur(); }} + onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.tempHp || 0; }} + className={`w-7 bg-transparent text-base text-center border-b focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${(p.tempHp || 0) > 0 ? 'text-cyan-200 border-cyan-500' : 'text-stone-300 border-transparent hover:border-stone-400'}`} + aria-label={`Temp HP for ${p.name}`} + /> + + + {participantStatus === 'dead' && ( + + )} + {isGeneric && !isDead && ( + + )} +
    - {participantStatus === 'dead' && ( - - )} - {hasDeathSaves && participantStatus === 'stable' && (
    Stable — regains 1 HP after 1d4 hours
    )} @@ -1679,6 +2094,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp })}
+ {lastRollDetails && ( +

+ {lastRollDetails.manual + ? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}` + : `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative` + } +

+ )} + + {editingParticipant && ( { + const old = cw.find(c => c.id === p.id); + return old ? { ...p, defaultMaxHp: old.defaultMaxHp, defaultAc: old.defaultAc, defaultCurrentHp: old.defaultCurrentHp } : p; + }); + await storage.updateDoc(getPath.campaign(wbId), { players: restored }); + } + } catch (e) { console.error('characterWriteback restore failed:', e); } + } } } catch (err) { console.error('Error undoing action:', err); @@ -1788,7 +2228,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { try { await startEncounter(encounter, buildCtx(encounterPath)); - await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true }); + // Only claim player display if slot is empty or already showing this encounter. + // Don't steal display from another live encounter. + const displayEmpty = !activeDisplayData || !activeDisplayData.activeEncounterId; + const displayIsOurs = activeDisplayData && activeDisplayData.activeCampaignId === campaignId && activeDisplayData.activeEncounterId === encounter.id; + if (displayEmpty || displayIsOurs) { + await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true }); + } } catch (err) { showToast(err.message || "Failed to start encounter. Please try again."); } }; @@ -1823,8 +2269,20 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { const confirmEndEncounter = async () => { if (!db) return; try { - await endEncounter(encounter, buildCtx(encounterPath)); - await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true }); + const ctx = { ...buildCtx(encounterPath), campaignId }; + if (campaignId) { + try { + ctx.campaign = await storage.getDoc(getPath.campaign(campaignId)); + } catch {} + } + await endEncounter(encounter, ctx); + // Only clear display if THIS encounter is the one showing. + const displayIsThis = activeDisplayData && + activeDisplayData.activeCampaignId === campaignId && + activeDisplayData.activeEncounterId === encounter.id; + if (displayIsThis) { + await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true }); + } } catch (err) { showToast("Failed to end encounter. Please try again."); } @@ -1835,95 +2293,95 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { return ( <> -
-

Combat Controls

-
+
+

Combat Controls

+
{!encounter.isStarted ? ( ) : ( <> {/* Round Counter */} -
-

Round: {encounter.round}

+
+

Round: {encounter.round}

{encounter.isPaused && ( -

(Paused)

+

(Paused)

)}
)}
- {/* Undo / Redo — queried on click (no live logs subscription during combat) */} -
+ {/* Undo / Redo */} +
{/* Display Settings */} -
+
Player Display
@@ -1944,28 +2402,88 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { // ENCOUNTER MANAGER COMPONENT // ============================================================================ -function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) { +function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) { const { showToast } = useUIFeedback(); const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection( campaignId ? getPath.encounters(campaignId) : null ); const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay()); + const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null); const [encounters, setEncounters] = useState([]); - const [selectedEncounterId, setSelectedEncounterId] = useState(null); + const [selectedEncounterId, setSelectedEncounterId] = useState(() => { + try { return localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; } + catch { return null; } + }); const [showCreateModal, setShowCreateModal] = useState(false); + const [encounterFullscreen, setEncounterFullscreen] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); + const [draggedEncounterId, setDraggedEncounterId] = useState(null); const selectedEncounterIdRef = useRef(selectedEncounterId); + // Restore scoped encounter selection when campaign changes. + useEffect(() => { + if (!campaignId) { setSelectedEncounterId(null); return; } + try { + const saved = localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; + setSelectedEncounterId(saved); + } catch { + setSelectedEncounterId(null); + } + }, [campaignId]); + useEffect(() => { if (encountersData) setEncounters(encountersData); }, [encountersData]); + // Sort by order field (fallback createdAt). Drag reorder writes order. + const sortedEncounters = [...(encounters || [])].sort((a, b) => { + const ao = a.order ?? null, bo = b.order ?? null; + if (ao !== null && bo !== null) return ao - bo; + if (ao !== null) return -1; + if (bo !== null) return 1; + return (a.createdAt || '').localeCompare(b.createdAt || ''); + }); + + const handleEncounterDragStart = (e, id) => { + setDraggedEncounterId(id); + e.dataTransfer.effectAllowed = 'move'; + }; + const handleEncounterDragOver = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + const handleEncounterDrop = async (e, targetId) => { + e.preventDefault(); + if (!db || !draggedEncounterId || draggedEncounterId === targetId) { + setDraggedEncounterId(null); + return; + } + const reordered = [...sortedEncounters]; + const fromIdx = reordered.findIndex(x => x.id === draggedEncounterId); + const toIdx = reordered.findIndex(x => x.id === targetId); + if (fromIdx === -1 || toIdx === -1) { setDraggedEncounterId(null); return; } + const [moved] = reordered.splice(fromIdx, 1); + reordered.splice(toIdx, 0, moved); + // assign order = index, batch update + const ops = reordered.map((enc, i) => ({ + type: 'update', + path: `${getPath.encounters(campaignId)}/${enc.id}`, + data: { order: i }, + })); + try { await storage.batchWrite(ops); } catch (err) {} + setDraggedEncounterId(null); + }; + useEffect(() => { selectedEncounterIdRef.current = selectedEncounterId; - }, [selectedEncounterId]); + try { + if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId); + else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`); + } catch {} + }, [selectedEncounterId, campaignId]); useEffect(() => { if (!campaignId) { @@ -1992,7 +2510,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac } }, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]); - const handleCreateEncounter = async (name) => { + const handleCreateEncounter = async (name, ruleset = '5e') => { if (!db || !name.trim() || !campaignId) return; const newEncounterId = generateId(); @@ -2000,12 +2518,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac try { await storage.setDoc(`${getPath.encounters(campaignId)}/${newEncounterId}`, { name: name.trim(), + campaignId, createdAt: new Date().toISOString(), participants: [], round: 0, currentTurnParticipantId: null, isStarted: false, - isPaused: false + isPaused: false, + ruleset: ruleset || '5e', + order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1, }); setShowCreateModal(false); @@ -2079,13 +2600,22 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId); + useEffect(() => { + if (encounterStartedRef) { + encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused); + } + if (encounterActiveRef) { + encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted); + } + }, [selectedEncounter, encounterStartedRef, encounterActiveRef]); + if (isLoadingEncounters && campaignId) { return

Loading encounters...

; } return ( <> -
+

Encounters @@ -2103,7 +2633,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac )}
- {encounters?.map(encounter => { + {sortedEncounters.map(encounter => { const isLive = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id; @@ -2111,14 +2641,24 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac return (
handleEncounterDragStart(e, encounter.id)} + onDragOver={handleEncounterDragOver} + onDrop={(e) => handleEncounterDrop(e, encounter.id)} + onDragEnd={() => setDraggedEncounterId(null)} + className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${encounter.isStarted && !encounter.endedAt ? 'ring-2 ring-red-500' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`} >
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow"> -

{encounter.name}

+

{encounter.name} {encounter.ruleset === 'generic' ? 'GEN' : '5e'}{encounter.isStarted && !encounter.endedAt && ⚔ IN PROGRESS}

- Participants: {encounter.participants?.length || 0} + {encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}

+ {(encounter.startedAt || encounter.endedAt) && ( +

+ {encounter.startedAt && `⚔ Started: ${new Date(encounter.startedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}{encounter.startedAt && encounter.endedAt && ' · '}{encounter.endedAt && `Ended: ${new Date(encounter.endedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`} +

+ )} {isLive && ( LIVE ON PLAYER DISPLAY @@ -2126,6 +2666,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac )}
+

-
- {/* Combat Controls - Left Side (Sticky on large screens) */} -
+
+ {/* Combat Controls - always left */} +
{ + try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; } + catch { return false; } + }); + const wakeLockRef = useRef(null); + + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else { + document.exitFullscreen(); + } + }; + + useEffect(() => { + const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', onFsChange); + return () => document.removeEventListener('fullscreenchange', onFsChange); + }, []); + + useEffect(() => { + if (!wakeLockEnabled) { + wakeLockRef.current?.release(); + wakeLockRef.current = null; + return; + } + const acquire = async () => { + try { wakeLockRef.current = await navigator.wakeLock.request('screen'); } + catch (e) { + console.error('Wake lock failed:', e); + showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure'); + } + }; + acquire(); + const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); }; + const onFsChange = () => { if (document.fullscreenElement) acquire(); }; + document.addEventListener('visibilitychange', onVisChange); + document.addEventListener('fullscreenchange', onFsChange); + return () => { + document.removeEventListener('visibilitychange', onVisChange); + document.removeEventListener('fullscreenchange', onFsChange); + wakeLockRef.current?.release(); + wakeLockRef.current = null; + }; + }, [wakeLockEnabled, showToast]); + const [campaignsWithDetails, setCampaignsWithDetails] = useState([]); - const [selectedCampaignId, setSelectedCampaignId] = useState(null); + const [draggedCampaignId, setDraggedCampaignId] = useState(null); + const [selectedCampaignId, setSelectedCampaignId] = useState(() => { + try { return localStorage.getItem('ttrpg.selectedCampaign') || null; } + catch { return null; } + }); + const encounterStartedRef = useRef(false); + const encounterActiveRef = useRef(false); + const manualSelectRef = useRef(false); + const prevDisplayCampaignRef = useRef(null); + const scrollRestoredRef = useRef(false); + + // Save scroll on unload + visibility change + interval. + useEffect(() => { + const onSave = () => { + try { localStorage.setItem(`ttrpg.scrollY.${selectedCampaignId}`, String(window.scrollY)); } catch {} + }; + window.addEventListener('beforeunload', onSave); + window.addEventListener('pagehide', onSave); + document.addEventListener('visibilitychange', onSave); + const interval = setInterval(onSave, 2000); + return () => { + window.removeEventListener('beforeunload', onSave); + window.removeEventListener('pagehide', onSave); + document.removeEventListener('visibilitychange', onSave); + clearInterval(interval); + }; + }, [selectedCampaignId]); + + // Restore scroll once data loaded. + useEffect(() => { + if (scrollRestoredRef.current) return; + if (campaignsWithDetails.length === 0) return; + try { + const y = parseInt(localStorage.getItem(`ttrpg.scrollY.${selectedCampaignId}`) || '0', 10); + if (y > 0) { + scrollRestoredRef.current = true; + setTimeout(() => window.scrollTo(0, y), 300); + } + } catch {} + }, [campaignsWithDetails, selectedCampaignId]); + + // Persist selections across reload. + useEffect(() => { + try { + if (selectedCampaignId) localStorage.setItem('ttrpg.selectedCampaign', selectedCampaignId); + else localStorage.removeItem('ttrpg.selectedCampaign'); + } catch {} + }, [selectedCampaignId]); + + // External display change (replay/other DM) = clear manual override, allow follow. + useEffect(() => { + const cur = initialActiveInfo?.activeCampaignId || null; + if (cur !== prevDisplayCampaignRef.current) { + manualSelectRef.current = false; + prevDisplayCampaignRef.current = cur; + } + }, [initialActiveInfo]); const [showCreateModal, setShowCreateModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); @@ -2244,8 +2901,12 @@ function AdminView({ userId }) { }) ); - // newest first by createdAt (fallback to name for stable order). + // sort by order field (fallback createdAt) detailedCampaigns.sort((a, b) => { + const ao = a.order ?? null, bo = b.order ?? null; + if (ao !== null && bo !== null) return ao - bo; + if (ao !== null) return -1; + if (bo !== null) return 1; const at = a.createdAt || 0; const bt = b.createdAt || 0; if (at === bt) return (a.name || '').localeCompare(b.name || ''); @@ -2258,6 +2919,10 @@ function AdminView({ userId }) { fetchDetails(); } else if (campaignsData) { const sorted = [...campaignsData].sort((a, b) => { + const ao = a.order ?? null, bo = b.order ?? null; + if (ao !== null && bo !== null) return ao - bo; + if (ao !== null) return -1; + if (bo !== null) return 1; const at = a.createdAt || 0; const bt = b.createdAt || 0; if (at === bt) return (a.name || '').localeCompare(b.name || ''); @@ -2271,10 +2936,15 @@ function AdminView({ userId }) { }, [campaignsData]); useEffect(() => { + // Skip follow only if user manually selected AND display hasn't changed. + // (external display change clears manualSelectRef via prevDisplay effect) + if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return; if ( initialActiveInfo && initialActiveInfo.activeCampaignId && - campaignsWithDetails.length > 0 + campaignsWithDetails.length > 0 && + !encounterStartedRef.current && + !encounterActiveRef.current ) { const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId); if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) { @@ -2283,7 +2953,7 @@ function AdminView({ userId }) { } }, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]); - const handleCreateCampaign = async (name, backgroundUrl) => { + const handleCreateCampaign = async (name, backgroundUrl, ruleset = '5e') => { if (!db || !name.trim()) return; const newCampaignId = generateId(); @@ -2295,6 +2965,8 @@ function AdminView({ userId }) { ownerId: userId, createdAt: new Date().toISOString(), players: [], + ruleset: ruleset || '5e', + order: (campaignsWithDetails || []).reduce((max, c) => Math.max(max, c.order ?? -1), -1) + 1, }); setShowCreateModal(false); @@ -2406,7 +3078,7 @@ function AdminView({ userId }) { return ( <> -
+
+
+ + +
{!campaignsCollapsed && ( @@ -2441,12 +3129,36 @@ function AdminView({ userId }) { ? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` } : {}; - const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'}`; + const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-grab transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'} ${draggedCampaignId === campaign.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`; return (
setSelectedCampaignId(campaign.id)} + draggable + onDragStart={(e) => { setDraggedCampaignId(campaign.id); e.dataTransfer.effectAllowed = 'move'; }} + onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} + onDrop={async (e) => { + e.preventDefault(); + if (!db || !draggedCampaignId || draggedCampaignId === campaign.id) { setDraggedCampaignId(null); return; } + const reordered = [...campaignsWithDetails]; + const fromIdx = reordered.findIndex(c => c.id === draggedCampaignId); + const toIdx = reordered.findIndex(c => c.id === campaign.id); + if (fromIdx === -1 || toIdx === -1) { setDraggedCampaignId(null); return; } + const [moved] = reordered.splice(fromIdx, 1); + reordered.splice(toIdx, 0, moved); + const ops = reordered.map((c, i) => ({ type: 'update', path: getPath.campaign(c.id), data: { order: i } })); + try { await storage.batchWrite(ops); } catch (err) {} + setDraggedCampaignId(null); + }} + onDragEnd={() => setDraggedCampaignId(null)} + onClick={() => { + if (encounterStartedRef.current) { + showToast('End or pause active encounter before switching campaigns.'); + return; + } + manualSelectRef.current = true; + setSelectedCampaignId(campaign.id); + }} className={cardClasses} style={cardStyle} > @@ -2454,7 +3166,10 @@ function AdminView({ userId }) { className={`relative z-10 flex flex-col justify-between h-full ${campaign.playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4'}`} >
-

{campaign.name}

+
+ +

{campaign.name}

+
{campaign.characters?.length || 0} Characters @@ -2478,11 +3193,29 @@ function AdminView({ userId }) { > Delete + + {campaign.ruleset === 'generic' ? 'GEN' : '5e'} +
); })}
+ + {isDevToolsEnabled() && campaignsWithDetails.length > 0 && ( +
+ +
+ )} )}
@@ -2497,13 +3230,14 @@ function AdminView({ userId }) { )} {selectedCampaign && ( -
+

Managing: {selectedCampaign.name}


)} @@ -2527,18 +3263,6 @@ function AdminView({ userId }) { message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`} /> - {process.env.NODE_ENV === 'development' && campaignsWithDetails.length > 0 && ( -
- -
- )} - setShowDeleteAllConfirm(false)} @@ -2620,7 +3344,10 @@ function DisplayView() { const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState(''); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); - const [wakeLockEnabled, setWakeLockEnabled] = useState(false); + const [wakeLockEnabled, setWakeLockEnabled] = useState(() => { + try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; } + catch { return false; } + }); const [displayParticipants, setDisplayParticipants] = useState([]); const wakeLockRef = useRef(null); const currentParticipantRef = useRef(null); @@ -2686,8 +3413,12 @@ function DisplayView() { // Re-acquire after tab becomes visible again (browser auto-releases on hide) const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); }; document.addEventListener('visibilitychange', onVisChange); + // Re-acquire on fullscreen change (Android discards wakeLock on screen off) + const onFsChange = () => { if (document.fullscreenElement) acquire(); }; + document.addEventListener('fullscreenchange', onFsChange); return () => { document.removeEventListener('visibilitychange', onVisChange); + document.removeEventListener('fullscreenchange', onFsChange); wakeLockRef.current?.release(); wakeLockRef.current = null; }; @@ -2778,6 +3509,22 @@ function DisplayView() { if (!isPlayerDisplayActive || !activeEncounterData) { return (
+
+ + +

Game Session Paused

The Dungeon Master has not activated an encounter for display.

@@ -2810,7 +3557,7 @@ function DisplayView() { >