WIP: add ttrpg-encounter-builder skill + server-mode API docs
UNTESTED work in progress. Do not assume correct. Scope: SERVER BACKEND ONLY (SQLite/Express, REACT_APP_STORAGE=server). Does NOT work with Firebase SDK mode — no HTTP backend there, different transport/auth. Skill + doc explicitly call this out. - .agents/skills/ttrpg-encounter-builder/: harness-agnostic skill (pi/claude/codex via .agents/skills + symlinks). SKILL.md + helper script that batch-writes encounters via REST, rolls initiative, verifies. - docs/ENCOUNTER_BUILDER.md: add Path normalization section, Build flow (API/scripts) section with REST endpoint table, object templates, recipe. Server-mode-only caveat noted. Helper script syntax-checked + campaign lookup verified against running instance, but full seed flow not regression-tested against test suite.
This commit is contained in:
@@ -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); });
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user