Files
vanity-delve/core/skeleton.mjs
T
slaguru666andClaude Opus 5 7af690205c feat: wild family complete — cave, mountain, river, lake, valley theme packs
Five wild-kernel themes, each 4 motifs:
  cave      dark · deep · echo · keeping        (cave geometry)
  mountain  ascent · cold · pass · weight       (cave)
  river     crossing · current · course · drowned  (fen)
  lake      stillness · mirror · sunken · rising   (fen)
  valley    ridges · shelter · bottom · weather (forest)

Two rendering fixes surfaced by the new claimants, both in skeleton.mjs:
  - `{conscript}'s` rendered "the climbers's"; conscripts are plural noun
    phrases, so fill() now collapses s's to s'.
  - claimants carrying their own article rendered "The The Grey"; named()
    only prefixes when one is absent.

All 16 themes validate; 39 tests pass. Module → 0.6.0.

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

145 lines
6.5 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}}`)
// Conscripts are plural noun phrases ("the climbers"), so a template's `{conscript}'s`
// renders "the climbers's". English takes a bare apostrophe on a plural already ending in s.
.replace(/s's\b/g, "s'");
/** Claimants may already carry their own article — "The Grey" must not become "The The Grey". */
const named = c => (/^(the|a|an) /i.test(c) ? c : `The ${c}`);
/** 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 =
`${named(claimant)} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`so that ${appetite.purpose}.`;
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
const bottomProblem = {
label: named(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));
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
// after strangers. A settlement is not: it has its own name and the claimant merely runs it.
const placeName = pack.placeNameStyle === 'own'
? `${r.derive('place1').pick(pack.placeNames.first)} ${r.derive('place2').pick(pack.placeNames.second)}`
: `${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(' ');
}