Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc0a1f1633 | ||
|
|
119d252d02 | ||
|
|
30712f0e1e | ||
|
|
7d0ea883b0 | ||
|
|
5c062bf944 | ||
|
|
b5b339a5dd | ||
|
|
eef11c3b6e | ||
|
|
2c6dfdafc8 | ||
|
|
c05a283cf0 | ||
|
|
69ab0c35c0 | ||
|
|
91e23856b4 | ||
|
|
fd03ae48c8 | ||
|
|
96770099f6 | ||
|
|
ebd91ca42a | ||
|
|
41c1e48874 | ||
|
|
863e8b3719 | ||
|
|
4a92c667c5 | ||
|
|
6f11edab94 | ||
|
|
2dfa155469 | ||
|
|
0fa3928bf8 | ||
|
|
9870d4df0d | ||
|
|
43b1f6ce41 | ||
|
|
907484fc7f | ||
|
|
9dbbb88730 | ||
|
|
1078062b53 | ||
|
|
94126567e7 | ||
|
|
f3ed971592 | ||
|
|
95db6fdfdc | ||
|
|
a5b6d61e83 | ||
|
|
76f5dc09ab | ||
|
|
c2bb5cca29 | ||
|
|
7955bed4a9 | ||
|
|
66f3cc1380 | ||
|
|
19de9fcf75 | ||
|
|
c41a8aa994 | ||
|
|
02344eb3f5 | ||
|
|
e191aa5ad2 | ||
|
|
c544bc305a | ||
|
|
995347d255 |
@@ -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
-1
@@ -1,3 +1,4 @@
|
|||||||
|
TODO.md
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
.git
|
.git
|
||||||
@@ -6,6 +7,5 @@ data
|
|||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env*
|
|
||||||
coverage
|
coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Dockerfile
|
# Dockerfile
|
||||||
|
|
||||||
# Stage 1: Build the React application
|
# Stage 1: Build the React application
|
||||||
FROM node:18-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
LABEL stage="build-local-testing"
|
LABEL stage="build-local-testing"
|
||||||
|
|
||||||
|
|||||||
Vendored
+172
@@ -0,0 +1,172 @@
|
|||||||
|
// Jenkinsfile — build + publish both images to the thinkserver:5000 registry.
|
||||||
|
//
|
||||||
|
// ttrpg-tracker-firebase : root Dockerfile, nginx serving the static SPA,
|
||||||
|
// Firebase storage (config baked in at build time).
|
||||||
|
// ttrpg-tracker-sqlite : docker/Dockerfile, Caddy + Node, server/SQLite
|
||||||
|
// storage (REACT_APP_STORAGE=server).
|
||||||
|
//
|
||||||
|
// Requirements on the Jenkins agent:
|
||||||
|
// * Docker CLI available, agent user in the docker group (or DooD socket).
|
||||||
|
// * thinkserver:5000 reachable and trusted by the Docker daemon. If it is a
|
||||||
|
// plain-HTTP registry, add it to /etc/docker/daemon.json on the AGENT host:
|
||||||
|
// { "insecure-registries": ["thinkserver:5000"] } (then restart docker)
|
||||||
|
//
|
||||||
|
// Jenkins credentials this pipeline expects:
|
||||||
|
// * ttrpg-firebase-env-local (Secret file) — the .env.local contents with
|
||||||
|
// REACT_APP_FIREBASE_* values. Baked into the firebase image at build time.
|
||||||
|
// * thinkserver-registry (Username/pw) — OPTIONAL. Only needed if the
|
||||||
|
// registry requires auth; the login stage is guarded below.
|
||||||
|
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
timeout(time: 30, unit: 'MINUTES')
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
string(name: 'TRACKER_APP_ID',
|
||||||
|
defaultValue: 'ttrpg-initiative-tracker-default',
|
||||||
|
description: 'Firestore/app namespace baked into the SQLite image (REACT_APP_TRACKER_APP_ID).')
|
||||||
|
booleanParam(name: 'PUSH_LATEST', defaultValue: true,
|
||||||
|
description: 'Also tag and push :latest in addition to the commit-SHA tag.')
|
||||||
|
booleanParam(name: 'REGISTRY_AUTH', defaultValue: false,
|
||||||
|
description: 'Enable docker login using the thinkserver-registry credential.')
|
||||||
|
booleanParam(name: 'REDEPLOY_PORTAINER', defaultValue: true,
|
||||||
|
description: 'After pushing, POST to the Portainer stack webhooks to re-pull and redeploy.')
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
REGISTRY = 'thinkserver:5000'
|
||||||
|
FIREBASE_IMAGE = "${REGISTRY}/ttrpg-tracker-firebase"
|
||||||
|
SQLITE_IMAGE = "${REGISTRY}/ttrpg-tracker-sqlite"
|
||||||
|
DOCKER_BUILDKIT = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
script {
|
||||||
|
env.SHORT_SHA = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
|
||||||
|
// Only build/push on main. BRANCH_NAME is set by Multibranch jobs; it
|
||||||
|
// is unset in a plain "Pipeline from SCM" job, which already only ever
|
||||||
|
// checks out main, so treat unset as main too.
|
||||||
|
env.IS_MAIN = (env.BRANCH_NAME == null || env.BRANCH_NAME == 'main').toString()
|
||||||
|
if (env.IS_MAIN != 'true') {
|
||||||
|
echo "Branch '${env.BRANCH_NAME}' is not main — skipping image build/push."
|
||||||
|
} else {
|
||||||
|
echo "Building tag ${env.SHORT_SHA} (build #${env.BUILD_NUMBER})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Registry login') {
|
||||||
|
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REGISTRY_AUTH } } }
|
||||||
|
steps {
|
||||||
|
withCredentials([usernamePassword(credentialsId: 'thinkserver-registry',
|
||||||
|
usernameVariable: 'REG_USER',
|
||||||
|
passwordVariable: 'REG_PASS')]) {
|
||||||
|
sh 'echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build firebase image') {
|
||||||
|
when { expression { env.IS_MAIN == 'true' } }
|
||||||
|
steps {
|
||||||
|
// Firebase config is compiled into the static bundle, so it must exist
|
||||||
|
// at build time. Inject .env.local from the Jenkins secret file, then
|
||||||
|
// the root Dockerfile's `COPY .env.local .env` picks it up.
|
||||||
|
withCredentials([file(credentialsId: 'ttrpg-firebase-env-local', variable: 'FIREBASE_ENV')]) {
|
||||||
|
sh '''
|
||||||
|
cp "$FIREBASE_ENV" .env.local
|
||||||
|
docker build \
|
||||||
|
-f Dockerfile \
|
||||||
|
-t "$FIREBASE_IMAGE:$SHORT_SHA" \
|
||||||
|
.
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build sqlite image') {
|
||||||
|
when { expression { env.IS_MAIN == 'true' } }
|
||||||
|
steps {
|
||||||
|
// Server/SQLite build. BuildKit is required (syntax directive + cache
|
||||||
|
// mount in docker/Dockerfile). Context is the repo root.
|
||||||
|
sh '''
|
||||||
|
docker build \
|
||||||
|
-f docker/Dockerfile \
|
||||||
|
--build-arg REACT_APP_TRACKER_APP_ID="$TRACKER_APP_ID" \
|
||||||
|
-t "$SQLITE_IMAGE:$SHORT_SHA" \
|
||||||
|
.
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Tag latest') {
|
||||||
|
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.PUSH_LATEST } } }
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
docker tag "$FIREBASE_IMAGE:$SHORT_SHA" "$FIREBASE_IMAGE:latest"
|
||||||
|
docker tag "$SQLITE_IMAGE:$SHORT_SHA" "$SQLITE_IMAGE:latest"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Push') {
|
||||||
|
when { expression { env.IS_MAIN == 'true' } }
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
docker push "$FIREBASE_IMAGE:$SHORT_SHA"
|
||||||
|
docker push "$SQLITE_IMAGE:$SHORT_SHA"
|
||||||
|
'''
|
||||||
|
script {
|
||||||
|
if (params.PUSH_LATEST) {
|
||||||
|
sh '''
|
||||||
|
docker push "$FIREBASE_IMAGE:latest"
|
||||||
|
docker push "$SQLITE_IMAGE:latest"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Redeploy (Portainer webhooks)') {
|
||||||
|
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REDEPLOY_PORTAINER } } }
|
||||||
|
steps {
|
||||||
|
// Webhook URLs are Secret-text credentials, so they never appear in the
|
||||||
|
// repo and Jenkins masks them in the console. They are bound to shell
|
||||||
|
// env vars and referenced inside a single-quoted sh block, so Groovy
|
||||||
|
// never interpolates the value into the pipeline log.
|
||||||
|
withCredentials([
|
||||||
|
string(credentialsId: 'portainer-webhook-firebase', variable: 'WEBHOOK_FIREBASE'),
|
||||||
|
string(credentialsId: 'portainer-webhook-sqlite', variable: 'WEBHOOK_SQLITE'),
|
||||||
|
]) {
|
||||||
|
sh '''
|
||||||
|
set +x
|
||||||
|
echo "Triggering Portainer redeploy (firebase stack)"
|
||||||
|
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_FIREBASE"
|
||||||
|
echo "Triggering Portainer redeploy (sqlite stack)"
|
||||||
|
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_SQLITE"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
// Never leave Firebase secrets in the workspace or log out cleanly.
|
||||||
|
sh 'rm -f .env.local || true'
|
||||||
|
sh 'docker logout "$REGISTRY" || true'
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
echo "Pushed ${FIREBASE_IMAGE}:${SHORT_SHA} and ${SQLITE_IMAGE}:${SHORT_SHA}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -307,6 +307,26 @@ ttrpg-initiative-tracker/
|
|||||||
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
|
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
|
||||||
* `TODO.md` — known bugs and backlog
|
* `TODO.md` — known bugs and backlog
|
||||||
|
|
||||||
|
## Prevent Sleep (Wake Lock)
|
||||||
|
|
||||||
|
DM and Player views have a Coffee/Moon toggle to keep screen awake during combat.
|
||||||
|
|
||||||
|
**Requires secure context** (HTTPS or localhost). The Screen Wake Lock API refuses
|
||||||
|
on plain HTTP.
|
||||||
|
|
||||||
|
- **Prod (HTTPS):** works automatically on all devices.
|
||||||
|
- **Local dev (localhost):** works automatically — localhost is a trusted origin.
|
||||||
|
- **LAN IP (phones, plain HTTP):** wake lock fails silently. Two options:
|
||||||
|
1. **Android Chrome flag (dev testing):** `chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add full URL with port, e.g. `http://10.0.0.5:3999`. Enable flag, relaunch.
|
||||||
|
2. **mkcert local CA:** generate trusted cert for LAN IP. Heavier setup.
|
||||||
|
|
||||||
|
**iOS Safari:** Wake Lock API broken in standalone PWA mode (iOS 16.4–18.4, WebKit bug 254545). Fixed in 18.4+. Works in browser tab (not installed PWA).
|
||||||
|
|
||||||
|
**Troubleshooting:** if toggle shows active but screen still sleeps, check:
|
||||||
|
- Battery saver / power save mode (browser refuses)
|
||||||
|
- Chrome devtools console for `WakeLockError`
|
||||||
|
- Secure context: `window.isSecureContext` must be `true`
|
||||||
|
|
||||||
## Contributing
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -2,12 +2,51 @@
|
|||||||
|
|
||||||
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||||
|
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
|
x caff doent work on fron tpage or big tablet
|
||||||
|
|
||||||
|
x max/caff need to float or at least be availabe in combagt popout
|
||||||
|
|
||||||
|
x save needs bigger save button on char editor
|
||||||
|
|
||||||
|
|
||||||
|
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
|
||||||
|
|
||||||
|
x also better vert tab layout - labelt friendly
|
||||||
|
|
||||||
|
x needs AC for players dude
|
||||||
|
|
||||||
|
x and quick entry hp
|
||||||
|
|
||||||
|
x hp do not carry from encounter to ecnounter!!!
|
||||||
|
^feature to add back to campaign character after encounter?
|
||||||
|
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
|
||||||
|
|
||||||
|
x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
|
||||||
|
|
||||||
|
x encounters cards need clear "in progress"...
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
x I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
|
||||||
|
npcs.....
|
||||||
|
|
||||||
|
x hp wont go over max and no temp hp support
|
||||||
|
|
||||||
|
|
||||||
|
## monsters per campaign and npcs
|
||||||
|
## or/and ....copy from encoubnter?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### npm install warnings cleanup pass
|
||||||
|
lots of updates
|
||||||
|
|
||||||
|
|
||||||
### dm list - keep active particpant in view (scroll)
|
|
||||||
not sure good way to do this
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -47,19 +86,6 @@ not sure good way to do this
|
|||||||
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
|
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
|
||||||
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
|
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
|
||||||
|
|
||||||
### BUG: select campaign during active combat = screen flicker, no action
|
|
||||||
- In active encounter, click different campaign → flicker, no nav, no end.
|
|
||||||
- Either block (toast: end encounter first) or auto-end current + switch.
|
|
||||||
- Decide UX before fix.
|
|
||||||
|
|
||||||
|
|
||||||
### FEAT: generic/non-5e rules mode
|
|
||||||
- Campaign/encounter ruleset toggle: `5e` vs `generic`.
|
|
||||||
- Generic mode turns off death saves/state machine.
|
|
||||||
- Generic mode allows negative HP.
|
|
||||||
- 5e mode rejects negative damage/heal and clamps HP at 0.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### FEAT: clarify "Is NPC" in add-participant
|
### FEAT: clarify "Is NPC" in add-participant
|
||||||
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
# docker/Dockerfile --- single container: caddy (front) + node (back).
|
# docker/Dockerfile --- single container: caddy (front) + node (back).
|
||||||
# Build context = repo root.
|
# Build context = repo root.
|
||||||
# ---- build stage: frontend + install backend deps ----
|
# ---- build stage: frontend + install backend deps ----
|
||||||
FROM node:18-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@@ -30,7 +30,7 @@ RUN cd server && npm rebuild better-sqlite3
|
|||||||
|
|
||||||
|
|
||||||
# ---- runtime stage: caddy + node ----
|
# ---- runtime stage: caddy + node ----
|
||||||
FROM node:18-alpine
|
FROM node:22-alpine
|
||||||
RUN apk add --no-cache caddy
|
RUN apk add --no-cache caddy
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# docker/portainer-stack.yml — Portainer stack for the SQLite/server build.
|
||||||
|
# Deploys the prebuilt image from the local registry (built + pushed by Jenkins).
|
||||||
|
# Paste into Portainer's web editor (Stacks -> Add stack -> Web editor).
|
||||||
|
#
|
||||||
|
# TLS is terminated by the external nginx, which should reverse_proxy to
|
||||||
|
# http://<docker-host>:8080 (and pass the WebSocket upgrade headers for /ws).
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: thinkserver:5000/ttrpg-tracker-sqlite:latest
|
||||||
|
container_name: ttrpg-tracker
|
||||||
|
ports:
|
||||||
|
- "8080:80" # host:container — nginx upstream is :8080
|
||||||
|
volumes:
|
||||||
|
- app-data:/data # SQLite DB lives here; back this up
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/tracker.sqlite
|
||||||
|
- PORT=4001 # node backend port (internal; caddy proxies to it)
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
@@ -63,6 +63,14 @@ git config core.hooksPath .githooks # enable pre-push test gate
|
|||||||
|
|
||||||
Idempotent: if port busy, leaves existing proc as-is.
|
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:
|
Smoke check:
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:4001/health # -> {"ok":true}
|
curl http://127.0.0.1:4001/health # -> {"ok":true}
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ Object in `encounter.participants[]`:
|
|||||||
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
|
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
|
||||||
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
|
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
|
||||||
|
|
||||||
|
## Path normalization
|
||||||
|
|
||||||
|
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Server-mode adapter `norm()` strips prefix → bare canonical (`campaigns/...`).
|
||||||
|
|
||||||
|
- **UI / app code** → prefixed paths (`getPath.*` helpers)
|
||||||
|
- **REST API / direct DB / scripts** → bare canonical paths (`campaigns/...`)
|
||||||
|
|
||||||
|
Mixing = empty results. If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used the prefixed form. Drop the prefix.
|
||||||
|
|
||||||
## Build flow (UI)
|
## Build flow (UI)
|
||||||
|
|
||||||
Admin view at `/`. Steps:
|
Admin view at `/`. Steps:
|
||||||
@@ -121,6 +130,101 @@ Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initia
|
|||||||
|
|
||||||
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
|
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
|
||||||
|
|
||||||
|
## Build flow (API / scripts)
|
||||||
|
|
||||||
|
Same logical steps as UI, via REST. For LLM agents or seed scripts.
|
||||||
|
|
||||||
|
**Server mode only.** REST endpoints (`/api/*`) exist only when tracker runs
|
||||||
|
with `REACT_APP_STORAGE=server` (bundled Express backend mounted). Firebase
|
||||||
|
SDK mode has no HTTP backend — data lives in Firestore via in-browser SDK,
|
||||||
|
different auth + write paths. This section does not apply to firebase mode.
|
||||||
|
|
||||||
|
Confirm server mode before scripting:
|
||||||
|
```bash
|
||||||
|
curl -s <HOST>/api/collection?path=campaigns | head -c 200
|
||||||
|
```
|
||||||
|
JSON array = server mode. HTML (SPA shell) = firebase mode or backend not
|
||||||
|
mounted — API path won't work.
|
||||||
|
|
||||||
|
### REST endpoints
|
||||||
|
|
||||||
|
| Method | Path | Body / Query | Action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET` | `/api/doc` | `?path=` | read one doc |
|
||||||
|
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
|
||||||
|
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
|
||||||
|
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
|
||||||
|
| `GET` | `/api/collection` | `?path=` | list docs under collection |
|
||||||
|
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
|
||||||
|
| `POST` | `/api/batch` | `{ops:[{type:'set'\|'update'\|'delete', path, data?}]}` | atomic multi-write |
|
||||||
|
|
||||||
|
All paths bare canonical (no `artifacts/...` prefix).
|
||||||
|
|
||||||
|
### id format
|
||||||
|
|
||||||
|
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`). Use either. Server `POST /api/collection` generates its own UUID — fine for campaigns/encounters. Participants are inline array items; generate their ids client-side so they're stable across batch writes.
|
||||||
|
|
||||||
|
### Object templates
|
||||||
|
|
||||||
|
Encounter doc (full, for `PUT` / batch `set`):
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
name: 'Barrowdeep 1 - Antechamber',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
participants: [], // inline, NOT a sub-collection
|
||||||
|
round: 0,
|
||||||
|
currentTurnParticipantId: null,
|
||||||
|
isStarted: false,
|
||||||
|
isPaused: false,
|
||||||
|
ruleset: '5e',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Participant object (item in `encounter.participants[]`):
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'Ankheg',
|
||||||
|
type: 'monster', // 'character' | 'npc' | 'monster'
|
||||||
|
originalCharacterId: null, // set only if type==='character'
|
||||||
|
initiative: rollD20() + initMod, // rolled ONCE at add; stored, not re-derived
|
||||||
|
maxHp: 45,
|
||||||
|
currentHp: 45, // start at full
|
||||||
|
conditions: [],
|
||||||
|
isActive: true,
|
||||||
|
status: 'conscious',
|
||||||
|
deathSaveSuccesses: 0, // character/npc only
|
||||||
|
deathSaveFailures: 0, // character/npc only
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initiative semantics
|
||||||
|
|
||||||
|
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], roll fresh per participant).
|
||||||
|
- Manual override (e.g. pre-rolled values): set `initiative` directly, skip the roll.
|
||||||
|
- Fixed at add time. NOT re-derived from `initMod` after — `initMod` is not stored on participant.
|
||||||
|
|
||||||
|
### Recipe: build encounters for a campaign
|
||||||
|
|
||||||
|
1. **Find campaign id by name:**
|
||||||
|
```bash
|
||||||
|
curl -s "$HOST/api/collection?path=campaigns" \
|
||||||
|
| jq -r '.[] | select(.name=="2026 D&D") | .id'
|
||||||
|
```
|
||||||
|
2. **Build encounter docs + participants client-side** (object templates above), roll initiative per participant.
|
||||||
|
3. **Write all encounters in one batch:**
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$HOST/api/batch" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"ops":[{"type":"set","path":"campaigns/CID/encounters/EID1","data":{...}},{"type":"set","path":"campaigns/CID/encounters/EID2","data":{...}}]}'
|
||||||
|
```
|
||||||
|
4. **Verify:**
|
||||||
|
```bash
|
||||||
|
curl -s "$HOST/api/collection?path=campaigns/CID/encounters" | jq '.[] | {name, count: (.participants|length)}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Participants go inline in the encounter doc — there is no `participants` sub-collection. A single batch `set` per encounter writes the whole roster atomically.
|
||||||
|
|
||||||
## Combat flow (UI)
|
## Combat flow (UI)
|
||||||
|
|
||||||
InitiativeControls panel (sticky, right side).
|
InitiativeControls panel (sticky, right side).
|
||||||
@@ -198,6 +302,8 @@ No re-sort after `startEncounter`.
|
|||||||
|
|
||||||
## Storage paths quick reference
|
## Storage paths quick reference
|
||||||
|
|
||||||
|
Bare canonical (server mode). App code prefixes these with `artifacts/{APP_ID}/public/data/` — adapter strips it.
|
||||||
|
|
||||||
```
|
```
|
||||||
campaigns/{cid} campaign doc
|
campaigns/{cid} campaign doc
|
||||||
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
|
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
|
||||||
@@ -206,6 +312,8 @@ activeDisplay/status player display control
|
|||||||
logs/{logId} action log entry
|
logs/{logId} action log entry
|
||||||
```
|
```
|
||||||
|
|
||||||
|
REST mapping: `campaigns/{cid}` doc → `GET/PUT/PATCH/DELETE /api/doc?path=campaigns/{cid}`. Collection parent (`campaigns`, `campaigns/{cid}/encounters`) → `GET/POST /api/collection?path=...`.
|
||||||
|
|
||||||
## DM tips
|
## DM tips
|
||||||
|
|
||||||
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
|
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
|
||||||
|
|||||||
Generated
+7
-21
@@ -7151,14 +7151,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "11.10.0",
|
"version": "12.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"prebuild-install": "^7.1.1"
|
"prebuild-install": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bfj": {
|
"node_modules/bfj": {
|
||||||
@@ -21316,23 +21319,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss/node_modules/yaml": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
@@ -23021,7 +23007,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ttrpg/shared": "*",
|
"@ttrpg/shared": "*",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^12.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
|
|||||||
@@ -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 |
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"short_name": "TTRPG Tracker",
|
"short_name": "TTRPG Tracker",
|
||||||
"name": "TTRPG Initiative Tracker",
|
"name": "TTRPG Initiative Tracker",
|
||||||
"icons": [
|
"icons": [
|
||||||
@@ -9,17 +10,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"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",
|
"type": "image/png",
|
||||||
"sizes": "192x192"
|
"sizes": "192x192"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"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",
|
"theme_color": "#2D3748",
|
||||||
"background_color": "#1A202C"
|
"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
@@ -27,10 +27,15 @@ fi
|
|||||||
|
|
||||||
# frontend: server storage, :3999
|
# frontend: server storage, :3999
|
||||||
if ! lsof -ti :3999 >/dev/null 2>&1; then
|
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_STORAGE=server \
|
||||||
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
|
REACT_APP_BACKEND_URL=http://${LAN_IP}:4001 \
|
||||||
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
|
REACT_APP_BACKEND_REALTIME_URL=ws://${LAN_IP}:4001/ws \
|
||||||
|
DANGEROUSLY_DISABLE_HOST_CHECK=true \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
BROWSER=none PORT=3999 \
|
BROWSER=none PORT=3999 \
|
||||||
nohup npm start > tmp/fe.log 2>&1 &
|
nohup npm start > tmp/fe.log 2>&1 &
|
||||||
echo $! > tmp/fe.pid
|
echo $! > tmp/fe.pid
|
||||||
@@ -50,5 +55,11 @@ done
|
|||||||
echo ""
|
echo ""
|
||||||
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
|
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 "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 "logs : tmp/server.log tmp/fe.log"
|
||||||
echo "stop : ./scripts/dev-stop.sh"
|
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)"
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ttrpg/shared": "*",
|
"@ttrpg/shared": "*",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^12.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
|
// 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.
|
// dead code, undefined vars before they hit the browser console.
|
||||||
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
|
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
const ROOT = path.resolve(__dirname, '..', '..');
|
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 = [
|
const TARGETS = [
|
||||||
'src/App.js',
|
...walkJs(path.join(ROOT, 'src')),
|
||||||
'src/storage/contract.js',
|
...walkJs(path.join(ROOT, 'shared')),
|
||||||
'src/storage/firebase.js',
|
|
||||||
'src/storage/index.js',
|
|
||||||
'src/storage/server.js',
|
|
||||||
'shared/index.js',
|
|
||||||
'shared/logEvent.js',
|
|
||||||
'shared/turn.js',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function runEslint(files) {
|
function runEslint(files) {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// AC field on participant + builders.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { makeParticipant, buildMonsterParticipant, buildCharacterParticipant, rollHpFormula } = shared;
|
||||||
|
|
||||||
|
describe('AC field', () => {
|
||||||
|
test('makeParticipant accepts ac', () => {
|
||||||
|
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15, ac: 13 });
|
||||||
|
expect(p.ac).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('makeParticipant ac defaults null', () => {
|
||||||
|
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15 });
|
||||||
|
expect(p.ac).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMonsterParticipant accepts ac', () => {
|
||||||
|
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7, initMod: 2, ac: 15 });
|
||||||
|
expect(participant.ac).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMonsterParticipant ac defaults null', () => {
|
||||||
|
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7 });
|
||||||
|
expect(participant.ac).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCharacterParticipant accepts character ac', () => {
|
||||||
|
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2, defaultAc: 18 });
|
||||||
|
expect(participant.ac).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCharacterParticipant ac defaults null when unset', () => {
|
||||||
|
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30 });
|
||||||
|
expect(participant.ac).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// addParticipants (bulk) must slot by initiative, not append.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
const { buildCharacterParticipant, buildMonsterParticipant, addParticipants, makeParticipant } = shared;
|
||||||
|
|
||||||
|
function enc(ps = []) {
|
||||||
|
return { name:'t', participants: ps, isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [] };
|
||||||
|
}
|
||||||
|
function p(id, init) {
|
||||||
|
return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 10, currentHp: 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('addParticipants slot order', () => {
|
||||||
|
test('bulk add slots by initiative desc', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([]);
|
||||||
|
e = await addParticipants(e, [p('a', 5), p('b', 20), p('c', 10)], ctx);
|
||||||
|
expect(e.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk add preserves existing order + slots new', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a', 20), p('b', 5)]);
|
||||||
|
e = await addParticipants(e, [p('c', 10)], ctx);
|
||||||
|
expect(e.participants.map(x => x.id)).toEqual(['a', 'c', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk add same-init appends after existing (stable tie)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a', 10)]);
|
||||||
|
e = await addParticipants(e, [p('b', 10), p('c', 10)], ctx);
|
||||||
|
expect(e.participants.map(x => x.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk add mixed inits into existing list', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([p('a', 15), p('b', 5)]);
|
||||||
|
e = await addParticipants(e, [p('c', 20), p('d', 10), p('e', 15)], ctx);
|
||||||
|
expect(e.participants.map(x => x.id)).toEqual(['c', 'a', 'e', 'd', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk add updates turnOrderIds to match', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([]);
|
||||||
|
e = await addParticipants(e, [p('a', 5), p('b', 20)], ctx);
|
||||||
|
expect(e.turnOrderIds).toEqual(['b', 'a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { mockCtx } = require('./_helpers');
|
||||||
|
const {
|
||||||
|
buildCharacterParticipant, buildMonsterParticipant,
|
||||||
|
startEncounter, applyHpChange, deathSave, markDead, reviveParticipant,
|
||||||
|
} = shared;
|
||||||
|
|
||||||
|
function char(id, hp = 100) {
|
||||||
|
const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 });
|
||||||
|
participant.id = id;
|
||||||
|
return participant;
|
||||||
|
}
|
||||||
|
function mon(id, hp = 100) {
|
||||||
|
const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 });
|
||||||
|
participant.id = id;
|
||||||
|
return participant;
|
||||||
|
}
|
||||||
|
function enc(ps, ruleset = 'generic') {
|
||||||
|
return {
|
||||||
|
name: 't', participants: ps, isStarted: false, isPaused: false,
|
||||||
|
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||||
|
ruleset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||||
|
|
||||||
|
describe('generic ruleset', () => {
|
||||||
|
test('damage past 0 = negative HP, status down (character)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(-5);
|
||||||
|
expect(p.status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage exactly to 0 = status down', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(0);
|
||||||
|
expect(p.status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal from negative = conscious', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(5);
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heal caps at maxHp', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(10);
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monster death = auto-inactive (generic)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([mon('m', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id.startsWith('m'));
|
||||||
|
expect(p.currentHp).toBe(-10);
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
expect(p.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monster at exactly 0 = dead + inactive (generic)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([mon('m', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id.startsWith('m'));
|
||||||
|
expect(p.currentHp).toBe(0);
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
expect(p.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deathSave throws in generic mode', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||||
|
await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revive in generic mode: dead→conscious (no stable)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||||
|
e = await markDead(e, 'a', ctx); // dead
|
||||||
|
const newEnc = await reviveParticipant(e, 'a', ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
expect(p.currentHp).toBe(0); // HP unchanged from down
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revive generic reactivates if inactive', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([mon('m', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await markDead(e, e.participants[0].id, ctx); // dead+inactive
|
||||||
|
const newEnc = await reviveParticipant(e, e.participants[0].id, ctx);
|
||||||
|
const p = newEnc.participants[0];
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
expect(p.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markDead sets status dead (character, stays active)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||||
|
const newEnc = await markDead(e, 'a', ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
expect(p.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markDead on monster = dead + inactive', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([mon('m', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp)
|
||||||
|
const newEnc = await markDead(e, e.participants[0].id, ctx);
|
||||||
|
const p = newEnc.participants[0];
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
expect(p.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markDead on conscious = dead', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await markDead(e, 'a', ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('dead');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('damage while down = more negative (no death saves)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.currentHp).toBe(-10);
|
||||||
|
expect(p.status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no unconscious condition auto-added (generic)', async () => {
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.conditions).not.toContain('unconscious');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('down→conscious clears dead? no, dead is separate', async () => {
|
||||||
|
// sanity: down heal to conscious, dead untouched
|
||||||
|
const { ctx } = mockCtx();
|
||||||
|
let e = enc([char('a', 10)]);
|
||||||
|
e = await startEncounter(e, ctx);
|
||||||
|
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
|
||||||
|
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
|
||||||
|
const p = newEnc.participants.find(x => x.id === 'a');
|
||||||
|
expect(p.status).toBe('conscious');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// HP formula roll: parse "2d6+9" style, roll, return total.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { rollHpFormula } = shared;
|
||||||
|
|
||||||
|
describe('rollHpFormula', () => {
|
||||||
|
test('basic NdM', () => {
|
||||||
|
const r = rollHpFormula('2d6');
|
||||||
|
expect(r).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(r).toBeLessThanOrEqual(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NdM+bonus', () => {
|
||||||
|
const r = rollHpFormula('2d6+9');
|
||||||
|
expect(r).toBeGreaterThanOrEqual(11);
|
||||||
|
expect(r).toBeLessThanOrEqual(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NdM-bonus', () => {
|
||||||
|
const r = rollHpFormula('3d8-2');
|
||||||
|
expect(r).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(r).toBeLessThanOrEqual(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single die', () => {
|
||||||
|
const r = rollHpFormula('1d20');
|
||||||
|
expect(r).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(r).toBeLessThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case insensitive', () => {
|
||||||
|
const r = rollHpFormula('2D6+5');
|
||||||
|
expect(r).toBeGreaterThanOrEqual(7);
|
||||||
|
expect(r).toBeLessThanOrEqual(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whitespace tolerant', () => {
|
||||||
|
const r = rollHpFormula(' 2d6 + 9 ');
|
||||||
|
expect(r).toBeGreaterThanOrEqual(11);
|
||||||
|
expect(r).toBeLessThanOrEqual(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large die', () => {
|
||||||
|
const r = rollHpFormula('4d12+10');
|
||||||
|
expect(r).toBeGreaterThanOrEqual(14);
|
||||||
|
expect(r).toBeLessThanOrEqual(58);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid returns null', () => {
|
||||||
|
expect(rollHpFormula('abc')).toBeNull();
|
||||||
|
expect(rollHpFormula('')).toBeNull();
|
||||||
|
expect(rollHpFormula(null)).toBeNull();
|
||||||
|
expect(rollHpFormula(undefined)).toBeNull();
|
||||||
|
expect(rollHpFormula('2d')).toBeNull();
|
||||||
|
expect(rollHpFormula('d6')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero dice invalid', () => {
|
||||||
|
expect(rollHpFormula('0d6')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,13 @@ function enc(ps) {
|
|||||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||||
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
||||||
}
|
}
|
||||||
const snap = (e) => JSON.parse(JSON.stringify(e));
|
const snap = (e) => {
|
||||||
|
const o = JSON.parse(JSON.stringify(e));
|
||||||
|
// normalize nullable timestamp fields: null = absent (undo clears via null)
|
||||||
|
if (o.startedAt === null) delete o.startedAt;
|
||||||
|
if (o.endedAt === null) delete o.endedAt;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
|
||||||
describe('undo + redo roundtrip', () => {
|
describe('undo + redo roundtrip', () => {
|
||||||
test('startEncounter', async () => {
|
test('startEncounter', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Character writeback on endEncounter: sync maxHp/ac to campaign players.
|
||||||
|
const shared = require('@ttrpg/shared');
|
||||||
|
const { endEncounter } = shared;
|
||||||
|
|
||||||
|
function makeMockStorage() {
|
||||||
|
const docs = new Map();
|
||||||
|
const writes = [];
|
||||||
|
return {
|
||||||
|
getDoc: jest.fn(async (p) => docs.get(p) || null),
|
||||||
|
setDoc: jest.fn(async (p, d) => { docs.set(p, d); }),
|
||||||
|
updateDoc: jest.fn(async (p, patch) => {
|
||||||
|
writes.push({ path: p, patch });
|
||||||
|
const cur = docs.get(p) || {};
|
||||||
|
docs.set(p, { ...cur, ...patch });
|
||||||
|
}),
|
||||||
|
addDoc: jest.fn(async (p, data) => {
|
||||||
|
writes.push({ path: p, patch: data });
|
||||||
|
docs.set(p, data);
|
||||||
|
}),
|
||||||
|
deleteDoc: jest.fn(async () => {}),
|
||||||
|
getCollection: jest.fn(async () => []),
|
||||||
|
_docs: docs,
|
||||||
|
_writes: writes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEncounter(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'enc1',
|
||||||
|
name: 'Test',
|
||||||
|
campaignId: 'camp1',
|
||||||
|
isStarted: true,
|
||||||
|
isPaused: false,
|
||||||
|
currentTurnParticipantId: 'p1',
|
||||||
|
round: 3,
|
||||||
|
turnOrderIds: ['p1', 'p2'],
|
||||||
|
participants: [
|
||||||
|
{ id: 'p1', name: 'Fighter', type: 'character', originalCharacterId: 'char1', initiative: 15, maxHp: 28, currentHp: 20, ac: 18 },
|
||||||
|
{ id: 'p2', name: 'Goblin', type: 'monster', originalCharacterId: null, initiative: 12, maxHp: 7, currentHp: 7, ac: 13 },
|
||||||
|
{ id: 'p3', name: 'Cleric', type: 'character', originalCharacterId: 'char2', initiative: 10, maxHp: 22, currentHp: 22, ac: 16 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('character writeback on endEncounter', () => {
|
||||||
|
test('no writeback when syncCharacters false/missing', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
storage._docs.set('campaigns/camp1', {
|
||||||
|
id: 'camp1', name: 'Camp', players: [
|
||||||
|
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
// no campaign write
|
||||||
|
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeback updates char maxHp + ac + currentHp when syncCharacters true', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
const players = [
|
||||||
|
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
|
||||||
|
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
|
||||||
|
];
|
||||||
|
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
|
||||||
|
expect(campWrite).toBeDefined();
|
||||||
|
const updated = campWrite.patch.players;
|
||||||
|
const fighter = updated.find(p => p.id === 'char1');
|
||||||
|
expect(fighter.defaultMaxHp).toBe(28);
|
||||||
|
expect(fighter.defaultAc).toBe(18);
|
||||||
|
expect(fighter.defaultCurrentHp).toBe(20);
|
||||||
|
const cleric = updated.find(p => p.id === 'char2');
|
||||||
|
expect(cleric.defaultMaxHp).toBe(22);
|
||||||
|
expect(cleric.defaultAc).toBe(16);
|
||||||
|
expect(cleric.defaultCurrentHp).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeback skips monsters (no originalCharacterId)', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }];
|
||||||
|
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
|
||||||
|
// players array still 1 entry, goblin not added
|
||||||
|
expect(campWrite.patch.players.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undo payload includes old char values for restore', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
const players = [
|
||||||
|
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
|
||||||
|
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
|
||||||
|
];
|
||||||
|
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
|
||||||
|
storage._docs.set('logs/log1', null);
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
const logWrite = storage._writes.find(w => w.path === 'logs/log1');
|
||||||
|
expect(logWrite).toBeDefined();
|
||||||
|
const log = logWrite.patch;
|
||||||
|
expect(log.undo.characterWriteback).toBeDefined();
|
||||||
|
expect(log.undo.characterWriteback).toHaveLength(2);
|
||||||
|
const fighterOld = log.undo.characterWriteback.find(c => c.id === 'char1');
|
||||||
|
expect(fighterOld.defaultMaxHp).toBe(30);
|
||||||
|
expect(fighterOld.defaultAc).toBe(17);
|
||||||
|
expect(fighterOld.defaultCurrentHp).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeback no-op if character missing from campaign', async () => {
|
||||||
|
const storage = makeMockStorage();
|
||||||
|
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [], syncCharacters: true });
|
||||||
|
const enc = makeEncounter();
|
||||||
|
const ctx = {
|
||||||
|
storage,
|
||||||
|
encounterPath: 'campaigns/camp1/encounters/enc1',
|
||||||
|
logPath: 'logs/log1',
|
||||||
|
logCollection: 'logs',
|
||||||
|
displayPath: 'activeDisplay/status',
|
||||||
|
};
|
||||||
|
await endEncounter(enc, ctx);
|
||||||
|
// no writeback (no matching chars = no change)
|
||||||
|
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
+289
-21
@@ -28,6 +28,22 @@ const generateId = () =>
|
|||||||
|
|
||||||
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
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) => {
|
const formatInitMod = (mod) => {
|
||||||
if (mod === undefined || mod === null) return 'N/A';
|
if (mod === undefined || mod === null) return 'N/A';
|
||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
@@ -102,6 +118,8 @@ function snapshotOf(enc) {
|
|||||||
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
||||||
turnOrderIds: [...(enc.turnOrderIds || [])],
|
turnOrderIds: [...(enc.turnOrderIds || [])],
|
||||||
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
||||||
|
startedAt: enc.startedAt ?? null,
|
||||||
|
endedAt: enc.endedAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +225,7 @@ function expandUndo(entry, currentEnc) {
|
|||||||
case 'death_save':
|
case 'death_save':
|
||||||
case 'stabilize':
|
case 'stabilize':
|
||||||
case 'revive':
|
case 'revive':
|
||||||
|
case 'mark_dead':
|
||||||
case 'deactivate_dead_monster': {
|
case 'deactivate_dead_monster': {
|
||||||
// Full death-state restore: currentHp, status, death-save counters,
|
// Full death-state restore: currentHp, status, death-save counters,
|
||||||
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
||||||
@@ -304,12 +323,18 @@ function expandUndo(entry, currentEnc) {
|
|||||||
redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'start_encounter':
|
case 'start_encounter': {
|
||||||
|
return {
|
||||||
|
encounterPath: encPath,
|
||||||
|
updates: { ...u, startedAt: u.startedAt ?? null },
|
||||||
|
redo: { isStarted: true, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], startedAt: snap.startedAt ?? Date.now() },
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'end_encounter': {
|
case 'end_encounter': {
|
||||||
return {
|
return {
|
||||||
encounterPath: encPath,
|
encounterPath: encPath,
|
||||||
updates: u,
|
updates: { ...u, endedAt: u.endedAt ?? null },
|
||||||
redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -335,6 +360,9 @@ function makeParticipant(opts) {
|
|||||||
status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
|
status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
|
||||||
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
||||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||||
|
hpFormula: opts.hpFormula || null,
|
||||||
|
ac: opts.ac !== undefined ? opts.ac : null,
|
||||||
|
tempHp: opts.tempHp !== undefined ? opts.tempHp : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,24 +371,37 @@ function buildCharacterParticipant(character) {
|
|||||||
const modifier = character.defaultInitMod || 0;
|
const modifier = character.defaultInitMod || 0;
|
||||||
const finalInitiative = initiativeRoll + modifier;
|
const finalInitiative = initiativeRoll + modifier;
|
||||||
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
|
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
|
||||||
|
const currentHp = character.defaultCurrentHp != null ? character.defaultCurrentHp : maxHp;
|
||||||
return {
|
return {
|
||||||
participant: makeParticipant({
|
participant: makeParticipant({
|
||||||
name: character.name,
|
name: character.name,
|
||||||
type: 'character',
|
type: character.isNpc ? 'npc' : 'character',
|
||||||
originalCharacterId: character.id,
|
originalCharacterId: character.id,
|
||||||
initiative: finalInitiative,
|
initiative: finalInitiative,
|
||||||
maxHp,
|
maxHp,
|
||||||
currentHp: maxHp,
|
currentHp,
|
||||||
|
ac: character.defaultAc !== undefined ? character.defaultAc : null,
|
||||||
}),
|
}),
|
||||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
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 initiativeRoll = rollD20();
|
||||||
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
||||||
const finalInitiative = initiativeRoll + modifier;
|
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 {
|
return {
|
||||||
participant: makeParticipant({
|
participant: makeParticipant({
|
||||||
name,
|
name,
|
||||||
@@ -368,8 +409,10 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
|
|||||||
initiative: finalInitiative,
|
initiative: finalInitiative,
|
||||||
maxHp: hp,
|
maxHp: hp,
|
||||||
currentHp: hp,
|
currentHp: hp,
|
||||||
|
hpFormula: hpFormula || null,
|
||||||
|
ac: ac !== undefined ? ac : null,
|
||||||
}),
|
}),
|
||||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +433,8 @@ async function startEncounter(encounter, ctx) {
|
|||||||
participants: sortedParticipants,
|
participants: sortedParticipants,
|
||||||
currentTurnParticipantId: firstActive.id,
|
currentTurnParticipantId: firstActive.id,
|
||||||
turnOrderIds: sortedParticipants.map(p => p.id),
|
turnOrderIds: sortedParticipants.map(p => p.id),
|
||||||
|
startedAt: Date.now(),
|
||||||
|
endedAt: null,
|
||||||
};
|
};
|
||||||
const log = {
|
const log = {
|
||||||
type: 'start_encounter',
|
type: 'start_encounter',
|
||||||
@@ -402,6 +447,8 @@ async function startEncounter(encounter, ctx) {
|
|||||||
round: encounter.round ?? 0,
|
round: encounter.round ?? 0,
|
||||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||||
|
startedAt: encounter.startedAt ?? null,
|
||||||
|
endedAt: encounter.endedAt ?? null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return commit(encounter, patch, log, ctx);
|
return commit(encounter, patch, log, ctx);
|
||||||
@@ -478,14 +525,20 @@ async function addParticipant(encounter, participant, ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addParticipants(encounter, newParticipants, ctx) {
|
async function addParticipants(encounter, newParticipants, ctx) {
|
||||||
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
// Slot each new participant into list by initiative (desc), preserve
|
||||||
|
// existing order + drag-established ties. Matches addParticipant semantics.
|
||||||
|
let base = [...(encounter.participants || [])];
|
||||||
|
for (const np of newParticipants) {
|
||||||
|
const idx = slotIndexForInit(base, np.initiative);
|
||||||
|
base.splice(idx, 0, np);
|
||||||
|
}
|
||||||
const names = newParticipants.map(p => p.name).join(', ');
|
const names = newParticipants.map(p => p.name).join(', ');
|
||||||
const patch = { participants: updatedParticipants };
|
const patch = { participants: base, ...syncTurnOrder(base) };
|
||||||
const log = {
|
const log = {
|
||||||
type: 'add_participants',
|
type: 'add_participants',
|
||||||
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
|
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
|
||||||
delta: { count: newParticipants.length },
|
delta: { count: newParticipants.length },
|
||||||
undo: { participants: newParticipants },
|
undo: { participants: newParticipants, newTurnOrderIds: patch.turnOrderIds },
|
||||||
};
|
};
|
||||||
return commit(encounter, patch, log, ctx);
|
return commit(encounter, patch, log, ctx);
|
||||||
}
|
}
|
||||||
@@ -571,9 +624,29 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
|||||||
return commit(encounter, patch, log, 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) {
|
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
||||||
const ctx = maybeCtx || optionsOrCtx;
|
const ctx = maybeCtx || optionsOrCtx;
|
||||||
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
||||||
|
const ruleset = encounter.ruleset || '5e';
|
||||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
if (!participant) throw new Error('Participant not found.');
|
if (!participant) throw new Error('Participant not found.');
|
||||||
if (isNaN(amount) || amount === 0) return encounter;
|
if (isNaN(amount) || amount === 0) return encounter;
|
||||||
@@ -584,6 +657,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
|
|
||||||
const oldValues = {
|
const oldValues = {
|
||||||
currentHp: participant.currentHp,
|
currentHp: participant.currentHp,
|
||||||
|
tempHp: participant.tempHp || 0,
|
||||||
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
|
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
|
||||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||||
@@ -595,8 +669,42 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
let message = '';
|
let message = '';
|
||||||
let logDelta = { amount, from: participant.currentHp };
|
let logDelta = { amount, from: participant.currentHp };
|
||||||
const status = oldValues.status;
|
const status = oldValues.status;
|
||||||
|
const currentTemp = participant.tempHp || 0;
|
||||||
|
|
||||||
|
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
|
||||||
|
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||||
|
if (ruleset === 'generic') {
|
||||||
|
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if (changeType === 'damage') {
|
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 (participant.currentHp === 0) {
|
||||||
if (status === 'dead') {
|
if (status === 'dead') {
|
||||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||||
@@ -628,23 +736,23 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
const add = options.isCriticalHit ? 2 : 1;
|
const add = options.isCriticalHit ? 2 : 1;
|
||||||
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
|
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
|
||||||
if (failures >= 3) {
|
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 {
|
} 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'}`;
|
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
|
||||||
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
|
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
|
||||||
} else if (amount >= participant.currentHp + participant.maxHp) {
|
} 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`;
|
message = `Massive damage! ${participant.name} instantly killed`;
|
||||||
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
|
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
|
||||||
} else {
|
} else {
|
||||||
const newHp = Math.max(0, participant.currentHp - amount);
|
const newHp = Math.max(0, participant.currentHp - amount);
|
||||||
if (newHp === 0) {
|
if (newHp === 0) {
|
||||||
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
|
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 {
|
} else {
|
||||||
updates = { currentHp: newHp, status: 'conscious' };
|
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
|
||||||
}
|
}
|
||||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||||
@@ -683,7 +791,99 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
|||||||
return commit(encounter, patch, log, ctx);
|
return commit(encounter, patch, log, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
|
||||||
|
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||||
|
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
|
||||||
|
const participantId = participant.id;
|
||||||
|
let updates, message, logDelta = { amount, from: participant.currentHp };
|
||||||
|
const currentTemp = participant.tempHp || 0;
|
||||||
|
|
||||||
|
if (changeType === 'damage') {
|
||||||
|
// Temp HP absorbs damage first.
|
||||||
|
let remainingDmg = amount;
|
||||||
|
let tempAfter = currentTemp;
|
||||||
|
if (tempAfter > 0) {
|
||||||
|
const absorbed = Math.min(tempAfter, remainingDmg);
|
||||||
|
tempAfter -= absorbed;
|
||||||
|
remainingDmg -= absorbed;
|
||||||
|
}
|
||||||
|
if (remainingDmg === 0) {
|
||||||
|
const updatedParticipants0 = (encounter.participants || []).map(p =>
|
||||||
|
p.id === participantId ? { ...p, tempHp: tempAfter } : p
|
||||||
|
);
|
||||||
|
const log0 = {
|
||||||
|
type: 'hp_change', participantId, participantName: participant.name,
|
||||||
|
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
|
||||||
|
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
|
||||||
|
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
|
||||||
|
};
|
||||||
|
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
|
||||||
|
}
|
||||||
|
amount = remainingDmg;
|
||||||
|
const tempFinal = tempAfter;
|
||||||
|
if (oldValues.status === 'dead') {
|
||||||
|
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
|
p.id === participantId ? { ...p, isActive: false } : p
|
||||||
|
);
|
||||||
|
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||||
|
const newValues = {
|
||||||
|
currentHp: newP.currentHp, status: newP.status,
|
||||||
|
deathSaveSuccesses: 0, deathSaveFailures: 0,
|
||||||
|
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||||
|
};
|
||||||
|
return commit(encounter, { participants: updatedParticipants }, {
|
||||||
|
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
|
||||||
|
message: `${participant.name} marked inactive (dead monster)`,
|
||||||
|
delta: { status: 'dead', isActive: false },
|
||||||
|
undo: { oldValues, newValues },
|
||||||
|
}, ctx);
|
||||||
|
}
|
||||||
|
return encounter;
|
||||||
|
}
|
||||||
|
const newHp = participant.currentHp - amount;
|
||||||
|
const isMonster = participant.type === 'monster';
|
||||||
|
if (newHp <= 0 && isMonster) {
|
||||||
|
updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal };
|
||||||
|
} else if (newHp <= 0) {
|
||||||
|
updates = { currentHp: newHp, status: 'down', tempHp: tempFinal };
|
||||||
|
} else {
|
||||||
|
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
|
||||||
|
}
|
||||||
|
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||||
|
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||||
|
} else if (changeType === 'heal') {
|
||||||
|
if (oldValues.status === 'dead') return encounter;
|
||||||
|
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
|
||||||
|
updates = { currentHp: newHp, status: 'conscious' };
|
||||||
|
message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`;
|
||||||
|
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown HP change type: ${changeType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
|
p.id === participantId ? { ...p, ...updates } : p
|
||||||
|
);
|
||||||
|
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||||
|
const newValues = {
|
||||||
|
currentHp: newP.currentHp, status: newP.status,
|
||||||
|
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||||
|
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||||
|
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||||
|
};
|
||||||
|
const undoAmount = changeType === 'damage' ? amount : -amount;
|
||||||
|
return commit(encounter, { participants: updatedParticipants }, {
|
||||||
|
type: changeType, participantId, participantName: participant.name,
|
||||||
|
message, delta: logDelta,
|
||||||
|
undo: { amount: undoAmount, oldValues, newValues },
|
||||||
|
}, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
async function deathSave(encounter, participantId, outcome, ctx) {
|
async function deathSave(encounter, participantId, outcome, ctx) {
|
||||||
|
if ((encounter.ruleset || '5e') === 'generic') {
|
||||||
|
throw new Error('Death saves not available in generic ruleset.');
|
||||||
|
}
|
||||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
if (!participant) throw new Error('Participant not found.');
|
if (!participant) throw new Error('Participant not found.');
|
||||||
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||||
@@ -764,6 +964,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function stabilizeParticipant(encounter, participantId, ctx) {
|
async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||||
|
if ((encounter.ruleset || '5e') === 'generic') {
|
||||||
|
throw new Error('Stabilize not available in generic ruleset.');
|
||||||
|
}
|
||||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
if (!participant) throw new Error('Participant not found.');
|
if (!participant) throw new Error('Participant not found.');
|
||||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||||
@@ -806,12 +1009,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reviveParticipant(encounter, participantId, ctx) {
|
async function reviveParticipant(encounter, participantId, ctx) {
|
||||||
|
const ruleset = encounter.ruleset || '5e';
|
||||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
if (!participant) throw new Error('Participant not found.');
|
if (!participant) throw new Error('Participant not found.');
|
||||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||||
if (status !== 'dead') return encounter;
|
if (status !== 'dead') return encounter;
|
||||||
|
|
||||||
const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
|
// Generic: dead→conscious (no stable state). HP unchanged (may be negative).
|
||||||
|
// 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
|
||||||
|
const updates = ruleset === 'generic'
|
||||||
|
? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
|
||||||
|
: { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
|
||||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||||
);
|
);
|
||||||
@@ -845,6 +1053,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
|||||||
return commit(encounter, patch, log, ctx);
|
return commit(encounter, patch, log, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
|
||||||
|
async function markDead(encounter, participantId, ctx) {
|
||||||
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||||
|
if (!participant) throw new Error('Participant not found.');
|
||||||
|
if (participant.status === 'dead') return encounter;
|
||||||
|
const oldValues = {
|
||||||
|
currentHp: participant.currentHp,
|
||||||
|
status: participant.status || 'conscious',
|
||||||
|
isActive: participant.isActive,
|
||||||
|
conditions: [...(participant.conditions || [])],
|
||||||
|
};
|
||||||
|
const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||||
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||||
|
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||||
|
);
|
||||||
|
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||||
|
const newValues = {
|
||||||
|
currentHp: newP.currentHp, status: newP.status,
|
||||||
|
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||||
|
};
|
||||||
|
return commit(encounter, { participants: updatedParticipants }, {
|
||||||
|
type: 'mark_dead', participantId, participantName: participant.name,
|
||||||
|
message: `${participant.name} marked dead`,
|
||||||
|
delta: { status: 'dead' },
|
||||||
|
undo: { oldValues, newValues },
|
||||||
|
}, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||||
const participants = [...(encounter.participants || [])];
|
const participants = [...(encounter.participants || [])];
|
||||||
const dragged = participants.find(p => p.id === draggedId);
|
const dragged = participants.find(p => p.id === draggedId);
|
||||||
@@ -884,13 +1120,45 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function endEncounter(encounter, ctx) {
|
async function endEncounter(encounter, ctx) {
|
||||||
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] };
|
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [], endedAt: Date.now() };
|
||||||
const log = {
|
const log = {
|
||||||
type: 'end_encounter',
|
type: 'end_encounter',
|
||||||
message: `Combat ended: "${encounter.name}"`,
|
message: `Combat ended: "${encounter.name}"`,
|
||||||
delta: {},
|
delta: {},
|
||||||
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])] },
|
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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);
|
return commit(encounter, patch, log, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,13 +1181,13 @@ async function toggleHidePlayerHp(currentValue, ctx) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
|
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
|
||||||
generateId, rollD20, formatInitMod,
|
generateId, rollD20, rollHpFormula, formatInitMod,
|
||||||
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
|
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
|
||||||
snapshotOf, buildEntry, expandUndo,
|
snapshotOf, buildEntry, expandUndo,
|
||||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||||
startEncounter, nextTurn, togglePause,
|
startEncounter, nextTurn, togglePause,
|
||||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
|
toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||||
reorderParticipants, endEncounter,
|
reorderParticipants, endEncounter,
|
||||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||||
};
|
};
|
||||||
|
|||||||
+1014
-174
File diff suppressed because it is too large
Load Diff
@@ -105,9 +105,9 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs.
|
// Apply Firestore-style query constraints (orderBy desc/asc, limit, where)
|
||||||
// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit
|
// to mock docs. Mirrors real SDK semantics for contract tests. Offset handled
|
||||||
// supported (App's LOG_QUERY uses exactly these).
|
// by adapter (firebase.js slices) — mock never sees it.
|
||||||
function applyConstraints(docs, constraints) {
|
function applyConstraints(docs, constraints) {
|
||||||
let out = [...docs];
|
let out = [...docs];
|
||||||
for (const c of constraints) {
|
for (const c of constraints) {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -294,6 +294,7 @@ function runStorageContract(name, factory) {
|
|||||||
describe('subscribeCollection queryConstraints', () => {
|
describe('subscribeCollection queryConstraints', () => {
|
||||||
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
|
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
|
||||||
const limitC = (n) => ({ __type: 'limit', n });
|
const limitC = (n) => ({ __type: 'limit', n });
|
||||||
|
const offsetC = (n) => ({ __type: 'offset', offset: n });
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// seed 5 log docs, timestamps out of order
|
// seed 5 log docs, timestamps out of order
|
||||||
@@ -324,6 +325,23 @@ function runStorageContract(name, factory) {
|
|||||||
});
|
});
|
||||||
expect(result).toHaveLength(5);
|
expect(result).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log page pagination (LogsView pageQuery): orderBy + limit + offset.
|
||||||
|
// Server pushes offset to SQL; firebase adapter emulates (widen limit,
|
||||||
|
// slice). Both MUST return the same page.
|
||||||
|
test('offset skips first N after ordering (pagination)', async () => {
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(2)]);
|
||||||
|
});
|
||||||
|
expect(result.map(d => d.msg)).toEqual(['two', 'four']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offset past end returns empty page', async () => {
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(10)]);
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-21
@@ -71,6 +71,27 @@ export function getAuthInstance() { return authInstance; }
|
|||||||
// App.js can now import { storage } and call storage.setDoc(path, data).
|
// App.js can now import { storage } and call storage.setDoc(path, data).
|
||||||
// Hooks (useFirestoreDocument etc) still use SDK directly for now.
|
// Hooks (useFirestoreDocument etc) still use SDK directly for now.
|
||||||
|
|
||||||
|
// Translate neutral {__type} constraints (src/storage/index.js builders) to
|
||||||
|
// SDK builders. The client SDK has no offset, so emulate it: widen limit to
|
||||||
|
// offset+limit and report offsetN for the caller to slice off the leading
|
||||||
|
// docs. Read cost matches native offset (admin SDK also bills skipped docs);
|
||||||
|
// the server adapter pushes offset into SQL instead. Unknown constraint types
|
||||||
|
// are dropped — a raw object handed to query() throws and white-screens.
|
||||||
|
function toSdkConstraints(queryConstraints) {
|
||||||
|
let offsetN = 0;
|
||||||
|
let limitN = null;
|
||||||
|
const fbConstraints = [];
|
||||||
|
for (const c of queryConstraints || []) {
|
||||||
|
if (!c || !c.__type) continue;
|
||||||
|
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
|
||||||
|
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
|
||||||
|
else if (c.__type === 'limit') limitN = c.n;
|
||||||
|
else if (c.__type === 'offset') offsetN = c.offset || 0;
|
||||||
|
}
|
||||||
|
if (limitN !== null) fbConstraints.push(limit(limitN + offsetN));
|
||||||
|
return { fbConstraints, offsetN };
|
||||||
|
}
|
||||||
|
|
||||||
export function createFirebaseStorage() {
|
export function createFirebaseStorage() {
|
||||||
const db = dbInstance;
|
const db = dbInstance;
|
||||||
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
|
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
|
||||||
@@ -101,18 +122,10 @@ export function createFirebaseStorage() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getCollection(collectionPath, queryConstraints = []) {
|
async getCollection(collectionPath, queryConstraints = []) {
|
||||||
// Translate neutral {__type} constraints to firebase SDK builders.
|
const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
|
||||||
const fbConstraints = [];
|
|
||||||
for (const c of queryConstraints || []) {
|
|
||||||
if (!c || !c.__type) continue;
|
|
||||||
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
|
|
||||||
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
|
|
||||||
else if (c.__type === 'limit') fbConstraints.push(limit(c.n));
|
|
||||||
// firebase SDK has no offset; skip (UI uses server adapter in dev).
|
|
||||||
}
|
|
||||||
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
|
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
|
||||||
const snapshot = await getDocsReal(q);
|
const snapshot = await getDocsReal(q);
|
||||||
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
|
return snapshot.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() }));
|
||||||
},
|
},
|
||||||
|
|
||||||
async countCollection(collectionPath) {
|
async countCollection(collectionPath) {
|
||||||
@@ -174,19 +187,12 @@ export function createFirebaseStorage() {
|
|||||||
|
|
||||||
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
|
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
|
||||||
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
||||||
// queryConstraints = neutral {__type} builders (from index.js).
|
const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
|
||||||
// Translate to SDK orderBy/limit.
|
const q = fbConstraints.length > 0
|
||||||
const sdkConstraints = queryConstraints.map(c => {
|
? query(collection(db, collectionPath), ...fbConstraints)
|
||||||
if (c.__type === 'where') return where(c.field, c.op, c.value);
|
|
||||||
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
|
|
||||||
if (c.__type === 'limit') return limit(c.n);
|
|
||||||
return c; // pass-through (forward compat)
|
|
||||||
});
|
|
||||||
const q = sdkConstraints.length > 0
|
|
||||||
? query(collection(db, collectionPath), ...sdkConstraints)
|
|
||||||
: collection(db, collectionPath);
|
: collection(db, collectionPath);
|
||||||
return onSnapshot(q, (snap) => {
|
return onSnapshot(q, (snap) => {
|
||||||
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
cb(snap.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() })));
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
console.error(`subscribeCollection ${collectionPath}:`, err);
|
console.error(`subscribeCollection ${collectionPath}:`, err);
|
||||||
if (typeof errCb === 'function') errCb(err);
|
if (typeof errCb === 'function') errCb(err);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('Campaign -> Firebase', () => {
|
|||||||
|
|
||||||
// CharacterManager form
|
// CharacterManager form
|
||||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
|
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.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
describe('DisplayView characterization', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
const favicon = document.createElement('link');
|
||||||
|
favicon.rel = 'icon';
|
||||||
|
favicon.href = '/favicon.ico';
|
||||||
|
document.head.appendChild(favicon);
|
||||||
window.history.replaceState({}, '', '/display');
|
window.history.replaceState({}, '', '/display');
|
||||||
global.alert = jest.fn();
|
global.alert = jest.fn();
|
||||||
window.open = jest.fn();
|
window.open = jest.fn();
|
||||||
@@ -46,9 +50,18 @@ describe('DisplayView characterization', () => {
|
|||||||
Element.prototype.scrollIntoView = jest.fn();
|
Element.prototype.scrollIntoView = jest.fn();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
document.querySelectorAll('link[rel="icon"]').forEach(link => link.remove());
|
||||||
window.history.replaceState({}, '', '/');
|
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 () => {
|
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
|
||||||
seedActiveDisplay();
|
seedActiveDisplay();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('FEAT-3 reslot: inline init change reorders list', () => {
|
|||||||
const addOne = async (name, hp, mod, init) => {
|
const addOne = async (name, hp, mod, init) => {
|
||||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
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(/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.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ async function addCharacterToEncounter(name = 'Hero', hp = 10) {
|
|||||||
await selectCampaignByName(`DS-${name}`);
|
await selectCampaignByName(`DS-${name}`);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 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 }));
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||||
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
|
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;
|
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 () => {
|
test('toggleActive: updateDoc flips isActive', async () => {
|
||||||
await setupReady();
|
await setupReady();
|
||||||
await addMonsterViaUI('Toggle', 10, 0);
|
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);
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false);
|
||||||
expect(lastEncCall().data.participants[0].isActive).toBe(false);
|
expect(lastEncCall().data.participants[0].isActive).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -116,7 +116,7 @@ describe('Participant -> Firebase', () => {
|
|||||||
test('toggleCondition: updateDoc adds condition to array', async () => {
|
test('toggleCondition: updateDoc adds condition to array', async () => {
|
||||||
await setupReady();
|
await setupReady();
|
||||||
await addMonsterViaUI('Cond', 10, 0);
|
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 }));
|
await waitFor(() => screen.getByRole('button', { name: /Blinded/i }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
|
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ function lastParticipantsUpdate() {
|
|||||||
async function addOne(form, name, hp, mod, init) {
|
async function addOne(form, name, hp, mod, init) {
|
||||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
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(/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.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
|
||||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -57,7 +57,7 @@ describe('reslot on all mutation paths', () => {
|
|||||||
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
|
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
|
||||||
|
|
||||||
// open edit modal for Goblin, bump init to 8
|
// 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'));
|
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
|
||||||
fireEvent.click(goblinEdit);
|
fireEvent.click(goblinEdit);
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,7 +74,7 @@ export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
|
|||||||
const form = within(getParticipantForm());
|
const form = within(getParticipantForm());
|
||||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
|
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(/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 }));
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||||
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
const { getCalls } = require('../__mocks__/firebase/_mock-db');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user