diff --git a/foundry-module/module.json b/foundry-module/module.json index bfb0518..bb0b260 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.2", + "version": "0.6.3", "compatibility": { "minimum": "13", "verified": "14.365" diff --git a/foundry-module/module/forge-app.mjs b/foundry-module/module/forge-app.mjs index aedd119..6c18315 100644 --- a/foundry-module/module/forge-app.mjs +++ b/foundry-module/module/forge-app.mjs @@ -109,7 +109,9 @@ export async function raiseDungeon(params = {}) { const PACK = await game.delve.loadPack(params.theme ?? 'barrow'); if (!PACK) return ui.notifications.error(`DELVE: could not load the ${params.theme} theme.`); const seed = params.seed || coinSeed(new Rng(String(Date.now()))); - const d = generateDelve({ pack: PACK, ...params, seed }); + // pack last: a programmatic caller passing params.pack would otherwise generate from one pack + // while the maps below are staged from the one actually loaded. + const d = generateDelve({ ...params, seed, pack: PACK }); const sk = d.skeleton; const title = sk.placeName; @@ -242,9 +244,13 @@ function buildPages(d, scenes) { ${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 foes, so the plan and the table above are different lists. - Its guidance still applies to the scene: harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — only true if you cast the fight yourself' : ''}. ${esc(R.avoid)}.

` : ''} + + ${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)}.

` : ''} ${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 345c6bd..bb1ef81 100644 --- a/foundry-module/module/vanity-delve.mjs +++ b/foundry-module/module/vanity-delve.mjs @@ -36,18 +36,28 @@ const setState = async s => game.settings.set(MOD, FLAG, s); * says quayside and the geometry says burial chamber. Resolved per call rather than once at load, * because the global resets on a page reload while the staged delve in world state does not. * - * `params.theme` is written from `pack.id` at generation time, so it is always present and always - * right, whatever the caller passed. + * `params.theme` is written from `pack.id` at generation time, so every delve the generator made + * carries it. `load()` also accepts hand-edited JSON, and there the field may be absent — which is + * why this fails closed rather than falling back to barrow. Guessing the geometry is the bug. */ -async function packFor(d) { - const id = d?.params?.theme ?? 'barrow'; +async function packById(id) { + if (!id) return null; if (PACK?.id === id) return PACK; const p = await loadPack(id); - if (!p) { - ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`); + if (!p?.forgeStageType) return null; + PACK = p; + return p; +} + +/** The pack a staged delve was authored against. Never guesses. */ +async function packFor(d) { + const id = d?.params?.theme; + if (!id) { + ui.notifications.error('DELVE: that delve does not record a theme — refusing to stage, the geometry would be a guess.'); return null; } - PACK = p; + const p = await packById(id); + if (!p) ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`); return p; } const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); @@ -99,8 +109,9 @@ async function loadFile(name) { /** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */ async function draft(params = {}) { const seed = params.seed || coinSeed(new Rng(String(game.world.id))); - const pack = await packFor({ params }); - if (!pack) return; + 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 }); ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.'); return load(d); @@ -136,12 +147,26 @@ async function enter() { 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}.

` + : ''; await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`, `${area.situation ? `

Here: ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.
They can: ${area.situation.offer}. ${cap(area.situation.because)}.

` : ''}

${cap(area.decision.cue)}${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `
Miss: ${rv.failure}` : ''}${rv.orElse ? `
Or: ${rv.orElse}` : ''}

- ${foes.length ? `

${cap(area.encounter.heat)} — in the world:

${list(foes.map(foeLine))}` : ''} - ${R ? `

DELVE planned ${R.line} — the Forge rolled its own, so run the block above. ${R.avoid}.

` : ''} + ${foeBlock} ${area.temptation ? `

${cap(area.temptation.id)}: ${area.temptation.benefit}. Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.

` : ''} ${w.notes ? `

Your note: ${w.notes}

` : ''}

${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}

`);