Make the module installable: vendor core, point at release assets

What was on git could not be installed. foundry-module/ referenced core/ rather
than containing it, so an installed copy had no generator at all — it only ever
worked here because the live module directory had core/ copied in by hand.

core/ is now vendored into foundry-module/module/core/, minus cli.mjs and
test.mjs which are desk tools. module.json points manifest and download at
release assets, the way mapwright distributes, so a remote server can install by
URL without reaching into the repo tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 11:18:23 +01:00
co-authored by Claude Opus 5
parent ded836d4c8
commit 9d953dba94
13 changed files with 3376 additions and 541 deletions
+2
View File
@@ -1,2 +1,4 @@
node_modules/
*.log
core/*.json
!core/content/*.json
-535
View File
@@ -1,535 +0,0 @@
{
"schema": 1,
"generatorVersion": "0.1.0",
"catalogVersion": "barrow@1",
"seed": "my-delve",
"params": {
"areas": 6,
"depth": 2,
"party": 4,
"deadliness": "standard",
"density": "standard",
"greed": "standard",
"clock": "standard",
"ending": "authored",
"theme": "barrow"
},
"skeleton": {
"placeName": "The Tomb of the Silent Margrave",
"knot": {
"appetiteId": "first",
"accommodationId": "bound",
"claimant": "Silent Margrave",
"conscript": "a procession",
"transgression": "The Silent Margrave bound a procession by oath they could not break, so that someone would always go behind them.",
"cruelty": 2
},
"motif": {
"id": "precedence",
"danger": "procession",
"dangerLine": "the barrow keeps its order, and you have joined the queue",
"trigger": "announced"
},
"prize": {
"kind": "regalia",
"label": "the crown worn first, and only once",
"function": "the proof of precedence"
},
"bottomProblem": {
"label": "The Silent Margrave",
"wantNow": "to be first in all things",
"failureState": "is second now, and cannot bear it",
"handledBy": "giving them what they want, at a price"
},
"appeasement": {
"move": "stand aside and let it go ahead of you",
"attribute": "Poise",
"successes": 2,
"why": "it goes first",
"gain": {
"vanity": 1
},
"cost": {
"bane": 1
},
"repeatable": true,
"faction": "The Silent Host"
},
"factions": [
{
"id": "host",
"name": "The Silent Host",
"kind": "undead",
"essence": "still performing the ritual they were buried for",
"handledBy": "courtesy",
"conscriptedAs": "a procession"
},
{
"id": "wardens",
"name": "the Barrow-Wardens",
"kind": "undead",
"essence": "sealed this place and stayed to make sure",
"handledBy": "proof of right"
}
],
"foreshadow": [
{
"index": 1,
"role": "approach",
"facet": "institution",
"fragment": "numbered flagstones, counting down"
},
{
"index": 2,
"role": "complication",
"facet": "ritual",
"fragment": "banners in sequence, each house a place further back"
},
{
"index": 3,
"role": "complication",
"facet": "ritual",
"fragment": "a herald's script with the order still marked"
},
{
"index": 4,
"role": "turn",
"facet": "demand",
"fragment": "a door only one person may pass at a time, and only first"
},
{
"index": 5,
"role": "descent",
"facet": "wound",
"fragment": "a name moved to the front by force, repeatedly"
},
{
"index": 6,
"role": "threshold",
"facet": "anchor",
"fragment": "the crown worn first, and only once"
},
{
"index": 7,
"role": "ending",
"facet": "demand",
"fragment": "a corridor that will not let two walk abreast"
}
]
},
"areas": [
{
"index": 1,
"role": "approach",
"name": "The Outer Cut",
"facet": "institution",
"cueFragments": [
"numbered flagstones, counting down",
"the queue disturbed — three of the dead pushed aside"
],
"situation": {
"occupant": "two of the Host",
"doing": "refusing, each of them, to go through a door second",
"onArrival": "they both look at you to break it",
"because": "neither may be seen to yield",
"offer": "rule between them and the loser attaches to you as a lesser"
},
"truth": "This is how the place was ordered, and it still is",
"decision": {
"cue": "banners in strict order, and one you could move",
"cost": "reordering them opens a way and insults everyone buried here",
"resolve": {
"roll": "Wits 2",
"success": "you reorder them correctly and a way opens",
"failure": "you reorder wrongly and insult everyone buried here: +1 Bane",
"orElse": "leave them: the way stays shut, one Turn to find another"
}
},
"encounter": null,
"hoard": null,
"temptation": null,
"trigger": "disturbed",
"baneBeat": "noticing",
"clue": {
"automatic": "numbered flagstones, counting down — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 2,
"role": "complication",
"name": "The Waiting Room",
"facet": "ritual",
"cueFragments": [
"banners in sequence, each house a place further back",
"a rank-plate prised off and dropped, still shiny where it sat"
],
"situation": {
"occupant": "a procession",
"doing": "passing through, unhurried, with you in its way",
"onArrival": "it does not stop or go round",
"because": "it has right of way and has had for three centuries",
"offer": "stand aside and be counted courteous; join it and be counted one of them"
},
"truth": "The office is still being performed, for someone who is no longer served by it",
"decision": {
"cue": "a corridor that will not let two walk abreast",
"cost": "whoever goes first meets whatever is there first",
"resolve": {
"roll": "Poise 1",
"success": "you settle the order and whoever leads meets what is there, ready",
"failure": "you argue in the corridor and something arrives while you do",
"orElse": "send one scout: slow, one Turn, and they are alone"
}
},
"encounter": null,
"hoard": "pocket",
"temptation": {
"id": "herald",
"cue": "a herald's script with your name pencilled in",
"benefit": "once, be announced as whatever you claim",
"useCost": {
"bane": 1
},
"standingDrawback": "you must live up to it in front of the Host"
},
"trigger": "seenTwice",
"baneBeat": "noticing",
"clue": {
"automatic": "banners in sequence, each house a place further back — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 3,
"role": "complication",
"name": "The Attendants' Row",
"facet": "ritual",
"cueFragments": [
"a herald's script with the order still marked",
"someone has walked the corridor the wrong way, scuffing one side"
],
"situation": {
"occupant": "a living robber",
"doing": "prising number-plates off the flagstones",
"onArrival": "they freeze, then offer you one",
"because": "they worked out the order is what keeps the doors shut",
"offer": "take a plate and go first anywhere — the Host will hate you for it"
},
"truth": "The office is still being performed, for someone who is no longer served by it",
"decision": {
"cue": "a door that opens only for the foremost of a party",
"cost": "someone must be named first, aloud, and the others must accept it",
"resolve": {
"roll": "Flair 2",
"success": "someone is named first, aloud, and the door opens for them",
"failure": "the naming is disputed and the door reads that as no answer",
"orElse": "let the barrow choose: it picks the loudest, and remembers"
}
},
"encounter": {
"heat": "fight",
"kind": "undead",
"roster": {
"line": "1 Ghoul and 2 Skeletons",
"foes": [
{
"n": 1,
"name": "Ghoul",
"atk": 4,
"def": 3,
"grit": 4,
"nerve": 5,
"note": "on a hit, a free Called Shot to freeze for a round"
},
{
"n": 2,
"name": "Skeleton",
"atk": 3,
"def": 2,
"grit": 1,
"nerve": 5,
"note": "fearless"
}
],
"harmedBy": "anything",
"avoid": "the Ghoul goes for court dress first — send someone plain"
}
},
"hoard": "cache",
"temptation": {
"id": "rankplate",
"cue": "a rank-plate prised off something and pocketed",
"benefit": "go first through any door the barrow guards",
"useCost": {
"bane": 1
},
"standingDrawback": "going first is now expected of you"
},
"trigger": "loud",
"baneBeat": "noticing",
"clue": {
"automatic": "a herald's script with the order still marked — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 4,
"role": "turn",
"name": "The Middle Vault",
"facet": "demand",
"cueFragments": [
"a door only one person may pass at a time, and only first",
"a seat at the head of the hall dragged back, as if to sit in"
],
"situation": {
"occupant": "a queue of the Host",
"doing": "waiting to pass a door, in strict order, not moving",
"onArrival": "the queue opens a gap at exactly your rank",
"because": "the first in line is waiting for someone who never arrived",
"offer": "take the gap and go with them; take the front and they will remember it"
},
"truth": "Here the want is explicit — the place asks something of whoever enters",
"decision": {
"cue": "a claim to settle: which of you the barrow should address",
"cost": "settling it opens the way; refusing to settle it does not",
"resolve": {
"roll": "Poise 2",
"success": "the claim is settled and the barrow addresses one of you from now on",
"failure": "it is not settled, and the barrow keeps asking, in worse places",
"orElse": "refuse to settle: the way on does not open"
}
},
"encounter": null,
"hoard": null,
"temptation": null,
"trigger": "announced",
"baneBeat": "noticing",
"clue": {
"automatic": "a door only one person may pass at a time, and only first — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 5,
"role": "descent",
"name": "The Lower Cut",
"facet": "wound",
"cueFragments": [
"a name moved to the front by force, repeatedly",
"a numbered flagstone lifted and not replaced"
],
"situation": {
"occupant": "one of the Host with a herald's staff",
"doing": "announcing arrivals to an empty stair",
"onArrival": "it announces you, inventing a rank",
"because": "the post requires arrivals and there have been none",
"offer": "accept the invented rank and the barrow will treat you as it"
},
"truth": "This is the damage the want did to those who had to satisfy it",
"decision": {
"cue": "a stair where the order of descent is visibly recorded",
"cost": "whoever goes last is remembered as having gone last",
"resolve": {
"roll": "Poise 2",
"success": "you settle the order before you descend and the record reads as a party, not a rabble",
"failure": "you descend in a scramble: whoever is recorded last is treated as least by the Host from here",
"orElse": "descend however you like and accept the record as it falls"
}
},
"encounter": null,
"hoard": null,
"temptation": null,
"trigger": "announced",
"baneBeat": "noticing",
"clue": {
"automatic": "a name moved to the front by force, repeatedly — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 6,
"role": "threshold",
"name": "The Last Door",
"facet": "anchor",
"cueFragments": [
"the crown worn first, and only once",
"banners pulled down and stacked without regard for order"
],
"situation": {
"occupant": "one of the Host",
"doing": "reading out an order of precedence to an empty hall",
"onArrival": "it pauses and asks where you fall in it",
"because": "the list must be read in full before anyone may proceed",
"offer": "name your own place in the order and it will be honoured, exactly"
},
"truth": "The thing the whole barrow is arranged around is close",
"decision": {
"cue": "a queue of the dead, and a way to step round it",
"cost": "waiting costs two Turns; pushing in costs the Host's patience",
"resolve": {
"roll": "Poise 2",
"success": "you step round without breaking the order, and the Host allows it",
"failure": "you push in: +1 Bane and the queue closes behind you",
"orElse": "wait: two Turns, and the Host thinks better of you"
}
},
"encounter": {
"heat": "battle",
"kind": "undead",
"roster": {
"line": "1 Wraith and 3 Skeletons",
"foes": [
{
"n": 1,
"name": "Wraith",
"atk": 5,
"def": 3,
"grit": 10,
"nerve": 5,
"note": "INCORPOREAL — chill touch ignores armour and drains 1 max Grit"
},
{
"n": 3,
"name": "Skeleton",
"atk": 3,
"def": 2,
"grit": 1,
"nerve": 5,
"note": "fearless"
}
],
"harmedBy": "blessed, silvered or magical weapons ONLY",
"avoid": "it will take an appeasement over a fight every time"
}
},
"hoard": "chest",
"temptation": {
"id": "coronet",
"cue": "a small crown, sized for someone who never grew",
"benefit": "+1 die when others must defer to you",
"useCost": {
"bane": 1
},
"standingDrawback": "you must be addressed correctly or lose the benefit"
},
"trigger": "announced",
"baneBeat": null,
"clue": {
"automatic": "the crown worn first, and only once — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, the ending opens with them holding nothing the bottom problem wants.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
}
],
"ending": {
"index": 7,
"role": "ending",
"mode": "authored",
"heat": null,
"hoard": null,
"name": "The Audience",
"cueFragments": [
"a corridor that will not let two walk abreast"
],
"question": "\"Who goes first?\"",
"authored": true
},
"triggerGlossary": {
"announced": "entered somewhere grandly — the big doors, a shout, a herald",
"seenTwice": "reflected, echoed or witnessed twice over in one place",
"loud": "a raised voice, a broken door, steel on stone",
"lingering": "stayed in one place longer than it takes to search it",
"disturbed": "struck or moved something that had not moved in centuries"
},
"budget": {
"plannedFights": 2,
"heatBase": "fight",
"wantFights": 2
},
"pressure": {
"turn": 0,
"clockRolls": 0,
"clockFired": 0,
"triggerNumber": 1,
"escalated": false,
"tab": 5,
"target": 5
},
"authored": {
"title": null,
"intro": null,
"areas": {
"1": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"2": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"3": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"4": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"5": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"6": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
}
},
"ending": {
"readAloud": null,
"notes": null,
"locked": []
}
}
}
+31 -6
View File
@@ -3,9 +3,34 @@
"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.0",
"compatibility": { "minimum": "13", "verified": "14.365" },
"relationships": { "systems": [{ "id": "vanity", "type": "requires", "compatibility": { "minimum": "0.10.0" } }] },
"esmodules": ["module/vanity-delve.mjs"],
"styles": ["styles/vanity-delve.css"],
"authors": [{ "name": "slaguru666" }]
}
"compatibility": {
"minimum": "13",
"verified": "14.365"
},
"relationships": {
"systems": [
{
"id": "vanity",
"type": "requires",
"compatibility": {
"minimum": "0.10.0"
}
}
]
},
"esmodules": [
"module/vanity-delve.mjs"
],
"styles": [
"styles/vanity-delve.css"
],
"authors": [
{
"name": "slaguru666"
}
],
"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"
}
+150
View File
@@ -0,0 +1,150 @@
/**
* DELVE as an authoring system.
*
* Four readings of generated output landed on the same verdict: the fiction and the structure are
* good, the table-readiness is not, and the honest product is a strong first draft a GM finishes.
* "The handwritten delve still tells the GM what matters faster."
*
* That reframes the density problem rather than solving it. Everything on the page is a vice at
* the table and a virtue at the desk. So a delve now has two surfaces:
*
* WORKSHEET — everything, labelled, with explicit prompts for what only a human can write.
* PLAY SHEET — only what survived, in the GM's own words, tight enough to scan.
*
* The delve JSON is the working file. It carries an `authored` layer that the generator never
* touches, so rerolling a component cannot destroy written prose.
*/
import { Rng } from './rng.mjs';
import { generateDelve } from './delve.mjs';
/** Fields a GM writes. Anything here, once written, outranks the generated text. */
export const AUTHORED_FIELDS = ['readAloud', 'notes', 'nameOverride'];
/** Components that can be individually rerolled. */
export const REROLLABLE = ['situation', 'decision', 'temptation', 'feature', 'name'];
/** A fresh working file: the generated delve plus an empty authored layer per area. */
export function newWorkingFile(params) {
const delve = generateDelve(params);
delve.authored = { title: null, intro: null, areas: {} };
for (const a of delve.areas) {
delve.authored.areas[a.index] = { readAloud: null, notes: null, nameOverride: null, locked: [] };
}
delve.authored.ending = { readAloud: null, notes: null, locked: [] };
return delve;
}
const authoredFor = (d, i) => d.authored?.areas?.[i] ?? { locked: [] };
export const isLocked = (d, i, component) => (authoredFor(d, i).locked ?? []).includes(component);
export function lock(d, i, component) {
const a = authoredFor(d, i);
if (!a.locked.includes(component)) a.locked.push(component);
return d;
}
export function unlock(d, i, component) {
const a = authoredFor(d, i);
a.locked = (a.locked ?? []).filter(c => c !== component);
return d;
}
export function setAuthored(d, i, field, value) {
if (!AUTHORED_FIELDS.includes(field)) throw new Error(`not an authored field: ${field}`);
if (i === 'ending') { d.authored.ending[field] = value; return d; }
authoredFor(d, i)[field] = value;
return d;
}
/**
* Reroll one component of one area, keeping everything else — including anything written.
*
* Regeneration draws from a salted seed so a reroll gives something genuinely different rather
* than the same draw again, and the salt is recorded so the file stays reproducible.
*/
export function reroll(d, i, component, pack) {
if (!REROLLABLE.includes(component)) throw new Error(`not rerollable: ${component}`);
if (isLocked(d, i, component)) return d;
d.rerolls = d.rerolls ?? {};
const key = `${i}:${component}`;
d.rerolls[key] = (d.rerolls[key] ?? 0) + 1;
const salt = `${d.seed}::reroll::${key}::${d.rerolls[key]}`;
const rng = new Rng(salt);
const motif = pack.motifs?.[d.skeleton.motif.id] ?? {};
const area = d.areas.find(a => a.index === i);
if (!area) throw new Error(`no area ${i}`);
/**
* Draw fresh, avoiding what the rest of the delve uses. When every option is already in play —
* six areas against a six-item pool — prefer duplicating another area over handing back what is
* already here. A reroll that changes nothing looks broken.
*/
const choose = (all, taken, keyOf, current) => {
const fresh = all.filter(x => !taken.has(keyOf(x)));
if (fresh.length) return rng.pick(fresh);
const notCurrent = all.filter(x => keyOf(x) !== current);
return notCurrent.length ? rng.pick(notCurrent) : null;
};
// Draw fresh, avoiding what the rest of the delve is already using.
const used = (get, current) => {
const taken = new Set(d.areas.filter(x => x.index !== i).map(get).filter(Boolean));
if (current) taken.add(current); // never hand back what is already there
return taken;
};
switch (component) {
case 'situation': {
const taken = used(a => a.situation?.occupant + a.situation?.doing, area.situation ? area.situation.occupant + area.situation.doing : null);
const pick = choose(motif.situations ?? [], taken, x => x.occupant + x.doing,
area.situation ? area.situation.occupant + area.situation.doing : null);
if (pick) area.situation = { occupant: pick.occupant, doing: pick.doing, onArrival: pick.onArrival, because: pick.because, offer: pick.offer };
break;
}
case 'decision': {
const taken = used(a => a.decision?.cue, area.decision?.cue);
const pick = choose(motif.decisions ?? [], taken, x => x.cue, area.decision?.cue);
if (pick) area.decision = pick;
break;
}
case 'temptation': {
if (!area.hoard) break;
const taken = used(a => a.temptation?.id, area.temptation?.id);
const pick = choose(motif.temptations ?? [], taken, x => x.id, area.temptation?.id);
if (pick) area.temptation = pick;
break;
}
case 'feature': {
const taken = used(a => a.cueFragments?.[1], area.cueFragments?.[1]);
const pick = choose(motif.features ?? [], taken, x => x, area.cueFragments?.[1]);
if (pick) area.cueFragments = [area.cueFragments[0], pick].filter(Boolean);
break;
}
case 'name': {
const names = pack.areaNames?.[area.role] ?? [];
const taken = used(a => a.name, area.name);
const pick = choose(names, taken, x => x, area.name);
if (pick) area.name = pick;
break;
}
}
return d;
}
/** What still needs a human. This is the worksheet's to-do list and the play sheet's gate. */
export function outstanding(d) {
const todo = [];
if (!d.authored?.intro) todo.push({ where: 'intro', what: 'an opening paragraph for the delve' });
for (const a of d.areas) {
const au = authoredFor(d, a.index);
if (!au.readAloud) todo.push({ where: `area ${a.index}`, what: 'read-aloud text' });
}
if (!d.authored?.ending?.readAloud) todo.push({ where: 'ending', what: 'read-aloud text' });
if (d.ending.authored && !d.authored?.ending?.notes) todo.push({ where: 'ending', what: 'the authored climax — DELVE deliberately leaves this to you' });
return todo;
}
export const readyToPlay = d => outstanding(d).length === 0;
+80
View File
@@ -0,0 +1,80 @@
/**
* An area is a beat. Draft 5 §6 — two faces, not four.
*
* Cue (what players perceive) and Truth (what is going on) are required. Consequence is inline
* bracketed mechanics, the way the house style already does it. Reveal appears only where a
* secret genuinely differs from the Cue.
*
* DELVE does not write prose. The playtest was explicit that the read-aloud paragraphs are most
* of what makes a delve playable and they are hand-written. So the Cue is emitted as *fragments*
* for the GM to assemble aloud, labelled as such. Faking prose here would be the one dishonest
* thing this tool could do.
*/
const FACET_TRUTH = {
institution: 'this is how the place was ordered, and it still is',
ritual: 'the office is still being performed, for someone who is no longer served by it',
demand: 'here the want is explicit — the place asks something of whoever enters',
wound: 'this is the damage the want did to those who had to satisfy it',
anchor: 'the thing the whole barrow is arranged around is close',
demandEnd: 'the question itself, asked directly',
};
export function buildArea({ skeleton, planned, pack, pressure, rng, baneBeat = null, feature, decision, temptation = null, situation = null, name = null }) {
const r = rng.derive('area', String(planned.index));
const fs = skeleton.foreshadow.find(f => f.index === planned.index) ?? {};
// feature/decision are dealt by the caller without replacement across the delve — drawing them
// per-area from pools of 8 and 6 made repeats near-certain inside a single delve.
// Every area emits at least one attention trigger — draft 5 §9 weights triggers over the timer.
const trigger = fs.facet === 'demand' ? skeleton.motif.trigger
: r.derive('trigger').pick(['announced', 'loud', 'lingering', 'seenTwice', 'disturbed']);
return {
index: planned.index,
role: planned.role,
name,
facet: fs.facet ?? null,
// Cue: fragments, not prose.
cueFragments: [fs.fragment, feature].filter(Boolean),
// The situation is the room as the players find it: someone doing something, and what
// changes when they walk in. The decision is one of the things you can do about it.
situation: situation ? {
occupant: situation.occupant, doing: situation.doing, onArrival: situation.onArrival,
because: situation.because, offer: situation.offer,
} : null,
truth: (t => t ? t[0].toUpperCase() + t.slice(1) : '')(FACET_TRUTH[fs.facet] ?? ''),
decision,
encounter: planned.heat
? { heat: planned.heat, kind: skeleton.factions[0]?.kind ?? 'undead', roster: pack.rosters?.[planned.heat] ?? null }
: null,
hoard: planned.hoard ?? null,
temptation,
trigger,
baneBeat,
// Clues are automatic on entry; the roll buys extra. Playtest 1 lost both its clues to
// single unopposed rolls and the motif never reached the table.
clue: {
automatic: fs.fragment ? `${fs.fragment} — and it was done deliberately` : null,
extra: '[VANITY: Observation — 1 success] buys who did it, and roughly when',
},
failureCase: planned.role === 'threshold'
? 'If they skip this, the ending opens with them holding nothing the bottom problem wants.'
: 'If they skip this, move its cue fragment into the next area so the motif still completes.',
};
}
/** Every area must offer a way on that is not gated. Playtest 1 died on a gate with no fallback. */
export function fallbackRoute(area, skeleton) {
return {
route: `${skeleton.factions[0]?.name} will receive anyone who ${skeleton.factions[0]?.handledBy === 'courtesy' ? 'behaves as a guest' : 'offers a trade'}`,
cost: 'one Bane on the tab, and they will expect it again',
};
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
/**
* generateDelve — the whole core, composed. Emits a plain data object; no Foundry, no prose.
* The CLI renders it to markdown; the Foundry adapter renders it to a panel and Forge calls.
*/
import { Rng, coinSeed } from './rng.mjs';
import { planDelve } from './director.mjs';
import { generateSkeleton } from './skeleton.mjs';
import { buildArea, fallbackRoute } from './beat.mjs';
import { Pressure } from './pressure.mjs';
import { baneBeatFor } from './director.mjs';
export const GENERATOR_VERSION = '0.1.0';
export function generateDelve(params = {}) {
const {
pack,
seed = coinSeed(),
areas = 6, depth = 2, party = 4,
deadliness = 'standard', density = 'standard', greed = 'standard',
clock = 'standard', ending = 'authored',
} = params;
if (!pack) throw new Error('generateDelve requires a theme pack');
const rng = new Rng(seed);
const plan = planDelve({ areas, depth, party, deadliness, density, greed, ending, rng });
const skeleton = generateSkeleton({ pack, areas, ending, rng });
const pressure = new Pressure({ areas, clock });
// Deal features and decisions without replacement, cycling only if the delve outruns the pack.
// Deal without replacement. If a delve outruns its motif pool, top up from the generic pool
// before ever repeating — a repeated decision reads as a bug, generic furniture only as thin.
const deal = (list, n, tag, overflow = []) => {
const r = rng.derive(tag);
const out = [...r.shuffle(list)];
if (out.length < n) out.push(...r.shuffle(overflow.filter(x => !out.includes(x))));
while (out.length < n) out.push(...r.shuffle(list));
return out.slice(0, n);
};
// Motif-scoped furniture. Global pools are a fallback only: drawing decisions and temptations
// globally made the output "a consistent wrapper around generic room furniture" — the fiction
// cohered but the things in the rooms did not belong to it.
const motifPack = pack.motifs?.[skeleton.motif.id] ?? {};
const decisionPool = motifPack.decisions?.length ? motifPack.decisions : (pack.decisions ?? []);
const featurePool = motifPack.features?.length ? motifPack.features : (pack.features ?? []);
const features = deal(featurePool, plan.areas.length, 'features', pack.features ?? []);
// A room must arrive as a place with something happening in it. Without this an area is a
// prompt plus a resolver: the review's verdict was "choices attached to prompts, not spaces
// that naturally produce play".
const situations = deal(motifPack.situations ?? [], plan.areas.length, 'situations');
// Names are dealt globally, not per role: two `complication` areas both drawing from the same
// four-name list produced two scenes called "The Lesser Vault" in one dungeon.
const nameRng = rng.derive('names');
const usedNames = new Set();
const allNames = Object.values(pack.areaNames ?? {}).flat();
const areaNames = plan.areas.map(a => {
const forRole = (pack.areaNames?.[a.role] ?? []).filter(n => !usedNames.has(n));
const pool = forRole.length ? forRole : allNames.filter(n => !usedNames.has(n));
const pick = nameRng.pick(pool.length ? pool : allNames);
if (pick) usedNames.add(pick);
return pick;
});
const decisions = deal(decisionPool, plan.areas.length, 'decisions', pack.decisions ?? []);
// Temptations are dealt across the areas that carry a hoard, for the same reason as decisions:
// picking independently per area from a pool of three repeated in 75 of 80 test delves.
const temptPool = motifPack.temptations?.length ? motifPack.temptations : (pack.temptations ?? []);
const hoardCount = plan.areas.filter(a => a.hoard).length;
const temptations = deal(temptPool, hoardCount, 'temptations'); // no generic overflow — see note
let tIdx = 0;
const built = plan.areas.map((planned, i) => {
const beat = baneBeatFor(planned, pressure, rng.derive('banebeat', String(planned.index)));
if (beat) pressure.bankBane('(offered)', beat, planned.index);
const area = buildArea({ skeleton, planned, pack, pressure, rng, baneBeat: beat, feature: features[i], decision: decisions[i], situation: situations[i], name: areaNames[i], temptation: planned.hoard ? temptations[tIdx++] : null });
area.fallback = fallbackRoute(area, skeleton);
return area;
});
// Resolve {priorCue} against a cue from an earlier area, so an investigation route points at
// something the table has already been shown rather than granting the answer by fiat.
for (let i = 0; i < built.length; i++) {
const rv = built[i].decision?.resolve;
if (!rv?.success?.includes('{priorCue}')) continue;
const prior = built.slice(0, i).flatMap(a => a.cueFragments).filter(Boolean);
const pick = prior.length ? rng.derive('priorcue', String(i)).pick(prior) : null;
built[i] = { ...built[i], decision: { ...built[i].decision, resolve: { ...rv,
success: rv.success.replace('{priorCue}', pick ?? 'what the approach already showed you') } } };
}
const endFs = skeleton.foreshadow.find(f => f.role === 'ending');
return {
schema: 1,
generatorVersion: GENERATOR_VERSION,
catalogVersion: `${pack.id}@${pack.schema ?? 1}`,
seed,
params: { areas, depth, party, deadliness, density, greed, clock, ending, theme: pack.id },
skeleton,
areas: built,
ending: {
...plan.ending,
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
cueFragments: [endFs?.fragment].filter(Boolean),
question: endingQuestion(skeleton),
authored: plan.ending.mode === 'authored',
},
triggerGlossary: pack.triggerGlossary ?? {},
budget: plan.budget,
pressure: pressure.summary(),
};
}
/** 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?"';
}
+128
View File
@@ -0,0 +1,128 @@
/**
* The director: decides, never generates. Draft 5 §4.
*
* Emits semantic instructions — "area 3 is a complication, heat=fight, hoard=cache, and it owes
* the tab a Bane" — which the Foundry adapter executes against the Forge. The core never sees an
* Actor, a UUID or a die.
*
* Two playtest findings are baked in:
* - Deadliness must not also change how *often* fights appear (draft 4 double-dipped). Severity
* and density are separate dials.
* - Every area needs a fallback route, not just a gate. Playtest 1 died because the good ending
* was gated on a threshold the table could fail without knowing.
*/
export const HEATS = ['skirmish', 'fight', 'battle', 'nightmare'];
export const HOARDS = ['pocket', 'cache', 'chest', 'vault', 'kingly'];
/** The Forge's own ladder (vanity.mjs HEAT_HOARD). */
const HEAT_TO_HOARD = { skirmish: 'pocket', fight: 'cache', battle: 'chest', nightmare: 'vault' };
const DEADLINESS_SHIFT = { forgiving: -1, standard: 0, cruel: +1 };
const GREED_SHIFT = { lean: -1, standard: 0, glutted: +1 };
/**
* Fraction of areas carrying a planned encounter.
* Standard ≈ 2 fights in 6 areas — playtest 2 ran that and it worked; playtest 1's ~60% was far
* too many and the session overran with the finale unresolved.
*/
const DENSITY = { sparse: 0.20, standard: 0.34, infested: 0.55 };
const clamp = (i, arr) => Math.max(0, Math.min(arr.length - 1, i));
/**
* The arc. Areas are assigned roles by position so a delve has a shape rather than a flat run of
* rooms. Proportional, so it works at 3 areas or 12.
*/
export function arcRole(index, total) {
const t = total <= 1 ? 1 : (index - 1) / (total - 1); // 0..1
if (t < 0.20) return 'approach';
if (t < 0.45) return 'complication';
if (t < 0.65) return 'turn';
if (t < 0.85) return 'descent';
return 'threshold';
}
/** Roles that should stay quiet. An empty area is what makes an occupied one land. */
const QUIET_ROLES = new Set(['approach']);
export function baseHeatIndex({ depth = 2, deadliness = 'standard', party = 4 }) {
let i = Math.round((depth - 1) * (HEATS.length - 1) / 4); // depth 1..5 → 0..3
i += DEADLINESS_SHIFT[deadliness] ?? 0;
if (party <= 2) i -= 1;
if (party >= 7) i += 1;
return clamp(i, HEATS);
}
/**
* Plan the whole delve up front — the skeleton is cheap and must be coherent. Areas are
* *materialised* lazily elsewhere; this is only the decision layer.
*/
export function planDelve(params = {}) {
const {
areas = 6, depth = 2, party = 4,
deadliness = 'standard', density = 'standard', greed = 'standard',
ending = 'authored', rng,
} = params;
if (!rng) throw new Error('planDelve requires an Rng (seeded)');
const heatIdx = baseHeatIndex({ depth, deadliness, party });
const wantFights = Math.max(1, Math.round(areas * (DENSITY[density] ?? DENSITY.standard)));
// Choose which areas carry fights. Never the first area; prefer complication/turn/descent.
const plan = [];
const candidates = [];
for (let i = 1; i <= areas; i++) {
const role = arcRole(i, areas);
plan.push({ index: i, role, heat: null, hoard: null, quiet: QUIET_ROLES.has(role) });
if (i > 1 && !QUIET_ROLES.has(role)) candidates.push(i);
}
const chosen = new Set(rng.derive('density').pickN(candidates, Math.min(wantFights, candidates.length)));
// The area just before the ending always carries something — the threshold should have teeth.
if (areas >= 3) chosen.add(areas);
for (const a of plan) {
if (!chosen.has(a.index)) continue;
// Escalate along the arc: later areas run hotter, within the band the dials allow.
const lift = a.role === 'threshold' ? 1 : a.role === 'descent' ? 1 : 0;
a.heat = HEATS[clamp(heatIdx + lift, HEATS)];
const hoardBase = HOARDS.indexOf(HEAT_TO_HOARD[a.heat] ?? 'cache');
a.hoard = HOARDS[clamp(hoardBase + (GREED_SHIFT[greed] ?? 0), HOARDS)];
}
// Treasure is not only behind fights: lean delves still need something to want.
const treasureless = plan.filter(a => !a.hoard);
if (greed !== 'lean' && treasureless.length) {
const bonus = rng.derive('greed').pick(treasureless.filter(a => a.role !== 'approach')) ?? treasureless[0];
if (bonus) bonus.hoard = HOARDS[clamp(HOARDS.indexOf('pocket') + (GREED_SHIFT[greed] ?? 0), HOARDS)];
}
return {
areas: plan,
ending: {
index: areas + 1,
role: 'ending',
mode: ending,
// Draft 5 §2: authored is a default, not a law. Generated endings get a real heat.
heat: ending === 'generated' ? HEATS[clamp(heatIdx + 1, HEATS)] : null,
hoard: ending === 'generated' ? HOARDS[clamp(HOARDS.indexOf('vault') + (GREED_SHIFT[greed] ?? 0), HOARDS)] : null,
},
budget: { plannedFights: chosen.size, heatBase: HEATS[heatIdx], wantFights },
};
}
/**
* Which Bane-banking beat an area should offer, given how the tab is tracking.
* Playtest 1: the tab reached 1 of 6 because nothing emitted Banes deliberately.
*/
export function baneBeatFor(area, pressure, rng) {
const pace = pressure.onPace(area.index);
const pool = area.quiet
? ['noticing', 'announcing']
: ['flattery', 'noticing', 'relicUse', 'refusal'];
// Behind pace → always offer one. On pace → sometimes, so it does not feel mechanical.
if (!pace.behind && !rng.chance(0.5)) return null;
return rng.pick(pool);
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Pressure: the clock and the tab. Draft 5 §9.
*
* Two engines, both rebuilt after playtest 1 falsified the originals:
* - The clock fired 0 times in 4 rolls on a 3-Turn timer; the attention trigger fired on the
* first roll of pass 2. So triggers lead and the timer is a floor.
* - The tab banked 1 Bane in ~40 rolls, because Stumbles are rare. So Banes are *emitted* by
* named beats, never hoped for.
*
* This module NEVER rolls a die. Draft 5 §11 puts the clock in the "live" column: the core
* decides *when* a roll is due and what it means; the adapter rolls it at the table. Keeping
* generation and table dice apart is the whole reason the seed promise is honest.
*/
export const CLOCK_SETTINGS = {
slow: { period: 4, trigger: 1 },
standard: { period: 3, trigger: 1 },
hunted: { period: 2, trigger: 1 },
};
/** Attention triggers. Each area must emit at least one. */
export const TRIGGERS = {
announced: 'entered somewhere grandly, announcing yourself',
seenTwice: 'reflected, echoed or witnessed twice over',
loud: 'a raised voice, a broken door, a shout',
lingering: 'stayed too long in one place',
disturbed: 'struck or moved something that had not moved in years',
};
/** Bane sources. The tab's engine — deliberate, not incidental. */
export const BANE_SOURCES = {
flattery: 'played to something’s vanity to be received',
noticing: 'caught your own reflection and looked',
relicUse: 'spent a relic’s favour',
announcing: 'announced yourself rather than slipping in',
refusal: 'refused an offer that would have cost you nothing yet',
push: 'pushed a roll',
stumble: 'stumbled — a failure with two or more ones',
};
export class Pressure {
/**
* @param {object} opts
* @param {number} opts.areas area count (excluding the ending slot)
* @param {string} opts.clock 'slow' | 'standard' | 'hunted'
* @param {number} opts.baneTarget Banes wanted by the ending (draft 5 says 4–6; UNCALIBRATED —
* playtest 2 reached 3. Treated as a target, not a promise.)
*/
constructor({ areas = 6, clock = 'standard', baneTarget = 5 } = {}) {
this.settings = CLOCK_SETTINGS[clock] ?? CLOCK_SETTINGS.standard;
this.areas = areas;
this.baneTarget = baneTarget;
this.turn = 0;
this.lastClockTurn = 0;
this.clockRolls = []; // { turn, cause, die, fired }
this.tab = []; // { hero, source, area, note }
this.escalated = false;
}
// ---- time -------------------------------------------------------------
advanceTurn(n = 1) { this.turn += n; return this.turn; }
/** True when the timer has come round. Triggers bypass this entirely. */
timerDue() { return this.turn - this.lastClockTurn >= this.settings.period; }
/** One Turn before the timer is due — the GM voices a cue. */
telegraphDue() { return this.turn - this.lastClockTurn === this.settings.period - 1; }
/**
* Escalation: if the clock has not fired by the halfway area, widen the trigger number.
* Playtest 1 rolled 1-in-6 four times and never fired; a clock that never bites is decoration.
*/
escalate(areaIndex) {
const halfway = Math.ceil(this.areas / 2);
if (!this.escalated && areaIndex >= halfway && !this.clockRolls.some(r => r.fired)) {
this.escalated = true;
this.settings = { ...this.settings, trigger: this.settings.trigger + 1 };
}
return this.escalated;
}
/** What number the die must come up at or under for "something comes". */
get triggerNumber() { return this.settings.trigger; }
/**
* Record a clock roll. The die is supplied by the caller — a real table roll.
* @param {number} die 1..6, rolled at the table
* @param {string} cause 'timer' or a TRIGGERS key
*/
recordClock(die, cause = 'timer') {
const fired = die <= this.settings.trigger;
this.clockRolls.push({ turn: this.turn, cause, die, fired });
this.lastClockTurn = this.turn;
return fired;
}
// ---- the tab ----------------------------------------------------------
bankBane(hero, source, area = null, note = '') {
if (!(source in BANE_SOURCES)) throw new Error(`unknown Bane source: ${source}`);
this.tab.push({ hero, source, area, note });
return this.tabFor(hero);
}
tabFor(hero) { return this.tab.filter(b => b.hero === hero).length; }
tabTotal() { return this.tab.length; }
reckoningDue(hero) { return this.tabFor(hero) >= 6; }
/**
* Is the tab on pace? Used by the director to decide whether an area should offer another
* Bane-banking beat. Without this the Reckoning never arrives — playtest 1's central failure.
*/
onPace(areaIndex) {
const expected = (this.baneTarget * areaIndex) / Math.max(this.areas, 1);
return { expected: Math.round(expected * 10) / 10, actual: this.tabTotal(), behind: this.tabTotal() < expected };
}
summary() {
return {
turn: this.turn,
clockRolls: this.clockRolls.length,
clockFired: this.clockRolls.filter(r => r.fired).length,
triggerNumber: this.triggerNumber,
escalated: this.escalated,
tab: this.tabTotal(),
target: this.baneTarget,
};
}
}
@@ -0,0 +1,163 @@
/**
* Two renders of one delve.
*
* WORKSHEET is for the desk: everything the generator knows, labelled, with the reroll commands
* beside each component and an explicit prompt where only a human can write.
*
* PLAY is for the table: the GM's own prose, the live leverage point, the decision on one line,
* the numbers. Nothing else. Four reviews said the same thing — "the handwritten delve tells the
* GM what matters faster" — and the only way to be fast is to carry less.
*/
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const cost = c => {
if (!c) return 'free';
const b = [];
if (c.bane) b.push(`+${c.bane} Bane${c.bane > 1 ? 's' : ''}`);
if (c.vice) b.push(`the Vice ${c.vice}`);
return b.length ? b.join(', ') : 'free';
};
const au = (d, i) => d.authored?.areas?.[i] ?? {};
/* ------------------------------------------------------------- worksheet */
export function renderWorksheet(d) {
const L = [];
const sk = d.skeleton, ap = sk.appeasement;
const todo = outstanding(d);
L.push(`# WORKSHEET — ${d.authored?.title ?? sk.placeName}`);
L.push('');
L.push(`\`seed ${d.seed} · ${d.params.theme} · ${d.params.areas} areas · depth ${d.params.depth} · generator ${d.generatorVersion}\``);
L.push('');
L.push(todo.length
? `**${todo.length} things still need you.** This is a first draft, not a play document. Finish it, then export the play sheet.`
: `**Finished.** Export the play sheet.`);
L.push('');
if (todo.length) { for (const t of todo) L.push(`- [ ] **${t.where}** — ${t.what}`); L.push(''); }
L.push('---');
L.push('');
L.push('## The kernel');
L.push('');
L.push(`| | |`);
L.push(`|---|---|`);
L.push(`| **Transgression** | ${sk.knot.transgression} |`);
L.push(`| **Bottom problem** | ${sk.bottomProblem.label} — ${sk.bottomProblem.failureState} |`);
L.push(`| **Prize** | ${sk.prize.label} — ${sk.prize.function} |`);
L.push(`| **Motif / danger** | ${sk.motif.id} · ${sk.motif.danger} — ${sk.motif.dangerLine} |`);
L.push(`| **Appeasement** | ${ap.move} \`[${ap.attribute} ${ap.successes}]\` — +${ap.gain.vanity} Vanity, +${ap.cost.bane} Bane, repeatable |`);
L.push(`| **It asks** | ${d.ending.question} |`);
L.push('');
L.push(`**WRITE ▸ an opening paragraph.** ${d.authored?.intro ? '✔ written' : '_(nothing yet — set `authored.intro`)_'}`);
if (d.authored?.intro) { L.push(''); L.push(`> ${d.authored.intro}`); }
L.push('');
L.push('---');
L.push('');
for (const a of d.areas) {
const w = au(d, a.index);
const locks = (w.locked ?? []);
const rerollable = REROLLABLE.filter(c => !locks.includes(c) && (c !== 'temptation' || a.hoard));
L.push(`## Area ${a.index} — ${w.nameOverride ?? a.name}`);
L.push(`\`${a.role} · ${a.facet}${a.encounter ? ` · ${a.encounter.heat}` : ''}${a.hoard ? ` · ${a.hoard}` : ''}\``);
L.push(`*reroll:* ${rerollable.map(c => `\`--reroll=${a.index}:${c}\``).join(' ')}${locks.length ? ` · *locked:* ${locks.join(', ')}` : ''}`);
L.push('');
L.push(`**IMAGE** ${a.cueFragments.join(' · ')}`);
if (a.situation) {
L.push(`**SITUATION** ${cap(a.situation.occupant)} — ${a.situation.doing}. On arrival, ${a.situation.onArrival}.`);
L.push(`**WHY** ${cap(a.situation.because)}. **OFFERS** ${a.situation.offer}.`);
}
L.push(`**DECISION** ${cap(a.decision.cue)} — *${a.decision.cost}*`);
const rv = a.decision.resolve ?? {};
if (rv.roll) L.push(` · \`[${rv.roll}]\` → ${rv.success}`);
if (rv.failure) L.push(` · **miss** → ${rv.failure}`);
if (rv.orElse) L.push(` · **or** ${rv.orElse}`);
if (a.encounter?.roster) {
const R = a.encounter.roster;
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
L.push(` · harmed by ${R.harmedBy} · ${R.avoid}`);
}
if (a.temptation) {
const t = a.temptation;
L.push(`**TEMPTATION** ${cap(t.id)} — ${t.cue}: ${t.benefit}`);
L.push(` · take ${cost(t.acceptanceCost)} · use ${cost(t.useCost)} · standing: ${t.standingDrawback}`);
}
L.push(`**PRESSURE** trigger ${a.trigger}${a.baneBeat ? ` · bane beat ${a.baneBeat}` : ''} · fallback ${a.fallback.route}`);
L.push('');
L.push(`**WRITE ▸ read-aloud** — ≤3 sentences, built from the IMAGE and SITUATION above.`);
L.push(w.readAloud ? `> ${w.readAloud}` : `> _(nothing yet)_`);
if (w.notes) { L.push(''); L.push(`**Notes.** ${w.notes}`); }
L.push('');
L.push('---');
L.push('');
}
const we = d.authored?.ending ?? {};
L.push(`## Ending — ${d.ending.name}`);
L.push(d.ending.authored ? '`AUTHORED SLOT — DELVE stops here on purpose`' : '`generated`');
L.push('');
L.push(`**IMAGE** ${d.ending.cueFragments.join(' · ')}`);
L.push(`**IT WANTS** ${sk.bottomProblem.wantNow}. **IT ASKS** ${d.ending.question}`);
L.push(`**APPEASED EVEN ONCE** it listens, and will trade ${sk.prize.label}.`);
L.push('');
L.push('**WRITE ▸ read-aloud**');
L.push(we.readAloud ? `> ${we.readAloud}` : `> _(nothing yet)_`);
L.push('');
L.push('**WRITE ▸ the climax.** How it goes, what harms it, what it will take instead of a fight.');
L.push(we.notes ? we.notes : `_(nothing yet — this is the seam; DELVE will not write it for you)_`);
L.push('');
return L.join('\n');
}
/* ------------------------------------------------------------ play sheet */
export function renderPlay(d) {
const L = [];
const sk = d.skeleton, ap = sk.appeasement;
const todo = outstanding(d);
L.push(`# ${d.authored?.title ?? sk.placeName}`);
L.push('');
if (todo.length) {
L.push(`> ⚠ **${todo.length} unfinished** — ${todo.slice(0, 3).map(t => t.where).join(', ')}${todo.length > 3 ? '…' : ''}.`);
L.push('> Fragments are shown where nothing was written. Run the worksheet first.');
L.push('');
}
if (d.authored?.intro) { L.push(`> ${d.authored.intro}`); L.push(''); }
L.push(`**Appease:** ${ap.move} \`[${ap.attribute} ${ap.successes}]\` — +1 Vanity now, +1 Bane later. Repeatable, every area.`);
L.push(`**Clock:** 1d6 on a trigger. **1 — something comes.** **Tab:** ${d.pressure.target} by the end.`);
L.push(`**GM truth:** ${sk.knot.transgression} ${sk.bottomProblem.label} ${sk.bottomProblem.failureState}.`);
L.push('');
L.push('---');
L.push('');
for (const a of d.areas) {
const w = au(d, a.index);
const rv = a.decision.resolve ?? {};
L.push(`### ${a.index} · ${w.nameOverride ?? a.name}`);
L.push('');
L.push(w.readAloud ? `> ${w.readAloud}` : `> *(unwritten)* ${a.cueFragments.join('. ')}.`);
L.push('');
if (a.situation) L.push(`**Here:** ${cap(a.situation.occupant)}, ${a.situation.doing} — ${a.situation.onArrival}. **They can:** ${a.situation.offer}.`);
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
if (a.encounter?.roster) {
const R = a.encounter.roster;
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`);
}
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
if (w.notes) L.push(`**Note:** ${w.notes}`);
L.push(`\`${a.trigger}${a.baneBeat ? ` · ${a.baneBeat}` : ''} · skipped → move the image on\``);
L.push('');
}
const we = d.authored?.ending ?? {};
L.push(`### ${d.ending.name}`);
L.push('');
L.push(we.readAloud ? `> ${we.readAloud}` : `> *(unwritten)* ${d.ending.cueFragments.join('. ')}.`);
L.push('');
L.push(`**It asks:** ${d.ending.question}`);
L.push(we.notes ? we.notes : `*(the climax is unwritten — this is the authored slot)*`);
L.push('');
return L.join('\n');
}
+170
View File
@@ -0,0 +1,170 @@
/** Render a delve to GM-readable markdown, in the house style. */
import { skeletonBlurb } from './skeleton.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
/** The appeasement, in the compact form used inside an area. */
const skeletonAppeasement = d => `${d.skeleton.appeasement.attribute} ${d.skeleton.appeasement.successes}: ${d.skeleton.appeasement.move}`;
/** Costs read as English in a GM document, never as JSON. */
const cost = c => {
if (!c) return 'free';
const bits = [];
if (c.bane) bits.push(`**+${c.bane} Bane${c.bane > 1 ? 's' : ''}** on the tab`);
if (c.vice) bits.push(`take the Vice **${c.vice}**`);
if (c.vanity) bits.push(`**+${c.vanity} Vanity**`);
return bits.length ? bits.join(', ') : 'free';
};
export function renderMarkdown(d) {
const L = [];
const sk = d.skeleton;
L.push(`# ${sk.placeName.toUpperCase()}`);
L.push('');
L.push(`> *${sk.motif.dangerLine}.*`);
L.push('');
L.push(`**VANITY · ${cap(d.params.theme)} · Depth ${d.params.depth} · ${d.params.party} heroes · ` +
`${d.params.areas} areas + ${d.ending.authored ? 'an authored ending' : 'a generated ending'}**`);
L.push(`**Seed \`${d.seed}\` · generator ${d.generatorVersion} · catalog ${d.catalogVersion}**`);
L.push('');
L.push('> **Skeleton seed, not a replay seed.** It reproduces the fiction, the area order, the');
L.push('> heat and the hoard tiers. It does **not** reproduce encounter composition, mood or');
L.push('> hoard contents — those come from the Forge, which is not seeded.');
L.push('');
L.push('---');
L.push('');
L.push('## The truth (GM only)');
L.push('');
L.push(skeletonBlurb(sk));
L.push('');
L.push(`**They will ask you:** ${d.ending.question}`);
L.push('');
L.push('## Pressure');
L.push('');
L.push(`- **The clock.** Roll 1d6 on a trigger, or every ${d.pressure.triggerNumber === 1 ? '3' : '3'} Turns. ` +
`**${d.pressure.triggerNumber} or under — something comes.** Telegraph one Turn ahead.`);
L.push(`- **Triggers this delve:** ${[...new Set(d.areas.map(a => a.trigger))].join(' · ')}`);
L.push(`- **The tab.** Target **${d.pressure.target} Banes** by the ending. Banes are *offered by named beats*, never hoped for.`);
L.push(`- **The danger is ${sk.motif.danger}:** ${sk.motif.dangerLine}.`);
L.push('');
const ap = sk.appeasement;
L.push('## The way through — the appeasement');
L.push('');
L.push(`Anyone may **${ap.move}**. \`[VANITY: ${ap.attribute} — ${ap.successes} successes]\``);
L.push(`On success ${ap.why}, and ${sk.factions[0]?.name} will not open hostilities first.`);
L.push(`**Cost: +${ap.gain.vanity} Vanity now, +${ap.cost.bane} Bane on the tab.** Shine now, bill later.`);
L.push(`**Repeatable in every area.** Say the Bane aloud when it banks — the table must be able to see the route is open.`);
L.push('');
L.push('## Factions');
L.push('');
for (const f of sk.factions) L.push(`- **${f.name}** (${f.kind}) — ${f.essence}. Handled by *${f.handledBy}*.`);
L.push('');
L.push(`**Planned fights:** ${d.budget.plannedFights} of ${d.params.areas} areas · base heat *${d.budget.heatBase}*`);
L.push('');
L.push('---');
L.push('');
for (const a of d.areas) {
const S = a.situation;
const rv = a.decision?.resolve ?? {};
const R = a.encounter?.roster;
L.push(`### AREA ${a.index} — ${a.name}`);
L.push(`*${a.role} · ${a.facet}${a.encounter ? ` · ${a.encounter.heat}` : ''}${a.hoard ? ` · ${a.hoard}` : ''}*`);
L.push('');
// ONE opening: image, situation and the automatic clue as a single thing to read aloud from.
// Previously these were three stacked blocks; the review's word for the whole document was
// "integration" — image, situation, pressure and choice reading as one, not as components.
const opening = [
a.cueFragments.map(c => cap(c)).join('. ') + '.',
S ? `${cap(S.occupant)} — ${S.doing}. When you walk in, ${S.onArrival}.` : '',
a.clue.automatic ? `None of it is accident — it was done this way on purpose.` : '',
].filter(Boolean).join(' ');
L.push(`> ${opening}`);
L.push('');
const gm = [
S ? `${cap(S.because)}.` : '',
a.truth ? `${a.truth.replace(/\.?$/, '.')}` : '',
].filter(Boolean).join(' ');
L.push(`**GM.** ${gm}`);
if (S) L.push(`**They can:** ${S.offer}. \`[${skeletonAppeasement(d)}]\` also works here, as everywhere.`);
L.push(`**Looking closer:** ${a.clue.extra.replace(/^\[VANITY: /, '`[').replace(/\]/, ']`')}`);
L.push('');
// The decision on one line where it can be: attempt → hit → miss → or.
L.push(`**${cap(a.decision.cue)}.** *${a.decision.cost}.*`);
if (rv.roll) {
L.push(`\`[${rv.roll}]\` → ${rv.success}`);
L.push(`**Miss** → ${rv.failure}`);
}
if (rv.orElse) L.push(`**Or** ${rv.orElse}.`);
L.push('');
if (R) {
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
L.push('');
L.push('| Foe | atk | def | Grit | Nerve | |');
L.push('|---|---|---|---|---|---|');
for (const f of R.foes) L.push(`| ${f.n}× ${f.name} | ${f.atk} | ${f.def} | ${f.grit} | ${f.nerve} | ${f.note} |`);
L.push('');
}
if (a.temptation) {
const t = a.temptation;
L.push(`**${cap(a.hoard)} hoard.** **${cap(t.id)}** — ${t.cue}: ${t.benefit}.`);
L.push(`*Take it ${cost(t.acceptanceCost)}; using it costs ${cost(t.useCost)}; while you carry it, ${t.standingDrawback}.*`);
L.push('');
} else if (a.hoard) {
L.push(`**${cap(a.hoard)} hoard.**`);
L.push('');
}
// Everything the GM needs but never reads aloud, on one line.
const beat = a.baneBeat ? ` · bane beat: ${a.baneBeat}` : '';
L.push(`\`trigger: ${a.trigger}${beat} · fallback: ${a.fallback.route} (${a.fallback.cost}) · if skipped: ${a.failureCase.replace(/^If they skip this, /, '')}\``);
L.push('');
L.push('---');
L.push('');
}
L.push(`### ENDING — ${d.ending.name}`);
L.push(d.ending.authored ? '### ⟨ AUTHORED SLOT — this is the seam ⟩' : `*generated · heat ${d.ending.heat} · hoard ${d.ending.hoard}*`);
L.push('');
if (d.ending.authored) {
L.push('**Areas 1–' + d.params.areas + ' are what DELVE generates. This is what it hands over.**');
L.push('');
}
L.push('**Cue** — *fragments*');
for (const c of d.ending.cueFragments) L.push(`- ${c}`);
L.push('');
L.push(`**${sk.bottomProblem.label}** — ${sk.bottomProblem.failureState}. Wants: *${sk.bottomProblem.wantNow}*.`);
L.push(`Handled by ${sk.bottomProblem.handledBy}.`);
L.push('');
L.push(`**They open by asking:** ${d.ending.question}`);
L.push('');
L.push('| Party\'s route | The audience |');
L.push('|---|---|');
L.push(`| Appeased it even once (**${ap.move}**) | It listens. It will trade ${sk.prize.label} for the real thing, done to its face. \`[VANITY: ${ap.attribute} — ${ap.successes}]\` or take **Rattled**. |`);
L.push('| Refused throughout, tab under 4 | It offers a Vice — take the shine, bank the bill. Refusing starts the fight. |');
L.push(`| Carrying ${sk.prize.label} | It wants that more than it wants you. |`);
L.push('| Fighting | Winnable by two engaged heroes. **Name what harms it before initiative.** |');
L.push('');
L.push('> **Before initiative, always say what can hurt it.** Playtest 1 was unwinnable because');
L.push('> the table discovered the immunity by failing.');
L.push('');
const g = d.triggerGlossary ?? {};
if (Object.keys(g).length) {
L.push('## Trigger glossary');
L.push('');
for (const [k, v] of Object.entries(g)) L.push(`- **${k}** — ${v}`);
L.push('');
}
L.push('---');
L.push('');
L.push('*Private VANITY development material — not for sale or distribution.*');
return L.join('\n');
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Seeded randomness with derived streams.
*
* Draft 5 §11: a delve is reproducible from its skeleton seed. That only holds if every
* component draws from its *own* stream — otherwise materialising areas in a different order
* changes every subsequent draw. So each component derives a named sub-seed:
*
* seed::skeleton
* seed::area::a3::feature
* seed::area::a3::encounter
*
* Two rules make this work:
* 1. Never share an Rng between components.
* 2. Never let draw *count* in one component affect another.
*/
/** xmur3: string → 32-bit seed. */
function xmur3(str) {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return () => {
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^= h >>> 16) >>> 0;
};
}
/** sfc32: small, fast, good enough for content generation. */
function sfc32(a, b, c, d) {
return () => {
a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
let t = (a + b) | 0;
a = b ^ (b >>> 9);
b = (c + (c << 3)) | 0;
c = (c << 21) | (c >>> 11);
d = (d + 1) | 0;
t = (t + d) | 0;
c = (c + t) | 0;
return (t >>> 0) / 4294967296;
};
}
export class Rng {
constructor(seedString) {
this.seed = String(seedString);
const h = xmur3(this.seed);
this._next = sfc32(h(), h(), h(), h());
this.draws = 0;
}
/** A new independent stream. Order-independent by construction. */
derive(...parts) {
return new Rng(`${this.seed}::${parts.join('::')}`);
}
float() { this.draws++; return this._next(); }
/** Integer in [0, n). */
int(n) { return Math.floor(this.float() * n); }
/** Integer in [min, max] inclusive. */
between(min, max) { return min + this.int(max - min + 1); }
chance(p) { return this.float() < p; }
pick(arr) {
if (!Array.isArray(arr) || arr.length === 0) return undefined;
return arr[this.int(arr.length)];
}
/** n distinct items, or as many as exist. Never returns undefined holes. */
pickN(arr, n) {
const pool = [...(arr ?? [])];
const out = [];
while (n-- > 0 && pool.length) out.push(pool.splice(this.int(pool.length), 1)[0]);
return out;
}
shuffle(arr) {
const a = [...(arr ?? [])];
for (let i = a.length - 1; i > 0; i--) {
const j = this.int(i + 1);
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/** Weighted pick from {key: weight} or [[value, weight], ...]. */
weighted(spec) {
const entries = Array.isArray(spec) ? spec : Object.entries(spec);
const total = entries.reduce((s, [, w]) => s + w, 0);
if (total <= 0) return undefined;
let r = this.float() * total;
for (const [value, w] of entries) { r -= w; if (r < 0) return value; }
return entries[entries.length - 1][0];
}
/** d6 pool; VANITY counts 5–6 as Successes. Used for generation only, never table dice. */
pool(n) { return Array.from({ length: n }, () => this.between(1, 6)); }
}
/** A random, human-sayable seed — two words and a number. */
export function coinSeed(rng = new Rng(String(Date.now()))) {
const a = ['gilded', 'hollow', 'salt', 'ember', 'quiet', 'crooked', 'pale', 'brazen'];
const b = ['court', 'crown', 'mirror', 'vigil', 'debt', 'mask', 'hymn', 'tally'];
return `${rng.pick(a)}-${rng.pick(b)}-${rng.between(100, 999)}`;
}
+134
View File
@@ -0,0 +1,134 @@
/**
* The skeleton: the fiction, generated before any area exists. Draft 5 §1.
*
* The hard problem this solves is coherence. Four independent draws — a crown, a flood, goblins,
* mirrors — produce nonsense. So there is exactly ONE root draw, and everything else is projected
* from it:
*
* APPETITE ──> motif ──> danger, trigger, every cue fragment
* │ ──> prize kind
* │ ──> who was conscripted
* └─ + ACCOMMODATION ──> the transgression, the bottom problem, the primary faction
*
* Content therefore scales additively, not combinatorially: 8 appetites × 6 accommodations ×
* 7 claimants = 336 kernels from 21 authored lines.
*
* The foreshadow chain is a fixed FACET progression mapped onto the arc. Area 2's held bows
* (ritual) and area 4's scratched-out eyes (wound) point at the same faceless queen because both
* are facets of one motif, drawn in a deliberate order. That progression was reverse-engineered
* from the hand-authored paper delve, which had arrived at it by instinct.
*/
import { arcRole } from './director.mjs';
/** Fills {conscript}-style slots. */
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
/**
* Draw an unused fragment for a facet. Light motifs carry only one fragment per facet, so
* without borrowing, two areas sharing a role would print the same image twice.
*/
function drawFragment(motif, facet, used, rng) {
const order = [facet, ...FACET_ORDER.filter(f => f !== facet)];
for (const f of order) {
const fresh = (motif.facets?.[f] ?? []).filter(x => !used.has(x));
if (fresh.length) { const pick = rng.pick(fresh); used.add(pick); return pick; }
}
return null;
}
export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng }) {
if (!pack) throw new Error('generateSkeleton requires a theme pack');
if (!rng) throw new Error('generateSkeleton requires an Rng (seeded)');
const r = rng.derive('skeleton');
// ---- the one root draw -------------------------------------------------
const appetite = r.derive('appetite').pick(pack.appetites);
// Only accommodations that make sense for this appetite. Drawing freely produced lines like
// "had the faces struck from the young, so that nothing would ever age" — equation output.
const fitting = pack.accommodations.filter(a => !a.fits || a.fits.includes(appetite.id));
const accommodation = r.derive('accommodation').pick(fitting.length ? fitting : pack.accommodations);
const claimant = r.derive('claimant').pick(pack.claimants);
// ---- everything else is projected --------------------------------------
const motifId = appetite.motif;
const motif = pack.motifs[motifId];
if (!motif) throw new Error(`theme pack has no motif "${motifId}"`);
const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
const transgression =
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`so that ${appetite.purpose}.`;
const bottomProblem = {
label: `The ${claimant}`,
wantNow: appetite.want,
failureState: `is ${appetite.lack} now, and cannot bear it`,
handledBy: 'giving them what they want, at a price',
};
// Primary faction is whoever was conscripted; a secondary is drawn for friction.
const primary = {
...(pack.factions.find(f => f.handledBy === 'courtesy') ?? pack.factions[0]),
conscriptedAs: appetite.conscript,
};
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}`;
// ---- the foreshadow chain ----------------------------------------------
const progression = pack.facetProgression ?? {};
const used = new Set();
const foreshadow = [];
for (let i = 1; i <= areas; i++) {
const role = arcRole(i, areas);
const facet = progression[role] ?? 'institution';
const fragment = drawFragment(motif, facet, used, r.derive('foreshadow', String(i)));
if (fragment) used.add(fragment);
foreshadow.push({ index: i, role, facet, fragment: fragment ?? null });
}
if (ending === 'authored' || ending === 'generated') {
const facet = progression.ending ?? 'demand';
foreshadow.push({
index: areas + 1, role: 'ending', facet,
fragment: drawFragment(motif, facet, used, r.derive('foreshadow', 'ending')),
});
}
return {
placeName,
knot: {
appetiteId: appetite.id,
accommodationId: accommodation.id,
claimant,
conscript: appetite.conscript,
transgression,
cruelty: accommodation.cruelty,
},
motif: { id: motifId, danger: motif.danger, dangerLine: motif.dangerLine, trigger: motif.trigger },
prize,
bottomProblem,
// The appeasement move is the delve's social spine: feed the appetite, pay the tab.
// It projects from the appetite, which is why it is always thematically exact.
appeasement: { ...appetite.appeasement, faction: primary.name },
factions: [primary, secondary].filter(Boolean),
foreshadow,
};
}
/** A one-paragraph GM-truth summary — the thing a GM reads first. */
export function skeletonBlurb(sk) {
return [
`**${sk.placeName}.** ${sk.knot.transgression}`,
`${sk.bottomProblem.label} ${sk.bottomProblem.failureState}.`,
`The prize is ${sk.prize.label} — ${sk.prize.function}.`,
`${sk.factions[0]?.name} are ${sk.factions[0]?.essence}; they are handled by ${sk.factions[0]?.handledBy}.`,
`The motif is **${sk.motif.id}**, and the danger is ${sk.motif.danger}: ${sk.motif.dangerLine}.`,
].join(' ');
}