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