Files
vanity-delve/core/delve.mjs
T
tevansandClaude Opus 5 ded836d4c8 Deal area names; fix the scene-control hook for Foundry v13+
Two bugs the first real button-press exposed.

The ⛏ never appeared. getSceneControlButtons was written for the old array
shape, but v13+ passes a record keyed by name and the token group is `tokens`,
plural — so the hook silently added nothing. Every earlier test had gone through
the API, which masked it completely. It now handles both shapes.

Area names were picked per-area from a small per-role list, so two areas sharing
a role could collide: a generated dungeon shipped with two scenes both called
"The Lesser Vault". Names are now dealt globally without replacement, falling
back to other roles' lists before ever repeating. This is the same bug already
fixed for features, decisions, temptations and situations — missed for names
because names were assigned in beat.mjs rather than dealt in delve.mjs.

A test now generates 180 delves at 6, 9 and 12 areas and asserts no two areas
in one delve share a name. Tests 38 -> 39.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:44:53 +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?"';
}