Files
david raistrick 43b1f6ce41 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.
2026-07-07 15:42:02 -04:00

11 KiB

name, description
name description
ttrpg-encounter-builder 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:

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

{
  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

{
  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:

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)

scripts/build-encounters.js handles id gen, initiative rolling, batch write, and verification. Reads a spec file or stdin.

Spec format (JSON array):

[
  {
    "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):

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:

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.

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.