The initiative warning becomes authored data, and staging gets a harness

Unreleased. The warning that v0.6.4 deleted is restored, but not as the
string heuristic that caused both faults — rosters carry beforeInitiative
now, and the renderers print it. 23 rosters have it: the 18 whose harmedBy
restricts what works, plus 5 Troll rosters that carried the instruction in
prose and so had two conventions between them. The prose copies are gone,
so it is said exactly once wherever it is said.

validate-pack enforces both halves — ONLY implies the field, and the phrase
may not appear in prose — and core/test.mjs now runs the validator over all
sixteen packs, so a pack regression fails the suite rather than waiting for
someone to remember the tool. Both rules were confirmed by breaking a pack
deliberately.

enter()'s decisions move to module/stage.mjs behind injected effects, which
makes the failure paths runnable under plain node. That immediately found a
bug in the first version of the extraction: Promise.resolve(fx.stage())
does not catch a synchronous throw, so a Forge that threw rather than
rejected would have taken the whole of enter() down. Nineteen of the new
tests are that matrix — failed scene, failed population with and without a
roster, failed hoard, failed cards, async rejection, and a Forge that
returns nothing.

The contract those tests hold: the scene is the commit point. Fail there
and nothing was entered, so a retry is safe; succeed and the turn advances
before anything else can fail, so a retry cannot restage. Everything after
degrades with a warning that says what was actually lost — the encounter
failure no longer promises a roster the area does not have, and the hoard
failure is no longer swallowed to the console.

