Files
ttrpg-initiative-tracker/.agents/skills/ttrpg-encounter-builder/scripts/build-encounters.js
T
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

150 lines
4.9 KiB
JavaScript

// 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); });