From 43b1f6ce41e1731b59d04dc8871bae1d869137aa Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:42:02 -0400 Subject: [PATCH] WIP: add ttrpg-encounter-builder skill + server-mode API docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../skills/ttrpg-encounter-builder/SKILL.md | 294 ++++++++++++++++++ .../scripts/build-encounters.js | 149 +++++++++ docs/ENCOUNTER_BUILDER.md | 108 +++++++ 3 files changed, 551 insertions(+) create mode 100644 .agents/skills/ttrpg-encounter-builder/SKILL.md create mode 100644 .agents/skills/ttrpg-encounter-builder/scripts/build-encounters.js diff --git a/.agents/skills/ttrpg-encounter-builder/SKILL.md b/.agents/skills/ttrpg-encounter-builder/SKILL.md new file mode 100644 index 0000000..89ff02c --- /dev/null +++ b/.agents/skills/ttrpg-encounter-builder/SKILL.md @@ -0,0 +1,294 @@ +--- +name: ttrpg-encounter-builder +description: | + Seeds D&D encounter rosters into the TTRPG Initiative Tracker via its REST API + (no UI clicking). Use when the user asks to "set up encounters", "build + encounters", "add participants to a campaign", "seed this encounter list", + or provides a statblock list (name + HP + init) for a campaign running on a + self-hosted tracker instance running in SERVER MODE (bundled Express + backend). NOT for Firebase SDK mode — that has no HTTP backend; write path + differs entirely. Harness-agnostic: pi, Claude Code, and Codex all run the + same script + curl recipes. +--- + +# TTRPG Encounter Builder + +Build encounter rosters directly via the tracker REST API. No UI clicking, no +browser automation. One batch write per session, then verify. + +## When to use + +User provides a campaign name + a list of encounters, each with named +participants carrying HP and initiative modifiers (or rolled initiative). +Examples: + +- "set up these encounters in the 2026 D&D campaign on bee.icantclick.org:8080" +- "build encounter: Barrowdeep 1 - Ankheg HP 45 Init +0" +- "seed these statblocks into my tracker" + +Do NOT use for: starting combat, rolling HP mid-fight, editing live combat +state, or driving the player display. Those are in-combat ops, not build ops. + +## Prerequisites + +- A running tracker instance in **SERVER MODE** (`REACT_APP_STORAGE=server`, + bundled Express backend mounted). Reachable over HTTP. + - **Firebase SDK mode NOT supported.** No `/api/*` endpoints exist; data + lives in Firestore via the in-browser SDK with different auth + write + paths. This skill cannot reach it. If the user is on firebase mode, tell + them: switch to server mode, or build encounters via the UI / a + firebase-admin script (out of scope here). +- Node.js 18+ (for `fetch` + `crypto.randomUUID`). Helper script uses only + builtins — no `npm install`. +- Campaign must already exist (created via UI or `POST /api/collection`). + This skill adds encounters to an existing campaign. + +Confirm host reachable + server mode before building: + +```bash +curl -s /api/collection?path=campaigns | head -c 200 +``` + +Returns JSON array of campaign docs = server mode confirmed. HTML (SPA shell) += firebase mode OR backend not mounted. Either way, this skill won't work — +tell the user. + +## Data model (read this before writing) + +Three nested entities, all opaque JSON docs in a path-keyed KV store: + +``` +Campaign campaigns/{campaignId} + └─ Encounter campaigns/{campaignId}/encounters/{encounterId} + └─ Participant (inline array item on the encounter doc) +``` + +**Participants are inline.** There is NO `participants` sub-collection. A +`participants` path returns empty. Each encounter doc carries its full roster +in a `participants[]` array. + +### Path normalization (gotcha) + +App code uses firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). +The REST API uses **bare canonical** paths (`campaigns/...`). The adapter +strips the prefix server-side. + +- REST / scripts / direct DB → bare: `campaigns/{cid}/encounters/{eid}` +- If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used + the prefixed form. Drop the prefix. + +### Encounter doc shape + +```js +{ + name: 'Barrowdeep 1 - Antechamber', + createdAt: new Date().toISOString(), + participants: [], // inline roster + round: 0, + currentTurnParticipantId: null, + isStarted: false, + isPaused: false, + ruleset: '5e', +} +``` + +Build encounters with `round: 0`, `isStarted: false`. Starting combat is a +separate in-combat op (UI Start button or a later skill). + +### Participant object shape + +```js +{ + id: , // crypto.randomUUID(), client-generated + name: 'Ankheg', + type: 'monster', // 'monster' | 'npc' | 'character' + originalCharacterId: null, // set only if type === 'character' + initiative: , // rolled ONCE at add; stored, not re-derived + maxHp: 45, + currentHp: 45, // start at full + conditions: [], + isActive: true, + status: 'conscious', + deathSaveSuccesses: 0, // character/npc only + deathSaveFailures: 0, // character/npc only +} +``` + +### type field + +- `monster` — DM-controlled, dead at 0 HP (auto-inactive). Default. +- `npc` — DM-controlled but keeps death saves (revivable). Use for named + allies/villains the DM runs. +- `character` — player character. Requires `originalCharacterId` linking to a + campaign roster entry. For build-from-statblock, use `monster` or `npc`, + not `character`. + +### Initiative semantics + +- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], rolled fresh per + participant). +- Manual override: set `initiative` directly (pre-rolled values), skip the + roll. `initMod` is NOT stored on the participant — only the final + `initiative` value persists. +- Fixed at add time. Never re-derived after. + +### id format + +`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`). +Generate participant ids client-side so they're stable across batch writes. +Server `POST /api/collection` auto-generates a UUID for the doc id — fine for +campaigns/encounters, but for inline participants you generate them yourself. + +## REST endpoints + +All paths bare canonical. + +| Method | Path | Body / Query | Action | +|---|---|---|---| +| `GET` | `/api/doc` | `?path=` | read one doc | +| `PUT` | `/api/doc` | `{path, data}` | replace doc | +| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss | +| `DELETE` | `/api/doc` | `?path=` | delete one doc | +| `GET` | `/api/collection` | `?path=` | list docs under collection | +| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection | +| `POST` | `/api/batch` | `{ops:[{type, path, data?}]}` | atomic multi-write | + +`POST /api/batch` is the workhorse for seeding. One batch, all encounters, +atomic. + +## Build flow + +### 1. Parse the user's request + +Extract: +- **Host** — the running tracker URL (e.g. `http://bee.icantclick.org:8080`). + If omitted, ask. Do not guess. +- **Campaign name** — must match an existing campaign doc's `name` field. +- **Encounters** — list of `{name, participants:[{name, type?, maxHp, initMod?|initiative?}]}`. + +A statblock line like `Ankheg - HP 45 - Init +0` parses to: +`{name:"Ankheg", type:"monster", maxHp:45, initMod:0}`. + +### 2. Resolve the campaign id + +Campaigns are keyed by id, not name. Look up by name: + +```bash +curl -s "/api/collection?path=campaigns" \ + | jq -r '.[] | select(.name=="") | .id' +``` + +Exactly one match expected. Zero → campaign doesn't exist (tell user to create +it first). Multiple → ambiguous name, ask user which id. + +### 3. Write encounters (two options) + +#### Option A — helper script (recommended) + +`scripts/build-encounters.js` handles id gen, initiative rolling, batch write, +and verification. Reads a spec file or stdin. + +Spec format (JSON array): +```json +[ + { + "name": "Optional Road/Trail Ankheg", + "participants": [ + { "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 } + ] + }, + { + "name": "Barrowdeep 2 - Minor Burial Chamber", + "participants": [ + { "name": "Cult Fanatic", "type": "monster", "maxHp": 39, "initMod": 2 }, + { "name": "Cultist 1", "type": "monster", "maxHp": 9, "initMod": 2 }, + { "name": "Cultist 2", "type": "monster", "maxHp": 9, "initMod": 2 } + ] + } +] +``` + +Run (resolve script path relative to this skill dir): +```bash +node scripts/build-encounters.js "" spec.json +# or stdin: +cat spec.json | node scripts/build-encounters.js "" - +``` + +Script prints each encounter id + participant init rolls, then verifies. + +#### Option B — raw curl (any harness, no node) + +Build the batch payload inline. Generate UUIDs and roll initiative yourself. +One `POST /api/batch` writes everything: + +```bash +curl -s -X POST "/api/batch" \ + -H 'Content-Type: application/json' \ + -d '{ + "ops": [ + { + "type": "set", + "path": "campaigns/CID/encounters/EID1", + "data": { + "name": "Optional Road/Trail Ankheg", + "createdAt": "2026-07-07T00:00:00.000Z", + "participants": [ + { "id": "UUID1", "name": "Ankheg", "type": "monster", "originalCharacterId": null, "initiative": 15, "maxHp": 45, "currentHp": 45, "conditions": [], "isActive": true, "status": "conscious", "deathSaveSuccesses": 0, "deathSaveFailures": 0 } + ], + "round": 0, "currentTurnParticipantId": null, "isStarted": false, "isPaused": false, "ruleset": "5e" + } + } + ] + }' +``` + +Response: `{"ok":true}`. If you get an error, check path is bare (no +`artifacts/` prefix) and JSON is valid. + +### 4. Verify + +Always verify after write. Don't trust the batch `ok` alone. + +```bash +curl -s "/api/collection?path=campaigns/CID/encounters" \ + | jq '.[] | {name, participants: (.participants|length), started: .isStarted}' +``` + +Expect one object per encounter with correct participant count and +`started: false`. + +### 5. Report back to user + +Tell the user: +- Host + campaign name resolved +- Per encounter: name, id, participant count, rolled initiatives +- Verification result (counts matched) +- That combat is NOT started (encounters sit at `round: 0, isStarted: false`). + User starts combat via the UI Start button when ready. + +## Common mistakes + +| Symptom | Cause | Fix | +|---|---|---| +| `GET /api/collection?path=artifacts/.../campaigns` returns `[]` | used firebase-prefixed path | drop `artifacts/{APP_ID}/public/data/` prefix, use bare `campaigns` | +| `GET /health` or collection returns HTML (SPA shell) | server in firebase mode, or backend not mounted | confirm `REACT_APP_STORAGE=server` + backend running on the instance | +| Batch returns `ok` but encounter missing | wrong campaign id, or path typo | re-resolve campaign id by name; check path segments | +| Participant count mismatch on verify | participant objects malformed / dropped in JSON | validate each has all required fields; use script to avoid hand-JSON errors | +| Initiative looks wrong | forgot `initiative` is stored value, not re-derived | roll `d20 + initMod` once at build, store the int. No `initMod` field on participant. | +| Duplicate cultist names across encounters | fine — names need only be unique WITHIN an encounter, not across | no fix needed | + +## Out of scope + +- Creating campaigns (use UI or `POST /api/collection` with a campaign doc) +- Starting combat / advancing turns / HP damage / death saves (in-combat ops) +- Player display control (`activeDisplay/status`) +- Firebase SDK mode (this skill is server-mode REST only) +- Campaign roster characters (`players[]` on campaign doc) — those are + templates; this skill adds inline combatants, not roster entries + +## Reference + +Full entity model, combat flow, and storage paths: `docs/ENCOUNTER_BUILDER.md` +in the repo root. REST + DB internals: `docs/DEVELOPMENT.md` and +`server/index.js`. diff --git a/.agents/skills/ttrpg-encounter-builder/scripts/build-encounters.js b/.agents/skills/ttrpg-encounter-builder/scripts/build-encounters.js new file mode 100644 index 0000000..66c1ddb --- /dev/null +++ b/.agents/skills/ttrpg-encounter-builder/scripts/build-encounters.js @@ -0,0 +1,149 @@ +// build-encounters.js — seed TTRPG encounters via REST API. +// Cross-harness: pi, claude, codex all call same script. +// No deps beyond node builtins. Reads spec JSON, writes batch, verifies. +// +// Usage: +// node build-encounters.js +// cat spec.json | node build-encounters.js - +// +// specFile = JSON array of encounters: +// [ +// { +// "name": "Barrowdeep 1 - Antechamber", +// "participants": [ +// { "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 }, +// { "name": "Boss", "type": "npc", "maxHp": 39, "initMod": 2, "initiative": 15 } +// ] +// } +// ] +// +// type: 'monster' | 'npc' | 'character'. Default 'monster'. +// initiative: optional manual override. If omitted, rolled d20 + initMod. +// initMod default 0. maxHp default 10. +// +// Output: written encounter ids + rolled initiatives. Exit 0 on success. + +'use strict'; +const crypto = require('crypto'); + +const genId = () => + (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID() + : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +const rollD20 = () => Math.floor(Math.random() * 20) + 1; + +function makeParticipant(spec) { + const type = spec.type || 'monster'; + const maxHp = spec.maxHp || 10; + const initMod = spec.initMod || 0; + const manual = spec.initiative !== undefined && spec.initiative !== null; + return { + id: genId(), + name: spec.name, + type, + originalCharacterId: spec.originalCharacterId || null, + initiative: manual ? Number(spec.initiative) : rollD20() + initMod, + maxHp, + currentHp: maxHp, + conditions: [], + isActive: true, + status: 'conscious', + deathSaveSuccesses: 0, + deathSaveFailures: 0, + }; +} + +async function getJson(url) { + const r = await fetch(url); + if (!r.ok) throw new Error(`GET ${url} -> ${r.status}: ${await r.text()}`); + return r.json(); +} +async function postJson(url, body) { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`POST ${url} -> ${r.status}: ${await r.text()}`); + return r.json(); +} + +async function findCampaignId(host, name) { + const list = await getJson(`${host}/api/collection?path=campaigns`); + const matches = list.filter(c => c.name === name); + if (matches.length === 0) { + console.error(`No campaign named "${name}". Existing:`); + list.forEach(c => console.error(` ${c.name}`)); + process.exit(2); + } + if (matches.length > 1) { + console.error(`Multiple campaigns named "${name}". Use unique name or pass id.`); + matches.forEach(c => console.error(` ${c.id} ${c.name}`)); + process.exit(2); + } + return matches[0].id; +} + +async function main() { + const [host, campaignName, specArg] = process.argv.slice(2); + if (!host || !campaignName || !specArg) { + console.error('Usage: node build-encounters.js '); + process.exit(1); + } + const specRaw = specArg === '-' + ? require('fs').readFileSync(0, 'utf8') + : require('fs').readFileSync(specArg, 'utf8'); + const encounters = JSON.parse(specRaw); + if (!Array.isArray(encounters)) throw new Error('spec must be a JSON array of encounters'); + + const cid = await findCampaignId(host, campaignName); + console.log(`campaign: "${campaignName}" -> ${cid}`); + + const encPath = `campaigns/${cid}/encounters`; + const now = new Date().toISOString(); + const ops = []; + const summary = []; + for (const enc of encounters) { + const id = genId(); + const path = `${encPath}/${id}`; + const parts = (enc.participants || []).map(makeParticipant); + ops.push({ + type: 'set', + path, + data: { + name: enc.name, + createdAt: now, + participants: parts, + round: 0, + currentTurnParticipantId: null, + isStarted: false, + isPaused: false, + ruleset: enc.ruleset || '5e', + }, + }); + summary.push({ name: enc.name, id, parts }); + } + + const res = await postJson(`${host}/api/batch`, { ops }); + if (!res.ok) throw new Error(`batch failed: ${JSON.stringify(res)}`); + console.log(`batch: ${ops.length} encounter(s) written\n`); + + // Verify + const written = await getJson(`${host}/api/collection?path=${encPath}`); + const byId = new Map(written.map(e => [e.id, e])); + + for (const s of summary) { + const got = byId.get(s.id); + const ok = got && got.name === s.name && (got.participants || []).length === s.parts.length; + console.log(`${ok ? '✓' : '✗'} ${s.name}`); + console.log(` id: ${s.id}`); + if (!ok) { console.log(` VERIFY FAILED: got ${JSON.stringify(got && { name: got.name, n: (got.participants || []).length })}`); continue; } + s.parts.forEach(p => { + const i = p.initiative >= 0 ? `+${p.initiative}` : `${p.initiative}`; + console.log(` - ${p.name} (${p.type}, hp ${p.maxHp}, init ${i})`); + }); + } + console.log('\nDone.'); +} + +main().catch(e => { console.error('FAIL:', e.message); process.exit(1); }); diff --git a/docs/ENCOUNTER_BUILDER.md b/docs/ENCOUNTER_BUILDER.md index 701e228..7659c37 100644 --- a/docs/ENCOUNTER_BUILDER.md +++ b/docs/ENCOUNTER_BUILDER.md @@ -64,6 +64,15 @@ Object in `encounter.participants[]`: | `deathSaveSuccesses` | int | character/NPC only, 0-3 successes | | `deathSaveFailures` | int | character/NPC only, 0-3 failures | +## Path normalization + +App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Server-mode adapter `norm()` strips prefix → bare canonical (`campaigns/...`). + +- **UI / app code** → prefixed paths (`getPath.*` helpers) +- **REST API / direct DB / scripts** → bare canonical paths (`campaigns/...`) + +Mixing = empty results. If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used the prefixed form. Drop the prefix. + ## Build flow (UI) Admin view at `/`. Steps: @@ -121,6 +130,101 @@ Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initia During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely. +## Build flow (API / scripts) + +Same logical steps as UI, via REST. For LLM agents or seed scripts. + +**Server mode only.** REST endpoints (`/api/*`) exist only when tracker runs +with `REACT_APP_STORAGE=server` (bundled Express backend mounted). Firebase +SDK mode has no HTTP backend — data lives in Firestore via in-browser SDK, +different auth + write paths. This section does not apply to firebase mode. + +Confirm server mode before scripting: +```bash +curl -s /api/collection?path=campaigns | head -c 200 +``` +JSON array = server mode. HTML (SPA shell) = firebase mode or backend not +mounted — API path won't work. + +### REST endpoints + +| Method | Path | Body / Query | Action | +|---|---|---|---| +| `GET` | `/api/doc` | `?path=` | read one doc | +| `PUT` | `/api/doc` | `{path, data}` | replace doc | +| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss | +| `DELETE` | `/api/doc` | `?path=` | delete one doc | +| `GET` | `/api/collection` | `?path=` | list docs under collection | +| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection | +| `POST` | `/api/batch` | `{ops:[{type:'set'\|'update'\|'delete', path, data?}]}` | atomic multi-write | + +All paths bare canonical (no `artifacts/...` prefix). + +### id format + +`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`). Use either. Server `POST /api/collection` generates its own UUID — fine for campaigns/encounters. Participants are inline array items; generate their ids client-side so they're stable across batch writes. + +### Object templates + +Encounter doc (full, for `PUT` / batch `set`): +```js +{ + name: 'Barrowdeep 1 - Antechamber', + createdAt: new Date().toISOString(), + participants: [], // inline, NOT a sub-collection + round: 0, + currentTurnParticipantId: null, + isStarted: false, + isPaused: false, + ruleset: '5e', +} +``` + +Participant object (item in `encounter.participants[]`): +```js +{ + id: generateId(), + name: 'Ankheg', + type: 'monster', // 'character' | 'npc' | 'monster' + originalCharacterId: null, // set only if type==='character' + initiative: rollD20() + initMod, // rolled ONCE at add; stored, not re-derived + maxHp: 45, + currentHp: 45, // start at full + conditions: [], + isActive: true, + status: 'conscious', + deathSaveSuccesses: 0, // character/npc only + deathSaveFailures: 0, // character/npc only +} +``` + +### Initiative semantics + +- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], roll fresh per participant). +- Manual override (e.g. pre-rolled values): set `initiative` directly, skip the roll. +- Fixed at add time. NOT re-derived from `initMod` after — `initMod` is not stored on participant. + +### Recipe: build encounters for a campaign + +1. **Find campaign id by name:** +```bash +curl -s "$HOST/api/collection?path=campaigns" \ + | jq -r '.[] | select(.name=="2026 D&D") | .id' +``` +2. **Build encounter docs + participants client-side** (object templates above), roll initiative per participant. +3. **Write all encounters in one batch:** +```bash +curl -s -X POST "$HOST/api/batch" \ + -H 'Content-Type: application/json' \ + -d '{"ops":[{"type":"set","path":"campaigns/CID/encounters/EID1","data":{...}},{"type":"set","path":"campaigns/CID/encounters/EID2","data":{...}}]}' +``` +4. **Verify:** +```bash +curl -s "$HOST/api/collection?path=campaigns/CID/encounters" | jq '.[] | {name, count: (.participants|length)}' +``` + +Participants go inline in the encounter doc — there is no `participants` sub-collection. A single batch `set` per encounter writes the whole roster atomically. + ## Combat flow (UI) InitiativeControls panel (sticky, right side). @@ -198,6 +302,8 @@ No re-sort after `startEncounter`. ## Storage paths quick reference +Bare canonical (server mode). App code prefixes these with `artifacts/{APP_ID}/public/data/` — adapter strips it. + ``` campaigns/{cid} campaign doc campaigns/{cid}/encounters/{eid} encounter doc (participants[]) @@ -206,6 +312,8 @@ activeDisplay/status player display control logs/{logId} action log entry ``` +REST mapping: `campaigns/{cid}` doc → `GET/PUT/PATCH/DELETE /api/doc?path=campaigns/{cid}`. Collection parent (`campaigns`, `campaigns/{cid}/encounters`) → `GET/POST /api/collection?path=...`. + ## DM tips - Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.