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,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); });
|
||||
Reference in New Issue
Block a user