--- 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`.