Unreleased. The warning that v0.6.4 deleted is restored, but not as the string heuristic that caused both faults — rosters carry beforeInitiative now, and the renderers print it. 23 rosters have it: the 18 whose harmedBy restricts what works, plus 5 Troll rosters that carried the instruction in prose and so had two conventions between them. The prose copies are gone, so it is said exactly once wherever it is said. validate-pack enforces both halves — ONLY implies the field, and the phrase may not appear in prose — and core/test.mjs now runs the validator over all sixteen packs, so a pack regression fails the suite rather than waiting for someone to remember the tool. Both rules were confirmed by breaking a pack deliberately. enter()'s decisions move to module/stage.mjs behind injected effects, which makes the failure paths runnable under plain node. That immediately found a bug in the first version of the extraction: Promise.resolve(fx.stage()) does not catch a synchronous throw, so a Forge that threw rather than rejected would have taken the whole of enter() down. Nineteen of the new tests are that matrix — failed scene, failed population with and without a roster, failed hoard, failed cards, async rejection, and a Forge that returns nothing. The contract those tests hold: the scene is the commit point. Fail there and nothing was entered, so a retry is safe; succeed and the turn advances before anything else can fail, so a retry cannot restage. Everything after degrades with a warning that says what was actually lost — the encounter failure no longer promises a roster the area does not have, and the hoard failure is no longer swallowed to the console. Also: game.delve.loadPack is the validating resolver, so raiseDungeon can no longer stage from a pack with no forgeStageType. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
7.8 KiB
JavaScript
171 lines
7.8 KiB
JavaScript
/** 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.beforeInitiative ? ` **${R.beforeInitiative}**` : ''} *${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');
|
||
}
|