Groundwork for many themes, plus the first new one. A pack validator enforces every structural rule this project learned the hard way: situations that render as broken English, decisions that gate progress with no alternative, motifs too thin to fill six areas without repeating, rosters that do not say what harms them. Fifteen packs cannot be hand-checked; this checks them in a second. Themes are now discovered from content/index.json rather than hardcoded, so the CLI takes --theme and --themes, the Foundry dropdown fills itself, and packs load on demand instead of all at once. Castle: four appetites — to be feared, never to give ground, to be obeyed at once, the name to go on — over barrow geometry, since a keep is rooms and corridors. 168 kernels from its own claimants and accommodations. Fixed a bug the second theme exposed: the question the bottom problem asks was hardcoded against barrow's appetite ids, so every other theme fell through to a generic "Well?". It lives on the appetite in the pack now, and the validator requires it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
132 lines
6.7 KiB
JavaScript
132 lines
6.7 KiB
JavaScript
/**
|
|
* Theme pack validator.
|
|
*
|
|
* A pack is ~136 authored entries and every structural rule in it was learned the hard way:
|
|
* situations that render as broken English, decisions with no way through, motifs too thin to
|
|
* fill six areas without repeating. Rather than rediscover those per theme, this checks them.
|
|
*
|
|
* Run: node validate-pack.mjs [name ...] (default: every pack in content/)
|
|
*/
|
|
import { readFileSync, readdirSync } from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname, join } from 'path';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const FACETS = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
|
|
const ROLES = ['approach', 'complication', 'turn', 'descent', 'threshold', 'ending'];
|
|
const GEOMETRIES = ['barrow', 'cave', 'fen', 'village', 'forest'];
|
|
const HEATS = ['skirmish', 'fight', 'battle', 'nightmare'];
|
|
|
|
/** Minimums sized so a 6-area delve never repeats — the default and most common case. */
|
|
const MIN = { fragmentsPerFacet: 3, features: 6, decisions: 6, temptations: 5, situations: 6, areaNamesPerRole: 4 };
|
|
|
|
export function validatePack(pack, name = pack?.id ?? '?') {
|
|
const errs = [], warns = [];
|
|
const E = m => errs.push(m), W = m => warns.push(m);
|
|
|
|
if (!pack.id) E('no id');
|
|
if (!GEOMETRIES.includes(pack.forgeStageType))
|
|
E(`forgeStageType "${pack.forgeStageType}" is not one VANITY can build (${GEOMETRIES.join(', ')})`);
|
|
|
|
for (const k of ['appetites', 'accommodations', 'claimants', 'motifs', 'prizes', 'areaNames', 'factions', 'rosters'])
|
|
if (!pack[k]) E(`missing ${k}`);
|
|
if (errs.length) return { name, errs, warns };
|
|
|
|
const motifIds = Object.keys(pack.motifs);
|
|
|
|
for (const a of pack.appetites) {
|
|
for (const f of ['id', 'want', 'lack', 'purpose', 'motif', 'prize', 'conscript', 'appeasement', 'asks'])
|
|
if (!a[f]) E(`appetite ${a.id ?? '?'}: missing ${f}`);
|
|
if (a.motif && !motifIds.includes(a.motif)) E(`appetite ${a.id}: motif "${a.motif}" does not exist`);
|
|
if (a.prize && !pack.prizes?.[a.prize]) E(`appetite ${a.id}: prize "${a.prize}" has no entry`);
|
|
const ap = a.appeasement ?? {};
|
|
for (const f of ['move', 'attribute', 'successes', 'why', 'gain', 'cost'])
|
|
if (ap[f] === undefined) E(`appetite ${a.id}: appeasement missing ${f}`);
|
|
}
|
|
|
|
// Every appetite must have at least one accommodation that fits it, or the kernel cannot form.
|
|
for (const a of pack.appetites) {
|
|
const fits = pack.accommodations.filter(x => !x.fits || x.fits.includes(a.id));
|
|
if (!fits.length) E(`appetite ${a.id}: no accommodation fits it`);
|
|
}
|
|
for (const acc of pack.accommodations) {
|
|
if (!acc.text?.includes('{conscript}')) W(`accommodation ${acc.id}: no {conscript} slot — will read oddly`);
|
|
}
|
|
|
|
for (const [id, m] of Object.entries(pack.motifs)) {
|
|
for (const f of ['danger', 'dangerLine', 'trigger', 'facets'])
|
|
if (!m[f]) E(`motif ${id}: missing ${f}`);
|
|
for (const facet of FACETS) {
|
|
const n = (m.facets?.[facet] ?? []).length;
|
|
if (n < MIN.fragmentsPerFacet) E(`motif ${id}: facet ${facet} has ${n} fragments, needs ${MIN.fragmentsPerFacet}`);
|
|
}
|
|
for (const [key, min] of [['features', MIN.features], ['decisions', MIN.decisions],
|
|
['temptations', MIN.temptations], ['situations', MIN.situations]]) {
|
|
const n = (m[key] ?? []).length;
|
|
if (n < min) E(`motif ${id}: ${n} ${key}, needs ${min} so a 6-area delve never repeats`);
|
|
}
|
|
|
|
for (const d of m.decisions ?? []) {
|
|
const r = d.resolve;
|
|
if (!r) { E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" has no resolve`); continue; }
|
|
if (!r.roll) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" has no attempt roll — a pick, not a situation`);
|
|
if (r.roll && (!r.success || !r.failure)) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" states only half its outcomes`);
|
|
if (!r.orElse) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" gates progress with no alternative`);
|
|
}
|
|
|
|
// The opener reads "<occupant> — <doing>. When you walk in, <onArrival>."
|
|
for (const s of m.situations ?? []) {
|
|
for (const f of ['occupant', 'doing', 'onArrival', 'because', 'offer'])
|
|
if (!s[f]) E(`motif ${id}: situation missing ${f}`);
|
|
if (/\b(is|are|but)\b|—/.test(s.occupant ?? '')) E(`motif ${id}: occupant "${s.occupant}" carries a verb; must be a noun phrase`);
|
|
if (!/^\w+ing\b/.test(s.doing ?? '') || /^(some|no|any|every)thing\b/.test(s.doing ?? ''))
|
|
E(`motif ${id}: doing "${String(s.doing).slice(0, 40)}" must be a participle phrase`);
|
|
}
|
|
|
|
for (const t of m.temptations ?? []) {
|
|
for (const f of ['id', 'cue', 'benefit', 'useCost', 'standingDrawback'])
|
|
if (!t[f]) E(`motif ${id}: temptation ${t.id ?? '?'} missing ${f}`);
|
|
}
|
|
}
|
|
|
|
for (const r of ROLES) {
|
|
const n = (pack.areaNames?.[r] ?? []).length;
|
|
if (n < MIN.areaNamesPerRole) E(`areaNames.${r}: ${n} names, needs ${MIN.areaNamesPerRole}`);
|
|
}
|
|
const allNames = Object.values(pack.areaNames ?? {}).flat();
|
|
if (new Set(allNames).size !== allNames.length) E('areaNames contains duplicates across roles');
|
|
|
|
for (const h of HEATS) {
|
|
const r = pack.rosters?.[h];
|
|
if (!r) { E(`rosters.${h} missing`); continue; }
|
|
if (!r.line || !r.foes?.length) E(`rosters.${h}: needs a line and foes`);
|
|
if (!r.harmedBy) E(`rosters.${h}: must say what harms them — a party discovering an immunity by failing is how playtest 1 died`);
|
|
if (!r.avoid) W(`rosters.${h}: no avoidance note`);
|
|
for (const f of r.foes ?? [])
|
|
for (const k of ['n', 'name', 'atk', 'def', 'grit', 'nerve'])
|
|
if (f[k] === undefined) E(`rosters.${h}: foe ${f.name ?? '?'} missing ${k}`);
|
|
}
|
|
|
|
for (const r of ROLES) if (!pack.facetProgression?.[r]) E(`facetProgression.${r} missing`);
|
|
if (!pack.triggerGlossary || Object.keys(pack.triggerGlossary).length < 3) W('triggerGlossary is thin');
|
|
|
|
return { name, errs, warns };
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const dir = join(here, 'content');
|
|
const names = process.argv.slice(2).length
|
|
? process.argv.slice(2)
|
|
: readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json').map(f => f.replace('.json', ''));
|
|
let bad = 0;
|
|
for (const n of names) {
|
|
const pack = JSON.parse(readFileSync(join(dir, `${n}.json`), 'utf8'));
|
|
const { errs, warns } = validatePack(pack, n);
|
|
const counts = `${Object.keys(pack.motifs ?? {}).length} motifs`;
|
|
if (!errs.length) console.log(` ✅ ${n.padEnd(10)} ${counts}${warns.length ? ` (${warns.length} warnings)` : ''}`);
|
|
else { bad++; console.log(` ❌ ${n.padEnd(10)} ${errs.length} errors`); for (const e of errs.slice(0, 12)) console.log(` ${e}`); }
|
|
for (const w of warns.slice(0, 4)) console.log(` ⚠ ${w}`);
|
|
}
|
|
process.exit(bad ? 1 : 0);
|
|
}
|