/**
* 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';
import { initiativeWarning } from './core/roster.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const list = items => `
${items.filter(Boolean).map(i => `- ${i}
`).join('')}
`;
/**
* 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 }; }
};
/**
* Wrap an async function so overlapping calls are refused rather than interleaved.
*
* enter() hangs off a button, and it awaits the Forge for seconds at a time. Two overlapping calls
* both read the same index and stage the same area twice. This closes that within one client; two
* separate GM browsers still race, and the durable fix for that is a world-level lock rather than
* a module variable.
*/
export function singleFlight(fn, onBusy) {
let busy = false;
return async (...args) => {
if (busy) return onBusy?.();
busy = true;
try { return await fn(...args); } finally { busy = false; }
};
}
/** 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 `${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.
` : ''}`;
if (c.kind === 'planned') return `${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}.${initiativeWarning(c.roster) ? ` ${initiativeWarning(c.roster)}` : ''} ${c.roster.avoid}.
`;
if (c.kind === 'unavailable') return `${cap(c.heat)} — nothing to run. No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.
`;
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 ?? `(unwritten) ${area.cueFragments.join('. ')}.`,
gm: {
label: `⛏ ${area.index} · ${name}`,
sub: `${area.role} · ${area.facet}`,
body: `${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}` : ''}
${foeBlock(classification)}
${area.temptation ? `${cap(area.temptation.id)}: ${area.temptation.benefit}. Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.
` : ''}
${authored.notes ? `Your note: ${authored.notes}
` : ''}
${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}
`,
},
};
}
/**
* @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. If the commit itself fails the scene is still real and the delve is still
// playable, but the index no longer matches the world — say so, because the next press of ⏩
// would raise this area a second time.
const committed = await attempt(fx, () => fx.commit());
if (!committed.ok) fx.warn(`DELVE: ${name} is staged, but the delve's progress could not be saved. Pressing ⏩ again will raise it a second time — reload and check before continuing.`);
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, committed: committed.ok, kind: classification.kind };
}