From 91a77a64d0fea6601af1d02a14445f1b39b57f8a Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:13:18 +0100 Subject: [PATCH] Forge seams: post/hoard/folderId options, structured hoard return, empty-pool guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELVE needs to sequence the Forge without the Forge talking to the table over it. Measured on an instrumented harness, three linked areas produced 9 chat cards, 3 Actor folders and 16 loose Actors, none of which a caller could suppress or redirect. With these options the same three areas produce 0 cards and 0 folders. - forgeHoard({ post }) — and it now RETURNS { size, label, goods, relics, lines, card } instead of undefined, so a caller can render it itself. - forgeEncounter({ hoard, post, folderId }) — loot decouples from combat, the card is optional, and actors can go in a caller-supplied folder rather than a fresh one per encounter. - forgeStage({ post, folderId }) — same, threaded through to the encounter it raises when populate is true. All three default to the current behaviour, so nothing changes for existing callers or the directory buttons. Also guards two unchecked rnd() calls in forgeHoard: rnd() returns undefined on an empty array, and the result was dereferenced for .uuid immediately after. A gear pack with no consumables would have thrown. The shipped packs are populated so this is latent, not live. Not included: injectable RNG (36 Math.random sites across four helpers) and turning the Reaction mood into a real Roll. Both are larger and neither gates the first DELVE release. Co-Authored-By: Claude Opus 5 --- vanity.mjs | 56 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/vanity.mjs b/vanity.mjs index 8518ba9..bd73733 100644 --- a/vanity.mjs +++ b/vanity.mjs @@ -1955,7 +1955,13 @@ const HOARDS = { kingly: { label: "Kingly", coin: () => `${sum(rollDice(6, 6)) * 10} gp`, draws: 5, consum: 1, trinkets: [2, 2], hook: 1, centerpiece: true, relics: 2 } }; -async function forgeHoard({ size = "chest" } = {}) { +/** + * Forge a hoard. + * @param {string} size pocket | cache | chest | vault | kingly + * @param {boolean} post post the GM card. false lets a caller own the presentation. + * @returns {{size, label, coin, goods, relics, trinkets, centerpiece, hook, lines, card}} + */ +async function forgeHoard({ size = "chest", post = true } = {}) { const tier = HOARDS[size] ?? HOARDS.chest; const [weapons, armour, gear, treasure] = await Promise.all(["weapons", "armour", "gear", "treasure"].map(packDocs)); const consumables = gear.filter(g => g.system.consumable); @@ -1965,14 +1971,17 @@ async function forgeHoard({ size = "chest" } = {}) { lines.push(`Coin: ${tier.coin()}`); const found = []; - if (Math.random() < tier.consum) found.push(rnd(consumables)); - for (let i = 0; i < tier.draws; i++) found.push(rnd(mundane)); + // rnd() returns undefined on an empty array; a pack with no consumables would then throw on + // .uuid below. Guard rather than assume every pack is populated. + if (Math.random() < tier.consum && consumables.length) found.push(rnd(consumables)); + for (let i = 0; i < tier.draws; i++) if (mundane.length) found.push(rnd(mundane)); if (found.length) lines.push(`Goods: ${found.map(d => `@UUID[${d.uuid}]{${d.name}}`).join(" · ")}`); // A magic relic or two in the richer hoards (a fraction is a chance, an integer a count). const nRelics = (tier.relics ?? 0) >= 1 ? tier.relics : (Math.random() < (tier.relics ?? 0) ? 1 : 0); + let relics = []; if (nRelics && treasure.length) { - const relics = pickN(treasure, Math.min(nRelics, treasure.length)); + relics = pickN(treasure, Math.min(nRelics, treasure.length)); lines.push(`Relic${relics.length > 1 ? "s" : ""}: ${relics.map(d => `@UUID[${d.uuid}]{${d.name}}`).join(" · ")}`); } @@ -1985,15 +1994,19 @@ async function forgeHoard({ size = "chest" } = {}) { } if (Math.random() < tier.hook) lines.push(`The catch: ${rnd(FORGE_HOOKS)}`); - await ChatMessage.create({ - speaker: { alias: "The Forge" }, - whisper: ChatMessage.getWhisperRecipients("GM"), - content: `
+ const card = `
⚒ ${tier.label} hoard gold you cling to buys nothing — gold you squander buys legend (§27)
${lines.map(l => `

${l}

`).join("")} -
` +
`; + + if (post) await ChatMessage.create({ + speaker: { alias: "The Forge" }, + whisper: ChatMessage.getWhisperRecipients("GM"), + content: card }); + + return { size, label: tier.label, goods: found, relics, lines, card }; } /* ------------ Forge dialogs & directory buttons ------------ */ @@ -2711,7 +2724,7 @@ async function stageRasterUpload(svg, W, H, name) { } finally { URL.revokeObjectURL(url); } } -async function forgeStage({ type = "barrow", size = "medium", name = "", populate = false, heat = "fight", kind = "", cast = null, activate = true } = {}) { +async function forgeStage({ type = "barrow", size = "medium", name = "", populate = false, heat = "fight", kind = "", cast = null, activate = true, post = true, folderId = null } = {}) { if (!game.user.isGM) return; const [cols, rows] = STAGE_SIZES[size] ?? STAGE_SIZES.medium; name ||= stageName(type); @@ -2778,7 +2791,7 @@ async function forgeStage({ type = "barrow", size = "medium", name = "", populat // placement: a supplied cast, else an auto-encounter let placed = cast; if (!placed && populate) { - const enc = await forgeEncounter({ heat, kind, forStage: name }); + const enc = await forgeEncounter({ heat, kind, forStage: name, post, folderId }); placed = enc.actors.map(a => ({ actorId: a.id, name: a.name, img: a.img, disposition: -1 })); } if (placed?.length) { @@ -2797,7 +2810,7 @@ async function forgeStage({ type = "barrow", size = "medium", name = "", populat } if (activate) await scene.activate(); - await ChatMessage.create({ + if (post) await ChatMessage.create({ speaker: { alias: "The Stage" }, whisper: ChatMessage.getWhisperRecipients("GM"), content: `
@@ -2872,10 +2885,19 @@ const HEAT_BUILDS = { }; const HEAT_HOARD = { skirmish: "pocket", fight: "cache", battle: "chest", nightmare: "vault" }; -async function forgeEncounter({ heat = "fight", kind = "", forStage = "" } = {}) { +/** + * Forge an encounter. + * @param {boolean} hoard also roll a hoard. false decouples loot from combat. + * @param {boolean} post post the GM card. + * @param {string} folderId put the actors in an existing folder instead of a new one, so a + * caller running many encounters does not litter the world. + */ +async function forgeEncounter({ heat = "fight", kind = "", forStage = "", hoard = true, post = true, folderId = null } = {}) { const t = FORGE_MONSTERS[kind] ? kind : rnd(Object.keys(FORGE_MONSTERS)); const build = (HEAT_BUILDS[heat] ?? HEAT_BUILDS.fight)(); - const folder = await Folder.create({ name: `Encounter — ${t} (${heat})`, type: "Actor" }); + const folder = folderId + ? (game.folders?.get?.(folderId) ?? { id: folderId }) + : await Folder.create({ name: `Encounter — ${t} (${heat})`, type: "Actor" }); const actors = []; for (const [menace, count] of Object.entries(build)) { @@ -2893,7 +2915,7 @@ async function forgeEncounter({ heat = "fight", kind = "", forStage = "" } = {}) mood = ["Hostile", "Wary", "Neutral", "Friendly"][Math.min(rs, 3)]; } - await ChatMessage.create({ + if (post) await ChatMessage.create({ speaker: { alias: "The Forge" }, whisper: ChatMessage.getWhisperRecipients("GM"), content: `
@@ -2905,8 +2927,8 @@ async function forgeEncounter({ heat = "fight", kind = "", forStage = "" } = {})

The catch: ${complication}

` }); - await forgeHoard({ size: HEAT_HOARD[heat] ?? "cache" }); - return { actors, situation, terrain, complication, mood, folder }; + const hoardResult = hoard ? await forgeHoard({ size: HEAT_HOARD[heat] ?? "cache", post }) : null; + return { actors, situation, terrain, complication, mood, folder, hoard: hoardResult }; } /* -------------------------------------------- */