/** * DELVE — the Foundry adapter. Stages a delve the GM has already finished at the desk. * * The core/ directory is copied verbatim from the standalone project; nothing in it knows what * Foundry is. This file is the only part that does. * * DELVE is an authoring system: the GM writes the read-aloud and the climax in the worksheet, * then brings the finished JSON here to be staged. So this module does not generate at the table. * It raises the scenes, populates them, and puts the right text in front of the right people — * two audiences, always: * * PLAYERS get the read-aloud. That is what it is for. * THE GM gets the truth, the numbers and the leverage point, whispered. * * A draft can still be generated in-world, but it announces itself as unfinished. */ 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'; const MOD = 'vanity-delve'; const FLAG = 'state'; let PACK = null; let seamsPresent = false; let loadPack = async () => null; const getState = () => game.settings.get(MOD, FLAG) ?? null; const setState = async s => game.settings.set(MOD, FLAG, s); const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); const list = items => ``; /** GM-only: truth, numbers, leverage. Never read aloud. */ const gmCard = (label, sub, body) => ChatMessage.create({ speaker: { alias: 'DELVE' }, whisper: ChatMessage.getWhisperRecipients('GM'), content: `
${label}${sub}
${body}
`, }); /** Everyone: the GM's own prose. This is what the players are here for. */ const readAloudCard = (title, prose) => ChatMessage.create({ speaker: { alias: title }, content: `

${prose}

`, }); /** Bring in a finished working file. This is the intended entry point. */ async function load(working) { if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.'); const d = typeof working === 'string' ? JSON.parse(working) : working; if (!d?.skeleton || !Array.isArray(d.areas)) return ui.notifications.error('DELVE: that is not a delve file.'); const todo = outstanding(d); const folder = await Folder.create({ name: `Delve — ${d.authored?.title ?? d.skeleton.placeName}`, type: 'Actor' }); await setState({ delve: d, folderId: folder.id, at: 0, turn: 0, tab: [], clock: [] }); const sk = d.skeleton, ap = sk.appeasement; await gmCard('⛏ Delve loaded', `${d.authored?.title ?? sk.placeName} · ${d.areas.length} areas · seed ${d.seed}`, `${todo.length ? `

⚠ ${todo.length} unfinished: ${todo.map(t => t.where).join(', ')}. Fragments will show where nothing was written.

` : '

Finished. Ready to stage.

'}

Truth. ${sk.knot.transgression} ${sk.bottomProblem.label} ${sk.bottomProblem.failureState}.

Prize: ${sk.prize.label}. It asks: ${d.ending.question}

Appease: ${ap.move} [${ap.attribute} ${ap.successes}] — +${ap.gain.vanity} Vanity now, +${ap.cost.bane} Bane later. Repeatable.

`); ui.notifications.info(`DELVE: ${d.authored?.title ?? sk.placeName} loaded${todo.length ? ` (${todo.length} unfinished)` : ''}.`); return d; } /** Load from the world's delves/ folder — where the CLI writes finished files. */ async function loadFile(name) { const path = `worlds/${game.world.id}/delves/${String(name).replace(/\.json$/, '')}.json`; const json = await foundry.utils.fetchJsonWithTimeout(path).catch(() => null); if (!json) return ui.notifications.error(`DELVE: could not read ${path}`); return load(json); } /** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */ async function draft(params = {}) { const seed = params.seed || coinSeed(new Rng(String(game.world.id))); const d = newWorkingFile({ pack: PACK, ...params, seed }); ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.'); return load(d); } async function enter() { const st = getState(); if (!st) return ui.notifications.warn('DELVE: nothing loaded.'); const d = st.delve; if (st.at >= d.areas.length) return ending(); const area = d.areas[st.at]; const w = d.authored?.areas?.[area.index] ?? {}; const name = w.nameOverride ?? area.name; ui.notifications.info(`DELVE: raising ${name}…`); 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, }); if (area.encounter) await game.vanity.forge.encounter({ heat: area.encounter.heat, forStage: name, ...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}), }); if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) }); // Players first — the scene is up and this is what they came for. await readAloudCard(name, w.readAloud ?? `(unwritten) ${area.cueFragments.join('. ')}.`); // Then the GM, quietly. const R = area.encounter?.roster; const rv = area.decision?.resolve ?? {}; await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`, `${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}` : ''}

