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 => `${rows}
FoeatkdefGritNerve
`; + if (c.kind === 'forged') return ` +

${esc(cap(c.heat))} — in the world. These are the actors the Forge created; run the fight off these.

+ ${table(c.foes.map(f => `@UUID[${f.uuid}]{${esc(f.name)}}${f.atk ?? '?'}${f.def ?? '?'}${f.grit ?? '?'}${f.nerve ?? '?'}${esc(f.trick)}`).join(''))} + ${c.planned ? `

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 => `${f.n}× ${esc(f.name)}${f.atk}${f.def}${f.grit}${f.nerve}${esc(f.note)}`).join(''))} +

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 ? `
  • Miss → ${esc(rv.failure)}
  • ` : ''} ${rv.orElse ? `
  • Or ${esc(rv.orElse)}
  • ` : ''} - ${a._foes?.length ? `

    ${esc(cap(a.encounter.heat))} — in the world. These are the actors the Forge created; run the fight off these.

    - - ${a._foes.map(f => ``).join('')} -
    FoeatkdefGritNerve
    @UUID[${f.uuid}]{${esc(f.name)}}${f.atk ?? '?'}${f.def ?? '?'}${f.grit ?? '?'}${f.nerve ?? '?'}${esc(f.trick)}
    - ${R ? `

    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:

    - - ${R.foes.map(f => ``).join('')} -
    FoeatkdefGritNerve
    ${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)}.

    ` : ''} ${a._hoard?.length ? `

    Hoard.

    ` : ''} diff --git a/foundry-module/module/vanity-delve.mjs b/foundry-module/module/vanity-delve.mjs index bb1ef81..4945ee4 100644 --- a/foundry-module/module/vanity-delve.mjs +++ b/foundry-module/module/vanity-delve.mjs @@ -16,7 +16,8 @@ */ import { coinSeed, Rng } from './core/rng.mjs'; import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs'; -import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes, foeStats, foeLine } from './forge-app.mjs'; +import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs'; +import { foeStats, foeLine, classifyFoes } from './foes.mjs'; const MOD = 'vanity-delve'; const FLAG = 'state'; @@ -42,7 +43,7 @@ const setState = async s => game.settings.set(MOD, FLAG, s); */ async function packById(id) { if (!id) return null; - if (PACK?.id === id) return PACK; + if (PACK?.id === id && PACK.forgeStageType) return PACK; // validate the cache too const p = await loadPack(id); if (!p?.forgeStageType) return null; PACK = p; @@ -112,7 +113,7 @@ async function draft(params = {}) { const theme = params.theme ?? 'barrow'; // drafting picks a theme; staging must be told one const pack = await packById(theme); if (!pack) return ui.notifications.error(`DELVE: could not load the ${theme} theme.`); - const d = newWorkingFile({ pack, ...params, seed }); + const d = newWorkingFile({ ...params, seed, pack }); // pack last — see raiseDungeon ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.'); return load(d); } @@ -130,37 +131,42 @@ async function enter() { if (!pack) return; ui.notifications.info(`DELVE: raising ${name}…`); + // A Forge failure must not leave the area half-raised. If the scene itself fails there is + // nothing to run, so stop before the turn advances and let the GM try again. If the population + // fails the scene is up and the delve is still playable — the planned roster stands in. const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {}; const stage = await game.vanity.forge.stage({ type: pack.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet, - }); + }).catch(e => { console.error('DELVE | stage failed', e); return null; }); + if (!stage) return ui.notifications.error(`DELVE: the Forge could not raise ${name}. Nothing staged; try again.`); + const enc = area.encounter ? await game.vanity.forge.encounter({ heat: area.encounter.heat, forStage: name, ...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}), + }).catch(e => { + console.error('DELVE | encounter failed', e); + ui.notifications.warn(`DELVE: could not populate ${name} — the planned roster stands in.`); + return null; }) : null; - if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) }); + if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) }) + .catch(e => { console.error('DELVE | hoard failed', e); return null; }); // Players first — the scene is up and this is what they came for. await readAloudCard(name, w.readAloud ?? `(unwritten) ${area.cueFragments.join('. ')}.`); // Then the GM, quietly. - const R = area.encounter?.roster; - const foes = (enc?.actors ?? []).map(foeStats); const rv = area.decision?.resolve ?? {}; - - /** - * One rule, both surfaces: show what exists. The roster's tactical guidance — what harms it, how - * to avoid it — describes the planned foes, so it travels with the plan and only when the plan - * IS the encounter. With nothing forged (populate off, or the Forge failed) the plan is all - * there is, and it must stay runnable. - */ - const foeBlock = foes.length - ? `

    ${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);