The Forge: one button, a whole dungeon in the world

The brief was a tool in Foundry that generates everything on a click and injects
it as a new dungeon. Previously the module could only stage a delve authored at
the desk, and the only way in was a console command — an API with a button glued
on, which is why it looked like nothing was there.

The ⛏ button now opens a form (theme, areas, depth, heroes, deadliness, density,
greed, clock, ending, seed, and whether to build maps and roll encounters), and
Raise the dungeon builds the lot in about five seconds:

  • a folder per document type, named for the dungeon
  • one scene per area, walls and lighting placed, via the VANITY Forge
  • encounter actors, foldered rather than scattered across the world
  • a JournalEntry — GM truth, a page per area, the ending — which is the
    adventure, and is written to be read cold

The journal is the deliverable. Scenes without it are a pile of maps.

Generating is cheap, so removing had to be. Every document DELVE creates is
stamped with a dungeonId, and a 🗑 button lists what it built and deletes one
dungeon entirely — confirming with exact counts first, and never deleting the
scene the GM is standing in. Colour is not an identifier: it is a Color object,
and a GM may recolour a folder.

Verified live: two dungeons raised from the form and the API, then one removed
cleanly at -3 scenes, -10 actors, -1 journal, -3 folders with nothing else
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:27:23 +01:00
co-authored by Claude Opus 5
parent cda61e63dc
commit 47778fdbf6
6 changed files with 951 additions and 5 deletions
+303
View File
@@ -0,0 +1,303 @@
/**
* 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';
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';
const THEMES = ['barrow'];
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.');
const PACK = game.delve.pack;
const seed = params.seed || coinSeed(new Rng(String(Date.now())));
const d = generateDelve({ pack: PACK, ...params, seed });
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(a => ({ name: a.name, uuid: a.uuid }));
}
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: `<div class="vanity-roll delve-card">
<header><span class="delve-label">⛏ ${esc(title)}</span>
<span class="delve-sub">${d.areas.length} areas · ${esc(d.params.theme)} · depth ${d.params.depth} · seed ${esc(seed)}</span></header>
<p>${scenes.filter(Boolean).length} scenes, ${game.actors.filter(a => a.folder?.id === folders.Actor.id).length} actors and a journal, all in the folder <b>${esc(title)}</b>.</p>
<p>@UUID[${journal.uuid}]{Open the adventure}</p>
${seams ? '' : '<p><b>⚠ Forge seams missing</b> — update the VANITY system to 0.10.5 for quiet output.</p>'}
</div>`,
});
return { delve: d, journal, scenes, folders, dungeonId };
}
/* ---------------------------------------------------------------- journal */
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: `
<h2>${esc(sk.placeName)}</h2>
<p><i>${esc(sk.motif.dangerLine)}.</i></p>
<p><b>What was done.</b> ${esc(sk.knot.transgression)}</p>
<p><b>What is left.</b> ${esc(sk.bottomProblem.label)} ${esc(sk.bottomProblem.failureState)}. It wants <b>${esc(sk.bottomProblem.wantNow)}</b>, and it opens by asking ${esc(d.ending.question)}</p>
<p><b>The prize.</b> ${esc(sk.prize.label)} — ${esc(sk.prize.function)}.</p>
<p><b>The danger.</b> ${esc(sk.motif.danger)}: ${esc(sk.motif.dangerLine)}.</p>
<h3>The way through</h3>
<p>Anyone may <b>${esc(ap.move)}</b> <code>[${esc(ap.attribute)} ${ap.successes}]</code>.
On success ${esc(ap.why)}, and ${esc(sk.factions[0]?.name ?? 'the dead')} will not open hostilities.
<b>It costs +${ap.gain.vanity} Vanity now and +${ap.cost.bane} Bane later.</b> Repeatable in every area —
say the Bane aloud when it banks, so the table can see the route is open.</p>
<h3>Pressure</h3>
<ul>
<li><b>Clock.</b> Roll 1d6 on any attention trigger, or every 3 Turns. <b>1 — something comes.</b> Telegraph one Turn ahead.</li>
<li><b>The tab.</b> Aim for ${d.pressure.target} Banes by the ending. Banes come from named beats, not from hoping for Stumbles.</li>
</ul>
<h3>Factions</h3>
<ul>${sk.factions.map(f => `<li><b>${esc(f.name)}</b> (${esc(f.kind)}) — ${esc(f.essence)}. Handled by <i>${esc(f.handledBy)}</i>.</li>`).join('')}</ul>
<p><code>seed ${esc(d.seed)} · generator ${esc(d.generatorVersion)} · ${d.budget.plannedFights} planned fights</code></p>` },
});
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: `
<p><code>${esc(a.role)} · ${esc(a.facet)}${a.encounter ? ` · ${esc(a.encounter.heat)}` : ''}${a.hoard ? ` · ${esc(a.hoard)} hoard` : ''}</code>
${scene ? ` — @UUID[${scene.uuid}]{open the map}` : ''}</p>
<blockquote><p>${esc(a.cueFragments.join('. '))}.
${a.situation ? `${esc(cap(a.situation.occupant))} — ${esc(a.situation.doing)}. When you walk in, ${esc(a.situation.onArrival)}.` : ''}</p></blockquote>
<p><b>Read that aloud, or rewrite it in your own words — it is a prompt, not a script.</b></p>
${a.situation ? `<p><b>Why.</b> ${esc(cap(a.situation.because))}. <b>They can:</b> ${esc(a.situation.offer)}.</p>` : ''}
<p><b>${esc(cap(a.decision.cue))}</b> — <i>${esc(a.decision.cost)}</i></p>
<ul>
${rv.roll ? `<li><code>[${esc(rv.roll)}]</code> → ${esc(rv.success)}</li>` : ''}
${rv.failure ? `<li><b>Miss</b> → ${esc(rv.failure)}</li>` : ''}
${rv.orElse ? `<li><b>Or</b> ${esc(rv.orElse)}</li>` : ''}
</ul>
${R ? `<p><b>${esc(cap(a.encounter.heat))} — ${esc(R.line)}.</b> Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — <b>say so before initiative</b>' : ''}. <i>${esc(R.avoid)}.</i></p>
<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>
${R.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('')}
</tbody></table>` : ''}
${a._foes?.length ? `<p><b>Rolled for you:</b> ${a._foes.map(f => `@UUID[${f.uuid}]{${esc(f.name)}}`).join(' · ')}</p>` : ''}
${a.temptation ? `<p><b>${esc(cap(a.temptation.id))}</b> — ${esc(a.temptation.cue)}: ${esc(a.temptation.benefit)}.<br>
<i>Using it costs ${a.temptation.useCost?.bane ? `+${a.temptation.useCost.bane} Bane` : '—'}. While carried, ${esc(a.temptation.standingDrawback)}.</i></p>` : ''}
${a._hoard?.length ? `<p><b>Hoard.</b></p><ul>${a._hoard.map(l => `<li>${l}</li>`).join('')}</ul>` : ''}
<p><code>trigger: ${esc(a.trigger)}${a.baneBeat ? ` · bane beat: ${esc(a.baneBeat)}` : ''} · fallback: ${esc(a.fallback.route)} · if skipped: ${esc(a.failureCase)}</code></p>` },
});
});
const e = d.ending;
pages.push({
name: `⌀ ${e.name}`, type: 'text', ownership: GM_ONLY,
text: { content: `
<blockquote><p>${esc(e.cueFragments.join('. '))}.</p></blockquote>
${e.authored ? '<p><b>This is the authored slot.</b> DELVE stages the approach and stops here on purpose — the climax is yours to write.</p>' : ''}
<p><b>${esc(sk.bottomProblem.label)}</b> — ${esc(sk.bottomProblem.failureState)}. It opens by asking ${esc(e.question)}</p>
<table><thead><tr><th>What they did</th><th>The audience</th></tr></thead><tbody>
<tr><td>Appeased it, even once</td><td>It listens, and will trade ${esc(sk.prize.label)} for the real thing, said to its face.</td></tr>
<tr><td>Never appeased, tab under 4</td><td>It offers a Vice — the shine now, the bill later. Refusing starts the fight.</td></tr>
<tr><td>Carrying the prize</td><td>It wants that more than it wants them.</td></tr>
<tr><td>Fighting</td><td>Winnable by two engaged heroes. <b>Name what harms it before initiative.</b></td></tr>
</tbody></table>
<p><b>The Reckoning.</b> 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.</p>` },
});
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: `<p>This deletes <b>${d.scenes.length} scenes</b>, <b>${d.actors.length} actors</b>,
<b>${d.journals.length} journal(s)</b> and <b>${d.folders.length} folders</b>.</p>
<p>Nothing else in the world is touched, and this cannot be undone.</p>`,
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 => `<option value="${d.id}">${d.name} — ${d.scenes.length} scenes, ${d.actors.length} actors</option>`).join('');
const id = await foundry.applications.api.DialogV2.prompt({
window: { title: 'DELVE — remove a dungeon' },
content: `<p>Which dungeon should go?</p><select name="id" style="width:100%">${options}</select>`,
ok: { label: 'Choose', callback: (e, b, dlg) => dlg.element.querySelector('[name=id]').value },
rejectClose: false, modal: true,
});
if (id) return removeDungeon(id);
}
+17 -5
View File
@@ -16,6 +16,7 @@
*/
import { coinSeed, Rng } from './core/rng.mjs';
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog } from './forge-app.mjs';
const MOD = 'vanity-delve';
const FLAG = 'state';
@@ -151,6 +152,7 @@ async function clock(cause = 'timer') {
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 () => {
@@ -159,7 +161,9 @@ Hooks.once('ready', async () => {
.catch(() => fetch(`modules/${MOD}/module/core/content/barrow.json`).then(r => r.json()));
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
game.delve = { load, loadFile, draft, enter, ending, bane, clock, state: getState,
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: [] }),
get pack() { return PACK; } };
@@ -172,10 +176,18 @@ Hooks.on('getSceneControlButtons', controls => {
const group = Array.isArray(controls) ? controls.find(c => c.name === 'token') : controls.token;
if (!group) return;
const tools = Array.isArray(group.tools) ? group.tools : Object.values(group.tools ?? {});
const warn = () => ui.notifications.warn('DELVE: load a finished delve first — game.delve.loadFile("name")');
const open = () => new DelveForgeApp().render(true);
tools.push({
name: 'delve', title: 'DELVE — stage the next area', icon: 'fas fa-mountain', button: true,
onClick: () => (getState() ? enter() : warn()),
onChange: () => (getState() ? enter() : warn()),
name: 'delve-forge', title: 'DELVE — raise a dungeon', icon: 'fas fa-mountain', button: true,
onClick: open, onChange: open,
});
tools.push({
name: 'delve-remove', title: 'DELVE — remove a generated dungeon', icon: 'fas fa-trash', button: true,
onClick: () => removeDungeonDialog(), onChange: () => removeDungeonDialog(),
});
tools.push({
name: 'delve-next', title: 'DELVE — stage the next area (pre-authored delve)', icon: 'fas fa-forward', button: true,
onClick: () => (getState() ? enter() : ui.notifications.info('DELVE: no staged delve — use the ⛏ button to raise one.')),
onChange: () => (getState() ? enter() : ui.notifications.info('DELVE: no staged delve — use the ⛏ button to raise one.')),
});
});