Files
vanity-delve/core/skeleton.mjs
T
tevansandClaude Opus 5 07148df8e6 DELVE core: seeded generator, fiction kernel, Barrow pack, CLI
Build steps 1, 2 and 4 of the plan in DESIGN.md.

core/ is pure JS with zero Foundry globals and 11 passing invariants. The
fiction model has one root draw — an APPETITE plus the ACCOMMODATION someone
made for it — and projects the motif, danger, prize, faction, cue fragments and
the appeasement move from it, so the fiction coheres instead of being four
unrelated draws. 336 kernels from 21 authored lines; 60 generated delves gave
51 distinct kernels.

Cue fragments are tagged by facet (institution/ritual/demand/wound/anchor) and
dealt without replacement. The facet progression across the arc reproduces the
hand-authored paper delve's structure, which had arrived at it by instinct.

Generation and table dice stay apart: pressure.mjs decides when a clock roll is
due and what it means, and never rolls. That is what makes the skeleton seed an
honest promise rather than a half-truth.

paper/ holds the hand-written delve and both playtest logs. spike/ holds the
instrumented harness that measured the Forge outside Foundry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:16:58 +01:00

135 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The skeleton: the fiction, generated before any area exists. Draft 5 §1.
*
* The hard problem this solves is coherence. Four independent draws — a crown, a flood, goblins,
* mirrors — produce nonsense. So there is exactly ONE root draw, and everything else is projected
* from it:
*
* APPETITE ──> motif ──> danger, trigger, every cue fragment
* │ ──> prize kind
* │ ──> who was conscripted
* └─ + ACCOMMODATION ──> the transgression, the bottom problem, the primary faction
*
* Content therefore scales additively, not combinatorially: 8 appetites × 6 accommodations ×
* 7 claimants = 336 kernels from 21 authored lines.
*
* The foreshadow chain is a fixed FACET progression mapped onto the arc. Area 2's held bows
* (ritual) and area 4's scratched-out eyes (wound) point at the same faceless queen because both
* are facets of one motif, drawn in a deliberate order. That progression was reverse-engineered
* from the hand-authored paper delve, which had arrived at it by instinct.
*/
import { arcRole } from './director.mjs';
/** Fills {conscript}-style slots. */
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
/**
* Draw an unused fragment for a facet. Light motifs carry only one fragment per facet, so
* without borrowing, two areas sharing a role would print the same image twice.
*/
function drawFragment(motif, facet, used, rng) {
const order = [facet, ...FACET_ORDER.filter(f => f !== facet)];
for (const f of order) {
const fresh = (motif.facets?.[f] ?? []).filter(x => !used.has(x));
if (fresh.length) { const pick = rng.pick(fresh); used.add(pick); return pick; }
}
return null;
}
export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng }) {
if (!pack) throw new Error('generateSkeleton requires a theme pack');
if (!rng) throw new Error('generateSkeleton requires an Rng (seeded)');
const r = rng.derive('skeleton');
// ---- the one root draw -------------------------------------------------
const appetite = r.derive('appetite').pick(pack.appetites);
// Only accommodations that make sense for this appetite. Drawing freely produced lines like
// "had the faces struck from the young, so that nothing would ever age" — equation output.
const fitting = pack.accommodations.filter(a => !a.fits || a.fits.includes(appetite.id));
const accommodation = r.derive('accommodation').pick(fitting.length ? fitting : pack.accommodations);
const claimant = r.derive('claimant').pick(pack.claimants);
// ---- everything else is projected --------------------------------------
const motifId = appetite.motif;
const motif = pack.motifs[motifId];
if (!motif) throw new Error(`theme pack has no motif "${motifId}"`);
const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
const transgression =
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`so that ${appetite.purpose}.`;
const bottomProblem = {
label: `The ${claimant}`,
wantNow: appetite.want,
failureState: `is ${appetite.lack} now, and cannot bear it`,
handledBy: 'giving them what they want, at a price',
};
// Primary faction is whoever was conscripted; a secondary is drawn for friction.
const primary = {
...(pack.factions.find(f => f.handledBy === 'courtesy') ?? pack.factions[0]),
conscriptedAs: appetite.conscript,
};
const secondary = r.derive('faction2').pick(pack.factions.filter(f => f.id !== primary.id));
// Named for its occupant. Drawing the name separately produced tombs named after strangers.
const placeName = `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
// ---- the foreshadow chain ----------------------------------------------
const progression = pack.facetProgression ?? {};
const used = new Set();
const foreshadow = [];
for (let i = 1; i <= areas; i++) {
const role = arcRole(i, areas);
const facet = progression[role] ?? 'institution';
const fragment = drawFragment(motif, facet, used, r.derive('foreshadow', String(i)));
if (fragment) used.add(fragment);
foreshadow.push({ index: i, role, facet, fragment: fragment ?? null });
}
if (ending === 'authored' || ending === 'generated') {
const facet = progression.ending ?? 'demand';
foreshadow.push({
index: areas + 1, role: 'ending', facet,
fragment: drawFragment(motif, facet, used, r.derive('foreshadow', 'ending')),
});
}
return {
placeName,
knot: {
appetiteId: appetite.id,
accommodationId: accommodation.id,
claimant,
conscript: appetite.conscript,
transgression,
cruelty: accommodation.cruelty,
},
motif: { id: motifId, danger: motif.danger, dangerLine: motif.dangerLine, trigger: motif.trigger },
prize,
bottomProblem,
// The appeasement move is the delve's social spine: feed the appetite, pay the tab.
// It projects from the appetite, which is why it is always thematically exact.
appeasement: { ...appetite.appeasement, faction: primary.name },
factions: [primary, secondary].filter(Boolean),
foreshadow,
};
}
/** A one-paragraph GM-truth summary — the thing a GM reads first. */
export function skeletonBlurb(sk) {
return [
`**${sk.placeName}.** ${sk.knot.transgression}`,
`${sk.bottomProblem.label} ${sk.bottomProblem.failureState}.`,
`The prize is ${sk.prize.label} — ${sk.prize.function}.`,
`${sk.factions[0]?.name} are ${sk.factions[0]?.essence}; they are handled by ${sk.factions[0]?.handledBy}.`,
`The motif is **${sk.motif.id}**, and the danger is ${sk.motif.danger}: ${sk.motif.dangerLine}.`,
].join(' ');
}