295 lines
11 KiB
Markdown
295 lines
11 KiB
Markdown
---
|
|||
|
|
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`.
|