// 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 // cat spec.json | node build-encounters.js - // // 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 '); 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); });