Files
vanity-delve/core/render.mjs
T
tevansandClaude Opus 5 09dd58bff0 Resolution blocks on every decision; motif-scope the features pool
Playtest 3 put four players (Codex) through a generated delve with only the
player-facing text. Verdict: "a list of room-prompts, not a place", which
"expects the GM to invent too much of the actual game". It localised that to
one thing — decisions had a cue and a cost but no resolution.

Every decision now carries: an attempt roll, an explicit success outcome, an
explicit failure outcome, and an always-available alternative. 48 blocks across
8 motifs. The alternative is not optional: playtest 1 died on a gate the table
could fail without knowing, and the sharpest complaint this time was a door
whose only options were "guess blind and bleed Banes" or "pay time".

Features are now motif-scoped too, so all three furniture pools belong to the
fiction rather than a global list. 136 authored entries per theme.

Three new invariants make the failure modes unauthorable: every decision has a
resolution, no decision gates progress without an alternative, and every rolled
decision states both success and failure. Tests 16 -> 19.

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

171 lines
7.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.
/** Render a delve to GM-readable markdown, in the house style. */
import { skeletonBlurb } from './skeleton.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
/** Costs read as English in a GM document, never as JSON. */
const cost = c => {
if (!c) return 'free';
const bits = [];
if (c.bane) bits.push(`**+${c.bane} Bane${c.bane > 1 ? 's' : ''}** on the tab`);
if (c.vice) bits.push(`take the Vice **${c.vice}**`);
if (c.vanity) bits.push(`**+${c.vanity} Vanity**`);
return bits.length ? bits.join(', ') : 'free';
};
export function renderMarkdown(d) {
const L = [];
const sk = d.skeleton;
L.push(`# ${sk.placeName.toUpperCase()}`);
L.push('');
L.push(`> *${sk.motif.dangerLine}.*`);
L.push('');
L.push(`**VANITY · ${cap(d.params.theme)} · Depth ${d.params.depth} · ${d.params.party} heroes · ` +
`${d.params.areas} areas + ${d.ending.authored ? 'an authored ending' : 'a generated ending'}**`);
L.push(`**Seed \`${d.seed}\` · generator ${d.generatorVersion} · catalog ${d.catalogVersion}**`);
L.push('');
L.push('> **Skeleton seed, not a replay seed.** It reproduces the fiction, the area order, the');
L.push('> heat and the hoard tiers. It does **not** reproduce encounter composition, mood or');
L.push('> hoard contents — those come from the Forge, which is not seeded.');
L.push('');
L.push('---');
L.push('');
L.push('## The truth (GM only)');
L.push('');
L.push(skeletonBlurb(sk));
L.push('');
L.push(`**They will ask you:** ${d.ending.question}`);
L.push('');
L.push('## Pressure');
L.push('');
L.push(`- **The clock.** Roll 1d6 on a trigger, or every ${d.pressure.triggerNumber === 1 ? '3' : '3'} Turns. ` +
`**${d.pressure.triggerNumber} or under — something comes.** Telegraph one Turn ahead.`);
L.push(`- **Triggers this delve:** ${[...new Set(d.areas.map(a => a.trigger))].join(' · ')}`);
L.push(`- **The tab.** Target **${d.pressure.target} Banes** by the ending. Banes are *offered by named beats*, never hoped for.`);
L.push(`- **The danger is ${sk.motif.danger}:** ${sk.motif.dangerLine}.`);
L.push('');
const ap = sk.appeasement;
L.push('## The way through — the appeasement');
L.push('');
L.push(`Anyone may **${ap.move}**. \`[VANITY: ${ap.attribute} — ${ap.successes} successes]\``);
L.push(`On success ${ap.why}, and ${sk.factions[0]?.name} will not open hostilities first.`);
L.push(`**Cost: +${ap.gain.vanity} Vanity now, +${ap.cost.bane} Bane on the tab.** Shine now, bill later.`);
L.push(`**Repeatable in every area.** Say the Bane aloud when it banks — the table must be able to see the route is open.`);
L.push('');
L.push('## Factions');
L.push('');
for (const f of sk.factions) L.push(`- **${f.name}** (${f.kind}) — ${f.essence}. Handled by *${f.handledBy}*.`);
L.push('');
L.push(`**Planned fights:** ${d.budget.plannedFights} of ${d.params.areas} areas · base heat *${d.budget.heatBase}*`);
L.push('');
L.push('---');
L.push('');
for (const a of d.areas) {
L.push(`### AREA ${a.index} — ${a.name}`);
L.push(`*${a.role} · facet: ${a.facet}${a.encounter ? ` · encounter: ${a.encounter.heat}` : ' · no encounter'}` +
`${a.hoard ? ` · hoard: ${a.hoard}` : ''}*`);
L.push('');
L.push('**Cue** — *assemble aloud; these are fragments, not read-aloud text*');
for (const c of a.cueFragments) L.push(`- ${c}`);
L.push('');
L.push(`**Truth.** ${a.truth}`);
L.push('');
if (a.clue.automatic) {
L.push(`**The clue is automatic:** ${a.clue.automatic}.`);
L.push(`${a.clue.extra}`);
L.push('');
}
L.push(`**The decision.** ${a.decision.cue} — *${a.decision.cost}*`);
const rv = a.decision.resolve;
if (rv) {
if (rv.roll) {
L.push(`- **Attempt it:** \`[VANITY: ${rv.roll}]\``);
L.push(`- **Success:** ${rv.success}`);
L.push(`- **Failure:** ${rv.failure}`);
}
L.push(`- **Or simply:** ${rv.orElse}`);
}
L.push('');
if (a.encounter) {
const R = a.encounter.roster;
L.push(`**Encounter — ${a.encounter.heat}.** ${R ? R.line : a.encounter.kind}`);
if (R) {
L.push('');
L.push('| Foe | atk | def | Grit | Nerve | |');
L.push('|---|---|---|---|---|---|');
for (const f of R.foes) L.push(`| ${f.n}× ${f.name} | ${f.atk} | ${f.def} | ${f.grit} | ${f.nerve} | ${f.note} |`);
L.push('');
L.push(`- **Harmed by:** ${R.harmedBy}${R.harmedBy.includes('ONLY') ? ' — **say this before initiative**' : ''}`);
L.push(`- **Avoidable:** ${R.avoid}`);
}
L.push(`- *Forge:* \`forgeEncounter({heat:"${a.encounter.heat}", hoard:false, post:false, folderId})\``);
}
if (a.hoard) L.push(`**Hoard.** Tier **${a.hoard}**.`);
if (a.temptation) {
const t = a.temptation;
L.push(`**Temptation — ${t.id}.** ${t.cue}`);
L.push(`- *Benefit:* ${t.benefit}`);
L.push(`- *Accept:* ${cost(t.acceptanceCost)} · *Use:* ${cost(t.useCost)}`);
L.push(`- *Standing:* ${t.standingDrawback}`);
}
L.push('');
L.push(`**Attention trigger:** ${a.trigger}.`);
if (a.baneBeat) {
const rules = {
flattery: ['whoever performs the appeasement', 'each time it is used', 'repeatable'],
noticing: ['whoever lingers on their own reflection', 'once per hero here', 'repeatable across areas'],
relicUse: ['whoever spends the relic', 'each use', 'repeatable'],
announcing: ['the whole party', 'once, on entering loudly', 'once only'],
refusal: ['whoever refuses the offer', 'once', 'once only'],
};
const [who, when, rep] = rules[a.baneBeat] ?? ['a hero', 'once', 'once only'];
L.push(`**Bane beat — ${a.baneBeat}.** Who: *${who}*. When: *${when}*. ${rep}. Say it aloud when it banks.`);
}
L.push(`**Fallback route.** ${a.fallback.route} — cost: ${a.fallback.cost}.`);
L.push('');
L.push(`*Failure case.* ${a.failureCase}`);
L.push('');
L.push('---');
L.push('');
}
L.push(`### ENDING — ${d.ending.name}`);
L.push(d.ending.authored ? '### ⟨ AUTHORED SLOT — this is the seam ⟩' : `*generated · heat ${d.ending.heat} · hoard ${d.ending.hoard}*`);
L.push('');
if (d.ending.authored) {
L.push('**Areas 1–' + d.params.areas + ' are what DELVE generates. This is what it hands over.**');
L.push('');
}
L.push('**Cue** — *fragments*');
for (const c of d.ending.cueFragments) L.push(`- ${c}`);
L.push('');
L.push(`**${sk.bottomProblem.label}** — ${sk.bottomProblem.failureState}. Wants: *${sk.bottomProblem.wantNow}*.`);
L.push(`Handled by ${sk.bottomProblem.handledBy}.`);
L.push('');
L.push(`**They open by asking:** ${d.ending.question}`);
L.push('');
L.push('| Party\'s route | The audience |');
L.push('|---|---|');
L.push(`| Appeased it even once (**${ap.move}**) | It listens. It will trade ${sk.prize.label} for the real thing, done to its face. \`[VANITY: ${ap.attribute} — ${ap.successes}]\` or take **Rattled**. |`);
L.push('| Refused throughout, tab under 4 | It offers a Vice — take the shine, bank the bill. Refusing starts the fight. |');
L.push(`| Carrying ${sk.prize.label} | It wants that more than it wants you. |`);
L.push('| Fighting | Winnable by two engaged heroes. **Name what harms it before initiative.** |');
L.push('');
L.push('> **Before initiative, always say what can hurt it.** Playtest 1 was unwinnable because');
L.push('> the table discovered the immunity by failing.');
L.push('');
const g = d.triggerGlossary ?? {};
if (Object.keys(g).length) {
L.push('## Trigger glossary');
L.push('');
for (const [k, v] of Object.entries(g)) L.push(`- **${k}** — ${v}`);
L.push('');
}
L.push('---');
L.push('');
L.push('*Private VANITY development material — not for sale or distribution.*');
return L.join('\n');
}