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.
This commit is contained in:
david raistrick
2026-07-07 15:42:02 -04:00
parent 907484fc7f
commit 43b1f6ce41
3 changed files with 551 additions and 0 deletions
+108
View File
@@ -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 <HOST>/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.