${R ? `

${cap(area.encounter.heat)}: ${R.line}

${list(R.foes.map(f => `${f.n}× ${f.name} — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. ${f.note}`))}

Harmed by ${R.harmedBy}. ${R.avoid}.

` : ''} ${area.temptation ? `

${cap(area.temptation.id)}: ${area.temptation.benefit}. Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.

` : ''} ${w.notes ? `

Your note: ${w.notes}

` : ''}

${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}

`); st.at += 1; st.turn += 1; await setState(st); return { stage, area }; } async function ending() { const st = getState(); const d = st.delve, sk = d.skeleton, we = d.authored?.ending ?? {}; await readAloudCard(d.ending.name, we.readAloud ?? `(unwritten) ${d.ending.cueFragments.join('. ')}.`); await gmCard('⛏ The ending', d.ending.authored ? 'the authored slot' : 'generated', `

It asks: ${d.ending.question}

${we.notes ? `

${we.notes}

` : '

⚠ The climax is unwritten. DELVE leaves this to you on purpose — improvise, or stop and write it.

'}

Appeased even once → it listens, and will trade ${sk.prize.label}.

`); return true; } async function bane(who = 'the party', why = 'a beat') { const st = getState(); if (!st) return; st.tab.push({ who, why }); await setState(st); await gmCard('⛏ The tab grows', `${who} — ${why}`, `

The tab reads ${st.tab.length}.${st.tab.length >= 6 ? ' THE RECKONING.' : ''}

`); } /** The clock is a live table roll. The core decides when; it never rolls. */ async function clock(cause = 'timer') { const st = getState(); if (!st) return; const roll = await new Roll('1d6').evaluate(); const fired = roll.total <= 1; st.clock.push({ cause, die: roll.total, fired }); await setState(st); await roll.toMessage({ speaker: { alias: 'DELVE' }, flavor: `Wandering the Dark — ${cause}` }); if (fired) await gmCard('⛏ Something comes', `${cause} · rolled ${roll.total}`, '

Roll Reaction (2d6) for its mood.

'); return fired; } Hooks.once('init', () => { game.settings.register(MOD, FLAG, { scope: 'world', config: false, type: Object, default: null }); game.settings.register(MOD, 'lastParams', { scope: 'world', config: false, type: Object, default: null }); }); Hooks.once('ready', async () => { if (!game.user.isGM) return; const fetchJson = p => foundry.utils.fetchJsonWithTimeout(p).catch(() => fetch(p).then(r => r.json()).catch(() => null)); const base = `modules/${MOD}/module/core/content`; const index = await fetchJson(`${base}/index.json`); if (index?.themes?.length) setThemes(index.themes); const packs = {}; loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`)); PACK = await loadPack('barrow'); seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? '')); game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon, list: listDungeons, remove: removeDungeon, removeDialog: removeDungeonDialog, load, loadFile, draft, enter, ending, bane, clock, state: getState, outstanding: () => outstanding(getState()?.delve ?? { areas: [] }), ready: () => readyToPlay(getState()?.delve ?? { areas: [] }), loadPack: id => loadPack(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.'); }); Hooks.on('getSceneControlButtons', controls => { if (!game.user.isGM) return; // Foundry v13+ passes controls as a record keyed by name — the token group is `tokens`, plural — // and each group's `tools` is a record too, not an array. Written for the old array shape, this // hook silently added nothing and the button never appeared. const group = controls.tokens ?? controls.token; if (!group?.tools) return; const tool = (name, title, icon, order, onClick) => { const entry = { name, title, icon, order, button: true, onChange: (event, active) => { if (active !== false) onClick(); }, onClick }; if (Array.isArray(group.tools)) group.tools.push(entry); else group.tools[name] = entry; }; tool('delve-forge', 'DELVE — raise a dungeon', 'fas fa-mountain', 90, () => new DelveForgeApp().render(true)); tool('delve-remove', 'DELVE — remove a generated dungeon', 'fas fa-trash-can', 91, () => removeDungeonDialog()); tool('delve-next', 'DELVE — stage the next area of a pre-authored delve', 'fas fa-forward', 92, () => (getState() ? enter() : ui.notifications.info('DELVE: no staged delve — use ⛏ to raise one.'))); });