beat.mjs embeds a pack's roster verbatim into every area, so adding beforeInitiative changed the shape of the emitted delve file — and two files from the same seed could carry different structures under identical stamps. The change originates in the packs, not in the generator's composition, so it belongs to catalogVersion rather than GENERATOR_VERSION: the packs are at schema 2 and a file now stamps barrow@2. The note on GENERATOR_VERSION says which stamp owns which kind of change, since that was the ambiguity. README's test counts were still 39 and silent about the adapter suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
6.7 KiB
JavaScript
134 lines
6.7 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.
|
|
*
|
|
* A change that originates in a pack rather than in this file belongs to `catalogVersion` instead
|
|
* — beat.mjs embeds the pack's roster verbatim, so a new roster field changes the emitted file
|
|
* without changing anything here. `beforeInitiative` is why the packs are at schema 2.
|
|
*/
|
|
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?"';
|
|
}
|