Files
vanity-delve/foundry-module/module/core/delve.mjs
T
slaguru666andClaude Opus 5 316716e001 Bump GENERATOR_VERSION to 0.2.0; let the tests run from anywhere
GENERATOR_VERSION had sat at 0.1.0 while the emitted object gained the
authored layer and the content model moved under it, so a delve file
claimed a generator that no longer existed — both samples say 0.1.0 and
only one of them has an authored layer. Nothing gates on the stamp, so
this is informational only and old files still load. The comment on it
now says what it tracks and when to bump it, which is what stopped it
being bumped before.

test.mjs read ./content/barrow.json relative to the working directory, so
it only ran from inside core/ and crashed from the repo root. It now
resolves from the module file, matching build-index.mjs and
validate-pack.mjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:58:29 +01:00

130 lines
6.4 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';
/**
* Stamped into every delve file and shown on both surfaces, so a file says which generator made
* it. Bump it when the shape of the emitted object changes or when content selection changes
* enough that the same seed no longer yields the same delve — not when the module version moves.
* 0.2.0: the `authored` layer, motif-scoped decisions/temptations/features, areas as situations.
*/
export const GENERATOR_VERSION = '0.2.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, pack),
authored: plan.ending.mode === 'authored',
},
triggerGlossary: pack.triggerGlossary ?? {},
budget: plan.budget,
pressure: pressure.summary(),
};
}
/**
* 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?"';
}