24 Commits
Author SHA1 Message Date
david raistrick 2dfa155469 Add /display manifest for Android home-screen install
Android Chrome only reads manifest at install time from current page. Root
manifest has start_url '.' (root). Installing from /display launched root.

Fix: separate display-manifest.json (start_url /display, scope /, landscape).
App.js swaps link[rel=manifest] href when on /display path. Install from
/display now launches /display standalone.
2026-07-07 16:44:29 -04:00
david raistrick 0fa3928bf8 Track encounter start/end timestamps; display on card
startEncounter sets startedAt (clears endedAt on restart). endEncounter sets
endedAt. snapshotOf includes both for undo/redo fidelity. expandUndo split
start/end cases to restore old timestamps.

Display: encounter card shows Started/Ended independently (endedAt shows
even without startedAt). Undo sets null (can't delete via merge), test snap
strips only startedAt/endedAt null for compare.

Reviewed by pi gpt-5.5: stale endedAt on restart fixed, snap null-strip
narrowed. Medium-low risk.
2026-07-07 16:41:34 -04:00
david raistrick 9870d4df0d Fix addParticipants: slot by initiative instead of append
addParticipants (bulk add all characters) appended to list end, ignoring
initiative. Pre-combat list showed random order until start encounter.
Now slots each participant by initiative desc, preserves existing order +
drag ties. Matches addParticipant single-add semantics.

Tests: 5 bulk-add slot cases (turn.bulkadd.test.js).
2026-07-07 16:07:20 -04:00
david raistrick 43b1f6ce41 WIP: add ttrpg-encounter-builder skill + server-mode API docs
UNTESTED work in progress. Do not assume correct.

Scope: SERVER BACKEND ONLY (SQLite/Express, REACT_APP_STORAGE=server).
Does NOT work with Firebase SDK mode — no HTTP backend there, different
transport/auth. Skill + doc explicitly call this out.

- .agents/skills/ttrpg-encounter-builder/: harness-agnostic skill
  (pi/claude/codex via .agents/skills + symlinks). SKILL.md + helper
  script that batch-writes encounters via REST, rolls initiative, verifies.
- docs/ENCOUNTER_BUILDER.md: add Path normalization section, Build flow
  (API/scripts) section with REST endpoint table, object templates,
  recipe. Server-mode-only caveat noted.

Helper script syntax-checked + campaign lookup verified against running
instance, but full seed flow not regression-tested against test suite.
2026-07-07 15:42:02 -04:00
david raistrick 907484fc7f Character drag reorder via players array
Character list items draggable (ChevronsUpDown handle). Drop reorders
players array on campaign doc (single updateDoc). Array order = display
order.
2026-07-07 15:40:58 -04:00
david raistrick 9dbbb88730 Campaign drag reorder: fix sort in fetchDetails path
fetchDetails (db active path) sorted by createdAt, ignored order field.
Drag persisted but UI didn't re-sort. Now both paths sort by order
(fallback createdAt).
2026-07-07 15:37:04 -04:00
david raistrick 1078062b53 Campaign drag reorder via order field
Campaign cards draggable (ChevronsUpDown handle in header). Drop reorders,
batch updates order field on affected campaign docs. Sort by order (fallback
createdAt). New campaign gets order = max+1.
2026-07-07 15:24:12 -04:00
david raistrick 94126567e7 Encounter drag reorder via order field
Encounter cards draggable (ChevronsUpDown handle). Drop reorders, batch
updates order field on affected encounter docs. Sort by order (fallback
createdAt). New encounter gets order = max+1.
2026-07-07 15:18:08 -04:00
david raistrick f3ed971592 Merge remote-tracking branch 'upstream/main' into feat/generic-ruleset 2026-07-07 14:53:42 -04:00
david raistrick 95db6fdfdc Character section rollup: clickable title, count, persisted collapse
Match campaigns rollup pattern. Title button toggles collapse (chevron
left, like campaigns). Character count in header. Collapse state persisted
to localStorage key ttrpg.charactersCollapsed.
2026-07-07 14:51:45 -04:00
david raistrick a5b6d61e83 Generic ruleset UI + campaign/encounter tags + switch-during-combat fix
Campaign + encounter cards: ruleset tag (5e/GEN), create date visible.
CreateEncounterForm keyed by campaignId so default ruleset syncs on campaign switch.

Campaign switch during active combat:
- encounterStartedRef (unpaused) blocks switch + toast
- encounterActiveRef (started paused-or-not) gates display-follow effect
- manualSelectRef tracks user clicks; external display change clears it
  (BUG-12 follow still works for replay/other-DM)
- Prevents revert race when EncounterManager unmounts and refs go false

UI:
- campaign card: ruleset tag bottom-right, opposite delete
- encounter card: tag inline title, date left of participants count
- EncounterManager fetches campaignDoc for default ruleset inheritance

Tests green: app 100, shared 186, server 40.
2026-07-07 14:28:32 -04:00
robertandClaude Opus 4.8 76f5dc09ab Trigger Portainer stack webhooks after image push
Adds a main-only Redeploy stage that POSTs to two Portainer stack
webhooks (re-pull + redeploy) after a successful push. URLs are read
from Secret-text credentials so they stay out of the repo and logs.
Gated by the REDEPLOY_PORTAINER parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:57:47 -04:00
david raistrick c2bb5cca29 Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive
New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic.

Generic mode:
- No death saves (deathSave/stabilize throw)
- Negative HP allowed (no clamp at 0)
- <=0 HP = status 'down' (no pips, no unconscious auto-condition)
- Monster death = dead + inactive (same as 5e)
- markDead button: DM sets dead manually (both modes)
- revive: dead->conscious (generic), dead->stable (5e)

5e mode: zero behavior change.

shared/turn.js:
- applyHpChangeGeneric: negative HP, down status, no death-save logic
- markDead: status dead, monster auto-inactive
- reviveParticipant: ruleset-aware (generic=conscious, 5e=stable)
- deathSave/stabilize: throw in generic
- expandUndo: mark_dead case added

UI (src/App.js):
- CreateCampaignForm + CreateEncounterForm: ruleset radio toggle
- handleCreateCampaign/handleCreateEncounter: store ruleset field
- EncounterManager: fetch campaignDoc for default ruleset inheritance
- DM participant: down/dead labels, Mark Dead button (generic), Revive both
- death-save pips/buttons gated 5e-only
- DisplayView: down label, generic status derivation

Tests: 15 generic cases (turn.generic.test.js). 186 shared total.
2026-07-07 13:36:16 -04:00
robertandClaude Opus 4.8 7955bed4a9 Add Jenkins CI and Portainer stack for image deployment
Jenkinsfile builds and pushes both the firebase (nginx/static) and
sqlite (caddy+node) images to thinkserver:5000, guarded to main only.
docker/portainer-stack.yml deploys the prebuilt sqlite image from the
registry behind the external TLS-terminating nginx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:43:46 -04:00
robert 66f3cc1380 Adding Jenkinsfile to simplify my deployment. 2026-07-07 12:13:16 -04:00
robert 19de9fcf75 Merge pull request 'Player display animations, death visual, upstream merge' (#5) from single-source into main
Reviewed-on: #5
2026-07-07 11:21:23 -04:00
robert c41a8aa994 Merge branch 'main' into single-source 2026-07-07 11:21:12 -04:00
david raistrick 02344eb3f5 Bump Docker base images to node 22; mock firestore comment cleanup
Both Dockerfiles (root firebase-mode + docker/ caddy) bumped node:18-alpine
to node:22-alpine. better-sqlite3 v12 (from PR #6) requires node 20+; node 18
builds fail with node-gyp/python errors. openssl-legacy-provider flag kept
for react-scripts 5 compat.

.dockerignore: removed .env* exclusion so .env.local COPY works in firebase
image build.

mock firestore applyConstraints comment: offset handled by adapter (firebase.js
slices), mock never sees it — clarified dead-code reasoning.
2026-07-06 23:49:47 -04:00
keen e191aa5ad2 Merge pull request 'Fix white /logs page in firebase mode: emulate offset in adapter' (#6) from white-log-page into main
Reviewed-on: #6
2026-07-06 23:32:37 -04:00
david raistrick c544bc305a Merge branch 'pr-6' into single-source 2026-07-06 23:24:25 -04:00
david raistrick 835a4663f7 Docker build cache: .dockerignore, cache mount, layer reorder, restart script
- .dockerignore: exclude node_modules/.git/tmp/data/logs
- Dockerfile: BuildKit cache mount for npm, --omit=dev at install, drop
  npm prune step
- Reorder layers: shared/src/frontend copy before build, server copy +
  better-sqlite3 rebuild after. Server/shared changes no longer invalidate
  frontend build layer unless shared (FE dep) changes
- Remove duplicate shared COPY in runtime stage (bundled in FE build)
- docker/restart.sh: stop + rebuild + start in one script
2026-07-06 23:22:19 -04:00
david raistrick 9159755846 Scope player card transitions: ring/shadow fast, fade/transform slow
transition-all duration-1000 on PlayerParticipantCard wrapper slowed current-
turn ring/border animation. Turn pointer visibly laggy at 400ms turn intervals.

Fix: scoped transition properties. opacity/transform/filter/border-color = 1s
for fade/death animations. box-shadow = 300ms for current-turn ring highlight,
so turn pointer snaps between participants while fades stay smooth.
2026-07-06 22:48:10 -04:00
david raistrick dbe018825b Merge remote-tracking branch 'upstream/main' into single-source 2026-07-06 22:39:57 -04:00
david raistrick 3b75ec9b3d Animate player display inactive transitions and death state
Player display now tracks displayParticipants separately from raw encounter
participants so active->inactive can animate out before removal, and inactive->
active can animate in. This preserves 1-list order, keeps DM display behavior
unchanged, and avoids fading dying/stable participants.

PlayerParticipantCard handles transitions:
- active->inactive: scale/slide/fade out, then parent removes from display list
- inactive->active: scale/slide/fade in
- alive->dead: keep visible with dim/desaturate/red-rim death cue + skull pulse
- dying/stable: full visible, no fade/removal

DisplayView inactive characterization updated to wait for exit animation before
asserting removal. TODO item removed.
2026-07-06 22:38:33 -04:00
19 changed files with 1560 additions and 245 deletions
@@ -0,0 +1,294 @@
---
name: ttrpg-encounter-builder
description: |
Seeds D&D encounter rosters into the TTRPG Initiative Tracker via its REST API
(no UI clicking). Use when the user asks to "set up encounters", "build
encounters", "add participants to a campaign", "seed this encounter list",
or provides a statblock list (name + HP + init) for a campaign running on a
self-hosted tracker instance running in SERVER MODE (bundled Express
backend). NOT for Firebase SDK mode — that has no HTTP backend; write path
differs entirely. Harness-agnostic: pi, Claude Code, and Codex all run the
same script + curl recipes.
---
# TTRPG Encounter Builder
Build encounter rosters directly via the tracker REST API. No UI clicking, no
browser automation. One batch write per session, then verify.
## When to use
User provides a campaign name + a list of encounters, each with named
participants carrying HP and initiative modifiers (or rolled initiative).
Examples:
- "set up these encounters in the 2026 D&D campaign on bee.icantclick.org:8080"
- "build encounter: Barrowdeep 1 - Ankheg HP 45 Init +0"
- "seed these statblocks into my tracker"
Do NOT use for: starting combat, rolling HP mid-fight, editing live combat
state, or driving the player display. Those are in-combat ops, not build ops.
## Prerequisites
- A running tracker instance in **SERVER MODE** (`REACT_APP_STORAGE=server`,
bundled Express backend mounted). Reachable over HTTP.
- **Firebase SDK mode NOT supported.** No `/api/*` endpoints exist; data
lives in Firestore via the in-browser SDK with different auth + write
paths. This skill cannot reach it. If the user is on firebase mode, tell
them: switch to server mode, or build encounters via the UI / a
firebase-admin script (out of scope here).
- Node.js 18+ (for `fetch` + `crypto.randomUUID`). Helper script uses only
builtins — no `npm install`.
- Campaign must already exist (created via UI or `POST /api/collection`).
This skill adds encounters to an existing campaign.
Confirm host reachable + server mode before building:
```bash
curl -s <HOST>/api/collection?path=campaigns | head -c 200
```
Returns JSON array of campaign docs = server mode confirmed. HTML (SPA shell)
= firebase mode OR backend not mounted. Either way, this skill won't work —
tell the user.
## Data model (read this before writing)
Three nested entities, all opaque JSON docs in a path-keyed KV store:
```
Campaign campaigns/{campaignId}
└─ Encounter campaigns/{campaignId}/encounters/{encounterId}
└─ Participant (inline array item on the encounter doc)
```
**Participants are inline.** There is NO `participants` sub-collection. A
`participants` path returns empty. Each encounter doc carries its full roster
in a `participants[]` array.
### Path normalization (gotcha)
App code uses firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`).
The REST API uses **bare canonical** paths (`campaigns/...`). The adapter
strips the prefix server-side.
- REST / scripts / direct DB → bare: `campaigns/{cid}/encounters/{eid}`
- If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used
the prefixed form. Drop the prefix.
### Encounter doc shape
```js
{
name: 'Barrowdeep 1 - Antechamber',
createdAt: new Date().toISOString(),
participants: [], // inline roster
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
ruleset: '5e',
}
```
Build encounters with `round: 0`, `isStarted: false`. Starting combat is a
separate in-combat op (UI Start button or a later skill).
### Participant object shape
```js
{
id: <uuid>, // crypto.randomUUID(), client-generated
name: 'Ankheg',
type: 'monster', // 'monster' | 'npc' | 'character'
originalCharacterId: null, // set only if type === 'character'
initiative: <int>, // rolled ONCE at add; stored, not re-derived
maxHp: 45,
currentHp: 45, // start at full
conditions: [],
isActive: true,
status: 'conscious',
deathSaveSuccesses: 0, // character/npc only
deathSaveFailures: 0, // character/npc only
}
```
### type field
- `monster` — DM-controlled, dead at 0 HP (auto-inactive). Default.
- `npc` — DM-controlled but keeps death saves (revivable). Use for named
allies/villains the DM runs.
- `character` — player character. Requires `originalCharacterId` linking to a
campaign roster entry. For build-from-statblock, use `monster` or `npc`,
not `character`.
### Initiative semantics
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], rolled fresh per
participant).
- Manual override: set `initiative` directly (pre-rolled values), skip the
roll. `initMod` is NOT stored on the participant — only the final
`initiative` value persists.
- Fixed at add time. Never re-derived after.
### id format
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`).
Generate participant ids client-side so they're stable across batch writes.
Server `POST /api/collection` auto-generates a UUID for the doc id — fine for
campaigns/encounters, but for inline participants you generate them yourself.
## REST endpoints
All paths bare canonical.
| Method | Path | Body / Query | Action |
|---|---|---|---|
| `GET` | `/api/doc` | `?path=` | read one doc |
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
| `GET` | `/api/collection` | `?path=` | list docs under collection |
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
| `POST` | `/api/batch` | `{ops:[{type, path, data?}]}` | atomic multi-write |
`POST /api/batch` is the workhorse for seeding. One batch, all encounters,
atomic.
## Build flow
### 1. Parse the user's request
Extract:
- **Host** — the running tracker URL (e.g. `http://bee.icantclick.org:8080`).
If omitted, ask. Do not guess.
- **Campaign name** — must match an existing campaign doc's `name` field.
- **Encounters** — list of `{name, participants:[{name, type?, maxHp, initMod?|initiative?}]}`.
A statblock line like `Ankheg - HP 45 - Init +0` parses to:
`{name:"Ankheg", type:"monster", maxHp:45, initMod:0}`.
### 2. Resolve the campaign id
Campaigns are keyed by id, not name. Look up by name:
```bash
curl -s "<HOST>/api/collection?path=campaigns" \
| jq -r '.[] | select(.name=="<CAMPAIGN NAME>") | .id'
```
Exactly one match expected. Zero → campaign doesn't exist (tell user to create
it first). Multiple → ambiguous name, ask user which id.
### 3. Write encounters (two options)
#### Option A — helper script (recommended)
`scripts/build-encounters.js` handles id gen, initiative rolling, batch write,
and verification. Reads a spec file or stdin.
Spec format (JSON array):
```json
[
{
"name": "Optional Road/Trail Ankheg",
"participants": [
{ "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 }
]
},
{
"name": "Barrowdeep 2 - Minor Burial Chamber",
"participants": [
{ "name": "Cult Fanatic", "type": "monster", "maxHp": 39, "initMod": 2 },
{ "name": "Cultist 1", "type": "monster", "maxHp": 9, "initMod": 2 },
{ "name": "Cultist 2", "type": "monster", "maxHp": 9, "initMod": 2 }
]
}
]
```
Run (resolve script path relative to this skill dir):
```bash
node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" spec.json
# or stdin:
cat spec.json | node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" -
```
Script prints each encounter id + participant init rolls, then verifies.
#### Option B — raw curl (any harness, no node)
Build the batch payload inline. Generate UUIDs and roll initiative yourself.
One `POST /api/batch` writes everything:
```bash
curl -s -X POST "<HOST>/api/batch" \
-H 'Content-Type: application/json' \
-d '{
"ops": [
{
"type": "set",
"path": "campaigns/CID/encounters/EID1",
"data": {
"name": "Optional Road/Trail Ankheg",
"createdAt": "2026-07-07T00:00:00.000Z",
"participants": [
{ "id": "UUID1", "name": "Ankheg", "type": "monster", "originalCharacterId": null, "initiative": 15, "maxHp": 45, "currentHp": 45, "conditions": [], "isActive": true, "status": "conscious", "deathSaveSuccesses": 0, "deathSaveFailures": 0 }
],
"round": 0, "currentTurnParticipantId": null, "isStarted": false, "isPaused": false, "ruleset": "5e"
}
}
]
}'
```
Response: `{"ok":true}`. If you get an error, check path is bare (no
`artifacts/` prefix) and JSON is valid.
### 4. Verify
Always verify after write. Don't trust the batch `ok` alone.
```bash
curl -s "<HOST>/api/collection?path=campaigns/CID/encounters" \
| jq '.[] | {name, participants: (.participants|length), started: .isStarted}'
```
Expect one object per encounter with correct participant count and
`started: false`.
### 5. Report back to user
Tell the user:
- Host + campaign name resolved
- Per encounter: name, id, participant count, rolled initiatives
- Verification result (counts matched)
- That combat is NOT started (encounters sit at `round: 0, isStarted: false`).
User starts combat via the UI Start button when ready.
## Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| `GET /api/collection?path=artifacts/.../campaigns` returns `[]` | used firebase-prefixed path | drop `artifacts/{APP_ID}/public/data/` prefix, use bare `campaigns` |
| `GET /health` or collection returns HTML (SPA shell) | server in firebase mode, or backend not mounted | confirm `REACT_APP_STORAGE=server` + backend running on the instance |
| Batch returns `ok` but encounter missing | wrong campaign id, or path typo | re-resolve campaign id by name; check path segments |
| Participant count mismatch on verify | participant objects malformed / dropped in JSON | validate each has all required fields; use script to avoid hand-JSON errors |
| Initiative looks wrong | forgot `initiative` is stored value, not re-derived | roll `d20 + initMod` once at build, store the int. No `initMod` field on participant. |
| Duplicate cultist names across encounters | fine — names need only be unique WITHIN an encounter, not across | no fix needed |
## Out of scope
- Creating campaigns (use UI or `POST /api/collection` with a campaign doc)
- Starting combat / advancing turns / HP damage / death saves (in-combat ops)
- Player display control (`activeDisplay/status`)
- Firebase SDK mode (this skill is server-mode REST only)
- Campaign roster characters (`players[]` on campaign doc) — those are
templates; this skill adds inline combatants, not roster entries
## Reference
Full entity model, combat flow, and storage paths: `docs/ENCOUNTER_BUILDER.md`
in the repo root. REST + DB internals: `docs/DEVELOPMENT.md` and
`server/index.js`.
@@ -0,0 +1,149 @@
// build-encounters.js — seed TTRPG encounters via REST API.
// Cross-harness: pi, claude, codex all call same script.
// No deps beyond node builtins. Reads spec JSON, writes batch, verifies.
//
// Usage:
// node build-encounters.js <host> <campaignName> <specFile>
// cat spec.json | node build-encounters.js <host> <campaignName> -
//
// specFile = JSON array of encounters:
// [
// {
// "name": "Barrowdeep 1 - Antechamber",
// "participants": [
// { "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 },
// { "name": "Boss", "type": "npc", "maxHp": 39, "initMod": 2, "initiative": 15 }
// ]
// }
// ]
//
// type: 'monster' | 'npc' | 'character'. Default 'monster'.
// initiative: optional manual override. If omitted, rolled d20 + initMod.
// initMod default 0. maxHp default 10.
//
// Output: written encounter ids + rolled initiatives. Exit 0 on success.
'use strict';
const crypto = require('crypto');
const genId = () =>
(typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
function makeParticipant(spec) {
const type = spec.type || 'monster';
const maxHp = spec.maxHp || 10;
const initMod = spec.initMod || 0;
const manual = spec.initiative !== undefined && spec.initiative !== null;
return {
id: genId(),
name: spec.name,
type,
originalCharacterId: spec.originalCharacterId || null,
initiative: manual ? Number(spec.initiative) : rollD20() + initMod,
maxHp,
currentHp: maxHp,
conditions: [],
isActive: true,
status: 'conscious',
deathSaveSuccesses: 0,
deathSaveFailures: 0,
};
}
async function getJson(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`GET ${url} -> ${r.status}: ${await r.text()}`);
return r.json();
}
async function postJson(url, body) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`POST ${url} -> ${r.status}: ${await r.text()}`);
return r.json();
}
async function findCampaignId(host, name) {
const list = await getJson(`${host}/api/collection?path=campaigns`);
const matches = list.filter(c => c.name === name);
if (matches.length === 0) {
console.error(`No campaign named "${name}". Existing:`);
list.forEach(c => console.error(` ${c.name}`));
process.exit(2);
}
if (matches.length > 1) {
console.error(`Multiple campaigns named "${name}". Use unique name or pass id.`);
matches.forEach(c => console.error(` ${c.id} ${c.name}`));
process.exit(2);
}
return matches[0].id;
}
async function main() {
const [host, campaignName, specArg] = process.argv.slice(2);
if (!host || !campaignName || !specArg) {
console.error('Usage: node build-encounters.js <host> <campaignName> <specFile|->');
process.exit(1);
}
const specRaw = specArg === '-'
? require('fs').readFileSync(0, 'utf8')
: require('fs').readFileSync(specArg, 'utf8');
const encounters = JSON.parse(specRaw);
if (!Array.isArray(encounters)) throw new Error('spec must be a JSON array of encounters');
const cid = await findCampaignId(host, campaignName);
console.log(`campaign: "${campaignName}" -> ${cid}`);
const encPath = `campaigns/${cid}/encounters`;
const now = new Date().toISOString();
const ops = [];
const summary = [];
for (const enc of encounters) {
const id = genId();
const path = `${encPath}/${id}`;
const parts = (enc.participants || []).map(makeParticipant);
ops.push({
type: 'set',
path,
data: {
name: enc.name,
createdAt: now,
participants: parts,
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
ruleset: enc.ruleset || '5e',
},
});
summary.push({ name: enc.name, id, parts });
}
const res = await postJson(`${host}/api/batch`, { ops });
if (!res.ok) throw new Error(`batch failed: ${JSON.stringify(res)}`);
console.log(`batch: ${ops.length} encounter(s) written\n`);
// Verify
const written = await getJson(`${host}/api/collection?path=${encPath}`);
const byId = new Map(written.map(e => [e.id, e]));
for (const s of summary) {
const got = byId.get(s.id);
const ok = got && got.name === s.name && (got.participants || []).length === s.parts.length;
console.log(`${ok ? '✓' : '✗'} ${s.name}`);
console.log(` id: ${s.id}`);
if (!ok) { console.log(` VERIFY FAILED: got ${JSON.stringify(got && { name: got.name, n: (got.participants || []).length })}`); continue; }
s.parts.forEach(p => {
const i = p.initiative >= 0 ? `+${p.initiative}` : `${p.initiative}`;
console.log(` - ${p.name} (${p.type}, hp ${p.maxHp}, init ${i})`);
});
}
console.log('\nDone.');
}
main().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
+10
View File
@@ -0,0 +1,10 @@
node_modules
**/node_modules
.git
tmp
data
*.log
npm-debug.log*
.DS_Store
coverage
.nyc_output
+2 -2
View File
@@ -1,7 +1,7 @@
# Dockerfile # Dockerfile
# Stage 1: Build the React application # Stage 1: Build the React application
FROM node:18-alpine AS build FROM node:22-alpine AS build
LABEL stage="build-local-testing" LABEL stage="build-local-testing"
@@ -48,4 +48,4 @@ COPY nginx-docker.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
# Start Nginx when the container launches # Start Nginx when the container launches
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
Vendored
+172
View File
@@ -0,0 +1,172 @@
// Jenkinsfile — build + publish both images to the thinkserver:5000 registry.
//
// ttrpg-tracker-firebase : root Dockerfile, nginx serving the static SPA,
// Firebase storage (config baked in at build time).
// ttrpg-tracker-sqlite : docker/Dockerfile, Caddy + Node, server/SQLite
// storage (REACT_APP_STORAGE=server).
//
// Requirements on the Jenkins agent:
// * Docker CLI available, agent user in the docker group (or DooD socket).
// * thinkserver:5000 reachable and trusted by the Docker daemon. If it is a
// plain-HTTP registry, add it to /etc/docker/daemon.json on the AGENT host:
// { "insecure-registries": ["thinkserver:5000"] } (then restart docker)
//
// Jenkins credentials this pipeline expects:
// * ttrpg-firebase-env-local (Secret file) — the .env.local contents with
// REACT_APP_FIREBASE_* values. Baked into the firebase image at build time.
// * thinkserver-registry (Username/pw) — OPTIONAL. Only needed if the
// registry requires auth; the login stage is guarded below.
pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
timeout(time: 30, unit: 'MINUTES')
}
parameters {
string(name: 'TRACKER_APP_ID',
defaultValue: 'ttrpg-initiative-tracker-default',
description: 'Firestore/app namespace baked into the SQLite image (REACT_APP_TRACKER_APP_ID).')
booleanParam(name: 'PUSH_LATEST', defaultValue: true,
description: 'Also tag and push :latest in addition to the commit-SHA tag.')
booleanParam(name: 'REGISTRY_AUTH', defaultValue: false,
description: 'Enable docker login using the thinkserver-registry credential.')
booleanParam(name: 'REDEPLOY_PORTAINER', defaultValue: true,
description: 'After pushing, POST to the Portainer stack webhooks to re-pull and redeploy.')
}
environment {
REGISTRY = 'thinkserver:5000'
FIREBASE_IMAGE = "${REGISTRY}/ttrpg-tracker-firebase"
SQLITE_IMAGE = "${REGISTRY}/ttrpg-tracker-sqlite"
DOCKER_BUILDKIT = '1'
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.SHORT_SHA = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
// Only build/push on main. BRANCH_NAME is set by Multibranch jobs; it
// is unset in a plain "Pipeline from SCM" job, which already only ever
// checks out main, so treat unset as main too.
env.IS_MAIN = (env.BRANCH_NAME == null || env.BRANCH_NAME == 'main').toString()
if (env.IS_MAIN != 'true') {
echo "Branch '${env.BRANCH_NAME}' is not main — skipping image build/push."
} else {
echo "Building tag ${env.SHORT_SHA} (build #${env.BUILD_NUMBER})"
}
}
}
}
stage('Registry login') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REGISTRY_AUTH } } }
steps {
withCredentials([usernamePassword(credentialsId: 'thinkserver-registry',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS')]) {
sh 'echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin'
}
}
}
stage('Build firebase image') {
when { expression { env.IS_MAIN == 'true' } }
steps {
// Firebase config is compiled into the static bundle, so it must exist
// at build time. Inject .env.local from the Jenkins secret file, then
// the root Dockerfile's `COPY .env.local .env` picks it up.
withCredentials([file(credentialsId: 'ttrpg-firebase-env-local', variable: 'FIREBASE_ENV')]) {
sh '''
cp "$FIREBASE_ENV" .env.local
docker build \
-f Dockerfile \
-t "$FIREBASE_IMAGE:$SHORT_SHA" \
.
'''
}
}
}
stage('Build sqlite image') {
when { expression { env.IS_MAIN == 'true' } }
steps {
// Server/SQLite build. BuildKit is required (syntax directive + cache
// mount in docker/Dockerfile). Context is the repo root.
sh '''
docker build \
-f docker/Dockerfile \
--build-arg REACT_APP_TRACKER_APP_ID="$TRACKER_APP_ID" \
-t "$SQLITE_IMAGE:$SHORT_SHA" \
.
'''
}
}
stage('Tag latest') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.PUSH_LATEST } } }
steps {
sh '''
docker tag "$FIREBASE_IMAGE:$SHORT_SHA" "$FIREBASE_IMAGE:latest"
docker tag "$SQLITE_IMAGE:$SHORT_SHA" "$SQLITE_IMAGE:latest"
'''
}
}
stage('Push') {
when { expression { env.IS_MAIN == 'true' } }
steps {
sh '''
docker push "$FIREBASE_IMAGE:$SHORT_SHA"
docker push "$SQLITE_IMAGE:$SHORT_SHA"
'''
script {
if (params.PUSH_LATEST) {
sh '''
docker push "$FIREBASE_IMAGE:latest"
docker push "$SQLITE_IMAGE:latest"
'''
}
}
}
}
stage('Redeploy (Portainer webhooks)') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REDEPLOY_PORTAINER } } }
steps {
// Webhook URLs are Secret-text credentials, so they never appear in the
// repo and Jenkins masks them in the console. They are bound to shell
// env vars and referenced inside a single-quoted sh block, so Groovy
// never interpolates the value into the pipeline log.
withCredentials([
string(credentialsId: 'portainer-webhook-firebase', variable: 'WEBHOOK_FIREBASE'),
string(credentialsId: 'portainer-webhook-sqlite', variable: 'WEBHOOK_SQLITE'),
]) {
sh '''
set +x
echo "Triggering Portainer redeploy (firebase stack)"
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_FIREBASE"
echo "Triggering Portainer redeploy (sqlite stack)"
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_SQLITE"
'''
}
}
}
}
post {
always {
// Never leave Firebase secrets in the workspace or log out cleanly.
sh 'rm -f .env.local || true'
sh 'docker logout "$REGISTRY" || true'
}
success {
echo "Pushed ${FIREBASE_IMAGE}:${SHORT_SHA} and ${SQLITE_IMAGE}:${SHORT_SHA}"
}
}
}
+2 -173
View File
@@ -10,17 +10,8 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
not sure good way to do this not sure good way to do this
### npm install warnings cleanup pass
### FEAT: player display fade transitions for inactive state lots of updates
- Inactive is DM-triggered via Mark Inactive.
- Player display should fade inactive participant out, then remove from display list.
- Reactivating should fade participant in.
- DM display keeps inactive participant visible.
- Dead state does not imply inactive/disabled.
- Dying/stable must not fade out or leave layout holes.
- Dead can keep skull/Dead label; create some good visual cues, no removal - but a cool transition to death would be nice.
@@ -59,43 +50,11 @@ not sure good way to do this
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)` - `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
- Returns participant obj (truthy) not boolean. Works by accident, fragile. - 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 ### FEAT: clarify "Is NPC" in add-participant
- Ambiguous label. May expand work based on what NPC means here (ally? monster? - Ambiguous label. May expand work based on what NPC means here (ally? monster?
display-only? skip in turn order?). Clarify intent before UX changes. display-only? skip in turn order?). Clarify intent before UX changes.
### FEAT: first-class undo/redo UI buttons (B --- do now)
- Toolbar buttons ↶/↷ in AdminView header, not buried in /logs.
- Undo = revert latest non-undone log. Redo = re-apply latest undone.
- Uses current 2-write undo (non-tx). Race safety = log refactor later.
- Disabled when stack empty. Keyboard shortcuts (cmd+z / cmd+shift+z).
### FEAT-LOG: unified log refactor (was FEAT-2 + M6, batched)
- Single event schema, one source of truth:
`{ ts, type, payload, undo_payload, undone, encounterId,
snapshot:{ round, currentTurnParticipantId, turnOrderIds, activeIds } }`
- Common format consumed by: UI log view, download/copy export,
replay-combat, analyze-turns. One shape, four consumers.
- Transactional undo: server endpoint `POST /api/undo/:eventId`. Single
SQLite tx applies undo_payload + flips `undone`. Replaces fragile 2-write
(log update + encounter update as separate calls).
- Download/copy: exports event stream as JSON for offline analysis.
- replay-combat + analyze-turns rewritten to emit/consume same event shape.
- Migration: keep old log entries readable; new format for new writes.
### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later) ### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later)
@@ -107,133 +66,3 @@ not sure good way to do this
## FEAT - clarify what end encounter does and what initiatives reset means ## FEAT - clarify what end encounter does and what initiatives reset means
## Done (history) ## Done (history)
### FEAT: first-class undo/redo UI buttons (DONE)
- ↶/↷ pills in InitiativeControls, always visible when encounter open.
- Undo = latest non-undone log (per encounter). Redo = latest undone.
- encounterPath added to all 14 log contexts (filter key).
- redo:patch (forward) added to undoData. Real redo replays forward state.
- Disabled when stack empty. Tooltip shows target action.
- Uses current 2-write undo (non-tx). Race safety = FEAT-LOG refactor.
### Architecture: 1-list turn order model (slot, never sort)
- Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) = same-init tie-break only. Cross-init blocked.
- startEncounter sorts ALL participants by init once, then frozen.
- addParticipant/updateParticipant slot by init (slotIndexForInit), preserve
drag order. Display renders participants[] directly (no sort).
- Static guard test errs if `.sort(` reintroduced outside allowlist.
- Design doc: docs/INITIATIVE_ORDERING.md.
### Single source of truth: combat logic
- All 15 App.js handlers delegate to @ttrpg/shared. ~498 lines inline dupes deleted.
- shared/turn.js = only place turn logic lives.
### Storage parity (firebase + server adapters)
- Neutral queryConstraints ({__type:'orderBy'|'limit'}) honored by both adapters.
- Shared contract test runs both identically. Memory adapter deleted; factory
throws on unknown mode.
- ws storage mode renamed to server (env var + adapter name).
### Logging contract
- Every mutating op logs message + undo payload. No-op = null log.
- Structural enforcement: per-op contract test (turn.logging.test.js) +
static source-scan guard (static.no-unlogged.test.js).
### Custom conditions per campaign (DONE)
- Freeform per-campaign conditions. Add applies to participant + persists to
campaign palette in one step. Badge render uses merged allConditions
(built-ins + custom). toggleCondition accepts any string. Dedup
case-insensitive. maxLength 40. Combat + replay tests prove arbitrary
string ids survive round-trip.
### Death saves: D&D 5e status model (DONE)
- `status` is source of truth: conscious, dying, stable, dead.
- Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP.
- Death-save actions: Success, Fail, Nat1, Nat20, Stabilize.
- Revive: dead → 0 HP, stable/unconscious, active.
- Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive.
- Player display hides inactive participants; DM display keeps them visible.
### FEAT-3: initiative first-class entry (DONE)
- Initiative field at add-char, add-monster, edit participant.
- Inline edit wired. Tie-break = drag order.
### UI feedback: toast + info modal (DONE)
- All 23 native alert() replaced. ToastStack (6s auto-dismiss + manual X)
for transient failures. InfoModal (persistent OK) for validations.
React context provider wraps all 3 App branches.
- Fixed: native alert vanished instantly on browser focus loss.
### Filter dup chars from add-participant dropdown (DONE)
- Character dropdown excludes chars already in encounter. Prevents
dup-add at source. No more dup alert path needed.
### Test timeouts (DONE)
- jest.setTimeout(10000) in setupTests.js (CRA blocks config-level timeout).
### Warning = failure in tests (DONE)
- console.error/warn throw in test env.
### BUG-1: addParticipant + pause/resume corrupts rotation
- RESOLVED as side effect of BUG-2 fix.
### BUG-2: addParticipant allows duplicate id
- FIXED (addParticipant throws on dup id).
### BUG-4: hide-player-HP breaks display view
- FIXED --- mock honors setDoc{merge}, all 5 activeDisplay sites use merge.
### BUG-5: mid-round addParticipant/revive corrupts rotation
- FIXED --- slot-array + DRY advance core nextActiveAfter.
### BUG-6: reorderParticipants doesn't update turnOrderIds
- FIXED structurally by 1-list model.
### BUG-7: reorderParticipants not logged
- FIXED --- returns log:{message, undo}. Handler calls logAction. deathSave,
addParticipants, updateParticipant logging gaps also closed.
### BUG-8: server adapter has no reconnect
- FIXED --- onclose reconnects + re-subscribes existing paths.
### BUG-10: deact+reactivate same round double-acts participant
- FIXED --- 1-list model keeps slot position on toggle. Reactivate does not
grant second turn. Test: turn.bug10.test.js.
### BUG-11: FE Combat.scenario test crashes
- FIXED --- moved to shared/turn.combat.test.js, pure functions, 100 rounds.
### BUG-12: campaign selection follows activeDisplay
- FIXED.
### BUG-13: reorderParticipants crossing current pointer = ambiguous
- FIXED --- block cross-pointer reorder during active encounter (both dirs).
Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double.
Pre-combat: free reorder. Test: turn.bug13.test.js.
### BUG-14: addParticipant init-insertion breaks after drag-reorder
- FIXED --- slotIndexForInit scans current list (post-drag aware).
### BUG-15: DisplayView re-sorts (drag order not preserved)
- FIXED --- display renders participants[] directly.
### BUG-16: subscribeCollection hook drops queryConstraints
- FIXED --- neutral builders, both adapters honor orderBy/limit.
### BUG-17: dead SDK imports in App.js
- FIXED --- trimmed (auth + getFirestore + getStorage remain).
### BUG-18: stale comments reference deleted memory adapter
- FIXED.
### FEAT-1: Dead characters/NPCs stay in turn order
- DONE --- character/NPC death does not auto-remove. DM controls inactive/remove.
- Non-NPC monsters still become inactive automatically at death.
### combat.scenario 100 rounds not turns
- DONE --- loops by actual round-wrap count.
### feat: add all characters to participants list
- DONE --- addParticipants bulk add wired.
+12 -11
View File
@@ -1,34 +1,36 @@
# docker/Dockerfile — single container: caddy (front) + node (back). # syntax=docker/dockerfile:1
# docker/Dockerfile --- single container: caddy (front) + node (back).
# Build context = repo root. # Build context = repo root.
# ---- build stage: frontend + install backend deps ---- # ---- build stage: frontend + install backend deps ----
FROM node:18-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
COPY shared/package.json ./shared/ COPY shared/package.json ./shared/
COPY server/package.json ./server/ COPY server/package.json ./server/
RUN npm install --include-workspace-root RUN --mount=type=cache,target=/root/.npm \
npm install --include-workspace-root --omit=dev --prefer-offline
# frontend code (changes often)
COPY shared/ ./shared/ COPY shared/ ./shared/
COPY server/ ./server/
COPY src/ ./src/ COPY src/ ./src/
COPY public/ ./public/ COPY public/ ./public/
COPY tailwind.config.js postcss.config.js ./ COPY tailwind.config.js postcss.config.js ./
# better-sqlite3 native build (alpine musl)
RUN cd server && npm rebuild better-sqlite3
# build frontend (server storage, same-origin /api + /ws via caddy) # build frontend (server storage, same-origin /api + /ws via caddy)
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
ENV REACT_APP_STORAGE=server ENV REACT_APP_STORAGE=server
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
# prune backend dev deps for runtime # server after backend - it's fast. so changing server only doesnt invalidate slow frontend.
RUN npm prune --omit=dev COPY server/ ./server/
# better-sqlite3 native build (alpine musl). Cached if server unchanged.
RUN cd server && npm rebuild better-sqlite3
# ---- runtime stage: caddy + node ---- # ---- runtime stage: caddy + node ----
FROM node:18-alpine FROM node:22-alpine
RUN apk add --no-cache caddy RUN apk add --no-cache caddy
WORKDIR /app WORKDIR /app
@@ -38,7 +40,6 @@ COPY --from=build /app/server/node_modules ./server/node_modules
COPY --from=build /app/package*.json ./ COPY --from=build /app/package*.json ./
COPY --from=build /app/shared/package.json ./shared/ COPY --from=build /app/shared/package.json ./shared/
COPY --from=build /app/server/package.json ./server/ COPY --from=build /app/server/package.json ./server/
COPY shared/ ./shared/
COPY server/ ./server/ COPY server/ ./server/
# built frontend served by caddy # built frontend served by caddy
COPY --from=build /app/build /srv COPY --from=build /app/build /srv
+27
View File
@@ -0,0 +1,27 @@
# docker/portainer-stack.yml — Portainer stack for the SQLite/server build.
# Deploys the prebuilt image from the local registry (built + pushed by Jenkins).
# Paste into Portainer's web editor (Stacks -> Add stack -> Web editor).
#
# TLS is terminated by the external nginx, which should reverse_proxy to
# http://<docker-host>:8080 (and pass the WebSocket upgrade headers for /ws).
services:
app:
image: thinkserver:5000/ttrpg-tracker-sqlite:latest
container_name: ttrpg-tracker
ports:
- "8080:80" # host:container — nginx upstream is :8080
volumes:
- app-data:/data # SQLite DB lives here; back this up
environment:
- DB_PATH=/data/tracker.sqlite
- PORT=4001 # node backend port (internal; caddy proxies to it)
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
app-data:
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# scripts/docker-restart.sh — rebuild + restart docker container.
# Usage: ./scripts/docker-restart.sh
set -euo pipefail
cd "$(dirname "$0")/.."
echo "=== stopping existing container ==="
docker compose -f docker/docker-compose.yml down 2>/dev/null || true
echo "=== rebuilding image ==="
docker compose -f docker/docker-compose.yml build
echo "=== starting container ==="
docker compose -f docker/docker-compose.yml up -d
echo "=== status ==="
docker compose -f docker/docker-compose.yml ps
echo ""
echo "app: http://localhost:${PORT:-8080}"
echo "logs: docker compose -f docker/docker-compose.yml logs -f"
+108
View File
@@ -64,6 +64,15 @@ Object in `encounter.participants[]`:
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes | | `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
| `deathSaveFailures` | int | character/NPC only, 0-3 failures | | `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) ## Build flow (UI)
Admin view at `/`. Steps: 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. During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
## Build flow (API / scripts)
Same logical steps as UI, via REST. For LLM agents or seed scripts.
**Server mode only.** REST endpoints (`/api/*`) exist only when tracker runs
with `REACT_APP_STORAGE=server` (bundled Express backend mounted). Firebase
SDK mode has no HTTP backend — data lives in Firestore via in-browser SDK,
different auth + write paths. This section does not apply to firebase mode.
Confirm server mode before scripting:
```bash
curl -s <HOST>/api/collection?path=campaigns | head -c 200
```
JSON array = server mode. HTML (SPA shell) = firebase mode or backend not
mounted — API path won't work.
### REST endpoints
| Method | Path | Body / Query | Action |
|---|---|---|---|
| `GET` | `/api/doc` | `?path=` | read one doc |
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
| `GET` | `/api/collection` | `?path=` | list docs under collection |
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
| `POST` | `/api/batch` | `{ops:[{type:'set'\|'update'\|'delete', path, data?}]}` | atomic multi-write |
All paths bare canonical (no `artifacts/...` prefix).
### id format
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`). Use either. Server `POST /api/collection` generates its own UUID — fine for campaigns/encounters. Participants are inline array items; generate their ids client-side so they're stable across batch writes.
### Object templates
Encounter doc (full, for `PUT` / batch `set`):
```js
{
name: 'Barrowdeep 1 - Antechamber',
createdAt: new Date().toISOString(),
participants: [], // inline, NOT a sub-collection
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
ruleset: '5e',
}
```
Participant object (item in `encounter.participants[]`):
```js
{
id: generateId(),
name: 'Ankheg',
type: 'monster', // 'character' | 'npc' | 'monster'
originalCharacterId: null, // set only if type==='character'
initiative: rollD20() + initMod, // rolled ONCE at add; stored, not re-derived
maxHp: 45,
currentHp: 45, // start at full
conditions: [],
isActive: true,
status: 'conscious',
deathSaveSuccesses: 0, // character/npc only
deathSaveFailures: 0, // character/npc only
}
```
### Initiative semantics
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], roll fresh per participant).
- Manual override (e.g. pre-rolled values): set `initiative` directly, skip the roll.
- Fixed at add time. NOT re-derived from `initMod` after — `initMod` is not stored on participant.
### Recipe: build encounters for a campaign
1. **Find campaign id by name:**
```bash
curl -s "$HOST/api/collection?path=campaigns" \
| jq -r '.[] | select(.name=="2026 D&D") | .id'
```
2. **Build encounter docs + participants client-side** (object templates above), roll initiative per participant.
3. **Write all encounters in one batch:**
```bash
curl -s -X POST "$HOST/api/batch" \
-H 'Content-Type: application/json' \
-d '{"ops":[{"type":"set","path":"campaigns/CID/encounters/EID1","data":{...}},{"type":"set","path":"campaigns/CID/encounters/EID2","data":{...}}]}'
```
4. **Verify:**
```bash
curl -s "$HOST/api/collection?path=campaigns/CID/encounters" | jq '.[] | {name, count: (.participants|length)}'
```
Participants go inline in the encounter doc — there is no `participants` sub-collection. A single batch `set` per encounter writes the whole roster atomically.
## Combat flow (UI) ## Combat flow (UI)
InitiativeControls panel (sticky, right side). InitiativeControls panel (sticky, right side).
@@ -198,6 +302,8 @@ No re-sort after `startEncounter`.
## Storage paths quick reference ## 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} campaign doc
campaigns/{cid}/encounters/{eid} encounter doc (participants[]) campaigns/{cid}/encounters/{eid} encounter doc (participants[])
@@ -206,6 +312,8 @@ activeDisplay/status player display control
logs/{logId} action log entry 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 ## DM tips
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override. - Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
+27
View File
@@ -0,0 +1,27 @@
{
"short_name": "TTRPG Display",
"name": "TTRPG Initiative Tracker — Player Display",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/display",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"theme_color": "#1A202C",
"background_color": "#1A202C"
}
+1
View File
@@ -1,6 +1,7 @@
// server/index.js — generic KV document store over HTTP + WebSocket. // server/index.js — generic KV document store over HTTP + WebSocket.
// firebase mirror: doc-tree model. Thin REST, path-based WS push. // firebase mirror: doc-tree model. Thin REST, path-based WS push.
// Adapter (src/storage/server.js) = passthrough, no shape translation. // Adapter (src/storage/server.js) = passthrough, no shape translation.
// TEST: cache layer rebuild check.
'use strict'; 'use strict';
+49
View File
@@ -0,0 +1,49 @@
// addParticipants (bulk) must slot by initiative, not append.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { buildCharacterParticipant, buildMonsterParticipant, addParticipants, makeParticipant } = shared;
function enc(ps = []) {
return { name:'t', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [] };
}
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 10, currentHp: 10 });
}
describe('addParticipants slot order', () => {
test('bulk add slots by initiative desc', async () => {
const { ctx } = mockCtx();
let e = enc([]);
e = await addParticipants(e, [p('a', 5), p('b', 20), p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
});
test('bulk add preserves existing order + slots new', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 20), p('b', 5)]);
e = await addParticipants(e, [p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['a', 'c', 'b']);
});
test('bulk add same-init appends after existing (stable tie)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 10)]);
e = await addParticipants(e, [p('b', 10), p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['a', 'b', 'c']);
});
test('bulk add mixed inits into existing list', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 15), p('b', 5)]);
e = await addParticipants(e, [p('c', 20), p('d', 10), p('e', 15)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['c', 'a', 'e', 'd', 'b']);
});
test('bulk add updates turnOrderIds to match', async () => {
const { ctx } = mockCtx();
let e = enc([]);
e = await addParticipants(e, [p('a', 5), p('b', 20)], ctx);
expect(e.turnOrderIds).toEqual(['b', 'a']);
});
});
+185
View File
@@ -0,0 +1,185 @@
// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, applyHpChange, deathSave, markDead, reviveParticipant,
} = shared;
function char(id, hp = 100) {
const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 });
participant.id = id;
return participant;
}
function mon(id, hp = 100) {
const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 });
participant.id = id;
return participant;
}
function enc(ps, ruleset = 'generic') {
return {
name: 't', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
ruleset,
};
}
const snap = (e) => JSON.parse(JSON.stringify(e));
describe('generic ruleset', () => {
test('damage past 0 = negative HP, status down (character)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(-5);
expect(p.status).toBe('down');
});
test('damage exactly to 0 = status down', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(0);
expect(p.status).toBe('down');
});
test('heal from negative = conscious', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(5);
expect(p.status).toBe('conscious');
});
test('heal caps at maxHp', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(10);
expect(p.status).toBe('conscious');
});
test('monster death = auto-inactive (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx);
const p = newEnc.participants.find(x => x.id.startsWith('m'));
expect(p.currentHp).toBe(-10);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('monster at exactly 0 = dead + inactive (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id.startsWith('m'));
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('deathSave throws in generic mode', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow();
});
test('revive in generic mode: dead→conscious (no stable)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
e = await markDead(e, 'a', ctx); // dead
const newEnc = await reviveParticipant(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('conscious');
expect(p.currentHp).toBe(0); // HP unchanged from down
});
test('revive generic reactivates if inactive', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
e = await markDead(e, e.participants[0].id, ctx); // dead+inactive
const newEnc = await reviveParticipant(e, e.participants[0].id, ctx);
const p = newEnc.participants[0];
expect(p.status).toBe('conscious');
expect(p.isActive).toBe(true);
});
test('markDead sets status dead (character, stays active)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
const newEnc = await markDead(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dead');
expect(p.isActive).toBe(true);
});
test('markDead on monster = dead + inactive', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp)
const newEnc = await markDead(e, e.participants[0].id, ctx);
const p = newEnc.participants[0];
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('markDead on conscious = dead', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await markDead(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dead');
});
test('damage while down = more negative (no death saves)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(-10);
expect(p.status).toBe('down');
});
test('no unconscious condition auto-added (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.conditions).not.toContain('unconscious');
});
test('down→conscious clears dead? no, dead is separate', async () => {
// sanity: down heal to conscious, dead untouched
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('conscious');
});
});
+7 -1
View File
@@ -29,7 +29,13 @@ function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false, return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] }; 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', () => { describe('undo + redo roundtrip', () => {
test('startEncounter', async () => { test('startEncounter', async () => {
+141 -10
View File
@@ -102,6 +102,8 @@ function snapshotOf(enc) {
currentTurnParticipantId: enc.currentTurnParticipantId ?? null, currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
turnOrderIds: [...(enc.turnOrderIds || [])], turnOrderIds: [...(enc.turnOrderIds || [])],
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id), activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
startedAt: enc.startedAt ?? null,
endedAt: enc.endedAt ?? null,
}; };
} }
@@ -207,6 +209,7 @@ function expandUndo(entry, currentEnc) {
case 'death_save': case 'death_save':
case 'stabilize': case 'stabilize':
case 'revive': case 'revive':
case 'mark_dead':
case 'deactivate_dead_monster': { case 'deactivate_dead_monster': {
// Full death-state restore: currentHp, status, death-save counters, // Full death-state restore: currentHp, status, death-save counters,
// isActive, conditions (unconscious added/removed by withDeathStatusConditions). // isActive, conditions (unconscious added/removed by withDeathStatusConditions).
@@ -304,12 +307,18 @@ function expandUndo(entry, currentEnc) {
redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] }, 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': { case 'end_encounter': {
return { return {
encounterPath: encPath, encounterPath: encPath,
updates: u, updates: { ...u, endedAt: u.endedAt ?? null },
redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] }, redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() },
}; };
} }
default: default:
@@ -390,6 +399,8 @@ async function startEncounter(encounter, ctx) {
participants: sortedParticipants, participants: sortedParticipants,
currentTurnParticipantId: firstActive.id, currentTurnParticipantId: firstActive.id,
turnOrderIds: sortedParticipants.map(p => p.id), turnOrderIds: sortedParticipants.map(p => p.id),
startedAt: Date.now(),
endedAt: null,
}; };
const log = { const log = {
type: 'start_encounter', type: 'start_encounter',
@@ -402,6 +413,8 @@ async function startEncounter(encounter, ctx) {
round: encounter.round ?? 0, round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])], turnOrderIds: [...(encounter.turnOrderIds || [])],
startedAt: encounter.startedAt ?? null,
endedAt: encounter.endedAt ?? null,
}, },
}; };
return commit(encounter, patch, log, ctx); return commit(encounter, patch, log, ctx);
@@ -478,14 +491,20 @@ async function addParticipant(encounter, participant, ctx) {
} }
async function addParticipants(encounter, newParticipants, 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 names = newParticipants.map(p => p.name).join(', ');
const patch = { participants: updatedParticipants }; const patch = { participants: base, ...syncTurnOrder(base) };
const log = { const log = {
type: 'add_participants', type: 'add_participants',
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`, message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
delta: { count: newParticipants.length }, delta: { count: newParticipants.length },
undo: { participants: newParticipants }, undo: { participants: newParticipants, newTurnOrderIds: patch.turnOrderIds },
}; };
return commit(encounter, patch, log, ctx); return commit(encounter, patch, log, ctx);
} }
@@ -574,6 +593,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) { async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
const ctx = maybeCtx || optionsOrCtx; const ctx = maybeCtx || optionsOrCtx;
const options = maybeCtx ? (optionsOrCtx || {}) : {}; const options = maybeCtx ? (optionsOrCtx || {}) : {};
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId); const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.'); if (!participant) throw new Error('Participant not found.');
if (isNaN(amount) || amount === 0) return encounter; if (isNaN(amount) || amount === 0) return encounter;
@@ -596,6 +616,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
let logDelta = { amount, from: participant.currentHp }; let logDelta = { amount, from: participant.currentHp };
const status = oldValues.status; const status = oldValues.status;
// 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') { if (changeType === 'damage') {
if (participant.currentHp === 0) { if (participant.currentHp === 0) {
if (status === 'dead') { if (status === 'dead') {
@@ -683,7 +709,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
return commit(encounter, patch, log, ctx); 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 };
if (changeType === 'damage') {
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 };
} else if (newHp <= 0) {
updates = { currentHp: newHp, status: 'down' };
} else {
updates = { currentHp: newHp, status: 'conscious' };
}
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) { 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); const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.'); if (!participant) throw new Error('Participant not found.');
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -764,6 +859,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
} }
async function stabilizeParticipant(encounter, participantId, 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); const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.'); if (!participant) throw new Error('Participant not found.');
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -806,12 +904,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
} }
async function reviveParticipant(encounter, participantId, ctx) { async function reviveParticipant(encounter, participantId, ctx) {
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId); const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.'); if (!participant) throw new Error('Participant not found.');
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
if (status !== 'dead') return encounter; 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 => const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
); );
@@ -845,6 +948,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
return commit(encounter, patch, log, 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) { async function reorderParticipants(encounter, draggedId, targetId, ctx) {
const participants = [...(encounter.participants || [])]; const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId); const dragged = participants.find(p => p.id === draggedId);
@@ -884,12 +1015,12 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
} }
async function endEncounter(encounter, 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 = { const log = {
type: 'end_encounter', type: 'end_encounter',
message: `Combat ended: "${encounter.name}"`, message: `Combat ended: "${encounter.name}"`,
delta: {}, 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 },
}; };
return commit(encounter, patch, log, ctx); return commit(encounter, patch, log, ctx);
} }
@@ -919,7 +1050,7 @@ module.exports = {
makeParticipant, buildCharacterParticipant, buildMonsterParticipant, makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant, addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition, toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
reorderParticipants, endEncounter, reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp, activateDisplay, clearDisplay, toggleHidePlayerHp,
}; };
+347 -43
View File
@@ -5,7 +5,7 @@ import {
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, 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, Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
Undo2, Redo2, Crosshair Undo2, Redo2, Crosshair
} from 'lucide-react'; } from 'lucide-react';
@@ -125,6 +125,7 @@ const {
deathSave: combatDeathSave, deathSave: combatDeathSave,
stabilizeParticipant, stabilizeParticipant,
reviveParticipant, reviveParticipant,
markDead,
toggleCondition: combatToggleCondition, toggleCondition: combatToggleCondition,
reorderParticipants, reorderParticipants,
} = shared; } = shared;
@@ -448,11 +449,12 @@ function ErrorDisplay({ message, critical = false }) {
function CreateCampaignForm({ onCreate, onCancel }) { function CreateCampaignForm({ onCreate, onCancel }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [backgroundUrl, setBackgroundUrl] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState('');
const [ruleset, setRuleset] = useState('5e');
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
if (name.trim()) { if (name.trim()) {
onCreate(name, backgroundUrl); onCreate(name, backgroundUrl, ruleset);
} }
}; };
@@ -484,6 +486,19 @@ 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" 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"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
D&D 5e (death saves)
</label>
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
Generic (no death saves, negative HP)
</label>
</div>
</div>
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<button <button
type="button" type="button"
@@ -503,13 +518,14 @@ function CreateCampaignForm({ onCreate, onCancel }) {
); );
} }
function CreateEncounterForm({ onCreate, onCancel }) { function CreateEncounterForm({ onCreate, onCancel, defaultRuleset = '5e' }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [ruleset, setRuleset] = useState(defaultRuleset);
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
if (name.trim()) { if (name.trim()) {
onCreate(name); onCreate(name, ruleset);
} }
}; };
@@ -528,6 +544,19 @@ function CreateEncounterForm({ onCreate, onCancel }) {
required required
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">Ruleset</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="5e" checked={ruleset === '5e'} onChange={() => setRuleset('5e')} />
D&D 5e (death saves)
</label>
<label className="flex items-center gap-2 text-sm text-stone-200">
<input type="radio" value="generic" checked={ruleset === 'generic'} onChange={() => setRuleset('generic')} />
Generic (no death saves, negative HP)
</label>
</div>
</div>
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<button <button
type="button" type="button"
@@ -653,7 +682,30 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const [editingCharacter, setEditingCharacter] = useState(null); const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
const [isOpen, setIsOpen] = useState(true); const [draggedCharId, setDraggedCharId] = useState(null);
const handleCharDragStart = (e, id) => { 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 () => { const handleAddCharacter = async () => {
if (!db || !characterName.trim() || !campaignId) return; if (!db || !characterName.trim() || !campaignId) return;
@@ -740,15 +792,14 @@ function CharacterManager({ campaignId, campaignCharacters }) {
<> <>
<div className="p-4 bg-stone-900 rounded-lg shadow"> <div className="p-4 bg-stone-900 rounded-lg shadow">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<h3 className="text-xl font-semibold text-amber-300 font-cinzel tracking-wide flex items-center">
<Users size={24} className="mr-2" /> Campaign Characters
</h3>
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="p-1 text-stone-400 hover:text-stone-200" className="flex items-center text-xl font-semibold text-amber-300 font-cinzel tracking-wide hover:text-amber-200 transition-colors"
aria-label={isOpen ? "Collapse" : "Expand"} aria-expanded={isOpen}
> >
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />} {isOpen ? <ChevronDown size={20} className="mr-1" /> : <ChevronRight size={20} className="mr-1" />}
<Users size={24} className="mr-2" /> Campaign Characters
<span className="text-sm font-normal text-stone-400 ml-1">({campaignCharacters.length})</span>
</button> </button>
</div> </div>
@@ -806,7 +857,17 @@ function CharacterManager({ campaignId, campaignCharacters }) {
<ul className="space-y-2"> <ul className="space-y-2">
{campaignCharacters.map(character => ( {campaignCharacters.map(character => (
<li key={character.id} className="flex justify-between items-center p-3 bg-stone-800 rounded-md"> <li
key={character.id}
draggable
onDragStart={(e) => 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' : ''}`}
>
<div className="flex items-center flex-grow">
<ChevronsUpDown size={16} className="text-stone-400 flex-shrink-0 mr-2" title="Drag to reorder" />
{editingCharacter && editingCharacter.id === character.id ? ( {editingCharacter && editingCharacter.id === character.id ? (
<form <form
onSubmit={(e) => { onSubmit={(e) => {
@@ -882,6 +943,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
</div> </div>
</> </>
)} )}
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -1153,6 +1215,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) => { const handleCritDamage = async (participantId) => {
if (!db) return; if (!db) return;
try { try {
@@ -1414,14 +1485,18 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId; const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative)); const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character'); 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]'; let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600'; if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'); const participantStatus = p.status || (isGeneric
const isZeroHp = p.currentHp === 0; ? (p.currentHp <= 0 ? 'down' : 'conscious')
: (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious'));
const isZeroHp = p.currentHp <= 0;
const isDead = participantStatus === 'dead'; const isDead = participantStatus === 'dead';
const isDown = participantStatus === 'down';
return ( return (
<li <li
@@ -1452,6 +1527,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
)} )}
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>} {hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>} {hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
{isDown && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Down)</span>}
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>} {isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
</p> </p>
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}> <div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
@@ -1477,11 +1553,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
</div> </div>
{participantStatus === 'dead' && ( {participantStatus === 'dead' && (
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive to 0 HP, stable and unconscious"> <button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
<HeartPulse size={14} /> Revive <HeartPulse size={14} /> Revive
</button> </button>
)} )}
{isGeneric && !isDead && (
<button onClick={() => handleMarkDead(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-red-900 hover:bg-red-800 text-white" title="Mark dead">
<HeartCrack size={14} /> Mark Dead
</button>
)}
{hasDeathSaves && participantStatus === 'stable' && ( {hasDeathSaves && participantStatus === 'stable' && (
<div className="mt-2 text-xs text-emerald-300/80 italic">Stable regains 1 HP after 1d4 hours</div> <div className="mt-2 text-xs text-emerald-300/80 italic">Stable regains 1 HP after 1d4 hours</div>
)} )}
@@ -1944,18 +2026,20 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
// ENCOUNTER MANAGER COMPONENT // ENCOUNTER MANAGER COMPONENT
// ============================================================================ // ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) { function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
const { showToast } = useUIFeedback(); const { showToast } = useUIFeedback();
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection( const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null campaignId ? getPath.encounters(campaignId) : null
); );
const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay()); const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay());
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
const [encounters, setEncounters] = useState([]); const [encounters, setEncounters] = useState([]);
const [selectedEncounterId, setSelectedEncounterId] = useState(null); const [selectedEncounterId, setSelectedEncounterId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
const selectedEncounterIdRef = useRef(selectedEncounterId); const selectedEncounterIdRef = useRef(selectedEncounterId);
@@ -1963,6 +2047,45 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
if (encountersData) setEncounters(encountersData); if (encountersData) setEncounters(encountersData);
}, [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(() => { useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId; selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]); }, [selectedEncounterId]);
@@ -1992,7 +2115,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
} }
}, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]); }, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]);
const handleCreateEncounter = async (name) => { const handleCreateEncounter = async (name, ruleset = '5e') => {
if (!db || !name.trim() || !campaignId) return; if (!db || !name.trim() || !campaignId) return;
const newEncounterId = generateId(); const newEncounterId = generateId();
@@ -2005,7 +2128,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
round: 0, round: 0,
currentTurnParticipantId: null, currentTurnParticipantId: null,
isStarted: false, isStarted: false,
isPaused: false isPaused: false,
ruleset: ruleset || '5e',
order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1,
}); });
setShowCreateModal(false); setShowCreateModal(false);
@@ -2079,6 +2204,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId); 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) { if (isLoadingEncounters && campaignId) {
return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>; return <p className="text-center text-stone-300 mt-4">Loading encounters...</p>;
} }
@@ -2103,7 +2237,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
)} )}
<div className="space-y-3"> <div className="space-y-3">
{encounters?.map(encounter => { {sortedEncounters.map(encounter => {
const isLive = activeDisplayInfo && const isLive = activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeCampaignId === campaignId &&
activeDisplayInfo.activeEncounterId === encounter.id; activeDisplayInfo.activeEncounterId === encounter.id;
@@ -2111,14 +2245,24 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
return ( return (
<div <div
key={encounter.id} key={encounter.id}
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' : ''}`} draggable
onDragStart={(e) => 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' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`}
> >
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow"> <div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
<h4 className="font-medium text-white">{encounter.name}</h4> <h4 className="font-medium text-white">{encounter.name} <span className={`ml-1 px-1.5 py-0.5 rounded text-xs font-bold tracking-wide ${encounter.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}>{encounter.ruleset === 'generic' ? 'GEN' : '5e'}</span></h4>
<p className="text-xs text-stone-300"> <p className="text-xs text-stone-300">
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}
</p> </p>
{(encounter.startedAt || encounter.endedAt) && (
<p className="text-xs text-green-400/80 mt-0.5">
{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 })}`}
</p>
)}
{isLive && ( {isLive && (
<span className="text-xs text-green-400 font-semibold block mt-1"> <span className="text-xs text-green-400 font-semibold block mt-1">
LIVE ON PLAYER DISPLAY LIVE ON PLAYER DISPLAY
@@ -2126,6 +2270,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
)} )}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<ChevronsUpDown
size={18}
className="text-stone-400 flex-shrink-0 cursor-grab"
title="Drag to reorder"
/>
<button <button
onClick={() => handleTogglePlayerDisplay(encounter.id)} onClick={() => handleTogglePlayerDisplay(encounter.id)}
className={`p-1 rounded transition-colors ${isLive ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-amber-400 hover:text-amber-300 bg-stone-700 hover:bg-stone-600'}`} className={`p-1 rounded transition-colors ${isLive ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-amber-400 hover:text-amber-300 bg-stone-700 hover:bg-stone-600'}`}
@@ -2153,8 +2302,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{showCreateModal && ( {showCreateModal && (
<Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter"> <Modal onClose={() => setShowCreateModal(false)} title="Create New Encounter">
<CreateEncounterForm <CreateEncounterForm
key={campaignId}
onCreate={handleCreateEncounter} onCreate={handleCreateEncounter}
onCancel={() => setShowCreateModal(false)} onCancel={() => setShowCreateModal(false)}
defaultRuleset={campaignDoc?.ruleset || '5e'}
/> />
</Modal> </Modal>
)} )}
@@ -2211,7 +2362,21 @@ function AdminView({ userId }) {
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay()); const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]); const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
const [selectedCampaignId, setSelectedCampaignId] = useState(null); const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const encounterStartedRef = useRef(false);
const encounterActiveRef = useRef(false);
const manualSelectRef = useRef(false);
const prevDisplayCampaignRef = useRef(null);
// 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 [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
@@ -2244,8 +2409,12 @@ function AdminView({ userId }) {
}) })
); );
// newest first by createdAt (fallback to name for stable order). // sort by order field (fallback createdAt)
detailedCampaigns.sort((a, b) => { 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 at = a.createdAt || 0;
const bt = b.createdAt || 0; const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || ''); if (at === bt) return (a.name || '').localeCompare(b.name || '');
@@ -2258,6 +2427,10 @@ function AdminView({ userId }) {
fetchDetails(); fetchDetails();
} else if (campaignsData) { } else if (campaignsData) {
const sorted = [...campaignsData].sort((a, b) => { 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 at = a.createdAt || 0;
const bt = b.createdAt || 0; const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || ''); if (at === bt) return (a.name || '').localeCompare(b.name || '');
@@ -2271,10 +2444,15 @@ function AdminView({ userId }) {
}, [campaignsData]); }, [campaignsData]);
useEffect(() => { 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 ( if (
initialActiveInfo && initialActiveInfo &&
initialActiveInfo.activeCampaignId && initialActiveInfo.activeCampaignId &&
campaignsWithDetails.length > 0 campaignsWithDetails.length > 0 &&
!encounterStartedRef.current &&
!encounterActiveRef.current
) { ) {
const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId); const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId);
if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) { if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) {
@@ -2283,7 +2461,7 @@ function AdminView({ userId }) {
} }
}, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]); }, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]);
const handleCreateCampaign = async (name, backgroundUrl) => { const handleCreateCampaign = async (name, backgroundUrl, ruleset = '5e') => {
if (!db || !name.trim()) return; if (!db || !name.trim()) return;
const newCampaignId = generateId(); const newCampaignId = generateId();
@@ -2295,6 +2473,8 @@ function AdminView({ userId }) {
ownerId: userId, ownerId: userId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
players: [], players: [],
ruleset: ruleset || '5e',
order: (campaignsWithDetails || []).reduce((max, c) => Math.max(max, c.order ?? -1), -1) + 1,
}); });
setShowCreateModal(false); setShowCreateModal(false);
@@ -2441,12 +2621,36 @@ function AdminView({ userId }) {
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` } ? { 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 ( return (
<div <div
key={campaign.id} key={campaign.id}
onClick={() => 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} className={cardClasses}
style={cardStyle} style={cardStyle}
> >
@@ -2454,7 +2658,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'}`} className={`relative z-10 flex flex-col justify-between h-full ${campaign.playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4'}`}
> >
<div> <div>
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3> <div className="flex items-center gap-1">
<ChevronsUpDown size={14} className="text-white/60 flex-shrink-0" title="Drag to reorder" />
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
</div>
<div className="text-xs text-stone-100 mt-1 space-x-3"> <div className="text-xs text-stone-100 mt-1 space-x-3">
<span className="inline-flex items-center"> <span className="inline-flex items-center">
<Users size={12} className="mr-1" /> {campaign.characters?.length || 0} Characters <Users size={12} className="mr-1" /> {campaign.characters?.length || 0} Characters
@@ -2478,6 +2685,12 @@ function AdminView({ userId }) {
> >
<Trash2 size={14} className="mr-1" /> Delete <Trash2 size={14} className="mr-1" /> Delete
</button> </button>
<span
className={`absolute bottom-2 right-2 z-20 px-2 py-0.5 rounded text-xs font-bold tracking-wide ${campaign.ruleset === 'generic' ? 'bg-purple-900/80 text-purple-200 border border-purple-500' : 'bg-amber-900/80 text-amber-200 border border-amber-500'}`}
title={campaign.ruleset === 'generic' ? 'Generic ruleset' : 'D&D 5e ruleset'}
>
{campaign.ruleset === 'generic' ? 'GEN' : '5e'}
</span>
</div> </div>
</div> </div>
); );
@@ -2514,6 +2727,8 @@ function AdminView({ userId }) {
: null : null
} }
campaignCharacters={selectedCampaign.characters || []} campaignCharacters={selectedCampaign.characters || []}
encounterStartedRef={encounterStartedRef}
encounterActiveRef={encounterActiveRef}
/> />
</div> </div>
)} )}
@@ -2554,6 +2769,61 @@ function AdminView({ userId }) {
// DISPLAY VIEW COMPONENT (Player View) // DISPLAY VIEW COMPONENT (Player View)
// ============================================================================ // ============================================================================
// Player participant card: animate state transitions.
// mount/activate : animate in (opacity+scale+slide)
// deactivate : animate out then signal exit
// alive→dead : transition to death cue (dim+desaturate+skull pulse+red rim)
// conscious/dying/stable: full visible, no fade
function PlayerParticipantCard({ id, isActive, status, onExit, className, children, divRef }) {
// phase: 'hidden' (opacity-0, start state) -> 'visible' (opacity-100)
// disable: 'visible' -> 'exiting' (opacity-0, transition plays) -> onExit
const [phase, setPhase] = useState('hidden');
const isDead = status === 'dead';
useEffect(() => {
if (!isActive) {
// ensure current 'visible' frame painted, THEN flip to exiting so
// browser sees opacity change and plays the fade-out.
const raf = requestAnimationFrame(() => {
setPhase('exiting');
});
const t = setTimeout(() => onExit(id), 1000);
return () => { cancelAnimationFrame(raf); clearTimeout(t); };
}
// enable/reactivate: paint hidden frame first, THEN flip to visible
// so browser sees opacity change and plays the transition.
setPhase('hidden');
const raf = requestAnimationFrame(() => {
requestAnimationFrame(() => setPhase('visible'));
});
return () => cancelAnimationFrame(raf);
}, [isActive, id, onExit]);
// animate: opacity + scale + slide. Visible motion, not just fade.
const fadeClass = phase === 'visible'
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-75 translate-y-4';
// death transition: fires once when alive→dead. dim + desaturate + red rim.
const deathClass = isDead
? 'brightness-50 saturate-0 border-2 border-red-900/70 shadow-red-900/50 shadow-2xl'
: '';
return (
<div
ref={divRef}
className={`[transition:opacity_1s_ease-in-out,transform_1s_ease-in-out,filter_1s_ease-in-out,border-color_1s_ease-in-out,box-shadow_300ms_ease-in-out] ${fadeClass} ${deathClass} ${className}`}
>
{children}
{isDead && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="text-6xl opacity-80 animate-pulse"></span>
</div>
)}
</div>
);
}
function DisplayView() { function DisplayView() {
const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument( const { data: activeDisplayData, isLoading: isLoadingActiveDisplay, error: activeDisplayError } = useFirestoreDocument(
getPath.activeDisplay() getPath.activeDisplay()
@@ -2566,9 +2836,37 @@ function DisplayView() {
const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [wakeLockEnabled, setWakeLockEnabled] = useState(false); const [wakeLockEnabled, setWakeLockEnabled] = useState(false);
const [displayParticipants, setDisplayParticipants] = useState([]);
const wakeLockRef = useRef(null); const wakeLockRef = useRef(null);
const currentParticipantRef = useRef(null); const currentParticipantRef = useRef(null);
// Player display transition state. Active participants render normally.
// Active→inactive: keep prior card, mark __displayActive=false, animate out,
// then PlayerParticipantCard calls handleExit to remove from display list.
// Inactive→active: card reappears with __displayActive=true and fades in.
useEffect(() => {
const source = (activeEncounterData && activeEncounterData.participants) || [];
setDisplayParticipants(prev => {
const prevById = new Map(prev.map(p => [p.id, p]));
const out = [];
for (const p of source) {
const prevP = prevById.get(p.id);
if (p.isActive !== false) {
out.push({ ...p, __displayActive: true });
} else if (prevP && prevP.__displayActive !== false) {
out.push({ ...p, __displayActive: false });
} else if (prevP && prevP.__displayActive === false) {
out.push(prevP);
}
}
return out;
});
}, [activeEncounterData]);
const handleExit = useCallback((id) => {
setDisplayParticipants(prev => prev.filter(p => p.id !== id));
}, []);
useEffect(() => { useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
@@ -2706,13 +3004,9 @@ function DisplayView() {
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false; const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
let participantsToRender = []; // 1-list model: displayParticipants IS the display order (participants[] order
if (participants) { // plus temporary exiting cards). Do NOT re-sort by initiative.
// 1-list model: participants[] IS the display order (DM drag = source of const participantsToRender = displayParticipants;
// truth). Do NOT re-sort by initiative — that diverges from AdminView /
// turnOrderIds after any cross-init drag (BUG-15).
participantsToRender = participants.filter(p => p.isActive !== false);
}
const displayStyles = campaignBackgroundUrl const displayStyles = campaignBackgroundUrl
? { ? {
@@ -2768,8 +3062,9 @@ function DisplayView() {
<div className="space-y-4 max-w-3xl mx-auto"> <div className="space-y-4 max-w-3xl mx-auto">
{participantsToRender.map(p => { {participantsToRender.map(p => {
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious'); const isGeneric = (activeEncounterData.ruleset || '5e') === 'generic';
const isZeroHp = p.currentHp === 0; const status = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (p.currentHp === 0 ? 'dying' : 'conscious'));
const isZeroHp = p.currentHp <= 0;
let participantBgColor = p.type === 'monster' || p.type === 'npc' let participantBgColor = p.type === 'monster' || p.type === 'npc'
? 'bg-[#8e351c]' ? 'bg-[#8e351c]'
: 'bg-indigo-950'; : 'bg-indigo-950';
@@ -2783,10 +3078,14 @@ function DisplayView() {
} }
return ( return (
<div <PlayerParticipantCard
key={p.id} key={p.id}
ref={isCurrentTurn ? currentParticipantRef : null} id={p.id}
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${status === 'dead' ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')}`} isActive={p.isActive !== false}
status={status}
onExit={handleExit}
divRef={isCurrentTurn ? currentParticipantRef : null}
className={`relative p-4 md:p-6 rounded-lg shadow-lg ${participantBgColor}`}
> >
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h3 <h3
@@ -2799,6 +3098,7 @@ function DisplayView() {
)} )}
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>} {status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>} {status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
{status === 'down' && <span className="text-red-300 text-lg ml-2 animate-pulse">(Down)</span>}
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>} {status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
</h3> </h3>
<span <span
@@ -2850,7 +3150,7 @@ function DisplayView() {
{!p.isActive && !isZeroHp && ( {!p.isActive && !isZeroHp && (
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p> <p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
)} )}
</div> </PlayerParticipantCard>
); );
})} })}
</div> </div>
@@ -3219,8 +3519,12 @@ function App() {
useEffect(() => { useEffect(() => {
const queryParams = new URLSearchParams(window.location.search); const queryParams = new URLSearchParams(window.location.search);
if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') { const isDisplay = queryParams.get('playerView') === 'true' || window.location.pathname === '/display';
if (isDisplay) {
setIsPlayerViewOnlyMode(true); setIsPlayerViewOnlyMode(true);
// swap manifest so Android home-screen installs launch /display
const link = document.querySelector('link[rel="manifest"]');
if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`;
} }
if (window.location.pathname === '/logs') { if (window.location.pathname === '/logs') {
setIsLogsMode(true); setIsLogsMode(true);
+3 -3
View File
@@ -105,9 +105,9 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
return unsub; return unsub;
} }
// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs. // Apply Firestore-style query constraints (orderBy desc/asc, limit, where)
// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit // to mock docs. Mirrors real SDK semantics for contract tests. Offset handled
// supported (App's LOG_QUERY uses exactly these). // by adapter (firebase.js slices) — mock never sees it.
function applyConstraints(docs, constraints) { function applyConstraints(docs, constraints) {
let out = [...docs]; let out = [...docs];
for (const c of constraints) { for (const c of constraints) {
@@ -94,7 +94,7 @@ describe('DisplayView characterization', () => {
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
}); });
test('DisplayView hides inactive participants for all types', async () => { test('DisplayView hides inactive participants for all types after fade-out', async () => {
seedActiveDisplay([ seedActiveDisplay([
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true }, { ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false }, { ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
@@ -103,7 +103,8 @@ describe('DisplayView characterization', () => {
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(); // inactive held during exit animation, removed after transition
await waitFor(() => expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument(), { timeout: 1500 });
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument(); expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
}); });
}); });