27 Commits
Author SHA1 Message Date
robert 23aa99ba0a Merge pull request 'Tablet controls, participant action states, and Android shortcut fixes' (#9) from chore/test-and-cleanup into main
Reviewed-on: #9
2026-07-15 15:08:22 -04:00
david raistrick dc0a1f1633 fix: preserve user-renamable Android shortcuts
Chrome Android forces the root URL into PWA installation when required
manifest icon files exist. PWA names come from the manifest and cannot be
edited, which breaks workflows using multiple tracker copies.

Keep required 192/512 icon assets intentionally unavailable by committing
generated files with .png-not suffixes. Removing the manifest link does not
restore root shortcut naming; Chrome still installs the root as a PWA.

Chrome handles /display differently: even with a working manifest it offers
Install app or Create shortcut, and Create shortcut permits renaming. Keep
both manifests intentionally non-installable for consistent user-controlled
shortcut names.

Also add separate player-display favicon/logo artwork and swap /display browser
favicon to player-favicon.ico. Keep generated standard/player logo sources
under renamed inert filenames.
2026-07-15 13:50:53 -04:00
david raistrick 119d252d02 feat: improve tablet screen controls and participant action states
- Add wake lock and browser fullscreen controls to encounter header/popout
- Re-acquire wake lock after browser release (still doesnt work on tablet)
- Retry fullscreen after Android unlock; keep manual control visible
- Add larger labeled character Save button
- Use distinct panel icon for encounter full-page expansion
- Add strong active/inactive, conditions-open, and editing button states
- Keep inactive participant controls visible
- Add regression tests and update affected characterization tests
- Update project TODO notes
2026-07-15 12:30:07 -04:00
david raistrick 30712f0e1e chore: strengthen lint coverage and update project housekeeping
- Recursively lint all production JS/JSX under src/ and shared/
- Exclude tests, mocks, and node_modules from static guard
- Update TODO status
- Exclude TODO.md from Docker build context
2026-07-14 22:38:44 -04:00
robertandClaude Opus 4.8 7d0ea883b0 fix: drop useless string concat in devTools KEY to unblock CI build
react-scripts build with CI=true treats the no-useless-concat ESLint
warning as an error. DefinePlugin inlining is already prevented by the
dynamic process.env[KEY] lookup, so the literal split was unnecessary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:07:04 -04:00
robert 5c062bf944 Merge pull request 'Generic (non-5e) ruleset mode + UI polish + combat switch fix' (#7) from feat/generic-ruleset into main
Reviewed-on: #7
2026-07-08 22:54:03 -04:00
david raistrick b5b339a5dd feat: temp HP, character isNpc, initiative box
Temp HP:
- setTempHp() in shared/turn.js (replaces, no stacking)
- Damage absorbs temp HP first (both 5e + generic rulesets)
- Inline temp HP input on participant card (cyan when active)
- Temp HP field in edit modal
- makeParticipant tempHp field (default 0)
- 8 shared tests

Character isNpc:
- isNpc field on character roster model
- buildCharacterParticipant: type 'npc' when isNpc
- Add form checkbox + edit form checkbox
- NPC badge on character list row
- Writeback preserves isNpc

Initiative box:
- Amber-bordered box around initiative input
- Gold text (amber-300), disabled stays gold
- Stone-950 bg matches page
- Bumped maxHp (stone-200) + tempHp (stone-300/cyan) visibility
2026-07-08 21:36:26 -04:00
david raistrick eef11c3b6e fix: scope selectedEncounter + scrollY localStorage per campaign
- selectedEncounter key: ttrpg.selectedEncounter.{campaignId}
- scrollY key: ttrpg.scrollY.{campaignId}
- Restore scoped encounter selection on campaign switch
- Global keys unchanged (selectedCampaign, wakeLock, collapses)
- Prevents same-browser tabs on different campaigns from fighting
- Tests: 3 scoping cases (scoped encounter, scoped scroll, global intact)
2026-07-08 20:00:13 -04:00
david raistrick 2c6dfdafc8 fix: guard player display from cross-encounter conflicts
- Start encounter only claims display if slot empty or already ours
  (prevents stealing display from another live encounter)
- End encounter only clears display if THIS encounter is the one showing
  (prevents killing display for a different live encounter)
- Use unwrapped activeDisplayData (not snapshot wrapper activeDisplayInfo)
- Tests: 4 display guard cases (claim empty, no-steal busy, clear own,
  no-clear other)
2026-07-08 19:56:00 -04:00
david raistrick c05a283cf0 feat: encounter card 'IN PROGRESS' badge + red ring for active combat
- Pulsing red ⚔ IN PROGRESS badge on encounter card title
- Red ring around card when combat started and not ended
2026-07-08 19:41:35 -04:00
david raistrick 69ab0c35c0 fix: move revive/mark dead to stats row, bump stat font size
- Revive/Mark Dead buttons moved to init/hp/maxhp row (right justified)
- Separate from damage/heal controls
- Stat inputs bumped text-sm -> text-base, widths increased
2026-07-08 19:39:32 -04:00
david raistrick 91e23856b4 feat: tablet-friendly encounter layout
- Combat controls always left column (narrow portrait, wide desktop)
- Short labels below lg breakpoint, full labels at lg+
- Sticky controls all viewports
- Collapsible Add Participants section (auto-collapse during combat)
- Fullscreen encounter toggle
- Modals via createPortal to escape stacking context
- Reduced padding/margins in portrait orientation
2026-07-08 19:28:05 -04:00
david raistrick fd03ae48c8 Character writeback on encounter end
Campaign-level toggle (CharacterManager, live flip). When ON, ending an
encounter writes each character participant's current HP, Max HP, and AC back
to the campaign roster so they carry into the next encounter.

- shared: buildCharacterParticipant uses defaultCurrentHp (fallback maxHp)
- endEncounter: syncs maxHp/ac/currentHp to campaign players if syncCharacters,
  snapshots old values into undo payload for restore. Works retroactive
  (ctx.campaignId fallback for encounters lacking campaignId field).
- New encounters store campaignId field.
- Undo end-encounter restores character values client-side.
- CharacterManager: styled sync toggle (checkbox badge), stat badges in list
  (HP/Current HP/AC/Init), edit box supports Current HP field.

Tests: 5 writeback cases (off/on/skip monsters/undo snapshot/missing char).
2026-07-08 16:33:21 -04:00
david raistrick 96770099f6 Persist campaign/encounter selection + scroll position across reload
UI reset to top on every reload/code update. Now:
- selectedCampaignId persisted via localStorage
- selectedEncounterId persisted via localStorage
- scrollY saved (beforeunload + pagehide + visibilitychange + 2s interval
  for Android Chrome reliability)
- scroll restored after campaigns data loads (300ms delay for mobile render)
2026-07-08 14:12:35 -04:00
david raistrick ebd91ca42a Inline editable fields (init/hp/maxhp/ac) with editing overlay
Participant card fields now editable in place (D-style): transparent bg,
underline on hover/focus, no spinners. Covers Initiative, Current HP, Max HP,
and AC badge value.

Click any value to edit. Blur/Enter saves. Escape cancels. Status recomputes
on HP change (conscious/dying/dead/down per ruleset).

Editing overlay: when a field is focused, amber ring highlights the card and
a centered pointer-events-none label shows '✎ Editing {field}'. Label floats
over card middle without blocking input — taps pass through to field.

Handlers: handleInlineCurrentHp (recomputes status), handleInlineMaxHp,
handleInlineAc. Keys prefixed to avoid React key collisions when values match.

Tests: selectors updated to use element id (monsterMaxHp) since inline
aria-labels now match form label queries.
2026-07-08 14:05:55 -04:00
david raistrick 41c1e48874 Wake lock persistence, button reposition, docs, dev LAN support
Wake lock (Prevent Sleep) toggles now persist across reloads via
localStorage in both AdminView and DisplayView. Buttons repositioned
inline in AdminView campaigns header bar (was floating overlay causing
overlap on tablets). DisplayView buttons persist localStorage too.

Wake lock acquire failure now shows toast with fix hint (HTTPS or
Chrome flag). Fullscreenchange listener re-acquires wake lock (Android
discards on screen off).

dev-start.sh: auto-detects LAN IP (en0/en1), frontend binds 0.0.0.0,
backend URL inlined as LAN IP so phones reach backend. DANGEROUSLY_DISABLE_HOST_CHECK
for LAN access. Outputs LAN URL + wake lock flag instructions.

Docs: README 'Prevent Sleep (Wake Lock)' section covering secure context
requirement, Android Chrome flag workaround for LAN testing, iOS Safari
standalone PWA bug. DEVELOPMENT.md LAN access + wake lock note.
2026-07-08 13:19:10 -04:00
david raistrick 863e8b3719 Add AC field (character, monster/npc, participant edit); layout polish
AC (Armor Class) optional field across all participant entry points:
- shared: ac field on makeParticipant + buildMonsterParticipant +
  buildCharacterParticipant, defaults null
- CharacterManager: defaultAc state + add form field + inline edit field +
  display in character list
- Monster add form: AC field
- EditParticipantModal: AC field next to Initiative
- ParticipantManager (DM list): AC badge on name row (sky-blue, stylized,
  large value, small label) for at-a-glance reading
- Player display: no AC (DM only)

Layout polish:
- Add participants form: 12-col grid, 5 fields single row (Init Mod, Initiative,
  AC, Max HP, HP Formula), shrunk from oversized fields
- Character add form: 12-col grid, name grows (col-span-6), Init Mod/AC/HP
  small right-aligned, order matches add participants
- Character inline edit: labels added (Name/HP/Init Mod/AC), name flex-grows
- HP Formula: label trimmed (example moved to placeholder 'e.g. 2d6+9'),
  Reroll button in edit modal
- ParticipantManager init input shrunk (w-8, centered)

Tests: 6 new AC builder tests (turn.ac.test.js). Existing test labels updated
for renamed fields.
2026-07-08 12:23:19 -04:00
david raistrick 4a92c667c5 Fix dev bulk-delete button gate + reposition inside campaigns
Gate was process.env.NODE_ENV === 'development' — unsafe default. react-scripts
inlines NODE_ENV=development when unset, so prod deploys forgetting the env var
exposed the delete-all button. Switched to explicit opt-in REACT_APP_DEV_TOOLS=1.

process.env.REACT_APP_DEV_TOOLS as static literal gets inlined by DefinePlugin at
webpack build time — runtime mutations in tests had no effect, and dev-start
without the env produced bundles with the branch dead-stripped. Extracted gate
to src/config/devTools.js using dynamic key access
(process.env['REACT_APP_' + 'DEV_TOOLS']) so DefinePlugin cannot inline it; the
value is read at runtime. dev-start.sh now exports REACT_APP_DEV_TOOLS=1.

Button had also drifted outside the campaigns collapse block to the page bottom;
moved it back inside the campaigns section after the grid.

Tests cover both paths: gate logic (unset/0/arbitrary/1) in BulkDelete.gate.test.js,
prod safety render (button absent when unset) in BulkDelete.render-hidden.test.js,
dev feature render (button present when DEV_TOOLS=1) in BulkDelete.render-shown.test.js.
2026-07-08 11:46:56 -04:00
david raistrick 6f11edab94 HP formula stored on participant, loads in edit modal
hpFormula field persisted on participant doc (makeParticipant + builder).
Add handler stores formula. Edit modal loads participant.hpFormula, reroll
button sets maxHp field (no save until save). Edit submit persists formula.
Formula only for monster/npc types.
2026-07-07 17:24:36 -04:00
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
41 changed files with 2609 additions and 256 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); });
+1
View File
@@ -1,3 +1,4 @@
TODO.md
node_modules
**/node_modules
.git
+20
View File
@@ -307,6 +307,26 @@ ttrpg-initiative-tracker/
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
* `TODO.md` — known bugs and backlog
## Prevent Sleep (Wake Lock)
DM and Player views have a Coffee/Moon toggle to keep screen awake during combat.
**Requires secure context** (HTTPS or localhost). The Screen Wake Lock API refuses
on plain HTTP.
- **Prod (HTTPS):** works automatically on all devices.
- **Local dev (localhost):** works automatically — localhost is a trusted origin.
- **LAN IP (phones, plain HTTP):** wake lock fails silently. Two options:
1. **Android Chrome flag (dev testing):** `chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add full URL with port, e.g. `http://10.0.0.5:3999`. Enable flag, relaunch.
2. **mkcert local CA:** generate trusted cert for LAN IP. Heavier setup.
**iOS Safari:** Wake Lock API broken in standalone PWA mode (iOS 16.418.4, WebKit bug 254545). Fixed in 18.4+. Works in browser tab (not installed PWA).
**Troubleshooting:** if toggle shows active but screen still sleeps, check:
- Battery saver / power save mode (browser refuses)
- Chrome devtools console for `WakeLockError`
- Secure context: `window.isSecureContext` must be `true`
## Contributing
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
+38 -2
View File
@@ -2,12 +2,45 @@
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open
x caff doent work on fron tpage or big tablet
x max/caff need to float or at least be availabe in combagt popout
x save needs bigger save button on char editor
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
x also better vert tab layout - labelt friendly
x needs AC for players dude
x and quick entry hp
x hp do not carry from encounter to ecnounter!!!
^feature to add back to campaign character after encounter?
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
x encounters cards need clear "in progress"...
x I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
npcs.....
x hp wont go over max and no temp hp support
## monsters per campaign and npcs
## or/and ....copy from encoubnter?
### dm list - keep active particpant in view (scroll)
not sure good way to do this
### npm install warnings cleanup pass
@@ -15,6 +48,9 @@ lots of updates
### TEST GAP: current branch changes need focused coverage
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
- Server SQL query test: `where + orderBy + limit` should return latest logs for one encounter only.
+8
View File
@@ -63,6 +63,14 @@ git config core.hooksPath .githooks # enable pre-push test gate
Idempotent: if port busy, leaves existing proc as-is.
**LAN access (phones):** frontend binds 0.0.0.0, LAN IP auto-detected.
Other devices hit `http://<lan-ip>:3999`.
**Wake Lock (Prevent Sleep) on LAN IP:** requires secure context.
Plain HTTP on LAN IP = wake lock silently fails. Android Chrome flag workaround:
`chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add
`http://<lan-ip>:3999` (with port), enable, relaunch. See README for full details.
Smoke check:
```bash
curl http://127.0.0.1:4001/health # -> {"ok":true}
+108
View File
@@ -64,6 +64,15 @@ Object in `encounter.participants[]`:
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
## Path normalization
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Server-mode adapter `norm()` strips prefix → bare canonical (`campaigns/...`).
- **UI / app code** → prefixed paths (`getPath.*` helpers)
- **REST API / direct DB / scripts** → bare canonical paths (`campaigns/...`)
Mixing = empty results. If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used the prefixed form. Drop the prefix.
## Build flow (UI)
Admin view at `/`. Steps:
@@ -121,6 +130,101 @@ Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initia
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
## Build flow (API / scripts)
Same logical steps as UI, via REST. For LLM agents or seed scripts.
**Server mode only.** REST endpoints (`/api/*`) exist only when tracker runs
with `REACT_APP_STORAGE=server` (bundled Express backend mounted). Firebase
SDK mode has no HTTP backend — data lives in Firestore via in-browser SDK,
different auth + write paths. This section does not apply to firebase mode.
Confirm server mode before scripting:
```bash
curl -s <HOST>/api/collection?path=campaigns | head -c 200
```
JSON array = server mode. HTML (SPA shell) = firebase mode or backend not
mounted — API path won't work.
### REST endpoints
| Method | Path | Body / Query | Action |
|---|---|---|---|
| `GET` | `/api/doc` | `?path=` | read one doc |
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
| `GET` | `/api/collection` | `?path=` | list docs under collection |
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
| `POST` | `/api/batch` | `{ops:[{type:'set'\|'update'\|'delete', path, data?}]}` | atomic multi-write |
All paths bare canonical (no `artifacts/...` prefix).
### id format
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`). Use either. Server `POST /api/collection` generates its own UUID — fine for campaigns/encounters. Participants are inline array items; generate their ids client-side so they're stable across batch writes.
### Object templates
Encounter doc (full, for `PUT` / batch `set`):
```js
{
name: 'Barrowdeep 1 - Antechamber',
createdAt: new Date().toISOString(),
participants: [], // inline, NOT a sub-collection
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
ruleset: '5e',
}
```
Participant object (item in `encounter.participants[]`):
```js
{
id: generateId(),
name: 'Ankheg',
type: 'monster', // 'character' | 'npc' | 'monster'
originalCharacterId: null, // set only if type==='character'
initiative: rollD20() + initMod, // rolled ONCE at add; stored, not re-derived
maxHp: 45,
currentHp: 45, // start at full
conditions: [],
isActive: true,
status: 'conscious',
deathSaveSuccesses: 0, // character/npc only
deathSaveFailures: 0, // character/npc only
}
```
### Initiative semantics
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], roll fresh per participant).
- Manual override (e.g. pre-rolled values): set `initiative` directly, skip the roll.
- Fixed at add time. NOT re-derived from `initMod` after — `initMod` is not stored on participant.
### Recipe: build encounters for a campaign
1. **Find campaign id by name:**
```bash
curl -s "$HOST/api/collection?path=campaigns" \
| jq -r '.[] | select(.name=="2026 D&D") | .id'
```
2. **Build encounter docs + participants client-side** (object templates above), roll initiative per participant.
3. **Write all encounters in one batch:**
```bash
curl -s -X POST "$HOST/api/batch" \
-H 'Content-Type: application/json' \
-d '{"ops":[{"type":"set","path":"campaigns/CID/encounters/EID1","data":{...}},{"type":"set","path":"campaigns/CID/encounters/EID2","data":{...}}]}'
```
4. **Verify:**
```bash
curl -s "$HOST/api/collection?path=campaigns/CID/encounters" | jq '.[] | {name, count: (.participants|length)}'
```
Participants go inline in the encounter doc — there is no `participants` sub-collection. A single batch `set` per encounter writes the whole roster atomically.
## Combat flow (UI)
InitiativeControls panel (sticky, right side).
@@ -198,6 +302,8 @@ No re-sort after `startEncounter`.
## Storage paths quick reference
Bare canonical (server mode). App code prefixes these with `artifacts/{APP_ID}/public/data/` — adapter strips it.
```
campaigns/{cid} campaign doc
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
@@ -206,6 +312,8 @@ activeDisplay/status player display control
logs/{logId} action log entry
```
REST mapping: `campaigns/{cid}` doc → `GET/PUT/PATCH/DELETE /api/doc?path=campaigns/{cid}`. Collection parent (`campaigns`, `campaigns/{cid}/encounters`) → `GET/POST /api/collection?path=...`.
## DM tips
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
+25
View File
@@ -0,0 +1,25 @@
{
"id": "/display",
"short_name": "TTRPG Display",
"name": "TTRPG Initiative Tracker - Player Display",
"icons": [
{
"src": "player-favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "player-logo192.png",
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": "/display",
"scope": "/display",
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
"display": "fullscreen",
"orientation": "portrait",
"theme_color": "#1A202C",
"background_color": "#1A202C"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+7 -7
View File
@@ -1,4 +1,5 @@
{
"id": "/",
"short_name": "TTRPG Tracker",
"name": "TTRPG Initiative Tracker",
"icons": [
@@ -9,17 +10,16 @@
},
{
"src": "logo192.png",
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"start_url": "/",
"scope": "/",
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
"display": "fullscreen",
"orientation": "portrait",
"theme_color": "#2D3748",
"background_color": "#1A202C"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+14 -3
View File
@@ -27,10 +27,15 @@ fi
# frontend: server storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then
echo "starting frontend :3999..."
# detect LAN ip so other devices (phones) can reach backend
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1)
echo "starting frontend :3999 (lan ip: $LAN_IP)..."
NODE_ENV=development REACT_APP_DEV_TOOLS=1 \
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
REACT_APP_BACKEND_URL=http://${LAN_IP}:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://${LAN_IP}:4001/ws \
DANGEROUSLY_DISABLE_HOST_CHECK=true \
HOST=0.0.0.0 \
BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid
@@ -50,5 +55,11 @@ done
echo ""
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
echo "frontend : http://127.0.0.1:3999 (admin / player /display)"
echo "lan url : http://${LAN_IP}:3999 (phones/other devices on network)"
echo "logs : tmp/server.log tmp/fe.log"
echo "stop : ./scripts/dev-stop.sh"
echo ""
echo "NOTE: Prevent Sleep (wake lock) requires secure context (HTTPS/localhost)."
echo " LAN IP (http) won't work unless Android Chrome flag set:"
echo " chrome://flags/#unsafely-treat-insecure-origin-as-secure"
echo " Add: http://${LAN_IP}:3999 (include port)"
+18 -9
View File
@@ -1,21 +1,30 @@
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
// Scans App.js + storage adapters + shared modules. Catches unused imports,
// Scans ALL .js/.jsx in src/ + shared/ (excl tests/mocks). Catches unused imports,
// dead code, undefined vars before they hit the browser console.
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const ROOT = path.resolve(__dirname, '..', '..');
function walkJs(dir) {
let out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'tests' || entry.name === '__mocks__') continue;
out = out.concat(walkJs(full));
} else if (/\.(js|jsx)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
const TARGETS = [
'src/App.js',
'src/storage/contract.js',
'src/storage/firebase.js',
'src/storage/index.js',
'src/storage/server.js',
'shared/index.js',
'shared/logEvent.js',
'shared/turn.js',
...walkJs(path.join(ROOT, 'src')),
...walkJs(path.join(ROOT, 'shared')),
];
function runEslint(files) {
+35
View File
@@ -0,0 +1,35 @@
// AC field on participant + builders.
const shared = require('@ttrpg/shared');
const { makeParticipant, buildMonsterParticipant, buildCharacterParticipant, rollHpFormula } = shared;
describe('AC field', () => {
test('makeParticipant accepts ac', () => {
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15, ac: 13 });
expect(p.ac).toBe(13);
});
test('makeParticipant ac defaults null', () => {
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15 });
expect(p.ac).toBeNull();
});
test('buildMonsterParticipant accepts ac', () => {
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7, initMod: 2, ac: 15 });
expect(participant.ac).toBe(15);
});
test('buildMonsterParticipant ac defaults null', () => {
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7 });
expect(participant.ac).toBeNull();
});
test('buildCharacterParticipant accepts character ac', () => {
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2, defaultAc: 18 });
expect(participant.ac).toBe(18);
});
test('buildCharacterParticipant ac defaults null when unset', () => {
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30 });
expect(participant.ac).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
// addParticipants (bulk) must slot by initiative, not append.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { buildCharacterParticipant, buildMonsterParticipant, addParticipants, makeParticipant } = shared;
function enc(ps = []) {
return { name:'t', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [] };
}
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 10, currentHp: 10 });
}
describe('addParticipants slot order', () => {
test('bulk add slots by initiative desc', async () => {
const { ctx } = mockCtx();
let e = enc([]);
e = await addParticipants(e, [p('a', 5), p('b', 20), p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
});
test('bulk add preserves existing order + slots new', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 20), p('b', 5)]);
e = await addParticipants(e, [p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['a', 'c', 'b']);
});
test('bulk add same-init appends after existing (stable tie)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 10)]);
e = await addParticipants(e, [p('b', 10), p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['a', 'b', 'c']);
});
test('bulk add mixed inits into existing list', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 15), p('b', 5)]);
e = await addParticipants(e, [p('c', 20), p('d', 10), p('e', 15)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['c', 'a', 'e', 'd', 'b']);
});
test('bulk add updates turnOrderIds to match', async () => {
const { ctx } = mockCtx();
let e = enc([]);
e = await addParticipants(e, [p('a', 5), p('b', 20)], ctx);
expect(e.turnOrderIds).toEqual(['b', 'a']);
});
});
+60
View File
@@ -0,0 +1,60 @@
// HP formula roll: parse "2d6+9" style, roll, return total.
const shared = require('@ttrpg/shared');
const { rollHpFormula } = shared;
describe('rollHpFormula', () => {
test('basic NdM', () => {
const r = rollHpFormula('2d6');
expect(r).toBeGreaterThanOrEqual(2);
expect(r).toBeLessThanOrEqual(12);
});
test('NdM+bonus', () => {
const r = rollHpFormula('2d6+9');
expect(r).toBeGreaterThanOrEqual(11);
expect(r).toBeLessThanOrEqual(21);
});
test('NdM-bonus', () => {
const r = rollHpFormula('3d8-2');
expect(r).toBeGreaterThanOrEqual(1);
expect(r).toBeLessThanOrEqual(22);
});
test('single die', () => {
const r = rollHpFormula('1d20');
expect(r).toBeGreaterThanOrEqual(1);
expect(r).toBeLessThanOrEqual(20);
});
test('case insensitive', () => {
const r = rollHpFormula('2D6+5');
expect(r).toBeGreaterThanOrEqual(7);
expect(r).toBeLessThanOrEqual(17);
});
test('whitespace tolerant', () => {
const r = rollHpFormula(' 2d6 + 9 ');
expect(r).toBeGreaterThanOrEqual(11);
expect(r).toBeLessThanOrEqual(21);
});
test('large die', () => {
const r = rollHpFormula('4d12+10');
expect(r).toBeGreaterThanOrEqual(14);
expect(r).toBeLessThanOrEqual(58);
});
test('invalid returns null', () => {
expect(rollHpFormula('abc')).toBeNull();
expect(rollHpFormula('')).toBeNull();
expect(rollHpFormula(null)).toBeNull();
expect(rollHpFormula(undefined)).toBeNull();
expect(rollHpFormula('2d')).toBeNull();
expect(rollHpFormula('d6')).toBeNull();
});
test('zero dice invalid', () => {
expect(rollHpFormula('0d6')).toBeNull();
});
});
+102
View File
@@ -0,0 +1,102 @@
// Temp HP: damage hits temp first, then regular.
// setTempHp replaces (no stacking). Healing regular HP ignores temp.
const shared = require('@ttrpg/shared');
const { makeParticipant, applyHpChange, setTempHp, addParticipant } = shared;
const { mockCtx } = require('./_helpers');
function setupEnc() {
const { ctx } = mockCtx();
return {
enc: { id: 'enc1', name: 'TempEnc', participants: [], isStarted: false, isPaused: false, round: 0 },
ctx,
};
}
describe('temp HP', () => {
test('setTempHp sets tempHp on participant', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
expect(e.participants[0].tempHp).toBe(5);
});
test('setTempHp replaces existing temp (no stacking)', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await setTempHp(e, p.id, 3, ctx);
expect(e.participants[0].tempHp).toBe(3);
});
test('setTempHp to 0 clears temp', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await setTempHp(e, p.id, 0, ctx);
expect(e.participants[0].tempHp).toBe(0);
});
test('damage: temp absorbs before regular HP', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'damage', 8, ctx);
expect(e.participants[0].tempHp).toBe(0);
expect(e.participants[0].currentHp).toBe(17);
});
test('damage less than temp: only temp reduced', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 10, ctx);
e = await applyHpChange(e, p.id, 'damage', 4, ctx);
expect(e.participants[0].tempHp).toBe(6);
expect(e.participants[0].currentHp).toBe(20);
});
test('heal: does not affect temp HP', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 10,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'heal', 8, ctx);
expect(e.participants[0].tempHp).toBe(5);
expect(e.participants[0].currentHp).toBe(18);
});
test('damage exactly equals temp: temp gone, regular intact', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'damage', 5, ctx);
expect(e.participants[0].tempHp).toBe(0);
expect(e.participants[0].currentHp).toBe(20);
});
test('makeParticipant: tempHp defaults to 0', async () => {
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
expect(p.tempHp).toBe(0);
});
});
+7 -1
View File
@@ -29,7 +29,13 @@ function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const snap = (e) => JSON.parse(JSON.stringify(e));
const snap = (e) => {
const o = JSON.parse(JSON.stringify(e));
// normalize nullable timestamp fields: null = absent (undo clears via null)
if (o.startedAt === null) delete o.startedAt;
if (o.endedAt === null) delete o.endedAt;
return o;
};
describe('undo + redo roundtrip', () => {
test('startEncounter', async () => {
+157
View File
@@ -0,0 +1,157 @@
// Character writeback on endEncounter: sync maxHp/ac to campaign players.
const shared = require('@ttrpg/shared');
const { endEncounter } = shared;
function makeMockStorage() {
const docs = new Map();
const writes = [];
return {
getDoc: jest.fn(async (p) => docs.get(p) || null),
setDoc: jest.fn(async (p, d) => { docs.set(p, d); }),
updateDoc: jest.fn(async (p, patch) => {
writes.push({ path: p, patch });
const cur = docs.get(p) || {};
docs.set(p, { ...cur, ...patch });
}),
addDoc: jest.fn(async (p, data) => {
writes.push({ path: p, patch: data });
docs.set(p, data);
}),
deleteDoc: jest.fn(async () => {}),
getCollection: jest.fn(async () => []),
_docs: docs,
_writes: writes,
};
}
function makeEncounter(overrides = {}) {
return {
id: 'enc1',
name: 'Test',
campaignId: 'camp1',
isStarted: true,
isPaused: false,
currentTurnParticipantId: 'p1',
round: 3,
turnOrderIds: ['p1', 'p2'],
participants: [
{ id: 'p1', name: 'Fighter', type: 'character', originalCharacterId: 'char1', initiative: 15, maxHp: 28, currentHp: 20, ac: 18 },
{ id: 'p2', name: 'Goblin', type: 'monster', originalCharacterId: null, initiative: 12, maxHp: 7, currentHp: 7, ac: 13 },
{ id: 'p3', name: 'Cleric', type: 'character', originalCharacterId: 'char2', initiative: 10, maxHp: 22, currentHp: 22, ac: 16 },
],
...overrides,
};
}
describe('character writeback on endEncounter', () => {
test('no writeback when syncCharacters false/missing', async () => {
const storage = makeMockStorage();
storage._docs.set('campaigns/camp1', {
id: 'camp1', name: 'Camp', players: [
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 },
],
});
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
// no campaign write
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
});
test('writeback updates char maxHp + ac + currentHp when syncCharacters true', async () => {
const storage = makeMockStorage();
const players = [
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
];
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
expect(campWrite).toBeDefined();
const updated = campWrite.patch.players;
const fighter = updated.find(p => p.id === 'char1');
expect(fighter.defaultMaxHp).toBe(28);
expect(fighter.defaultAc).toBe(18);
expect(fighter.defaultCurrentHp).toBe(20);
const cleric = updated.find(p => p.id === 'char2');
expect(cleric.defaultMaxHp).toBe(22);
expect(cleric.defaultAc).toBe(16);
expect(cleric.defaultCurrentHp).toBe(22);
});
test('writeback skips monsters (no originalCharacterId)', async () => {
const storage = makeMockStorage();
const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }];
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
// players array still 1 entry, goblin not added
expect(campWrite.patch.players.length).toBe(1);
});
test('undo payload includes old char values for restore', async () => {
const storage = makeMockStorage();
const players = [
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
];
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
storage._docs.set('logs/log1', null);
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
const logWrite = storage._writes.find(w => w.path === 'logs/log1');
expect(logWrite).toBeDefined();
const log = logWrite.patch;
expect(log.undo.characterWriteback).toBeDefined();
expect(log.undo.characterWriteback).toHaveLength(2);
const fighterOld = log.undo.characterWriteback.find(c => c.id === 'char1');
expect(fighterOld.defaultMaxHp).toBe(30);
expect(fighterOld.defaultAc).toBe(17);
expect(fighterOld.defaultCurrentHp).toBe(30);
});
test('writeback no-op if character missing from campaign', async () => {
const storage = makeMockStorage();
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [], syncCharacters: true });
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
// no writeback (no matching chars = no change)
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
});
});
+178 -23
View File
@@ -28,6 +28,22 @@ const generateId = () =>
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
// Parse HP formula like "2d6+9" or "3d8-2" or "1d20". Roll, return total.
// Returns null on invalid input. Whitespace/case tolerant.
function rollHpFormula(str) {
if (!str || typeof str !== 'string') return null;
const m = str.trim().toLowerCase().match(/^(\d+)d(\d+)\s*([+-]\s*\d+)?$/);
if (!m) return null;
const count = parseInt(m[1], 10);
const sides = parseInt(m[2], 10);
if (count === 0 || sides === 0) return null;
let bonus = 0;
if (m[3]) bonus = parseInt(m[3].replace(/\s/g, ''), 10);
let total = 0;
for (let i = 0; i < count; i++) total += Math.floor(Math.random() * sides) + 1;
return total + bonus;
}
const formatInitMod = (mod) => {
if (mod === undefined || mod === null) return 'N/A';
return mod >= 0 ? `+${mod}` : `${mod}`;
@@ -102,6 +118,8 @@ function snapshotOf(enc) {
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
turnOrderIds: [...(enc.turnOrderIds || [])],
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
startedAt: enc.startedAt ?? null,
endedAt: enc.endedAt ?? null,
};
}
@@ -305,12 +323,18 @@ function expandUndo(entry, currentEnc) {
redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
};
}
case 'start_encounter':
case 'start_encounter': {
return {
encounterPath: encPath,
updates: { ...u, startedAt: u.startedAt ?? null },
redo: { isStarted: true, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], startedAt: snap.startedAt ?? Date.now() },
};
}
case 'end_encounter': {
return {
encounterPath: encPath,
updates: u,
redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
updates: { ...u, endedAt: u.endedAt ?? null },
redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() },
};
}
default:
@@ -336,6 +360,9 @@ function makeParticipant(opts) {
status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
deathSaveFailures: opts.deathSaveFailures || 0,
hpFormula: opts.hpFormula || null,
ac: opts.ac !== undefined ? opts.ac : null,
tempHp: opts.tempHp !== undefined ? opts.tempHp : 0,
};
}
@@ -344,24 +371,37 @@ function buildCharacterParticipant(character) {
const modifier = character.defaultInitMod || 0;
const finalInitiative = initiativeRoll + modifier;
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
const currentHp = character.defaultCurrentHp != null ? character.defaultCurrentHp : maxHp;
return {
participant: makeParticipant({
name: character.name,
type: 'character',
type: character.isNpc ? 'npc' : 'character',
originalCharacterId: character.id,
initiative: finalInitiative,
maxHp,
currentHp: maxHp,
currentHp,
ac: character.defaultAc !== undefined ? character.defaultAc : null,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
};
}
function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula, ac }) {
const initiativeRoll = rollD20();
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
const finalInitiative = initiativeRoll + modifier;
const hp = maxHp || DEFAULT_MAX_HP;
// maxHp wins if both set; else roll formula; else default.
let hp;
let hpRoll = null;
if (maxHp) {
hp = maxHp;
} else if (hpFormula) {
const rolled = rollHpFormula(hpFormula);
hp = rolled || DEFAULT_MAX_HP;
hpRoll = rolled;
} else {
hp = DEFAULT_MAX_HP;
}
return {
participant: makeParticipant({
name,
@@ -369,8 +409,10 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
initiative: finalInitiative,
maxHp: hp,
currentHp: hp,
hpFormula: hpFormula || null,
ac: ac !== undefined ? ac : null,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
};
}
@@ -391,6 +433,8 @@ async function startEncounter(encounter, ctx) {
participants: sortedParticipants,
currentTurnParticipantId: firstActive.id,
turnOrderIds: sortedParticipants.map(p => p.id),
startedAt: Date.now(),
endedAt: null,
};
const log = {
type: 'start_encounter',
@@ -403,6 +447,8 @@ async function startEncounter(encounter, ctx) {
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
startedAt: encounter.startedAt ?? null,
endedAt: encounter.endedAt ?? null,
},
};
return commit(encounter, patch, log, ctx);
@@ -479,14 +525,20 @@ async function addParticipant(encounter, participant, ctx) {
}
async function addParticipants(encounter, newParticipants, ctx) {
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
// Slot each new participant into list by initiative (desc), preserve
// existing order + drag-established ties. Matches addParticipant semantics.
let base = [...(encounter.participants || [])];
for (const np of newParticipants) {
const idx = slotIndexForInit(base, np.initiative);
base.splice(idx, 0, np);
}
const names = newParticipants.map(p => p.name).join(', ');
const patch = { participants: updatedParticipants };
const patch = { participants: base, ...syncTurnOrder(base) };
const log = {
type: 'add_participants',
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
delta: { count: newParticipants.length },
undo: { participants: newParticipants },
undo: { participants: newParticipants, newTurnOrderIds: patch.turnOrderIds },
};
return commit(encounter, patch, log, ctx);
}
@@ -572,6 +624,25 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
return commit(encounter, patch, log, ctx);
}
async function setTempHp(encounter, participantId, tempHp, ctx) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const value = Math.max(0, tempHp || 0);
const oldValues = { tempHp: participant.tempHp || 0 };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: value } : p
);
const log = {
type: 'set_temp_hp',
participantId,
participantName: participant.name,
message: `${participant.name} temp HP set to ${value}`,
delta: { tempHp: value, oldTempHp: oldValues.tempHp },
undo: { oldValues, newValues: { tempHp: value } },
};
return commit(encounter, { participants: updatedParticipants }, log, ctx);
}
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
const ctx = maybeCtx || optionsOrCtx;
const options = maybeCtx ? (optionsOrCtx || {}) : {};
@@ -586,6 +657,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
const oldValues = {
currentHp: participant.currentHp,
tempHp: participant.tempHp || 0,
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
deathSaveFailures: participant.deathSaveFailures || 0,
@@ -597,6 +669,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
let message = '';
let logDelta = { amount, from: participant.currentHp };
const status = oldValues.status;
const currentTemp = participant.tempHp || 0;
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
@@ -605,6 +678,33 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
}
if (changeType === 'damage') {
// Temp HP absorbs damage first.
let remainingDmg = amount;
let tempAfter = currentTemp;
if (tempAfter > 0) {
const absorbed = Math.min(tempAfter, remainingDmg);
tempAfter -= absorbed;
remainingDmg -= absorbed;
updates.tempHp = tempAfter;
}
// If fully absorbed by temp, no regular HP change.
if (remainingDmg === 0 && participant.currentHp > 0) {
const updatedParticipants0 = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: tempAfter } : p
);
const log0 = {
type: 'hp_change',
participantId,
participantName: participant.name,
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
};
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
}
amount = remainingDmg;
const tempBefore = currentTemp;
const tempFinal = updates.tempHp !== undefined ? updates.tempHp : tempBefore;
if (participant.currentHp === 0) {
if (status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
@@ -636,23 +736,23 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
const add = options.isCriticalHit ? 2 : 1;
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
if (failures >= 3) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
} else {
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures };
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures, tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
} else if (amount >= participant.currentHp + participant.maxHp) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
message = `Massive damage! ${participant.name} instantly killed`;
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
} else {
const newHp = Math.max(0, participant.currentHp - amount);
if (newHp === 0) {
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
} else {
updates = { currentHp: newHp, status: 'conscious' };
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
@@ -696,8 +796,31 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
const participantId = participant.id;
let updates, message, logDelta = { amount, from: participant.currentHp };
const currentTemp = participant.tempHp || 0;
if (changeType === 'damage') {
// Temp HP absorbs damage first.
let remainingDmg = amount;
let tempAfter = currentTemp;
if (tempAfter > 0) {
const absorbed = Math.min(tempAfter, remainingDmg);
tempAfter -= absorbed;
remainingDmg -= absorbed;
}
if (remainingDmg === 0) {
const updatedParticipants0 = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: tempAfter } : p
);
const log0 = {
type: 'hp_change', participantId, participantName: participant.name,
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
};
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
}
amount = remainingDmg;
const tempFinal = tempAfter;
if (oldValues.status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
const updatedParticipants = (encounter.participants || []).map(p =>
@@ -721,11 +844,11 @@ async function applyHpChangeGeneric(encounter, participant, changeType, amount,
const newHp = participant.currentHp - amount;
const isMonster = participant.type === 'monster';
if (newHp <= 0 && isMonster) {
updates = { currentHp: newHp, status: 'dead', isActive: false };
updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal };
} else if (newHp <= 0) {
updates = { currentHp: newHp, status: 'down' };
updates = { currentHp: newHp, status: 'down', tempHp: tempFinal };
} else {
updates = { currentHp: newHp, status: 'conscious' };
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
@@ -997,13 +1120,45 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
}
async function endEncounter(encounter, ctx) {
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] };
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [], endedAt: Date.now() };
const log = {
type: 'end_encounter',
message: `Combat ended: "${encounter.name}"`,
delta: {},
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])] },
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null },
};
// Character writeback: sync maxHp/ac/currentHp to campaign players if enabled.
const wbCampaignId = encounter.campaignId || ctx.campaignId;
if ((wbCampaignId || ctx.campaign) && ctx.storage) {
let campaign;
try {
campaign = ctx.campaign || await ctx.storage.getDoc(`campaigns/${wbCampaignId}`);
} catch { campaign = null; }
if (campaign && campaign.syncCharacters && Array.isArray(campaign.players)) {
const charParticipants = (encounter.participants || []).filter(p => p.originalCharacterId);
const oldValues = [];
let changed = false;
const updatedPlayers = campaign.players.map(player => {
const cp = charParticipants.find(p => p.originalCharacterId === player.id);
if (!cp) return player;
const oldMaxHp = player.defaultMaxHp;
const oldAc = player.defaultAc;
const oldCurrentHp = player.defaultCurrentHp;
oldValues.push({ id: player.id, defaultMaxHp: oldMaxHp, defaultAc: oldAc, defaultCurrentHp: oldCurrentHp });
const newPlayer = { ...player };
if (cp.maxHp != null && cp.maxHp !== oldMaxHp) { newPlayer.defaultMaxHp = cp.maxHp; changed = true; }
if (cp.ac != null && cp.ac !== oldAc) { newPlayer.defaultAc = cp.ac; changed = true; }
if (cp.currentHp != null && cp.currentHp !== oldCurrentHp) { newPlayer.defaultCurrentHp = cp.currentHp; changed = true; }
return newPlayer;
});
if (changed) {
await ctx.storage.updateDoc(`campaigns/${wbCampaignId}`, { players: updatedPlayers });
log.undo.characterWriteback = oldValues;
}
}
}
return commit(encounter, patch, log, ctx);
}
@@ -1026,13 +1181,13 @@ async function toggleHidePlayerHp(currentValue, ctx) {
module.exports = {
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
generateId, rollD20, formatInitMod,
generateId, rollD20, rollHpFormula, formatInitMod,
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
snapshotOf, buildEntry, expandUndo,
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp,
};
+937 -201
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
// Safe default: any value other than exactly '1' = off.
// Dynamic key access (process.env[KEY]) so react-scripts DefinePlugin does NOT
// inline the value at build time — allows tests + runtime env changes to take
// effect. DefinePlugin only replaces static member access, never computed lookups.
const KEY = 'REACT_APP_DEV_TOOLS';
export const isDevToolsEnabled = () => process.env[KEY] === '1';
+1 -1
View File
@@ -62,7 +62,7 @@ describe('Campaign -> Firebase', () => {
// CharacterManager form
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
+30
View File
@@ -0,0 +1,30 @@
import { isDevToolsEnabled } from '../config/devTools';
describe('Bulk-delete-all dev-tools gate logic', () => {
const orig = process.env.REACT_APP_DEV_TOOLS;
afterEach(() => {
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
else process.env.REACT_APP_DEV_TOOLS = orig;
});
test('false when unset (safe default)', () => {
delete process.env.REACT_APP_DEV_TOOLS;
expect(isDevToolsEnabled()).toBe(false);
});
test('false when 0', () => {
process.env.REACT_APP_DEV_TOOLS = '0';
expect(isDevToolsEnabled()).toBe(false);
});
test('false when arbitrary string', () => {
process.env.REACT_APP_DEV_TOOLS = 'true';
expect(isDevToolsEnabled()).toBe(false);
});
test('true only when exactly 1', () => {
process.env.REACT_APP_DEV_TOOLS = '1';
expect(isDevToolsEnabled()).toBe(true);
});
});
@@ -0,0 +1,36 @@
import React from 'react';
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
import App from '../App';
describe('Bulk-delete-all button render gating', () => {
const orig = process.env.REACT_APP_DEV_TOOLS;
afterEach(() => {
cleanup();
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
else process.env.REACT_APP_DEV_TOOLS = orig;
});
async function renderAndCreateCampaign() {
window.history.replaceState({}, '', '/');
global.alert = jest.fn();
global.window.open = jest.fn();
render(<App />);
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
});
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
await act(async () => {
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
await new Promise(r => setTimeout(r, 200));
});
}
test('prod safety: button absent when DEV_TOOLS unset', async () => {
delete process.env.REACT_APP_DEV_TOOLS;
await renderAndCreateCampaign();
expect(screen.queryByText(/Delete All Campaigns/i)).not.toBeInTheDocument();
});
});
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
import App from '../App';
describe('Bulk-delete-all button dev feature', () => {
const orig = process.env.REACT_APP_DEV_TOOLS;
afterEach(() => {
cleanup();
if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS;
else process.env.REACT_APP_DEV_TOOLS = orig;
});
async function renderAndCreateCampaign() {
window.history.replaceState({}, '', '/');
global.alert = jest.fn();
global.window.open = jest.fn();
render(<App />);
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
});
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
await act(async () => {
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
await new Promise(r => setTimeout(r, 200));
});
}
test('dev: button present when DEV_TOOLS=1 and campaigns exist', async () => {
process.env.REACT_APP_DEV_TOOLS = '1';
await renderAndCreateCampaign();
await waitFor(() => screen.getByText(/Delete All Campaigns/i), { timeout: 5000 });
});
});
+94
View File
@@ -0,0 +1,94 @@
// Display guard: starting encounter does NOT steal display from another live encounter.
// Ending encounter does NOT clear display if different encounter showing.
import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
const DISPLAY_PATH = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
function findCallActiveDisplay(fn) {
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
}
describe('Display guard', () => {
test('startEncounter: claims display when slot empty', async () => {
await setupReady('GuardCamp1', 'GuardEnc1');
await addMonsterViaUI('Goblin', 10, 5);
await startCombatViaUI();
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
expect(last).toBeTruthy();
expect(last.data.activeEncounterId).toBeTruthy();
});
test('startEncounter: does NOT steal display from another live encounter', async () => {
// Pre-seed display as busy with a DIFFERENT encounter
MOCK_DB.set(DISPLAY_PATH, {
activeCampaignId: 'other-camp',
activeEncounterId: 'other-enc',
});
await setupReady('GuardCamp2', 'GuardEnc2');
await addMonsterViaUI('Orc', 15, 8);
await startCombatViaUI();
// Combat started on encounter doc
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
expect(encCalls.some(c => c.data.isStarted === true)).toBe(true);
// But display setDoc should NOT have been called to claim this encounter
const adClaims = findCallActiveDisplay('setDoc').filter(
c => c.data && c.data.activeEncounterId && c.data.activeEncounterId !== 'other-enc'
);
expect(adClaims).toHaveLength(0);
});
test('endEncounter: clears display when ending the displayed encounter', async () => {
await setupReady('GuardCamp3', 'GuardEnc3');
await addMonsterViaUI('Wolf', 8, 3);
await startCombatViaUI();
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
return last && last.data && last.data.activeCampaignId === null;
});
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
});
test('endEncounter: does NOT clear display when different encounter is live', async () => {
await setupReady('GuardCamp4', 'GuardEnc4');
await addMonsterViaUI('Bat', 4, 2);
await startCombatViaUI();
// Now hijack display to a different encounter (simulating eyeball toggle elsewhere)
MOCK_DB.set(DISPLAY_PATH, {
activeCampaignId: 'different-camp',
activeEncounterId: 'different-enc',
});
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
// Wait for encounter to end
await waitFor(() => {
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const last = encCalls[encCalls.length - 1];
return last && last.data.isStarted === false;
});
// Display should NOT have been cleared
const clearCalls = findCallActiveDisplay('setDoc').filter(
c => c.data && c.data.activeCampaignId === null
);
expect(clearCalls).toHaveLength(0);
});
});
@@ -39,6 +39,10 @@ function participant(status) {
describe('DisplayView characterization', () => {
beforeEach(() => {
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.href = '/favicon.ico';
document.head.appendChild(favicon);
window.history.replaceState({}, '', '/display');
global.alert = jest.fn();
window.open = jest.fn();
@@ -46,9 +50,18 @@ describe('DisplayView characterization', () => {
Element.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
document.querySelectorAll('link[rel="icon"]').forEach(link => link.remove());
window.history.replaceState({}, '', '/');
});
test('display route swaps browser favicon to player icon', async () => {
seedActiveDisplay();
render(<App />);
await waitFor(() => {
expect(document.querySelector('link[rel="icon"]')).toHaveAttribute('href', '/player-favicon.ico');
});
});
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
seedActiveDisplay();
render(<App />);
+1 -1
View File
@@ -26,7 +26,7 @@ describe('FEAT-3 reslot: inline init change reorders list', () => {
const addOne = async (name, hp, mod, init) => {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
+39
View File
@@ -0,0 +1,39 @@
// localStorage scoping: selectedEncounter + scrollY keyed per campaign.
// Two tabs on different campaigns don't fight over selection/scroll.
import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { setupReady, addMonsterViaUI } from './testHelpers';
describe('localStorage campaign scoping', () => {
test('selectedEncounter key is scoped (not bare)', async () => {
await setupReady('ScopeCamp1', 'ScopeEnc1');
// bare key should not exist
expect(localStorage.getItem('ttrpg.selectedEncounter')).toBeNull();
// scoped key with suffix should exist
const scoped = Object.keys(localStorage).find(
k => k.startsWith('ttrpg.selectedEncounter.') && k !== 'ttrpg.selectedEncounter'
);
expect(scoped).toBeTruthy();
expect(localStorage.getItem(scoped)).toBeTruthy();
});
test('scrollY key is scoped (not bare)', async () => {
await setupReady('ScopeCamp2', 'ScopeEnc2');
await addMonsterViaUI('Goblin', 10, 5);
expect(localStorage.getItem('ttrpg.scrollY')).toBeNull();
const scoped = Object.keys(localStorage).find(
k => k.startsWith('ttrpg.scrollY.') && k !== 'ttrpg.scrollY'
);
// key may not exist until save fires — trigger by checking bare is null
// and at least scoped format is used if any scrollY key present
const scrollKeys = Object.keys(localStorage).filter(k => k.startsWith('ttrpg.scrollY'));
scrollKeys.forEach(k => expect(k).not.toBe('ttrpg.scrollY'));
});
test('global keys still exist (campaign selection, wake lock)', async () => {
await setupReady('ScopeCamp3', 'ScopeEnc3');
expect(localStorage.getItem('ttrpg.selectedCampaign')).toBeTruthy();
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ async function addCharacterToEncounter(name = 'Hero', hp = 10) {
await selectCampaignByName(`DS-${name}`);
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: String(hp) } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
@@ -70,7 +70,7 @@ describe('Participant -> Firebase', () => {
test('toggleActive: updateDoc flips isActive', async () => {
await setupReady();
await addMonsterViaUI('Toggle', 10, 0);
fireEvent.click(screen.getByTitle('Mark Inactive'));
fireEvent.click(screen.getByRole('button', { name: 'Disable participant' }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false);
expect(lastEncCall().data.participants[0].isActive).toBe(false);
});
@@ -116,7 +116,7 @@ describe('Participant -> Firebase', () => {
test('toggleCondition: updateDoc adds condition to array', async () => {
await setupReady();
await addMonsterViaUI('Cond', 10, 0);
fireEvent.click(screen.getByTitle('Conditions'));
fireEvent.click(screen.getByRole('button', { name: 'Open conditions' }));
await waitFor(() => screen.getByRole('button', { name: /Blinded/i }));
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
await waitFor(() => {
+42
View File
@@ -0,0 +1,42 @@
import { screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { setupReady, addMonsterViaUI } from './testHelpers';
describe('participant action selected states', () => {
test('active toggle clearly changes to selected inactive state', async () => {
await setupReady('ActionCamp1', 'ActionEnc1');
await addMonsterViaUI('Toggle Orc', 15, 3);
const disable = screen.getByRole('button', { name: 'Disable participant' });
expect(disable).toHaveAttribute('aria-pressed', 'true');
expect(disable).toHaveClass('bg-emerald-700');
fireEvent.click(disable);
const enable = await screen.findByRole('button', { name: 'Enable participant' });
expect(enable).toHaveAttribute('aria-pressed', 'false');
expect(enable).toHaveClass('bg-red-900', 'ring-red-500');
});
test('conditions toggle highlights open state and gives close action', async () => {
await setupReady('ActionCamp2', 'ActionEnc2');
await addMonsterViaUI('Condition Orc', 15, 3);
const open = screen.getByRole('button', { name: 'Open conditions' });
expect(open).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(open);
const close = screen.getByRole('button', { name: 'Close conditions' });
expect(close).toHaveAttribute('aria-expanded', 'true');
expect(close).toHaveClass('bg-purple-700', 'ring-purple-300');
});
test('edit toggle exposes selected state while editor is open', async () => {
await setupReady('ActionCamp3', 'ActionEnc3');
await addMonsterViaUI('Edit Orc', 15, 3);
fireEvent.click(screen.getByRole('button', { name: 'Edit participant' }));
const closeEditor = screen.getByRole('button', { name: 'Close participant editor' });
expect(closeEditor).toHaveAttribute('aria-pressed', 'true');
expect(closeEditor).toHaveClass('bg-amber-500', 'ring-amber-200');
});
});
+2 -2
View File
@@ -16,7 +16,7 @@ function lastParticipantsUpdate() {
async function addOne(form, name, hp, mod, init) {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
@@ -57,7 +57,7 @@ describe('reslot on all mutation paths', () => {
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
// open edit modal for Goblin, bump init to 8
const editBtns = screen.getAllByTitle('Edit');
const editBtns = screen.getAllByRole('button', { name: 'Edit participant' });
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
fireEvent.click(goblinEdit);
+95
View File
@@ -0,0 +1,95 @@
import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { setupReady } from './testHelpers';
describe('screen controls', () => {
let originalRequestFullscreen;
let originalExitFullscreen;
let originalFullscreenElement;
let originalVisibilityState;
let originalWakeLock;
beforeEach(() => {
localStorage.clear();
originalRequestFullscreen = document.documentElement.requestFullscreen;
originalExitFullscreen = document.exitFullscreen;
originalFullscreenElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement');
originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
originalWakeLock = Object.getOwnPropertyDescriptor(navigator, 'wakeLock');
});
afterEach(() => {
document.documentElement.requestFullscreen = originalRequestFullscreen;
document.exitFullscreen = originalExitFullscreen;
if (originalFullscreenElement) Object.defineProperty(document, 'fullscreenElement', originalFullscreenElement);
else Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
if (originalVisibilityState) Object.defineProperty(document, 'visibilityState', originalVisibilityState);
if (originalWakeLock) Object.defineProperty(navigator, 'wakeLock', originalWakeLock);
else Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: undefined });
localStorage.clear();
});
test('encounter header exposes prevent-sleep, browser-fullscreen, and popout controls', async () => {
await setupReady('ScreenCamp', 'ScreenEnc');
expect(screen.getAllByRole('button', { name: 'Prevent sleep' }).length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByRole('button', { name: 'Enter browser fullscreen' })).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Expand encounter to full page' })).toBeInTheDocument();
});
test('re-acquires wake lock when tablet becomes visible after browser release', async () => {
let releaseListener;
const sentinel = {
released: false,
release: jest.fn().mockResolvedValue(undefined),
addEventListener: jest.fn((event, callback) => { if (event === 'release') releaseListener = callback; }),
};
const request = jest.fn().mockResolvedValue(sentinel);
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } });
let visibility = 'visible';
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
await setupReady('WakeCamp', 'WakeEnc');
fireEvent.click(screen.getAllByRole('button', { name: 'Prevent sleep' })[0]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
visibility = 'hidden';
releaseListener();
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
});
test('character editor has labeled tablet-sized save button', async () => {
await setupReady('SaveCamp', 'SaveEnc');
fireEvent.change(screen.getByLabelText('Name', { selector: '#characterName' }), { target: { value: 'Tablet Hero' } });
fireEvent.click(screen.getByRole('button', { name: 'Add Character' }));
await screen.findByText('Tablet Hero');
fireEvent.click(screen.getByRole('button', { name: 'Edit character' }));
const save = screen.getByRole('button', { name: 'Save character' });
expect(save).toHaveTextContent('Save');
expect(save).toHaveClass('min-h-[40px]');
});
test('attempts to restore fullscreen when tablet becomes visible after lock', async () => {
const requestFullscreen = jest.fn().mockResolvedValue(undefined);
document.documentElement.requestFullscreen = requestFullscreen;
document.exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => null });
let visibility = 'visible';
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
await setupReady('RestoreCamp', 'RestoreEnc');
fireEvent.click(screen.getByRole('button', { name: 'Enter browser fullscreen' }));
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(1));
visibility = 'hidden';
document.dispatchEvent(new Event('fullscreenchange'));
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(2));
});
});
+1 -1
View File
@@ -74,7 +74,7 @@ export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(maxHp) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
const { getCalls } = require('../__mocks__/firebase/_mock-db');
await waitFor(() => {