/** * The DELVE Forge — one button, a form, and a complete dungeon injected into the world. * * Everything the generator knows becomes real Foundry content in one pass: * • a folder per dungeon, holding its scenes, actors and journal * • one scene per area, walls and lighting placed, raised via the VANITY Forge * • encounter actors, in that folder rather than scattered * • a JournalEntry — GM truth, one page per area, the ending — which IS the adventure * * The journal is the deliverable. Scenes without it are a pile of maps; the journal is what a * GM actually reads, and it is written so it can be read cold. */ import { generateDelve } from './core/delve.mjs'; import { coinSeed, Rng } from './core/rng.mjs'; import { foeStats, classifyFoes } from './foes.mjs'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? ''); const MOD = 'vanity-delve'; let THEMES = [{ id: 'barrow', label: 'Barrow' }]; export function setThemes(list) { THEMES = list; } const PICKS = { depth: [1, 2, 3, 4, 5], party: [1, 2, 3, 4, 5, 6, 7, 8], deadliness: ['forgiving', 'standard', 'cruel'], density: ['sparse', 'standard', 'infested'], greed: ['lean', 'standard', 'glutted'], clock: ['slow', 'standard', 'hunted'], ending: ['authored', 'generated'], }; export class DelveForgeApp extends HandlebarsApplicationMixin(ApplicationV2) { static DEFAULT_OPTIONS = { id: 'delve-forge', tag: 'form', window: { title: 'DELVE — raise a dungeon', icon: 'fas fa-mountain', resizable: true }, position: { width: 520, height: 'auto' }, form: { handler: DelveForgeApp.#submit, closeOnSubmit: true }, }; static PARTS = { body: { template: 'modules/vanity-delve/templates/forge.hbs' } }; async _prepareContext() { const last = game.settings.get('vanity-delve', 'lastParams') ?? {}; return { themes: THEMES, picks: PICKS, v: { theme: last.theme ?? 'barrow', areas: last.areas ?? 6, depth: last.depth ?? 2, party: last.party ?? 4, deadliness: last.deadliness ?? 'standard', density: last.density ?? 'standard', greed: last.greed ?? 'standard', clock: last.clock ?? 'standard', ending: last.ending ?? 'authored', seed: '', maps: last.maps ?? true, populate: last.populate ?? true, }, }; } static async #submit(event, form, formData) { const o = formData.object; const params = { theme: o.theme, areas: Number(o.areas), depth: Number(o.depth), party: Number(o.party), deadliness: o.deadliness, density: o.density, greed: o.greed, clock: o.clock, ending: o.ending, maps: !!o.maps, populate: !!o.populate, seed: (o.seed ?? '').trim() || undefined, }; await game.settings.set('vanity-delve', 'lastParams', params); return raiseDungeon(params); } } /* ------------------------------------------------------------------ build */ /** * Build the whole dungeon. Long-running and chatty on purpose: raising five scenes takes a few * seconds each and a silent UI reads as a hang. */ export async function raiseDungeon(params = {}) { if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.'); // Load the chosen theme's pack on demand — only the default is preloaded. const PACK = await game.delve.loadPack(params.theme ?? 'barrow'); if (!PACK) return ui.notifications.error(`DELVE: could not load the ${params.theme} theme.`); const seed = params.seed || coinSeed(new Rng(String(Date.now()))); // pack last: a programmatic caller passing params.pack would otherwise generate from one pack // while the maps below are staged from the one actually loaded. const d = generateDelve({ ...params, seed, pack: PACK }); const sk = d.skeleton; const title = sk.placeName; const seams = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? '')); const quiet = seams ? { post: false } : {}; // One folder per dungeon, for every document type it creates. // Stamp every created document so a dungeon can be found and removed later. Colour is not an // identifier — it is a Color object, and a GM may recolour a folder. const dungeonId = foundry.utils.randomID(); const stamp = { flags: { [MOD]: { dungeonId, dungeonName: title } } }; const folders = {}; for (const type of ['Scene', 'Actor', 'JournalEntry']) { folders[type] = await Folder.create({ name: title, type, color: '#7a5cff', ...stamp }); } const notify = (i, n, what) => ui.notifications.info(`DELVE ${i}/${n} — ${what}`); const total = d.areas.length + 2; let step = 0; // 1. Scenes, one per area. const scenes = []; for (const area of d.areas) { notify(++step, total, `raising ${area.name}`); let scene = null; if (params.maps !== false) { scene = await game.vanity.forge.stage({ type: PACK.forgeStageType, size: 'medium', name: area.name, populate: false, activate: false, ...quiet, ...(seams ? { folderId: folders.Actor.id } : {}), }).catch(e => { console.error('DELVE | stage failed', e); return null; }); if (scene) await scene.update({ folder: folders.Scene.id, ...stamp }); } scenes.push(scene); if (params.populate !== false && area.encounter) { const enc = await game.vanity.forge.encounter({ heat: area.encounter.heat, forStage: area.name, ...(seams ? { hoard: false, post: false, folderId: folders.Actor.id } : {}), }).catch(e => { console.error('DELVE | encounter failed', e); return null; }); area._foes = (enc?.actors ?? []).map(foeStats); } if (area.hoard) { const h = await game.vanity.forge.hoard({ size: area.hoard, ...quiet }) .catch(() => null); area._hoard = h?.lines ?? null; } } // 2. The journal — this is the adventure. notify(++step, total, 'writing the journal'); const journal = await JournalEntry.create({ name: title, folder: folders.JournalEntry.id, ...stamp, pages: buildPages(d, scenes), }); // Stamp the actors the Forge made for us — it knows nothing about DELVE. const mine = game.actors.filter(a => a.folder?.id === folders.Actor.id); if (mine.length) await Actor.updateDocuments(mine.map(a => ({ _id: a.id, ...stamp }))); // 3. Land on something useful rather than leaving the GM to hunt. notify(++step, total, 'done'); await journal.sheet.render(true); if (scenes[0]) await scenes[0].view(); await ChatMessage.create({ speaker: { alias: 'DELVE' }, whisper: ChatMessage.getWhisperRecipients('GM'), content: `
⛏ ${esc(title)} ${d.areas.length} areas · ${esc(d.params.theme)} · depth ${d.params.depth} · seed ${esc(seed)}

${scenes.filter(Boolean).length} scenes, ${game.actors.filter(a => a.folder?.id === folders.Actor.id).length} actors and a journal, all in the folder ${esc(title)}.

@UUID[${journal.uuid}]{Open the adventure}

${seams ? '' : '

⚠ Forge seams missing — update the VANITY system to 0.10.5 for quiet output.

'}
`, }); return { delve: d, journal, scenes, folders, dungeonId }; } /* ---------------------------------------------------------------- journal */ /** * The journal's foe section, driven by classifyFoes so it cannot disagree with the chat card. * The roster's tactical notes name particular monsters, so they print only where the plan is * itself the encounter. */ function foeSection(c) { const table = rows => `${rows}
FoeatkdefGritNerve
`; if (c.kind === 'forged') return `

${esc(cap(c.heat))} — in the world. These are the actors the Forge created; run the fight off these.

${table(c.foes.map(f => `@UUID[${f.uuid}]{${esc(f.name)}}${f.atk ?? '?'}${f.def ?? '?'}${f.grit ?? '?'}${f.nerve ?? '?'}${esc(f.trick)}`).join(''))} ${c.planned ? `

DELVE planned ${esc(c.planned.line)}. The Forge rolls its own cast, so the plan's foes are not these — its tactical notes describe monsters that were not created.

` : ''}`; if (c.kind === 'planned') return `

${esc(cap(c.heat))} — not cast. Nothing was forged for this area, so the plan is the encounter. Cast it by hand:

${table(c.roster.foes.map(f => `${f.n}× ${esc(f.name)}${f.atk}${f.def}${f.grit}${f.nerve}${esc(f.note)}`).join(''))}

Harmed by ${esc(c.roster.harmedBy)}. ${esc(c.roster.avoid)}.

`; if (c.kind === 'unavailable') return `

${esc(cap(c.heat))} — nothing to run. 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.

`; return ''; } function buildPages(d, scenes) { const sk = d.skeleton, ap = sk.appeasement; const pages = []; const GM_ONLY = { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE }; pages.push({ name: '① The truth', type: 'text', ownership: GM_ONLY, text: { content: `

${esc(sk.placeName)}

${esc(sk.motif.dangerLine)}.

What was done. ${esc(sk.knot.transgression)}

What is left. ${esc(sk.bottomProblem.label)} ${esc(sk.bottomProblem.failureState)}. It wants ${esc(sk.bottomProblem.wantNow)}, and it opens by asking ${esc(d.ending.question)}

The prize. ${esc(sk.prize.label)} — ${esc(sk.prize.function)}.

The danger. ${esc(sk.motif.danger)}: ${esc(sk.motif.dangerLine)}.

The way through

Anyone may ${esc(ap.move)} [${esc(ap.attribute)} ${ap.successes}]. On success ${esc(ap.why)}, and ${esc(sk.factions[0]?.name ?? 'the dead')} will not open hostilities. It costs +${ap.gain.vanity} Vanity now and +${ap.cost.bane} Bane later. Repeatable in every area — say the Bane aloud when it banks, so the table can see the route is open.

Pressure

Factions

seed ${esc(d.seed)} · generator ${esc(d.generatorVersion)} · ${d.budget.plannedFights} planned fights

` }, }); d.areas.forEach((a, i) => { const scene = scenes[i]; const rv = a.decision?.resolve ?? {}; const R = a.encounter?.roster; pages.push({ name: `${a.index}. ${a.name}`, type: 'text', ownership: GM_ONLY, text: { content: `

${esc(a.role)} · ${esc(a.facet)}${a.encounter ? ` · ${esc(a.encounter.heat)}` : ''}${a.hoard ? ` · ${esc(a.hoard)} hoard` : ''} ${scene ? ` — @UUID[${scene.uuid}]{open the map}` : ''}

${esc(a.cueFragments.join('. '))}. ${a.situation ? `${esc(cap(a.situation.occupant))} — ${esc(a.situation.doing)}. When you walk in, ${esc(a.situation.onArrival)}.` : ''}

Read that aloud, or rewrite it in your own words — it is a prompt, not a script.

${a.situation ? `

Why. ${esc(cap(a.situation.because))}. They can: ${esc(a.situation.offer)}.

` : ''}

${esc(cap(a.decision.cue))} — ${esc(a.decision.cost)}

${foeSection(classifyFoes(a, a._foes ?? []))} ${a.temptation ? `

${esc(cap(a.temptation.id))} — ${esc(a.temptation.cue)}: ${esc(a.temptation.benefit)}.
Using it costs ${a.temptation.useCost?.bane ? `+${a.temptation.useCost.bane} Bane` : '—'}. While carried, ${esc(a.temptation.standingDrawback)}.

` : ''} ${a._hoard?.length ? `

Hoard.

` : ''}

trigger: ${esc(a.trigger)}${a.baneBeat ? ` · bane beat: ${esc(a.baneBeat)}` : ''} · fallback: ${esc(a.fallback.route)} · if skipped: ${esc(a.failureCase)}

` }, }); }); const e = d.ending; pages.push({ name: `⌀ ${e.name}`, type: 'text', ownership: GM_ONLY, text: { content: `

${esc(e.cueFragments.join('. '))}.

${e.authored ? '

This is the authored slot. DELVE stages the approach and stops here on purpose — the climax is yours to write.

' : ''}

${esc(sk.bottomProblem.label)} — ${esc(sk.bottomProblem.failureState)}. It opens by asking ${esc(e.question)}

What they didThe audience
Appeased it, even onceIt listens, and will trade ${esc(sk.prize.label)} for the real thing, said to its face.
Never appeased, tab under 4It offers a Vice — the shine now, the bill later. Refusing starts the fight.
Carrying the prizeIt wants that more than it wants them.
FightingWinnable by two engaged heroes. Name what harms it before initiative.

The Reckoning. If the tab reads six mid-audience, stop and roll it. A delve about vanity billing them in front of this thing is the best ending it has — do not soften it.

` }, }); return pages; } /* ----------------------------------------------------------------- remove */ /** Every dungeon DELVE has built in this world. */ export function listDungeons() { const byId = new Map(); for (const f of game.folders) { const id = f.getFlag(MOD, 'dungeonId'); if (!id) continue; if (!byId.has(id)) byId.set(id, { id, name: f.getFlag(MOD, 'dungeonName') ?? f.name, folders: [] }); byId.get(id).folders.push(f); } for (const d of byId.values()) { d.scenes = game.scenes.filter(s => s.getFlag(MOD, 'dungeonId') === d.id); d.actors = game.actors.filter(a => a.getFlag(MOD, 'dungeonId') === d.id); d.journals = game.journal.filter(j => j.getFlag(MOD, 'dungeonId') === d.id); } return [...byId.values()]; } /** Delete one generated dungeon and everything it made. Confirms with exact counts first. */ export async function removeDungeon(dungeonId, { confirm = true } = {}) { const d = listDungeons().find(x => x.id === dungeonId); if (!d) return ui.notifications.warn('DELVE: no such dungeon.'); if (confirm) { const ok = await foundry.applications.api.DialogV2.confirm({ window: { title: `Remove ${d.name}?` }, content: `

This deletes ${d.scenes.length} scenes, ${d.actors.length} actors, ${d.journals.length} journal(s) and ${d.folders.length} folders.

Nothing else in the world is touched, and this cannot be undone.

`, rejectClose: false, modal: true, }); if (!ok) return false; } // Never delete the scene the GM is standing in. if (d.scenes.some(s => s.active)) { const other = game.scenes.find(s => !d.scenes.includes(s)); if (other) await other.activate(); } if (d.scenes.length) await Scene.deleteDocuments(d.scenes.map(s => s.id)); if (d.actors.length) await Actor.deleteDocuments(d.actors.map(a => a.id)); if (d.journals.length) await JournalEntry.deleteDocuments(d.journals.map(j => j.id)); if (d.folders.length) await Folder.deleteDocuments(d.folders.map(f => f.id)); ui.notifications.info(`DELVE: removed ${d.name}.`); return true; } /** Pick one to remove. */ export async function removeDungeonDialog() { const all = listDungeons(); if (!all.length) return ui.notifications.info('DELVE: no generated dungeons in this world.'); const options = all.map(d => ``).join(''); const id = await foundry.applications.api.DialogV2.prompt({ window: { title: 'DELVE — remove a dungeon' }, content: `

Which dungeon should go?

`, ok: { label: 'Choose', callback: (e, b, dlg) => dlg.element.querySelector('[name=id]').value }, rejectClose: false, modal: true, }); if (id) return removeDungeon(id); }