The review's verdict after resolution blocks: 'still choices attached to
prompts, not spaces that naturally produce play'. The hand-written delve gives
a place — four people back-to-back around a dead lantern — and the decisions
fall out of it. DELVE gave a decision and asked the GM to imagine the room.
Every area now carries a situation: an occupant, what they are doing, what
changes when the party walks in, why (GM), and what it offers. 48 across 8
motifs, motif-scoped and dealt without replacement.
It produces chains nothing planned: in seed live-table-01 a grave-robber in
area 2 is trying to get a password's intonation right, and area 4 is a door
that needs one. Three routes to it now exist where there was a Wits roll.
Also closes the last two open complaints: the five roll-less decisions now have
real attempts (a voice you could not speak to was the weakest), and {priorCue}
resolves against a cue the party has actually seen instead of granting the
answer by fiat.
Tests 19 -> 24.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
5.4 KiB
JavaScript
116 lines
5.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';
|
|
|
|
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');
|
|
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], 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?"';
|
|
}
|