Also: game.delve.loadPack is the validating resolver, so raiseDungeon can
no longer stage from a pack with no forgeStageType.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-06 00:21:10 +01:00
co-authored by Claude Opus 5
parent 9d220a3428
commit 0bbf57b693
40 changed files with 422 additions and 184 deletions
@@ -2110,7 +2110,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons ONLY",
"avoid": "it will take an appeasement over a fight every time"
"avoid": "it will take an appeasement over a fight every time",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Wraith, 1 Ghoul and 4 Skeletons",
@@ -2156,4 +2157,4 @@
},
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
"placeNameStyle": "claimant"
}
}
@@ -381,7 +381,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1171,4 +1172,4 @@
}
},
"placeNameStyle": "claimant"
}
}
+4 -3
View File
@@ -346,8 +346,9 @@
"note": "douses open flames on a hit"
}
],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative, and note the swarm is putting your fires out",
"avoid": "it will not leave the water; stay off the streamway and it stays in"
"harmedBy": "anything, but the Troll only stays down if fire touches it, and note the swarm is putting your fires out",
"avoid": "it will not leave the water; stay off the streamway and it stays in",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Troll, 1 Ogre and 3 Ghouls of the Kept",
@@ -1170,4 +1171,4 @@
]
}
}
}
}
@@ -382,7 +382,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "it will not strike anyone kneeling — say so before initiative"
"avoid": "it will not strike anyone kneeling",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1171,4 +1172,4 @@
]
}
}
}
}
+3 -2
View File
@@ -396,7 +396,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1185,4 +1186,4 @@
]
}
}
}
}
@@ -383,7 +383,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; fire for the Troll; anything for the wolves",
"avoid": "the Hunt passes over anyone standing perfectly still — say so before initiative"
"avoid": "the Hunt passes over anyone standing perfectly still",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1173,4 +1174,4 @@
}
},
"placeNameStyle": "claimant"
}
}
+6 -4
View File
@@ -337,8 +337,9 @@
"note": "free Called Shot to freeze on a hit"
}
],
"harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet — say so before initiative",
"avoid": "it will not go into deep water; the boat is safe from it and the shore is not"
"harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet",
"avoid": "it will not go into deep water; the boat is safe from it and the shore is not",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Wraith standing on the water, 1 Troll and 4 Ghouls",
@@ -372,7 +373,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1161,4 +1163,4 @@
]
}
}
}
}
@@ -397,7 +397,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1186,4 +1187,4 @@
]
}
}
}
}
@@ -338,8 +338,9 @@
"note": "hold the top of the gully"
}
],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative, and there is no fuel above the snowline",
"avoid": "it will not leave the gully; the buttress is longer and it is climbable"
"harmedBy": "anything, but the Troll only stays down if fire touches it, and there is no fuel above the snowline",
"avoid": "it will not leave the gully; the buttress is longer and it is climbable",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Wraith above the snowline, 1 Troll and 3 Dire Wolves",
@@ -373,7 +374,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll",
"avoid": "do not run this without an appeasement route open and shelter within a Turn"
"avoid": "do not run this without an appeasement route open and shelter within a Turn",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1162,4 +1164,4 @@
]
}
}
}
}
@@ -347,8 +347,9 @@
"note": "fearless"
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative",
"avoid": "it would far rather be flattered than fight, and will pause to be"
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it would far rather be flattered than fight, and will pause to be",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Wraith, 1 Ogre in the under-stair and 4 Skeletons",
@@ -382,7 +383,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1171,4 +1173,4 @@
]
}
}
}
}
+6 -4
View File
@@ -360,8 +360,9 @@
"note": "fearless; still working the ship they died on"
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative",
"avoid": "it wants the crew accounted for; name one of the drowned and it stops to enter them"
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it wants the crew accounted for; name one of the drowned and it stops to enter them",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Wraith, 1 Ogre out of the mud berth and 5 Skeletons",
@@ -395,7 +396,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1184,4 +1186,4 @@
]
}
}
}
}
@@ -337,8 +337,9 @@
"note": "free Called Shot to freeze on a hit"
}
],
"harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet — say so before initiative",
"avoid": "it holds the bridge and takes a toll; pay it and it lets you over"
"harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet",
"avoid": "it holds the bridge and takes a toll; pay it and it lets you over",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Wraith at the ford, 1 Troll and 4 Ghouls",
@@ -372,7 +373,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1161,4 +1163,4 @@
]
}
}
}
}
@@ -382,7 +382,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "it does not strike anyone who has said nothing — say so before initiative"
"avoid": "it does not strike anyone who has said nothing",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1171,4 +1172,4 @@
]
}
}
}
}
+3 -2
View File
@@ -397,7 +397,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1187,4 +1188,4 @@
}
},
"placeNameStyle": "own"
}
}
@@ -337,8 +337,9 @@
"note": "hold the ground below it"
}
],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative; there is wood in the hanging wood",
"avoid": "it holds the head wall and nothing else; the col is a longer climb and it is clear"
"harmedBy": "anything, but the Troll only stays down if fire touches it; there is wood in the hanging wood",
"avoid": "it holds the head wall and nothing else; the col is a longer climb and it is clear",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Wraith on the col, 1 Troll, 1 Ogre and 2 Dire Wolves",
@@ -381,7 +382,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1170,4 +1172,4 @@
]
}
}
}
}
@@ -362,8 +362,9 @@
"note": "fearless"
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative",
"avoid": "it wants the count right; give it a name off the register and it stops to check"
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it wants the count right; give it a name off the register and it stops to check",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "1 Wraith, 1 Ogre off the common and 5 Skeletons",
@@ -397,7 +398,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -1186,4 +1188,4 @@
]
}
}
}
}
+2 -3
View File
@@ -104,9 +104,8 @@ export function renderMarkdown(d) {
L.push('');
if (R) {
// 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(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
+ `${R.beforeInitiative ? ` **${R.beforeInitiative}**` : ''} *${R.avoid}.*`);
L.push('');
L.push('| Foe | atk | def | Grit | Nerve | |');
L.push('|---|---|---|---|---|---|');
+1 -1
View File
@@ -181,7 +181,7 @@ function foeSection(c) {
if (c.kind === 'planned') return `
<p><b>${esc(cap(c.heat))} — not cast.</b> Nothing was forged for this area, so the plan is the encounter. Cast it by hand:</p>
${table(c.roster.foes.map(f => `<tr><td>${f.n}× ${esc(f.name)}</td><td>${f.atk}</td><td>${f.def}</td><td>${f.grit}</td><td>${f.nerve}</td><td><i>${esc(f.note)}</i></td></tr>`).join(''))}
<p>Harmed by ${esc(c.roster.harmedBy)}. <i>${esc(c.roster.avoid)}.</i></p>`;
<p>Harmed by ${esc(c.roster.harmedBy)}.${c.roster.beforeInitiative ? ` <b>${esc(c.roster.beforeInitiative)}</b>` : ''} <i>${esc(c.roster.avoid)}.</i></p>`;
if (c.kind === 'unavailable') return `
<p><b>${esc(cap(c.heat))} — nothing to run.</b> 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.</p>`;
return '';
+103
View File
@@ -0,0 +1,103 @@
/**
* Raising one area, as a sequence of decisions rather than a sequence of Foundry calls.
*
* Every effect arrives as an injected function, so the whole thing runs under plain node and its
* failure paths can be tested. That is the point: three releases in a row shipped a bug on a path
* that only runs when the Forge misbehaves, and reasoning about those paths turned out not to be
* evidence.
*
* The contract, which the tests hold:
*
* The scene is the commit point. If it fails, nothing was entered — no cards, no advance, and a
* retry is safe. Once it exists the area IS entered, so the turn advances immediately and
* everything after it degrades loudly rather than rolling back. A retry after a later failure
* must not raise the same scene twice.
*
* Every degradation says what the GM has lost. Silence is what made the old hoard failure
* invisible and the old population failure lie about a roster that did not exist.
*/
import { foeStats, foeLine, classifyFoes } from './foes.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const list = items => `<ul>${items.filter(Boolean).map(i => `<li>${i}</li>`).join('')}</ul>`;
/**
* Run one injected effect, surviving both a rejected promise and a synchronous throw.
* `Promise.resolve(fn())` is not enough: a synchronous throw escapes before there is a promise to
* attach a catch to, which is how the first version of this file crashed the whole of enter().
*/
const attempt = async (fx, fn) => {
try { return { ok: true, value: await fn() }; }
catch (e) { fx.log?.(e); return { ok: false, value: null }; }
};
/** The foe section of the GM card. Mirrors forge-app's foeSection; both switch on the same kind. */
export function foeBlock(c) {
if (c.kind === 'forged') return `<p><b>${cap(c.heat)} — in the world:</b></p>${list(c.foes.map(foeLine))}${
c.planned ? `<p><i>DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`;
if (c.kind === 'planned') return `<p><b>${cap(c.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
list(c.roster.foes.map(f => `${f.n}× <b>${f.name}</b> — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. <i>${f.note}</i>`))
}<p><b>Harmed by ${c.roster.harmedBy}.</b>${c.roster.beforeInitiative ? ` <b>${c.roster.beforeInitiative}</b>` : ''} ${c.roster.avoid}.</p>`;
if (c.kind === 'unavailable') return `<p><b>${cap(c.heat)} — nothing to run.</b> No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.</p>`;
return '';
}
/** The two cards an area posts: the players' read-aloud, and the GM's card. */
export function areaCards({ area, name, authored = {}, classification }) {
const rv = area.decision?.resolve ?? {};
return {
readAloud: authored.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`,
gm: {
label: `⛏ ${area.index} · ${name}`,
sub: `${area.role} · ${area.facet}`,
body: `${area.situation ? `<p><b>Here:</b> ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.<br>
<b>They can:</b> ${area.situation.offer}. <i>${cap(area.situation.because)}.</i></p>` : ''}
<p><b>${cap(area.decision.cue)}</b>${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `<br><b>Miss:</b> ${rv.failure}` : ''}${rv.orElse ? `<br><b>Or:</b> ${rv.orElse}` : ''}</p>
${foeBlock(classification)}
${area.temptation ? `<p><b>${cap(area.temptation.id)}:</b> ${area.temptation.benefit}. <i>Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.</i></p>` : ''}
${authored.notes ? `<p><b>Your note:</b> ${authored.notes}</p>` : ''}
<p><code>${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}</code></p>`,
},
};
}
/**
* @param {object} fx injected effects: stage, encounter, hoard, commit, readAloud, gm, warn, error
* @param {object} ctx { area, name, authored }
* @returns {{entered: boolean, failed?: string, kind?: string}}
*/
export async function stageArea(fx, { area, name, authored = {} }) {
const scene = await attempt(fx, () => fx.stage(name));
if (!scene.ok || !scene.value) {
fx.error(`DELVE: the Forge could not raise ${name}. Nothing staged; try again.`);
return { entered: false, failed: 'stage' };
}
// The scene exists, so this area has been entered. Commit before anything that may fail, or a
// retry restages it.
await fx.commit();
let forged = [];
if (area.encounter) {
const enc = await attempt(fx, () => fx.encounter(area.encounter.heat));
if (enc.ok && enc.value) forged = (enc.value.actors ?? []).map(foeStats);
else fx.warn(area.encounter.roster
? `DELVE: could not populate ${name} — the planned roster stands in.`
: `DELVE: could not populate ${name}, and this theme has no roster at ${area.encounter.heat}. Improvise the fight or skip it.`);
}
if (area.hoard) {
const h = await attempt(fx, () => fx.hoard(area.hoard));
if (!h.ok || !h.value) fx.warn(`DELVE: could not lay the ${area.hoard} hoard in ${name} — improvise it or skip it.`);
}
const classification = classifyFoes(area, forged);
const cards = areaCards({ area, name, authored, classification });
// Players first — the scene is up and this is what they came for.
const said = (await attempt(fx, () => fx.readAloud(name, cards.readAloud))).ok;
const told = (await attempt(fx, () => fx.gm(cards.gm.label, cards.gm.sub, cards.gm.body))).ok;
if (!said || !told) fx.warn(`DELVE: ${name} is staged but its ${!said && !told ? 'cards' : !said ? 'read-aloud' : 'GM card'} did not post. The turn has advanced; read from the worksheet.`);
return { entered: true, kind: classification.kind };
}
+22 -53
View File
@@ -17,7 +17,7 @@
import { coinSeed, Rng } from './core/rng.mjs';
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
import { foeStats, foeLine, classifyFoes } from './foes.mjs';
import { stageArea } from './stage.mjs';
const MOD = 'vanity-delve';
const FLAG = 'state';
@@ -61,9 +61,6 @@ async function packFor(d) {
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);
const list = items => `<ul>${items.filter(Boolean).map(i => `<li>${i}</li>`).join('')}</ul>`;
/** GM-only: truth, numbers, leverage. Never read aloud. */
const gmCard = (label, sub, body) => ChatMessage.create({
speaker: { alias: 'DELVE' },
@@ -131,55 +128,25 @@ 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.
// The decisions live in stage.mjs so their failure paths can be tested; this is only the wiring.
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 } : {}) })
.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 ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`);
// Then the GM, quietly.
const rv = area.decision?.resolve ?? {};
const c = classifyFoes(area, (enc?.actors ?? []).map(foeStats));
const foeBlock =
c.kind === 'forged'
? `<p><b>${cap(c.heat)} — in the world:</b></p>${list(c.foes.map(foeLine))}${
c.planned ? `<p><i>DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`
: c.kind === 'planned'
? `<p><b>${cap(c.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
list(c.roster.foes.map(f => `${f.n}× <b>${f.name}</b> — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. <i>${f.note}</i>`))
}<p><b>Harmed by ${c.roster.harmedBy}.</b> ${c.roster.avoid}.</p>`
: c.kind === 'unavailable'
? `<p><b>${cap(c.heat)} — nothing to run.</b> No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.</p>`
: '';
await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`,
`${area.situation ? `<p><b>Here:</b> ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.<br>
<b>They can:</b> ${area.situation.offer}. <i>${cap(area.situation.because)}.</i></p>` : ''}
<p><b>${cap(area.decision.cue)}</b>${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `<br><b>Miss:</b> ${rv.failure}` : ''}${rv.orElse ? `<br><b>Or:</b> ${rv.orElse}` : ''}</p>
${foeBlock}
${area.temptation ? `<p><b>${cap(area.temptation.id)}:</b> ${area.temptation.benefit}. <i>Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.</i></p>` : ''}
${w.notes ? `<p><b>Your note:</b> ${w.notes}</p>` : ''}
<p><code>${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}</code></p>`);
st.at += 1; st.turn += 1;
await setState(st);
return { stage, area };
const fx = {
stage: n => game.vanity.forge.stage({
type: pack.forgeStageType, size: 'medium', name: n, populate: false, activate: true, ...quiet,
}),
encounter: heat => game.vanity.forge.encounter({
heat, forStage: name,
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
}),
hoard: size => game.vanity.forge.hoard({ size, ...(seamsPresent ? { post: false } : {}) }),
commit: async () => { st.at += 1; st.turn += 1; await setState(st); },
readAloud: readAloudCard,
gm: gmCard,
warn: m => ui.notifications.warn(m),
error: m => ui.notifications.error(m),
log: e => console.error('DELVE | staging', e),
};
return stageArea(fx, { area, name, authored: w });
}
async function ending() {
@@ -232,7 +199,9 @@ Hooks.once('ready', async () => {
load, loadFile, draft, enter, ending, bane, clock, state: getState,
outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
loadPack: id => loadPack(id),
// the validating resolver, not the raw fetcher — raiseDungeon stages from this,
// so a pack with no forgeStageType must never reach forge.stage({type: undefined})
loadPack: id => packById(id),
get pack() { return PACK; } };
console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`);
if (!seamsPresent) ui.notifications.warn('DELVE: Forge seams not installed — see the delve-seams branch.');