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>
81 lines
3.7 KiB
JavaScript
81 lines
3.7 KiB
JavaScript
/**
|
|
* An area is a beat. Draft 5 §6 — two faces, not four.
|
|
*
|
|
* Cue (what players perceive) and Truth (what is going on) are required. Consequence is inline
|
|
* bracketed mechanics, the way the house style already does it. Reveal appears only where a
|
|
* secret genuinely differs from the Cue.
|
|
*
|
|
* DELVE does not write prose. The playtest was explicit that the read-aloud paragraphs are most
|
|
* of what makes a delve playable and they are hand-written. So the Cue is emitted as *fragments*
|
|
* for the GM to assemble aloud, labelled as such. Faking prose here would be the one dishonest
|
|
* thing this tool could do.
|
|
*/
|
|
|
|
const FACET_TRUTH = {
|
|
institution: 'this is how the place was ordered, and it still is',
|
|
ritual: 'the office is still being performed, for someone who is no longer served by it',
|
|
demand: 'here the want is explicit — the place asks something of whoever enters',
|
|
wound: 'this is the damage the want did to those who had to satisfy it',
|
|
anchor: 'the thing the whole barrow is arranged around is close',
|
|
demandEnd: 'the question itself, asked directly',
|
|
};
|
|
|
|
export function buildArea({ skeleton, planned, pack, pressure, rng, baneBeat = null, feature, decision, temptation = null, situation = null, name = null }) {
|
|
const r = rng.derive('area', String(planned.index));
|
|
const fs = skeleton.foreshadow.find(f => f.index === planned.index) ?? {};
|
|
|
|
|
|
// feature/decision are dealt by the caller without replacement across the delve — drawing them
|
|
// per-area from pools of 8 and 6 made repeats near-certain inside a single delve.
|
|
// Every area emits at least one attention trigger — draft 5 §9 weights triggers over the timer.
|
|
const trigger = fs.facet === 'demand' ? skeleton.motif.trigger
|
|
: r.derive('trigger').pick(['announced', 'loud', 'lingering', 'seenTwice', 'disturbed']);
|
|
|
|
return {
|
|
index: planned.index,
|
|
role: planned.role,
|
|
name,
|
|
facet: fs.facet ?? null,
|
|
|
|
// Cue: fragments, not prose.
|
|
cueFragments: [fs.fragment, feature].filter(Boolean),
|
|
|
|
// The situation is the room as the players find it: someone doing something, and what
|
|
// changes when they walk in. The decision is one of the things you can do about it.
|
|
situation: situation ? {
|
|
occupant: situation.occupant, doing: situation.doing, onArrival: situation.onArrival,
|
|
because: situation.because, offer: situation.offer,
|
|
} : null,
|
|
|
|
truth: (t => t ? t[0].toUpperCase() + t.slice(1) : '')(FACET_TRUTH[fs.facet] ?? ''),
|
|
|
|
decision,
|
|
encounter: planned.heat
|
|
? { heat: planned.heat, kind: skeleton.factions[0]?.kind ?? 'undead', roster: pack.rosters?.[planned.heat] ?? null }
|
|
: null,
|
|
hoard: planned.hoard ?? null,
|
|
temptation,
|
|
trigger,
|
|
baneBeat,
|
|
|
|
// Clues are automatic on entry; the roll buys extra. Playtest 1 lost both its clues to
|
|
// single unopposed rolls and the motif never reached the table.
|
|
clue: {
|
|
automatic: fs.fragment ? `${fs.fragment} — and it was done deliberately` : null,
|
|
extra: '[VANITY: Observation — 1 success] buys who did it, and roughly when',
|
|
},
|
|
|
|
failureCase: planned.role === 'threshold'
|
|
? 'If they skip this, the ending opens with them holding nothing the bottom problem wants.'
|
|
: 'If they skip this, move its cue fragment into the next area so the motif still completes.',
|
|
};
|
|
}
|
|
|
|
/** Every area must offer a way on that is not gated. Playtest 1 died on a gate with no fallback. */
|
|
export function fallbackRoute(area, skeleton) {
|
|
return {
|
|
route: `${skeleton.factions[0]?.name} will receive anyone who ${skeleton.factions[0]?.handledBy === 'courtesy' ? 'behaves as a guest' : 'offers a trade'}`,
|
|
cost: 'one Bane on the tab, and they will expect it again',
|
|
};
|
|
}
|