Files
vanity-delve/core/render.mjs
T
tevansandClaude Opus 5 decb3bf930 Replace the footers; the repo is public now
Eleven files carried "Private VANITY development material — not for sale or
distribution", which contradicted the repo the moment it went public.

Two replacements, because the files do two different jobs. core/render.mjs
*emits* its footer into every generated delve, so that one now says where the
document came from rather than restricting what can be done with it — a
generated dungeon belongs to whoever generated it. The project's own documents
— DESIGN.md, the paper delve, both playtests, the spike report — read as
development notes in a public repo.

Samples regenerated so they carry the new line. The worksheet and play sheet
never had a footer and still do not; they are the GM's own working documents.

Module to 0.1.1: the v0.1.0 zip vendors the old render.mjs and would stamp the
stale footer on everything it generated. Cutting a version rather than swapping
assets under the existing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:38:32 +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);
/** The appeasement, in the compact form used inside an area. */
const skeletonAppeasement = d => `${d.skeleton.appeasement.attribute} ${d.skeleton.appeasement.successes}: ${d.skeleton.appeasement.move}`;
/** 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) {
const S = a.situation;
const rv = a.decision?.resolve ?? {};
const R = a.encounter?.roster;
L.push(`### AREA ${a.index} — ${a.name}`);
L.push(`*${a.role} · ${a.facet}${a.encounter ? ` · ${a.encounter.heat}` : ''}${a.hoard ? ` · ${a.hoard}` : ''}*`);
L.push('');
// ONE opening: image, situation and the automatic clue as a single thing to read aloud from.
// Previously these were three stacked blocks; the review's word for the whole document was
// "integration" — image, situation, pressure and choice reading as one, not as components.
const opening = [
a.cueFragments.map(c => cap(c)).join('. ') + '.',
S ? `${cap(S.occupant)} — ${S.doing}. When you walk in, ${S.onArrival}.` : '',
a.clue.automatic ? `None of it is accident — it was done this way on purpose.` : '',
].filter(Boolean).join(' ');
L.push(`> ${opening}`);
L.push('');
const gm = [
S ? `${cap(S.because)}.` : '',
a.truth ? `${a.truth.replace(/\.?$/, '.')}` : '',
].filter(Boolean).join(' ');
L.push(`**GM.** ${gm}`);
if (S) L.push(`**They can:** ${S.offer}. \`[${skeletonAppeasement(d)}]\` also works here, as everywhere.`);
L.push(`**Looking closer:** ${a.clue.extra.replace(/^\[VANITY: /, '`[').replace(/\]/, ']`')}`);
L.push('');
// The decision on one line where it can be: attempt → hit → miss → or.
L.push(`**${cap(a.decision.cue)}.** *${a.decision.cost}.*`);
if (rv.roll) {
L.push(`\`[${rv.roll}]\` → ${rv.success}`);
L.push(`**Miss** → ${rv.failure}`);
}
if (rv.orElse) L.push(`**Or** ${rv.orElse}.`);
L.push('');
if (R) {
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
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('');
}
if (a.temptation) {
const t = a.temptation;
L.push(`**${cap(a.hoard)} hoard.** **${cap(t.id)}** — ${t.cue}: ${t.benefit}.`);
L.push(`*Take it ${cost(t.acceptanceCost)}; using it costs ${cost(t.useCost)}; while you carry it, ${t.standingDrawback}.*`);
L.push('');
} else if (a.hoard) {
L.push(`**${cap(a.hoard)} hoard.**`);
L.push('');
}
// Everything the GM needs but never reads aloud, on one line.
const beat = a.baneBeat ? ` · bane beat: ${a.baneBeat}` : '';
L.push(`\`trigger: ${a.trigger}${beat} · fallback: ${a.fallback.route} (${a.fallback.cost}) · if skipped: ${a.failureCase.replace(/^If they skip this, /, '')}\``);
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('*Generated by [DELVE](https://github.com/slaguru666/vanity-delve) for the VANITY RPG.*');
return L.join('\n');
}