diff --git a/core/render.mjs b/core/render.mjs index 5c9d1a9..566848b 100644 --- a/core/render.mjs +++ b/core/render.mjs @@ -104,8 +104,9 @@ export function renderMarkdown(d) { 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}.*`); + // 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('|---|---|---|---|---|---|'); diff --git a/foundry-module/module.json b/foundry-module/module.json index bb0b260..5a49152 100644 --- a/foundry-module/module.json +++ b/foundry-module/module.json @@ -2,7 +2,7 @@ "id": "vanity-delve", "title": "DELVE — a dungeon layer for VANITY", "description": "Generates a coherent delve and sequences VANITY's Forge to stage it, one area at a time.", - "version": "0.6.3", + "version": "0.6.4", "compatibility": { "minimum": "13", "verified": "14.365" diff --git a/foundry-module/module/core/render.mjs b/foundry-module/module/core/render.mjs index 5c9d1a9..566848b 100644 --- a/foundry-module/module/core/render.mjs +++ b/foundry-module/module/core/render.mjs @@ -104,8 +104,9 @@ export function renderMarkdown(d) { 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}.*`); + // 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('|---|---|---|---|---|---|'); diff --git a/foundry-module/module/foes.mjs b/foundry-module/module/foes.mjs new file mode 100644 index 0000000..b723162 --- /dev/null +++ b/foundry-module/module/foes.mjs @@ -0,0 +1,48 @@ +/** + * Who is actually in the room. + * + * A pack's roster is the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats and, + * in some themes, tactical notes naming specific monsters. The Forge takes no cast: it rolls its + * own from the heat. So the plan and the world are two different lists, and the rule both surfaces + * follow is **show what exists**: + * + * forged the Forge made actors — run those, and say the plan's tactics are not about them + * planned nothing was forged (population off, or the Forge failed) — the plan IS the + * encounter, so it renders in full and its guidance applies + * unavailable an area carries combat heat but has neither — say so rather than render nothing + * none no encounter here at all + * + * This file is deliberately Foundry-free so the decision can be tested without a VTT. The two + * surfaces disagreed about it twice — 0.6.2 lost the planned roster entirely when nothing was + * forged, and the caption claimed the plan's guidance applied either way — because each surface + * made the choice for itself in a ternary. They now both switch on `kind`. + */ + +/** What a forged actor actually is, read off the document rather than off the plan. */ +export const foeStats = a => ({ + name: a.name, + uuid: a.uuid, + atk: a.system?.attack1?.pool ?? null, + def: a.system?.defence?.pool ?? null, + grit: a.system?.grit?.value ?? null, + nerve: a.system?.nerve ?? null, + trick: a.system?.trick ?? '', +}); + +/** One forged foe as a line of stats, in the roster's vocabulary so the two read alike. */ +export const foeLine = f => + `@UUID[${f.uuid}]{${f.name}} — ${f.atk ?? '?'}/${f.def ?? '?'}/${f.grit ?? '?'}, Nerve ${f.nerve ?? '?'}${f.trick ? `. ${f.trick}` : ''}`; + +/** + * Decide which of the four cases an area is in. + * @param {object} area a delve area + * @param {Array} forged actors the Forge created, already through foeStats — [] if it failed + */ +export function classifyFoes(area, forged = []) { + if (!area?.encounter) return { kind: 'none' }; + const heat = area.encounter.heat; + const planned = area.encounter.roster ?? null; + if (forged.length) return { kind: 'forged', heat, foes: forged, planned }; + if (planned) return { kind: 'planned', heat, roster: planned }; + return { kind: 'unavailable', heat }; +} diff --git a/foundry-module/module/forge-app.mjs b/foundry-module/module/forge-app.mjs index 6c18315..4cf190a 100644 --- a/foundry-module/module/forge-app.mjs +++ b/foundry-module/module/forge-app.mjs @@ -12,39 +12,13 @@ */ import { generateDelve } from './core/delve.mjs'; import { coinSeed, Rng } from './core/rng.mjs'; +import { foeStats, classifyFoes } from './foes.mjs'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? ''); const MOD = 'vanity-delve'; - -/** - * What a forged foe actually is. - * - * A pack's roster describes the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats - * and, in some themes, "blessed, silvered or magical weapons ONLY". The Forge does not take a - * cast: it rolls its own monsters from the heat. So the roster and the actors in the world are two - * different lists, and printing the roster's numbers beside the Forge's actors told the GM to run - * a fight against foes that were never created. - * - * Read the numbers off the documents that exist. The plan is still worth showing — it carries - * authored tactical guidance — but it has to be labelled as the plan. - */ -export const foeStats = a => ({ - name: a.name, - uuid: a.uuid, - atk: a.system?.attack1?.pool ?? null, - def: a.system?.defence?.pool ?? null, - grit: a.system?.grit?.value ?? null, - nerve: a.system?.nerve ?? null, - trick: a.system?.trick ?? '', -}); - -/** One foe as a line of stats, in the roster's own vocabulary so the two read alike. */ -export const foeLine = f => - `@UUID[${f.uuid}]{${f.name}} — ${f.atk ?? '?'}/${f.def ?? '?'}/${f.grit ?? '?'}, Nerve ${f.nerve ?? '?'}${f.trick ? `. ${f.trick}` : ''}`; - let THEMES = [{ id: 'barrow', label: 'Barrow' }]; export function setThemes(list) { THEMES = list; } @@ -193,6 +167,26 @@ export async function raiseDungeon(params = {}) { /* ---------------------------------------------------------------- journal */ +/** + * The journal's foe section, driven by classifyFoes so it cannot disagree with the chat card. + * The roster's tactical notes name particular monsters, so they print only where the plan is + * itself the encounter. + */ +function foeSection(c) { + const table = rows => `
| Foe | atk | def | Grit | Nerve |
|---|
${esc(cap(c.heat))} — in the world. These are the actors the Forge created; run the fight off these.
+ ${table(c.foes.map(f => `DELVE planned ${esc(c.planned.line)}. The Forge rolls its own cast, so the plan's foes are not these — its tactical notes describe monsters that were not created.
` : ''}`; + if (c.kind === 'planned') return ` +${esc(cap(c.heat))} — not cast. Nothing was forged for this area, so the plan is the encounter. Cast it by hand:
+ ${table(c.roster.foes.map(f => `Harmed by ${esc(c.roster.harmedBy)}. ${esc(c.roster.avoid)}.
`; + if (c.kind === 'unavailable') return ` +${esc(cap(c.heat))} — nothing to run. No actors were created and this theme has no roster at this heat. Improvise the fight or skip it; the area's decision and fallback still stand.
`; + return ''; +} + function buildPages(d, scenes) { const sk = d.skeleton, ap = sk.appeasement; const pages = []; @@ -241,16 +235,7 @@ function buildPages(d, scenes) { ${rv.failure ? `${esc(cap(a.encounter.heat))} — in the world. These are the actors the Forge created; run the fight off these.
-| Foe | atk | def | Grit | Nerve | |
|---|---|---|---|---|---|
| @UUID[${f.uuid}]{${esc(f.name)}} | ${f.atk ?? '?'} | ${f.def ?? '?'} | ${f.grit ?? '?'} | ${f.nerve ?? '?'} | ${esc(f.trick)} |
DELVE planned ${esc(R.line)}. The Forge rolls its own cast, so the plan's foes are not these — its tactical notes describe monsters that were not created.
` : ''}` - : R ? `${esc(cap(a.encounter.heat))} — not cast. Nothing was forged for this area, so the plan is the encounter. Cast it by hand:
-| Foe | atk | def | Grit | Nerve | |
|---|---|---|---|---|---|
| ${f.n}× ${esc(f.name)} | ${f.atk} | ${f.def} | ${f.grit} | ${f.nerve} | ${esc(f.note)} |
Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — say so before initiative' : ''}. ${esc(R.avoid)}.
` : ''} + ${foeSection(classifyFoes(a, a._foes ?? []))} ${a.temptation ? `${esc(cap(a.temptation.id))} — ${esc(a.temptation.cue)}: ${esc(a.temptation.benefit)}.
Using it costs ${a.temptation.useCost?.bane ? `+${a.temptation.useCost.bane} Bane` : '—'}. While carried, ${esc(a.temptation.standingDrawback)}.
Hoard.
${cap(area.encounter.heat)} — in the world:
${list(foes.map(foeLine))}${ - R ? `DELVE planned ${R.line}; the Forge rolled its own, so the plan's tactics do not describe these.
` : ''}` - : R - ? `${cap(area.encounter.heat)} — not cast. Nothing was forged; run the plan by hand:
${ - list(R.foes.map(f => `${f.n}× ${f.name} — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. ${f.note}`)) - }Harmed by ${R.harmedBy}. ${R.avoid}.
` + const c = classifyFoes(area, (enc?.actors ?? []).map(foeStats)); + const foeBlock = + c.kind === 'forged' + ? `${cap(c.heat)} — in the world:
${list(c.foes.map(foeLine))}${ + c.planned ? `DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.
` : ''}` + : c.kind === 'planned' + ? `${cap(c.heat)} — not cast. Nothing was forged; run the plan by hand:
${ + list(c.roster.foes.map(f => `${f.n}× ${f.name} — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. ${f.note}`)) + }Harmed by ${c.roster.harmedBy}. ${c.roster.avoid}.
` + : c.kind === 'unavailable' + ? `${cap(c.heat)} — nothing to run. No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.
` : ''; await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`, `${area.situation ? `Here: ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.
@@ -218,7 +224,7 @@ Hooks.once('ready', async () => {
if (index?.themes?.length) setThemes(index.themes);
const packs = {};
loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`));
- PACK = await loadPack('barrow');
+ await packById('barrow'); // sets PACK, and validates it like any other
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
diff --git a/foundry-module/test.mjs b/foundry-module/test.mjs
new file mode 100644
index 0000000..c39d0ab
--- /dev/null
+++ b/foundry-module/test.mjs
@@ -0,0 +1,61 @@
+/**
+ * Adapter tests. No Foundry, no shim — foes.mjs is pure on purpose.
+ *
+ * These exist because the same decision broke twice in two releases. 0.6.2 showed the forged
+ * actors and silently dropped the planned roster when nothing had been forged; 0.6.3 fixed that
+ * but the choice still lived in two hand-written ternaries, one per surface. Every case below is
+ * a bug that shipped or nearly shipped.
+ *
+ * Run: node foundry-module/test.mjs
+ */
+import { foeStats, foeLine, classifyFoes } from './module/foes.mjs';
+
+let pass = 0, fail = 0;
+const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
+
+const actor = {
+ name: 'Morthollow-born', uuid: 'Actor.abc',
+ system: { attack1: { pool: 4 }, defence: { pool: 3 }, grit: { value: 5 }, nerve: 6, trick: 'freezes on a hit' },
+};
+const roster = {
+ line: '1 Ghoul and 2 Skeletons',
+ foes: [{ n: 1, name: 'Ghoul', atk: 4, def: 3, grit: 4, nerve: 5, note: 'freezes' }],
+ harmedBy: 'blessed, silvered or magical weapons ONLY — say so before initiative',
+ avoid: 'the Ghoul goes for court dress first',
+};
+const withEnc = (extra = {}) => ({ encounter: { heat: 'fight', roster, ...extra } });
+
+// --- reading a forged actor -------------------------------------------------
+const f = foeStats(actor);
+t('foeStats reads the stats off the document', f.atk === 4 && f.def === 3 && f.grit === 5 && f.nerve === 6);
+t('foeStats keeps the uuid so the GM can open it', f.uuid === 'Actor.abc');
+const bare = foeStats({ name: 'X', uuid: 'Actor.z', system: {} });
+t('a missing stat degrades rather than throwing', bare.atk === null && foeLine(bare).includes('?/?/?'));
+t('foeLine links the actor', foeLine(f).includes('@UUID[Actor.abc]{Morthollow-born}'));
+t('foeLine omits an empty trick', !foeLine(bare).includes(''));
+
+// --- the four cases ---------------------------------------------------------
+t('no encounter → none', classifyFoes({}, []).kind === 'none');
+t('no encounter → none, even with stray actors', classifyFoes({}, [f]).kind === 'none');
+
+const forged = classifyFoes(withEnc(), [f]);
+t('actors forged → forged', forged.kind === 'forged' && forged.foes.length === 1);
+t('forged keeps the plan, so it can be named as not-these', forged.planned === roster);
+
+const planned = classifyFoes(withEnc(), []);
+t('nothing forged but a roster → planned', planned.kind === 'planned' && planned.roster === roster,
+ 'the 0.6.2 regression: population off left no numbers at all');
+
+const nothing = classifyFoes({ encounter: { heat: 'fight' } }, []);
+t('heat but neither actors nor roster → unavailable', nothing.kind === 'unavailable',
+ 'must not render silence for an area labelled with combat');
+t('unavailable still reports the heat', nothing.heat === 'fight');
+
+// --- the invariant the two surfaces kept breaking ---------------------------
+t('the plan is never the encounter while actors exist', forged.kind !== 'planned',
+ 'roster guidance names monsters the Forge did not create');
+t('a forged classification carries no roster field to render from',
+ forged.roster === undefined, 'so a surface cannot accidentally run the plan');
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);