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:
@@ -0,0 +1,294 @@
|
||||
---
|
||||
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`.
|
||||
@@ -0,0 +1,149 @@
|
||||
// build-encounters.js — seed TTRPG encounters via REST API.
|
||||
// Cross-harness: pi, claude, codex all call same script.
|
||||
// No deps beyond node builtins. Reads spec JSON, writes batch, verifies.
|
||||
//
|
||||
// Usage:
|
||||
// node build-encounters.js <host> <campaignName> <specFile>
|
||||
// cat spec.json | node build-encounters.js <host> <campaignName> -
|
||||
//
|
||||
// specFile = JSON array of encounters:
|
||||
// [
|
||||
// {
|
||||
// "name": "Barrowdeep 1 - Antechamber",
|
||||
// "participants": [
|
||||
// { "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 },
|
||||
// { "name": "Boss", "type": "npc", "maxHp": 39, "initMod": 2, "initiative": 15 }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
//
|
||||
// type: 'monster' | 'npc' | 'character'. Default 'monster'.
|
||||
// initiative: optional manual override. If omitted, rolled d20 + initMod.
|
||||
// initMod default 0. maxHp default 10.
|
||||
//
|
||||
// Output: written encounter ids + rolled initiatives. Exit 0 on success.
|
||||
|
||||
'use strict';
|
||||
const crypto = require('crypto');
|
||||
|
||||
const genId = () =>
|
||||
(typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
||||
|
||||
function makeParticipant(spec) {
|
||||
const type = spec.type || 'monster';
|
||||
const maxHp = spec.maxHp || 10;
|
||||
const initMod = spec.initMod || 0;
|
||||
const manual = spec.initiative !== undefined && spec.initiative !== null;
|
||||
return {
|
||||
id: genId(),
|
||||
name: spec.name,
|
||||
type,
|
||||
originalCharacterId: spec.originalCharacterId || null,
|
||||
initiative: manual ? Number(spec.initiative) : rollD20() + initMod,
|
||||
maxHp,
|
||||
currentHp: maxHp,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function getJson(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`GET ${url} -> ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
async function postJson(url, body) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`POST ${url} -> ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function findCampaignId(host, name) {
|
||||
const list = await getJson(`${host}/api/collection?path=campaigns`);
|
||||
const matches = list.filter(c => c.name === name);
|
||||
if (matches.length === 0) {
|
||||
console.error(`No campaign named "${name}". Existing:`);
|
||||
list.forEach(c => console.error(` ${c.name}`));
|
||||
process.exit(2);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
console.error(`Multiple campaigns named "${name}". Use unique name or pass id.`);
|
||||
matches.forEach(c => console.error(` ${c.id} ${c.name}`));
|
||||
process.exit(2);
|
||||
}
|
||||
return matches[0].id;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [host, campaignName, specArg] = process.argv.slice(2);
|
||||
if (!host || !campaignName || !specArg) {
|
||||
console.error('Usage: node build-encounters.js <host> <campaignName> <specFile|->');
|
||||
process.exit(1);
|
||||
}
|
||||
const specRaw = specArg === '-'
|
||||
? require('fs').readFileSync(0, 'utf8')
|
||||
: require('fs').readFileSync(specArg, 'utf8');
|
||||
const encounters = JSON.parse(specRaw);
|
||||
if (!Array.isArray(encounters)) throw new Error('spec must be a JSON array of encounters');
|
||||
|
||||
const cid = await findCampaignId(host, campaignName);
|
||||
console.log(`campaign: "${campaignName}" -> ${cid}`);
|
||||
|
||||
const encPath = `campaigns/${cid}/encounters`;
|
||||
const now = new Date().toISOString();
|
||||
const ops = [];
|
||||
const summary = [];
|
||||
for (const enc of encounters) {
|
||||
const id = genId();
|
||||
const path = `${encPath}/${id}`;
|
||||
const parts = (enc.participants || []).map(makeParticipant);
|
||||
ops.push({
|
||||
type: 'set',
|
||||
path,
|
||||
data: {
|
||||
name: enc.name,
|
||||
createdAt: now,
|
||||
participants: parts,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
ruleset: enc.ruleset || '5e',
|
||||
},
|
||||
});
|
||||
summary.push({ name: enc.name, id, parts });
|
||||
}
|
||||
|
||||
const res = await postJson(`${host}/api/batch`, { ops });
|
||||
if (!res.ok) throw new Error(`batch failed: ${JSON.stringify(res)}`);
|
||||
console.log(`batch: ${ops.length} encounter(s) written\n`);
|
||||
|
||||
// Verify
|
||||
const written = await getJson(`${host}/api/collection?path=${encPath}`);
|
||||
const byId = new Map(written.map(e => [e.id, e]));
|
||||
|
||||
for (const s of summary) {
|
||||
const got = byId.get(s.id);
|
||||
const ok = got && got.name === s.name && (got.participants || []).length === s.parts.length;
|
||||
console.log(`${ok ? '✓' : '✗'} ${s.name}`);
|
||||
console.log(` id: ${s.id}`);
|
||||
if (!ok) { console.log(` VERIFY FAILED: got ${JSON.stringify(got && { name: got.name, n: (got.participants || []).length })}`); continue; }
|
||||
s.parts.forEach(p => {
|
||||
const i = p.initiative >= 0 ? `+${p.initiative}` : `${p.initiative}`;
|
||||
console.log(` - ${p.name} (${p.type}, hp ${p.maxHp}, init ${i})`);
|
||||
});
|
||||
}
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user