The foe block broke twice in two releases, both times because each surface decided for itself, in its own hand-written ternary, which of the cases it was in. classifyFoes() in the new module/foes.mjs makes that choice once — forged, planned, unavailable, none — and both surfaces switch on it. foes.mjs is Foundry-free on purpose, so foundry-module/ test.mjs can run it under plain node with no shim. Fourteen tests, each one a case that shipped broken or nearly did. enter() now survives a Forge that throws. It had no catch, so a failure left the scene raised, no cards posted and the turn not advanced, and a retry would stage the area twice — while the comment above it claimed the branch handled exactly that. A failed stage stops before the turn advances, since there is nothing to run; a failed population keeps going, because the scene is up and the planned roster stands in. An area with combat heat but neither actors nor a roster used to render nothing at all. It now says so and points at the decision and fallback. The "say so before initiative" addendum is gone from all three renderers. It was gated on the literal word ONLY, and the palace, port and village battle rosters already end with that exact phrase, so it printed twice. The packs that need the instruction carry it themselves. Also: draft() spreads params before the pack, like raiseDungeon; and packById validates the cached pack rather than trusting it, so the fail-closed rule holds at boot too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
7.9 KiB
JavaScript
172 lines
7.9 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) {
|
||
// No appended "say so before initiative" — the packs that need it already say it, and three
|
||
// battle rosters carry the phrase verbatim, so the addendum printed it twice.
|
||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${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');
|
||
}
|