Forge seams: post/hoard/folderId options, structured hoard return, empty-pool guard
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1045b06670
commit
91a77a64d0
+39
-17
@@ -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 }
|
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 tier = HOARDS[size] ?? HOARDS.chest;
|
||||||
const [weapons, armour, gear, treasure] = await Promise.all(["weapons", "armour", "gear", "treasure"].map(packDocs));
|
const [weapons, armour, gear, treasure] = await Promise.all(["weapons", "armour", "gear", "treasure"].map(packDocs));
|
||||||
const consumables = gear.filter(g => g.system.consumable);
|
const consumables = gear.filter(g => g.system.consumable);
|
||||||
@@ -1965,14 +1971,17 @@ async function forgeHoard({ size = "chest" } = {}) {
|
|||||||
lines.push(`<b>Coin:</b> ${tier.coin()}`);
|
lines.push(`<b>Coin:</b> ${tier.coin()}`);
|
||||||
|
|
||||||
const found = [];
|
const found = [];
|
||||||
if (Math.random() < tier.consum) found.push(rnd(consumables));
|
// rnd() returns undefined on an empty array; a pack with no consumables would then throw on
|
||||||
for (let i = 0; i < tier.draws; i++) found.push(rnd(mundane));
|
// .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(`<b>Goods:</b> ${found.map(d => `@UUID[${d.uuid}]{${d.name}}`).join(" · ")}`);
|
if (found.length) lines.push(`<b>Goods:</b> ${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).
|
// 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);
|
const nRelics = (tier.relics ?? 0) >= 1 ? tier.relics : (Math.random() < (tier.relics ?? 0) ? 1 : 0);
|
||||||
|
let relics = [];
|
||||||
if (nRelics && treasure.length) {
|
if (nRelics && treasure.length) {
|
||||||
const relics = pickN(treasure, Math.min(nRelics, treasure.length));
|
relics = pickN(treasure, Math.min(nRelics, treasure.length));
|
||||||
lines.push(`<b>Relic${relics.length > 1 ? "s" : ""}:</b> ${relics.map(d => `@UUID[${d.uuid}]{${d.name}}`).join(" · ")}`);
|
lines.push(`<b>Relic${relics.length > 1 ? "s" : ""}:</b> ${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(`<b>The catch:</b> <i>${rnd(FORGE_HOOKS)}</i>`);
|
if (Math.random() < tier.hook) lines.push(`<b>The catch:</b> <i>${rnd(FORGE_HOOKS)}</i>`);
|
||||||
|
|
||||||
await ChatMessage.create({
|
const card = `<div class="vanity-roll vanity-forge-card">
|
||||||
speaker: { alias: "The Forge" },
|
|
||||||
whisper: ChatMessage.getWhisperRecipients("GM"),
|
|
||||||
content: `<div class="vanity-roll vanity-forge-card">
|
|
||||||
<header><span class="vanity-roll-label">⚒ ${tier.label} hoard</span>
|
<header><span class="vanity-roll-label">⚒ ${tier.label} hoard</span>
|
||||||
<span class="vanity-roll-sub">gold you cling to buys nothing — gold you squander buys legend (§27)</span></header>
|
<span class="vanity-roll-sub">gold you cling to buys nothing — gold you squander buys legend (§27)</span></header>
|
||||||
${lines.map(l => `<p>${l}</p>`).join("")}
|
${lines.map(l => `<p>${l}</p>`).join("")}
|
||||||
</div>`
|
</div>`;
|
||||||
|
|
||||||
|
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 ------------ */
|
/* ------------ Forge dialogs & directory buttons ------------ */
|
||||||
@@ -2711,7 +2724,7 @@ async function stageRasterUpload(svg, W, H, name) {
|
|||||||
} finally { URL.revokeObjectURL(url); }
|
} 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;
|
if (!game.user.isGM) return;
|
||||||
const [cols, rows] = STAGE_SIZES[size] ?? STAGE_SIZES.medium;
|
const [cols, rows] = STAGE_SIZES[size] ?? STAGE_SIZES.medium;
|
||||||
name ||= stageName(type);
|
name ||= stageName(type);
|
||||||
@@ -2778,7 +2791,7 @@ async function forgeStage({ type = "barrow", size = "medium", name = "", populat
|
|||||||
// placement: a supplied cast, else an auto-encounter
|
// placement: a supplied cast, else an auto-encounter
|
||||||
let placed = cast;
|
let placed = cast;
|
||||||
if (!placed && populate) {
|
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 }));
|
placed = enc.actors.map(a => ({ actorId: a.id, name: a.name, img: a.img, disposition: -1 }));
|
||||||
}
|
}
|
||||||
if (placed?.length) {
|
if (placed?.length) {
|
||||||
@@ -2797,7 +2810,7 @@ async function forgeStage({ type = "barrow", size = "medium", name = "", populat
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activate) await scene.activate();
|
if (activate) await scene.activate();
|
||||||
await ChatMessage.create({
|
if (post) await ChatMessage.create({
|
||||||
speaker: { alias: "The Stage" },
|
speaker: { alias: "The Stage" },
|
||||||
whisper: ChatMessage.getWhisperRecipients("GM"),
|
whisper: ChatMessage.getWhisperRecipients("GM"),
|
||||||
content: `<div class="vanity-roll vanity-forge-card">
|
content: `<div class="vanity-roll vanity-forge-card">
|
||||||
@@ -2872,10 +2885,19 @@ const HEAT_BUILDS = {
|
|||||||
};
|
};
|
||||||
const HEAT_HOARD = { skirmish: "pocket", fight: "cache", battle: "chest", nightmare: "vault" };
|
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 t = FORGE_MONSTERS[kind] ? kind : rnd(Object.keys(FORGE_MONSTERS));
|
||||||
const build = (HEAT_BUILDS[heat] ?? HEAT_BUILDS.fight)();
|
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 = [];
|
const actors = [];
|
||||||
for (const [menace, count] of Object.entries(build)) {
|
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)];
|
mood = ["Hostile", "Wary", "Neutral", "Friendly"][Math.min(rs, 3)];
|
||||||
}
|
}
|
||||||
|
|
||||||
await ChatMessage.create({
|
if (post) await ChatMessage.create({
|
||||||
speaker: { alias: "The Forge" },
|
speaker: { alias: "The Forge" },
|
||||||
whisper: ChatMessage.getWhisperRecipients("GM"),
|
whisper: ChatMessage.getWhisperRecipients("GM"),
|
||||||
content: `<div class="vanity-roll vanity-forge-card">
|
content: `<div class="vanity-roll vanity-forge-card">
|
||||||
@@ -2905,8 +2927,8 @@ async function forgeEncounter({ heat = "fight", kind = "", forStage = "" } = {})
|
|||||||
<p><b>The catch:</b> <i>${complication}</i></p>
|
<p><b>The catch:</b> <i>${complication}</i></p>
|
||||||
</div>`
|
</div>`
|
||||||
});
|
});
|
||||||
await forgeHoard({ size: HEAT_HOARD[heat] ?? "cache" });
|
const hoardResult = hoard ? await forgeHoard({ size: HEAT_HOARD[heat] ?? "cache", post }) : null;
|
||||||
return { actors, situation, terrain, complication, mood, folder };
|
return { actors, situation, terrain, complication, mood, folder, hoard: hoardResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user