2 Commits
Author SHA1 Message Date
tevansandClaude Opus 5 e2d1de2fb8 Themes are pluggable; add Castle; validate packs
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>
2026-08-05 16:14:33 +01:00
tevansandClaude Opus 5 592d540910 v0.1.3 — fix the relationship schema that blocked manifest install
Installing by manifest URL failed on a remote server:

  [Module.vanity-delve] validation errors: relationships: systems:
  ArrayField 0: type: requires is not a valid choice

The relationship entry used type: "requires". In Foundry's schema `type` is the
PACKAGE type — module, system or world — and the relationship kind is expressed
by which array the entry sits in. So the correct value is "system".

The entry now also carries VANITY's manifest URL, so Foundry can offer to
install the system rather than just refusing.

This was never caught because the module had only ever been installed by copying
files into Data/modules by hand. The validator that rejected it only runs on the
manifest-install path, so every local test passed while the published artifact
could not be installed at all — the same shape as the scene-control hook bug,
where testing the layer below the interface hid a failure in the interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:55:14 +01:00
14 changed files with 2584 additions and 45 deletions
+11
View File
@@ -0,0 +1,11 @@
/** Regenerate content/index.json — the browser cannot list a directory, so packs need a manifest. */
import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const dir = join(dirname(fileURLToPath(import.meta.url)), 'content');
const themes = readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json')
.map(f => JSON.parse(readFileSync(join(dir, f), 'utf8')))
.map(p => ({ id: p.id, label: p.label ?? p.id, geometry: p.forgeStageType, motifs: Object.keys(p.motifs ?? {}).length }))
.sort((a, b) => a.label.localeCompare(b.label));
writeFileSync(join(dir, 'index.json'), JSON.stringify({ schema: 1, themes }, null, 2));
console.log(` ${themes.length} themes: ${themes.map(t => t.id).join(', ')}`);
+8 -1
View File
@@ -24,7 +24,14 @@ const args = Object.fromEntries(process.argv.slice(2).map(a => {
return [k, v === '' ? true : (/^\d+$/.test(v) ? Number(v) : v)]; return [k, v === '' ? true : (/^\d+$/.test(v) ? Number(v) : v)];
})); }));
const pack = JSON.parse(readFileSync(join(here, 'content', `${args.theme ?? 'barrow'}.json`), 'utf8')); const index = JSON.parse(readFileSync(join(here, 'content', 'index.json'), 'utf8'));
if (args.themes) { console.log(index.themes.map(t => `${t.id.padEnd(10)} ${t.geometry.padEnd(8)} ${t.motifs} motifs`).join('\n')); process.exit(0); }
const themeId = args.theme ?? 'barrow';
if (!index.themes.some(t => t.id === themeId)) {
console.error(`unknown theme "${themeId}" — available: ${index.themes.map(t => t.id).join(', ')}`);
process.exit(1);
}
const pack = JSON.parse(readFileSync(join(here, 'content', `${themeId}.json`), 'utf8'));
const file = args.file; const file = args.file;
const load = () => JSON.parse(readFileSync(file, 'utf8')); const load = () => JSON.parse(readFileSync(file, 'utf8'));
const save = d => writeFileSync(file, JSON.stringify(d, null, 2)); const save = d => writeFileSync(file, JSON.stringify(d, null, 2));
+16 -8
View File
@@ -25,7 +25,8 @@
"bane": 1 "bane": 1
}, },
"repeatable": true "repeatable": true
} },
"asks": "\"How do I look?\""
}, },
{ {
"id": "remembered", "id": "remembered",
@@ -47,7 +48,8 @@
"bane": 1 "bane": 1
}, },
"repeatable": true "repeatable": true
} },
"asks": "\"Say my name.\""
}, },
{ {
"id": "obeyed", "id": "obeyed",
@@ -69,7 +71,8 @@
"bane": 1 "bane": 1
}, },
"repeatable": true "repeatable": true
} },
"asks": "\"Do as you are told.\""
}, },
{ {
"id": "young", "id": "young",
@@ -91,7 +94,8 @@
"bane": 1 "bane": 1
}, },
"repeatable": true "repeatable": true
} },
"asks": "\"Tell me I have not changed.\""
}, },
{ {
"id": "envied", "id": "envied",
@@ -113,7 +117,8 @@
"bane": 1 "bane": 1
}, },
"repeatable": true "repeatable": true
} },
"asks": "\"What did you bring me?\""
}, },
{ {
"id": "attended", "id": "attended",
@@ -135,7 +140,8 @@
"bane": 1 "bane": 1
}, },
"repeatable": true "repeatable": true
} },
"asks": "\"You are not leaving.\""
}, },
{ {
"id": "forgiven", "id": "forgiven",
@@ -157,7 +163,8 @@
"bane": 1 "bane": 1
}, },
"repeatable": true "repeatable": true
} },
"asks": "\"Tell me it was not a sin.\""
}, },
{ {
"id": "first", "id": "first",
@@ -179,7 +186,8 @@
"bane": 1 "bane": 1
}, },
"repeatable": true "repeatable": true
} },
"asks": "\"Who goes first?\""
} }
], ],
"accommodations": [ "accommodations": [
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"schema": 1,
"themes": [
{
"id": "barrow",
"label": "Barrow",
"geometry": "barrow",
"motifs": 8
},
{
"id": "castle",
"label": "Castle",
"geometry": "barrow",
"motifs": 4
}
]
}
+9 -14
View File
@@ -103,7 +103,7 @@ export function generateDelve(params = {}) {
...plan.ending, ...plan.ending,
name: (pack.areaNames?.ending ?? ['The Ending'])[0], name: (pack.areaNames?.ending ?? ['The Ending'])[0],
cueFragments: [endFs?.fragment].filter(Boolean), cueFragments: [endFs?.fragment].filter(Boolean),
question: endingQuestion(skeleton), question: endingQuestion(skeleton, pack),
authored: plan.ending.mode === 'authored', authored: plan.ending.mode === 'authored',
}, },
triggerGlossary: pack.triggerGlossary ?? {}, triggerGlossary: pack.triggerGlossary ?? {},
@@ -112,17 +112,12 @@ export function generateDelve(params = {}) {
}; };
} }
/** The bottom problem asks one thing. It is the same question the whole delve has been about. */ /**
function endingQuestion(sk) { * The bottom problem asks one thing, and it is the same question the whole delve has been about.
const q = { * It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
seen: '"How do I look?"', * ids differed from barrow's silently fell through to a generic line.
remembered: '"Say my name."', */
obeyed: '"Do as you are told."', function endingQuestion(sk, pack) {
young: '"Tell me I have not changed."', const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
envied: '"What did you bring me?"', return appetite?.asks ?? '"Well?"';
attended: '"You are not leaving."',
forgiven: '"Tell me it was not a sin."',
first: '"Who goes first?"',
};
return q[sk.knot.appetiteId] ?? '"Well?"';
} }
+131
View File
@@ -0,0 +1,131 @@
/**
* 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);
}
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "vanity-delve", "id": "vanity-delve",
"title": "DELVE — a dungeon layer for VANITY", "title": "DELVE — a dungeon layer for VANITY",
"description": "Generates a coherent delve and sequences VANITY's Forge to stage it, one area at a time.", "description": "Generates a coherent delve and sequences VANITY's Forge to stage it, one area at a time.",
"version": "0.1.2", "version": "0.2.0",
"compatibility": { "compatibility": {
"minimum": "13", "minimum": "13",
"verified": "14.365" "verified": "14.365"
@@ -11,7 +11,8 @@
"systems": [ "systems": [
{ {
"id": "vanity", "id": "vanity",
"type": "requires", "type": "system",
"manifest": "https://raw.githubusercontent.com/slaguru666/Vanity/main/system.json",
"compatibility": { "compatibility": {
"minimum": "0.10.0" "minimum": "0.10.0"
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
{
"schema": 1,
"themes": [
{
"id": "barrow",
"label": "Barrow",
"geometry": "barrow",
"motifs": 8
},
{
"id": "castle",
"label": "Castle",
"geometry": "barrow",
"motifs": 4
}
]
}
+9 -14
View File
@@ -103,7 +103,7 @@ export function generateDelve(params = {}) {
...plan.ending, ...plan.ending,
name: (pack.areaNames?.ending ?? ['The Ending'])[0], name: (pack.areaNames?.ending ?? ['The Ending'])[0],
cueFragments: [endFs?.fragment].filter(Boolean), cueFragments: [endFs?.fragment].filter(Boolean),
question: endingQuestion(skeleton), question: endingQuestion(skeleton, pack),
authored: plan.ending.mode === 'authored', authored: plan.ending.mode === 'authored',
}, },
triggerGlossary: pack.triggerGlossary ?? {}, triggerGlossary: pack.triggerGlossary ?? {},
@@ -112,17 +112,12 @@ export function generateDelve(params = {}) {
}; };
} }
/** The bottom problem asks one thing. It is the same question the whole delve has been about. */ /**
function endingQuestion(sk) { * The bottom problem asks one thing, and it is the same question the whole delve has been about.
const q = { * It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
seen: '"How do I look?"', * ids differed from barrow's silently fell through to a generic line.
remembered: '"Say my name."', */
obeyed: '"Do as you are told."', function endingQuestion(sk, pack) {
young: '"Tell me I have not changed."', const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
envied: '"What did you bring me?"', return appetite?.asks ?? '"Well?"';
attended: '"You are not leaving."',
forgiven: '"Tell me it was not a sin."',
first: '"Who goes first?"',
};
return q[sk.knot.appetiteId] ?? '"Well?"';
} }
+6 -2
View File
@@ -18,7 +18,9 @@ const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? ''); const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? '');
const MOD = 'vanity-delve'; const MOD = 'vanity-delve';
const THEMES = ['barrow']; let THEMES = [{ id: 'barrow', label: 'Barrow' }];
export function setThemes(list) { THEMES = list; }
const PICKS = { const PICKS = {
depth: [1, 2, 3, 4, 5], depth: [1, 2, 3, 4, 5],
party: [1, 2, 3, 4, 5, 6, 7, 8], party: [1, 2, 3, 4, 5, 6, 7, 8],
@@ -76,7 +78,9 @@ export class DelveForgeApp extends HandlebarsApplicationMixin(ApplicationV2) {
*/ */
export async function raiseDungeon(params = {}) { export async function raiseDungeon(params = {}) {
if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.'); if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.');
const PACK = game.delve.pack; // Load the chosen theme's pack on demand — only the default is preloaded.
const PACK = await game.delve.loadPack(params.theme ?? 'barrow');
if (!PACK) return ui.notifications.error(`DELVE: could not load the ${params.theme} theme.`);
const seed = params.seed || coinSeed(new Rng(String(Date.now()))); const seed = params.seed || coinSeed(new Rng(String(Date.now())));
const d = generateDelve({ pack: PACK, ...params, seed }); const d = generateDelve({ pack: PACK, ...params, seed });
const sk = d.skeleton; const sk = d.skeleton;
+10 -3
View File
@@ -16,13 +16,14 @@
*/ */
import { coinSeed, Rng } from './core/rng.mjs'; import { coinSeed, Rng } from './core/rng.mjs';
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs'; import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog } from './forge-app.mjs'; import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
const MOD = 'vanity-delve'; const MOD = 'vanity-delve';
const FLAG = 'state'; const FLAG = 'state';
let PACK = null; let PACK = null;
let seamsPresent = false; let seamsPresent = false;
let loadPack = async () => null;
const getState = () => game.settings.get(MOD, FLAG) ?? null; const getState = () => game.settings.get(MOD, FLAG) ?? null;
const setState = async s => game.settings.set(MOD, FLAG, s); const setState = async s => game.settings.set(MOD, FLAG, s);
@@ -157,8 +158,13 @@ Hooks.once('init', () => {
Hooks.once('ready', async () => { Hooks.once('ready', async () => {
if (!game.user.isGM) return; if (!game.user.isGM) return;
PACK = await foundry.utils.fetchJsonWithTimeout(`modules/${MOD}/module/core/content/barrow.json`) const fetchJson = p => foundry.utils.fetchJsonWithTimeout(p).catch(() => fetch(p).then(r => r.json()).catch(() => null));
.catch(() => fetch(`modules/${MOD}/module/core/content/barrow.json`).then(r => r.json())); const base = `modules/${MOD}/module/core/content`;
const index = await fetchJson(`${base}/index.json`);
if (index?.themes?.length) setThemes(index.themes);
const packs = {};
loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`));
PACK = await loadPack('barrow');
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? '')); seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon, game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
@@ -166,6 +172,7 @@ Hooks.once('ready', async () => {
load, loadFile, draft, enter, ending, bane, clock, state: getState, load, loadFile, draft, enter, ending, bane, clock, state: getState,
outstanding: () => outstanding(getState()?.delve ?? { areas: [] }), outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }), ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
loadPack: id => loadPack(id),
get pack() { return PACK; } }; get pack() { return PACK; } };
console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`); console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`);
if (!seamsPresent) ui.notifications.warn('DELVE: Forge seams not installed — see the delve-seams branch.'); if (!seamsPresent) ui.notifications.warn('DELVE: Forge seams not installed — see the delve-seams branch.');
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="delve-grid"> <div class="delve-grid">
<label>Theme <label>Theme
<select name="theme"> <select name="theme">
{{#each themes}}<option value="{{this}}" {{#if (eq this ../v.theme)}}selected{{/if}}>{{this}}</option>{{/each}} {{#each themes}}<option value="{{this.id}}" {{#if (eq this.id ../v.theme)}}selected{{/if}}>{{this.label}}</option>{{/each}}
</select> </select>
</label> </label>
<label>Areas <label>Areas