Files
slaguru666andClaude Opus 5 1b2f9aa385 The warning reaches every surface; commit and concurrency are covered
Still unreleased. Answering the three blockers.

The worksheet and play sheet never rendered the new field, and stripping
the prose meant they said nothing at all — the two surfaces the whole
two-surface design is about. All five renderers now go through one helper,
core/roster.mjs, which also recovers the warning for delve files generated
before the field existed and refuses to add a line the prose already
carries. Measured across four themes: 40 of 40 delves carrying a roster now
warn on worksheet, play sheet and markdown alike.

Twenty-three was the wrong set. The spelling was never the semantics:
barrow's nightmare Wraith, cave's nightmare Troll and forest's battle Troll
carry the same non-discoverable restriction without the word ONLY. Marked,
so 26. I audited all 64 rosters rather than trusting the count — the ones
left unmarked are discoverable by trying (an Ogre with 12 Grit, mooks that
keep coming until the necromancer stops), and damage works on all of them.

commit() was unprotected, so a failed settings write threw out of stageArea
with the scene already real. It is caught now and says plainly that a retry
will raise the area twice. enter() also has a single-flight guard: it is
bound to a button, it awaits the Forge for seconds, and two overlapping
calls both read the same index.

Also: packById checks the geometry is one VANITY can build rather than just
truthy; foeSection is exported and the suite holds the two renderers to
agreement on all four kinds; and the module README and the seams warning
said 0.10.5 where everything else says 0.10.4.

41 core tests, 54 adapter tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:35:15 +01:00

173 lines
7.9 KiB
JavaScript
Raw Permalink 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';
import { initiativeWarning } from './roster.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) {
const warn = initiativeWarning(R);
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
+ `${warn ? ` **${warn}**` : ''} *${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');
}