7 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 7af690205c feat: wild family complete — cave, mountain, river, lake, valley theme packs
Five wild-kernel themes, each 4 motifs:
  cave      dark · deep · echo · keeping        (cave geometry)
  mountain  ascent · cold · pass · weight       (cave)
  river     crossing · current · course · drowned  (fen)
  lake      stillness · mirror · sunken · rising   (fen)
  valley    ridges · shelter · bottom · weather (forest)

Two rendering fixes surfaced by the new claimants, both in skeleton.mjs:
  - `{conscript}'s` rendered "the climbers's"; conscripts are plural noun
    phrases, so fill() now collapses s's to s'.
  - claimants carrying their own article rendered "The The Grey"; named()
    only prefixes when one is absent.

All 16 themes validate; 39 tests pass. Module → 0.6.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:22:01 +01:00
slaguru666andClaude Opus 5 d22421efbd feat: settlement family complete — village, market, city, port theme packs
Four settlement-kernel themes on village geometry, each 4 motifs:
  village  kinship · harvest · custom · quiet
  market   bargain · weights · crowd · credit
  city     works · rolls · wards · liberties
  port     cargo · passage · tide · wreck

All validate. 11 themes total; index.json rebuilt. Module → 0.5.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:57:31 +01:00
slaguru666andClaude Opus 5 b4990533a8 feat: interior family complete — church, temple, palace theme packs
Three interior-kernel themes on barrow geometry, each 4 motifs:
  church   faith · congregation · offering · sanctity
  temple   exactness · sacrifice · silence · exclusion
  palace   service · delight · splendour · audience

All validate. 7 themes total; index.json rebuilt. Module → 0.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:48:28 +01:00
tevansandClaude Opus 5 075be5c1de Town and Forest: two new kernels, not just two new packs
Barrow and Castle share a kernel — a dead claimant whose appetite outlived them.
A market or a river has no corpse in it, so the other two families needed the
kernel to generalise, and it does: something wanted a thing, and something was
done to keep it that way. Only who "something" is changes.

TOWN, on village geometry. The appetite belongs to the place and the claimants
are offices — a Corporation, a Watch Committee, a Guild of Mercers. It wants to
be prosperous, respectable, safe or necessary, and it priced people out,
chartered them away or struck them from the rolls to stay that way. Motifs:
ledger, appearances, vigilance, thoroughfare.

FOREST, on forest geometry. The appetite belongs to the wood, and the claimants
are its own names for itself. It wants to be entered, fed, spread or left
uncounted, and it took people, grew through them, or let them wander. Motifs:
paths, tithe, reclaiming, trackless.

Settlements also needed their own naming rule. A barrow is named for its
occupant; a town is not — it has a name and the claimant merely runs it. Packs
declare placeNameStyle, so "The Town of Watch Committee" is now Cold Harrow, run
by the Watch Committee.

Three of fifteen requested themes are done. The remaining twelve are the same
job repeated rather than a design question, since all three kernels now exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:41:10 +01:00
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
tevansandClaude Opus 5 1be441f775 Add MIT licence and a repo README
The repo went public with no LICENSE, which means default copyright — nobody
could legally reuse any of it. MIT throughout, covering the generator and the
VANITY prose alike.

Also adds a README, since the front page was previously a design document that
opened mid-argument, and sets module.json's license field so Foundry shows it.

