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.
150 lines
4.9 KiB
JavaScript
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); });
|