The review's sharpest finding was that the fiction cohered but the things in the rooms did not belong to it: only the motif cue fragment was projected, while decisions and temptations came from global pools. The output was a consistent wrapper around generic room furniture. Both now live under motifs.<id> — 6 decisions and 5 temptations each, 88 authored entries across 8 motifs. A `keeping` delve offers a flower that has not wilted and a room you may search or leave exactly as you found it; a `precedence` delve offers a corridor that will not let two walk abreast. Two bugs surfaced while wiring it, both the same shape as one fixed earlier for cue fragments: decisions and temptations were picked independently per area rather than dealt, so a pool of 3 repeated a temptation in 75 of 80 test delves. Both are now dealt without replacement. Where a long delve outruns its motif pool, decisions top up from the generic pool but temptations do not. A generic relic breaks the fiction; a repeated one only looks thin. Tests 11 -> 15: motif provenance for both pools, and no repeats at 6 and 9 areas. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
4.3 KiB
JavaScript
100 lines
4.3 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 features = deal(pack.features ?? [], plan.areas.length, 'features');
|
|
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], temptation: planned.hoard ? temptations[tIdx++] : null });
|
|
area.fallback = fallbackRoute(area, skeleton);
|
|
return area;
|
|
});
|
|
|
|
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?"';
|
|
}
|