Module to 0.1.2 so the released zip carries LICENSE too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:55:33 +01:00
48 changed files with 35862 additions and 66 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 slaguru666
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
# DELVE
An authoring system and Foundry VTT module for the **VANITY** tabletop RPG. Press one button and
it generates a whole dungeon — fiction, areas, maps, encounters, hoards and a journal you can read
cold — then drops it into your world.
## Install (Foundry)
**Add-on Modules → Install Module → Manifest URL:**
```
https://github.com/slaguru666/vanity-delve/releases/latest/download/module.json
```
Requires the [VANITY](https://github.com/slaguru666/Vanity) system, **0.10.5 or later**.
* **⛏** raise a dungeon
* **🗑** remove one you don't want — it deletes exactly what it made and nothing else
* **⏩** stage a delve you finished at the desk
## Also a CLI
`core/` is pure JavaScript with no Foundry dependencies, so a delve can be generated, rerolled and
finished outside the VTT:
```bash
node core/cli.mjs --new --seed=my-delve --file=my.json
node core/cli.mjs --file=my.json --worksheet # everything, with prompts for what only you can write
node core/cli.mjs --file=my.json --reroll=3:situation
node core/cli.mjs --file=my.json --play # the tight, table-facing sheet
```
## What it does and does not do
It generates the fiction, the order and the pressure. It does **not** write your read-aloud text
or your climax, and it says so — those are prompts, not output. It gets you to a strong first
draft in a minute instead of an evening, then gets out of the way.
[`DESIGN.md`](DESIGN.md) has the whole design history, including four rounds of adversarial review
and two paper playtests that failed before anything worked.
## Layout
| | |
|---|---|
| `core/` | the generator — pure JS, 39 tests |
| `foundry-module/` | the Foundry module, with `core/` vendored |
| `paper/` | a hand-written delve and both playtest logs |
| `spike/` | the harness that measured VANITY's Forge outside Foundry |
| `samples/` | generated output |
## Licence
MIT — see [LICENSE](LICENSE).
+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)];
}));
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 load = () => JSON.parse(readFileSync(file, 'utf8'));
const save = d => writeFileSync(file, JSON.stringify(d, null, 2));
+18 -9
View File
@@ -25,7 +25,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"How do I look?\""
},
{
"id": "remembered",
@@ -47,7 +48,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Say my name.\""
},
{
"id": "obeyed",
@@ -69,7 +71,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Do as you are told.\""
},
{
"id": "young",
@@ -91,7 +94,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Tell me I have not changed.\""
},
{
"id": "envied",
@@ -113,7 +117,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"What did you bring me?\""
},
{
"id": "attended",
@@ -135,7 +140,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"You are not leaving.\""
},
{
"id": "forgiven",
@@ -157,7 +163,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Tell me it was not a sin.\""
},
{
"id": "first",
@@ -179,7 +186,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Who goes first?\""
}
],
"accommodations": [
@@ -2146,5 +2154,6 @@
"lingering": "stayed in one place longer than it takes to search it",
"disturbed": "struck or moved something that had not moved in centuries"
},
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>."
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
"placeNameStyle": "claimant"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
{
"schema": 1,
"themes": [
{
"id": "barrow",
"label": "Barrow",
"geometry": "barrow",
"motifs": 8
},
{
"id": "castle",
"label": "Castle",
"geometry": "barrow",
"motifs": 4
},
{
"id": "cave",
"label": "Cave",
"geometry": "cave",
"motifs": 4
},
{
"id": "church",
"label": "Church",
"geometry": "barrow",
"motifs": 4
},
{
"id": "city",
"label": "City",
"geometry": "village",
"motifs": 4
},
{
"id": "forest",
"label": "Forest",
"geometry": "forest",
"motifs": 4
},
{
"id": "lake",
"label": "Lake",
"geometry": "fen",
"motifs": 4
},
{
"id": "market",
"label": "Market",
"geometry": "village",
"motifs": 4
},
{
"id": "mountain",
"label": "Mountain",
"geometry": "cave",
"motifs": 4
},
{
"id": "palace",
"label": "Palace",
"geometry": "barrow",
"motifs": 4
},
{
"id": "port",
"label": "Port",
"geometry": "village",
"motifs": 4
},
{
"id": "river",
"label": "River",
"geometry": "fen",
"motifs": 4
},
{
"id": "temple",
"label": "Temple",
"geometry": "barrow",
"motifs": 4
},
{
"id": "town",
"label": "Town",
"geometry": "village",
"motifs": 4
},
{
"id": "valley",
"label": "Valley",
"geometry": "forest",
"motifs": 4
},
{
"id": "village",
"label": "Village",
"geometry": "village",
"motifs": 4
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -14
View File
@@ -103,7 +103,7 @@ export function generateDelve(params = {}) {
...plan.ending,
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
cueFragments: [endFs?.fragment].filter(Boolean),
question: endingQuestion(skeleton),
question: endingQuestion(skeleton, pack),
authored: plan.ending.mode === 'authored',
},
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) {
const q = {
seen: '"How do I look?"',
remembered: '"Say my name."',
obeyed: '"Do as you are told."',
young: '"Tell me I have not changed."',
envied: '"What did you bring me?"',
attended: '"You are not leaving."',
forgiven: '"Tell me it was not a sin."',
first: '"Who goes first?"',
};
return q[sk.knot.appetiteId] ?? '"Well?"';
/**
* The bottom problem asks one thing, and it is the same question the whole delve has been about.
* It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
* ids differed from barrow's silently fell through to a generic line.
*/
function endingQuestion(sk, pack) {
const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
return appetite?.asks ?? '"Well?"';
}
+15 -5
View File
@@ -22,7 +22,13 @@
import { arcRole } from './director.mjs';
/** Fills {conscript}-style slots. */
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
// Conscripts are plural noun phrases ("the climbers"), so a template's `{conscript}'s`
// renders "the climbers's". English takes a bare apostrophe on a plural already ending in s.
.replace(/s's\b/g, "s'");
/** Claimants may already carry their own article — "The Grey" must not become "The The Grey". */
const named = c => (/^(the|a|an) /i.test(c) ? c : `The ${c}`);
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
@@ -62,11 +68,12 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
const transgression =
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`${named(claimant)} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`so that ${appetite.purpose}.`;
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
const bottomProblem = {
label: `The ${claimant}`,
label: named(claimant),
wantNow: appetite.want,
failureState: `is ${appetite.lack} now, and cannot bear it`,
handledBy: 'giving them what they want, at a price',
@@ -79,8 +86,11 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
};
const secondary = r.derive('faction2').pick(pack.factions.filter(f => f.id !== primary.id));
// Named for its occupant. Drawing the name separately produced tombs named after strangers.
const placeName = `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
// after strangers. A settlement is not: it has its own name and the claimant merely runs it.
const placeName = pack.placeNameStyle === 'own'
? `${r.derive('place1').pick(pack.placeNames.first)} ${r.derive('place2').pick(pack.placeNames.second)}`
: `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
// ---- the foreshadow chain ----------------------------------------------
const progression = pack.facetProgression ?? {};
+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);
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 slaguru666
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -3
View File
@@ -2,7 +2,7 @@
"id": "vanity-delve",
"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.",
"version": "0.1.1",
"version": "0.6.0",
"compatibility": {
"minimum": "13",
"verified": "14.365"
@@ -11,7 +11,8 @@
"systems": [
{
"id": "vanity",
"type": "requires",
"type": "system",
"manifest": "https://raw.githubusercontent.com/slaguru666/Vanity/main/system.json",
"compatibility": {
"minimum": "0.10.0"
}
@@ -32,5 +33,6 @@
"manifest": "https://github.com/slaguru666/vanity-delve/releases/latest/download/module.json",
"download": "https://github.com/slaguru666/vanity-delve/releases/latest/download/vanity-delve.zip",
"url": "https://github.com/slaguru666/vanity-delve",
"readme": "https://github.com/slaguru666/vanity-delve/blob/main/foundry-module/README.md"
"readme": "https://github.com/slaguru666/vanity-delve/blob/main/foundry-module/README.md",
"license": "https://github.com/slaguru666/vanity-delve/blob/main/LICENSE"
}
+18 -9
View File
@@ -25,7 +25,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"How do I look?\""
},
{
"id": "remembered",
@@ -47,7 +48,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Say my name.\""
},
{
"id": "obeyed",
@@ -69,7 +71,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Do as you are told.\""
},
{
"id": "young",
@@ -91,7 +94,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Tell me I have not changed.\""
},
{
"id": "envied",
@@ -113,7 +117,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"What did you bring me?\""
},
{
"id": "attended",
@@ -135,7 +140,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"You are not leaving.\""
},
{
"id": "forgiven",
@@ -157,7 +163,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Tell me it was not a sin.\""
},
{
"id": "first",
@@ -179,7 +186,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Who goes first?\""
}
],
"accommodations": [
@@ -2146,5 +2154,6 @@
"lingering": "stayed in one place longer than it takes to search it",
"disturbed": "struck or moved something that had not moved in centuries"
},
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>."
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
"placeNameStyle": "claimant"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
{
"schema": 1,
"themes": [
{
"id": "barrow",
"label": "Barrow",
"geometry": "barrow",
"motifs": 8
},
{
"id": "castle",
"label": "Castle",
"geometry": "barrow",
"motifs": 4
},
{
"id": "cave",
"label": "Cave",
"geometry": "cave",
"motifs": 4
},
{
"id": "church",
"label": "Church",
"geometry": "barrow",
"motifs": 4
},
{
"id": "city",
"label": "City",
"geometry": "village",
"motifs": 4
},
{
"id": "forest",
"label": "Forest",
"geometry": "forest",
"motifs": 4
},
{
"id": "lake",
"label": "Lake",
"geometry": "fen",
"motifs": 4
},
{
"id": "market",
"label": "Market",
"geometry": "village",
"motifs": 4
},
{
"id": "mountain",
"label": "Mountain",
"geometry": "cave",
"motifs": 4
},
{
"id": "palace",
"label": "Palace",
"geometry": "barrow",
"motifs": 4
},
{
"id": "port",
"label": "Port",
"geometry": "village",
"motifs": 4
},
{
"id": "river",
"label": "River",
"geometry": "fen",
"motifs": 4
},
{
"id": "temple",
"label": "Temple",
"geometry": "barrow",
"motifs": 4
},
{
"id": "town",
"label": "Town",
"geometry": "village",
"motifs": 4
},
{
"id": "valley",
"label": "Valley",
"geometry": "forest",
"motifs": 4
},
{
"id": "village",
"label": "Village",
"geometry": "village",
"motifs": 4
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -14
View File
@@ -103,7 +103,7 @@ export function generateDelve(params = {}) {
...plan.ending,
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
cueFragments: [endFs?.fragment].filter(Boolean),
question: endingQuestion(skeleton),
question: endingQuestion(skeleton, pack),
authored: plan.ending.mode === 'authored',
},
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) {
const q = {
seen: '"How do I look?"',
remembered: '"Say my name."',
obeyed: '"Do as you are told."',
young: '"Tell me I have not changed."',
envied: '"What did you bring me?"',
attended: '"You are not leaving."',
forgiven: '"Tell me it was not a sin."',
first: '"Who goes first?"',
};
return q[sk.knot.appetiteId] ?? '"Well?"';
/**
* The bottom problem asks one thing, and it is the same question the whole delve has been about.
* It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
* ids differed from barrow's silently fell through to a generic line.
*/
function endingQuestion(sk, pack) {
const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
return appetite?.asks ?? '"Well?"';
}
+15 -5
View File
@@ -22,7 +22,13 @@
import { arcRole } from './director.mjs';
/** Fills {conscript}-style slots. */
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
// Conscripts are plural noun phrases ("the climbers"), so a template's `{conscript}'s`
// renders "the climbers's". English takes a bare apostrophe on a plural already ending in s.
.replace(/s's\b/g, "s'");
/** Claimants may already carry their own article — "The Grey" must not become "The The Grey". */
const named = c => (/^(the|a|an) /i.test(c) ? c : `The ${c}`);
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
@@ -62,11 +68,12 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
const transgression =
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`${named(claimant)} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`so that ${appetite.purpose}.`;
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
const bottomProblem = {
label: `The ${claimant}`,
label: named(claimant),
wantNow: appetite.want,
failureState: `is ${appetite.lack} now, and cannot bear it`,
handledBy: 'giving them what they want, at a price',
@@ -79,8 +86,11 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
};
const secondary = r.derive('faction2').pick(pack.factions.filter(f => f.id !== primary.id));
// Named for its occupant. Drawing the name separately produced tombs named after strangers.
const placeName = `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
// after strangers. A settlement is not: it has its own name and the claimant merely runs it.
const placeName = pack.placeNameStyle === 'own'
? `${r.derive('place1').pick(pack.placeNames.first)} ${r.derive('place2').pick(pack.placeNames.second)}`
: `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
// ---- the foreshadow chain ----------------------------------------------
const progression = pack.facetProgression ?? {};
+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 MOD = 'vanity-delve';
const THEMES = ['barrow'];
let THEMES = [{ id: 'barrow', label: 'Barrow' }];
export function setThemes(list) { THEMES = list; }
const PICKS = {
depth: [1, 2, 3, 4, 5],
party: [1, 2, 3, 4, 5, 6, 7, 8],
@@ -76,7 +78,9 @@ export class DelveForgeApp extends HandlebarsApplicationMixin(ApplicationV2) {
*/
export async function raiseDungeon(params = {}) {
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 d = generateDelve({ pack: PACK, ...params, seed });
const sk = d.skeleton;
+10 -3
View File
@@ -16,13 +16,14 @@
*/
import { coinSeed, Rng } from './core/rng.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 FLAG = 'state';
let PACK = null;
let seamsPresent = false;
let loadPack = async () => null;
const getState = () => game.settings.get(MOD, FLAG) ?? null;
const setState = async s => game.settings.set(MOD, FLAG, s);
@@ -157,8 +158,13 @@ Hooks.once('init', () => {
Hooks.once('ready', async () => {
if (!game.user.isGM) return;
PACK = await foundry.utils.fetchJsonWithTimeout(`modules/${MOD}/module/core/content/barrow.json`)
.catch(() => fetch(`modules/${MOD}/module/core/content/barrow.json`).then(r => r.json()));
const fetchJson = p => foundry.utils.fetchJsonWithTimeout(p).catch(() => fetch(p).then(r => r.json()).catch(() => null));
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 ?? ''));
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,
outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
loadPack: id => loadPack(id),
get pack() { return PACK; } };
console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`);
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">
<label>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>
</label>
<label>Areas