Files
vanity-delve/foundry-module/module/core/delve.mjs
T
tevansandClaude Opus 5 9d953dba94 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>
2026-08-05 11:18:23 +01:00

129 lines
6.1 KiB
JavaScript

/**
* 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?"';
}