/** * 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 => ``; /** * 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 }; }