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
+535
View File
@@ -0,0 +1,535 @@
{
"schema": 1,
"generatorVersion": "0.1.0",
"catalogVersion": "barrow@1",
"seed": "my-delve",
"params": {
"areas": 6,
"depth": 2,
"party": 4,
"deadliness": "standard",
"density": "standard",
"greed": "standard",
"clock": "standard",
"ending": "authored",
"theme": "barrow"
},
"skeleton": {
"placeName": "The Tomb of the Silent Margrave",
"knot": {
"appetiteId": "first",
"accommodationId": "bound",
"claimant": "Silent Margrave",
"conscript": "a procession",
"transgression": "The Silent Margrave bound a procession by oath they could not break, so that someone would always go behind them.",
"cruelty": 2
},
"motif": {
"id": "precedence",
"danger": "procession",
"dangerLine": "the barrow keeps its order, and you have joined the queue",
"trigger": "announced"
},
"prize": {
"kind": "regalia",
"label": "the crown worn first, and only once",
"function": "the proof of precedence"
},
"bottomProblem": {
"label": "The Silent Margrave",
"wantNow": "to be first in all things",
"failureState": "is second now, and cannot bear it",
"handledBy": "giving them what they want, at a price"
},
"appeasement": {
"move": "stand aside and let it go ahead of you",
"attribute": "Poise",
"successes": 2,
"why": "it goes first",
"gain": {
"vanity": 1
},
"cost": {
"bane": 1
},
"repeatable": true,
"faction": "The Silent Host"
},
"factions": [
{
"id": "host",
"name": "The Silent Host",
"kind": "undead",
"essence": "still performing the ritual they were buried for",
"handledBy": "courtesy",
"conscriptedAs": "a procession"
},
{
"id": "wardens",
"name": "the Barrow-Wardens",
"kind": "undead",
"essence": "sealed this place and stayed to make sure",
"handledBy": "proof of right"
}
],
"foreshadow": [
{
"index": 1,
"role": "approach",
"facet": "institution",
"fragment": "numbered flagstones, counting down"
},
{
"index": 2,
"role": "complication",
"facet": "ritual",
"fragment": "banners in sequence, each house a place further back"
},
{
"index": 3,
"role": "complication",
"facet": "ritual",
"fragment": "a herald's script with the order still marked"
},
{
"index": 4,
"role": "turn",
"facet": "demand",
"fragment": "a door only one person may pass at a time, and only first"
},
{
"index": 5,
"role": "descent",
"facet": "wound",
"fragment": "a name moved to the front by force, repeatedly"
},
{
"index": 6,
"role": "threshold",
"facet": "anchor",
"fragment": "the crown worn first, and only once"
},
{
"index": 7,
"role": "ending",
"facet": "demand",
"fragment": "a corridor that will not let two walk abreast"
}
]
},
"areas": [
{
"index": 1,
"role": "approach",
"name": "The Outer Cut",
"facet": "institution",
"cueFragments": [
"numbered flagstones, counting down",
"the queue disturbed — three of the dead pushed aside"
],
"situation": {
"occupant": "two of the Host",
"doing": "refusing, each of them, to go through a door second",
"onArrival": "they both look at you to break it",
"because": "neither may be seen to yield",
"offer": "rule between them and the loser attaches to you as a lesser"
},
"truth": "This is how the place was ordered, and it still is",
"decision": {
"cue": "banners in strict order, and one you could move",
"cost": "reordering them opens a way and insults everyone buried here",
"resolve": {
"roll": "Wits 2",
"success": "you reorder them correctly and a way opens",
"failure": "you reorder wrongly and insult everyone buried here: +1 Bane",
"orElse": "leave them: the way stays shut, one Turn to find another"
}
},
"encounter": null,
"hoard": null,
"temptation": null,
"trigger": "disturbed",
"baneBeat": "noticing",
"clue": {
"automatic": "numbered flagstones, counting down — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 2,
"role": "complication",
"name": "The Waiting Room",
"facet": "ritual",
"cueFragments": [
"banners in sequence, each house a place further back",
"a rank-plate prised off and dropped, still shiny where it sat"
],
"situation": {
"occupant": "a procession",
"doing": "passing through, unhurried, with you in its way",
"onArrival": "it does not stop or go round",
"because": "it has right of way and has had for three centuries",
"offer": "stand aside and be counted courteous; join it and be counted one of them"
},
"truth": "The office is still being performed, for someone who is no longer served by it",
"decision": {
"cue": "a corridor that will not let two walk abreast",
"cost": "whoever goes first meets whatever is there first",
"resolve": {
"roll": "Poise 1",
"success": "you settle the order and whoever leads meets what is there, ready",
"failure": "you argue in the corridor and something arrives while you do",
"orElse": "send one scout: slow, one Turn, and they are alone"
}
},
"encounter": null,
"hoard": "pocket",
"temptation": {
"id": "herald",
"cue": "a herald's script with your name pencilled in",
"benefit": "once, be announced as whatever you claim",
"useCost": {
"bane": 1
},
"standingDrawback": "you must live up to it in front of the Host"
},
"trigger": "seenTwice",
"baneBeat": "noticing",
"clue": {
"automatic": "banners in sequence, each house a place further back — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 3,
"role": "complication",
"name": "The Attendants' Row",
"facet": "ritual",
"cueFragments": [
"a herald's script with the order still marked",
"someone has walked the corridor the wrong way, scuffing one side"
],
"situation": {
"occupant": "a living robber",
"doing": "prising number-plates off the flagstones",
"onArrival": "they freeze, then offer you one",
"because": "they worked out the order is what keeps the doors shut",
"offer": "take a plate and go first anywhere — the Host will hate you for it"
},
"truth": "The office is still being performed, for someone who is no longer served by it",
"decision": {
"cue": "a door that opens only for the foremost of a party",
"cost": "someone must be named first, aloud, and the others must accept it",
"resolve": {
"roll": "Flair 2",
"success": "someone is named first, aloud, and the door opens for them",
"failure": "the naming is disputed and the door reads that as no answer",
"orElse": "let the barrow choose: it picks the loudest, and remembers"
}
},
"encounter": {
"heat": "fight",
"kind": "undead",
"roster": {
"line": "1 Ghoul and 2 Skeletons",
"foes": [
{
"n": 1,
"name": "Ghoul",
"atk": 4,
"def": 3,
"grit": 4,
"nerve": 5,
"note": "on a hit, a free Called Shot to freeze for a round"
},
{
"n": 2,
"name": "Skeleton",
"atk": 3,
"def": 2,
"grit": 1,
"nerve": 5,
"note": "fearless"
}
],
"harmedBy": "anything",
"avoid": "the Ghoul goes for court dress first — send someone plain"
}
},
"hoard": "cache",
"temptation": {
"id": "rankplate",
"cue": "a rank-plate prised off something and pocketed",
"benefit": "go first through any door the barrow guards",
"useCost": {
"bane": 1
},
"standingDrawback": "going first is now expected of you"
},
"trigger": "loud",
"baneBeat": "noticing",
"clue": {
"automatic": "a herald's script with the order still marked — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 4,
"role": "turn",
"name": "The Middle Vault",
"facet": "demand",
"cueFragments": [
"a door only one person may pass at a time, and only first",
"a seat at the head of the hall dragged back, as if to sit in"
],
"situation": {
"occupant": "a queue of the Host",
"doing": "waiting to pass a door, in strict order, not moving",
"onArrival": "the queue opens a gap at exactly your rank",
"because": "the first in line is waiting for someone who never arrived",
"offer": "take the gap and go with them; take the front and they will remember it"
},
"truth": "Here the want is explicit — the place asks something of whoever enters",
"decision": {
"cue": "a claim to settle: which of you the barrow should address",
"cost": "settling it opens the way; refusing to settle it does not",
"resolve": {
"roll": "Poise 2",
"success": "the claim is settled and the barrow addresses one of you from now on",
"failure": "it is not settled, and the barrow keeps asking, in worse places",
"orElse": "refuse to settle: the way on does not open"
}
},
"encounter": null,
"hoard": null,
"temptation": null,
"trigger": "announced",
"baneBeat": "noticing",
"clue": {
"automatic": "a door only one person may pass at a time, and only first — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 5,
"role": "descent",
"name": "The Lower Cut",
"facet": "wound",
"cueFragments": [
"a name moved to the front by force, repeatedly",
"a numbered flagstone lifted and not replaced"
],
"situation": {
"occupant": "one of the Host with a herald's staff",
"doing": "announcing arrivals to an empty stair",
"onArrival": "it announces you, inventing a rank",
"because": "the post requires arrivals and there have been none",
"offer": "accept the invented rank and the barrow will treat you as it"
},
"truth": "This is the damage the want did to those who had to satisfy it",
"decision": {
"cue": "a stair where the order of descent is visibly recorded",
"cost": "whoever goes last is remembered as having gone last",
"resolve": {
"roll": "Poise 2",
"success": "you settle the order before you descend and the record reads as a party, not a rabble",
"failure": "you descend in a scramble: whoever is recorded last is treated as least by the Host from here",
"orElse": "descend however you like and accept the record as it falls"
}
},
"encounter": null,
"hoard": null,
"temptation": null,
"trigger": "announced",
"baneBeat": "noticing",
"clue": {
"automatic": "a name moved to the front by force, repeatedly — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
},
{
"index": 6,
"role": "threshold",
"name": "The Last Door",
"facet": "anchor",
"cueFragments": [
"the crown worn first, and only once",
"banners pulled down and stacked without regard for order"
],
"situation": {
"occupant": "one of the Host",
"doing": "reading out an order of precedence to an empty hall",
"onArrival": "it pauses and asks where you fall in it",
"because": "the list must be read in full before anyone may proceed",
"offer": "name your own place in the order and it will be honoured, exactly"
},
"truth": "The thing the whole barrow is arranged around is close",
"decision": {
"cue": "a queue of the dead, and a way to step round it",
"cost": "waiting costs two Turns; pushing in costs the Host's patience",
"resolve": {
"roll": "Poise 2",
"success": "you step round without breaking the order, and the Host allows it",
"failure": "you push in: +1 Bane and the queue closes behind you",
"orElse": "wait: two Turns, and the Host thinks better of you"
}
},
"encounter": {
"heat": "battle",
"kind": "undead",
"roster": {
"line": "1 Wraith and 3 Skeletons",
"foes": [
{
"n": 1,
"name": "Wraith",
"atk": 5,
"def": 3,
"grit": 10,
"nerve": 5,
"note": "INCORPOREAL — chill touch ignores armour and drains 1 max Grit"
},
{
"n": 3,
"name": "Skeleton",
"atk": 3,
"def": 2,
"grit": 1,
"nerve": 5,
"note": "fearless"
}
],
"harmedBy": "blessed, silvered or magical weapons ONLY",
"avoid": "it will take an appeasement over a fight every time"
}
},
"hoard": "chest",
"temptation": {
"id": "coronet",
"cue": "a small crown, sized for someone who never grew",
"benefit": "+1 die when others must defer to you",
"useCost": {
"bane": 1
},
"standingDrawback": "you must be addressed correctly or lose the benefit"
},
"trigger": "announced",
"baneBeat": null,
"clue": {
"automatic": "the crown worn first, and only once — and it was done deliberately",
"extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when"
},
"failureCase": "If they skip this, the ending opens with them holding nothing the bottom problem wants.",
"fallback": {
"route": "The Silent Host will receive anyone who behaves as a guest",
"cost": "one Bane on the tab, and they will expect it again"
}
}
],
"ending": {
"index": 7,
"role": "ending",
"mode": "authored",
"heat": null,
"hoard": null,
"name": "The Audience",
"cueFragments": [
"a corridor that will not let two walk abreast"
],
"question": "\"Who goes first?\"",
"authored": true
},
"triggerGlossary": {
"announced": "entered somewhere grandly — the big doors, a shout, a herald",
"seenTwice": "reflected, echoed or witnessed twice over in one place",
"loud": "a raised voice, a broken door, steel on stone",
"lingering": "stayed in one place longer than it takes to search it",
"disturbed": "struck or moved something that had not moved in centuries"
},
"budget": {
"plannedFights": 2,
"heatBase": "fight",
"wantFights": 2
},
"pressure": {
"turn": 0,
"clockRolls": 0,
"clockFired": 0,
"triggerNumber": 1,
"escalated": false,
"tab": 5,
"target": 5
},
"authored": {
"title": null,
"intro": null,
"areas": {
"1": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"2": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"3": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"4": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"5": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
},
"6": {
"readAloud": null,
"notes": null,
"nameOverride": null,
"locked": []
}
},
"ending": {
"readAloud": null,
"notes": null,
"locked": []
}
}
}
+25
View File
@@ -0,0 +1,25 @@
# DELVE — Foundry module
Install by copying this directory to `Data/modules/vanity-delve/`, then copying the generator
into it:
cp -R ../core foundry-module/module/core
`module/core/` is a verbatim copy of `core/`. It is not vendored in git because the standalone
generator is the source of truth — if the copy ever needs editing to run inside Foundry, the
portability claim in DESIGN.md §4 is false.
## Using it
The ⛏ button in the token controls opens the Forge. Set the parameters, press **Raise the
dungeon**, and it builds — in one folder per document type:
* one scene per area, walls and lighting placed, via the VANITY Forge
* encounter actors, foldered rather than scattered
* a JournalEntry: GM truth, one page per area, the ending — this is the adventure
Every document is stamped with a `dungeonId`, so the 🗑 button removes a whole dungeon and
nothing else.
Requires the VANITY system **0.10.5+** for quiet output; on older versions the Forge posts its
own cards over DELVE's and the module says so on startup.
+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 { coinSeed, Rng } from './core/rng.mjs';
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs'; import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog } from './forge-app.mjs';
const MOD = 'vanity-delve'; const MOD = 'vanity-delve';
const FLAG = 'state'; const FLAG = 'state';
@@ -151,6 +152,7 @@ async function clock(cause = 'timer') {
Hooks.once('init', () => { Hooks.once('init', () => {
game.settings.register(MOD, FLAG, { scope: 'world', config: false, type: Object, default: null }); 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 () => { 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())); .catch(() => fetch(`modules/${MOD}/module/core/content/barrow.json`).then(r => r.json()));
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? '')); 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: [] }), outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }), ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
get pack() { return PACK; } }; 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; const group = Array.isArray(controls) ? controls.find(c => c.name === 'token') : controls.token;
if (!group) return; if (!group) return;
const tools = Array.isArray(group.tools) ? group.tools : Object.values(group.tools ?? {}); 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({ tools.push({
name: 'delve', title: 'DELVE — stage the next area', icon: 'fas fa-mountain', button: true, name: 'delve-forge', title: 'DELVE — raise a dungeon', icon: 'fas fa-mountain', button: true,
onClick: () => (getState() ? enter() : warn()), onClick: open, onChange: open,
onChange: () => (getState() ? enter() : warn()), });
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.')),
}); });
}); });
+9
View File
@@ -7,3 +7,12 @@
.delve-read { border-left: 3px solid #e8c96a; font-size: 1.02em; } .delve-read { border-left: 3px solid #e8c96a; font-size: 1.02em; }
.delve-read p { margin: .2rem 0; } .delve-read p { margin: .2rem 0; }
.delve-forge .hint { opacity: .8; font-size: .92em; margin: 0 0 .6rem; }
.delve-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .45rem .7rem; }
.delve-grid label, .delve-toggles label { display: flex; flex-direction: column; font-size: .9em; gap: .15rem; }
.delve-grid .sub, .delve-toggles .sub { opacity: .6; font-size: .85em; font-weight: normal; }
.delve-toggles { margin-top: .6rem; display: flex; flex-direction: column; gap: .3rem; }
.delve-toggles .checkbox { flex-direction: row; align-items: center; gap: .4rem; }
.delve-actions { margin-top: .8rem; }
.delve-actions button.bright { width: 100%; }
+62
View File
@@ -0,0 +1,62 @@
<div class="delve-forge">
<p class="hint">Generates a complete dungeon — fiction, areas, maps, encounters, hoards and a
journal you can read cold — and drops it into this world in one folder.</p>
<div class="delve-grid">
<label>Theme
<select name="theme">
{{#each themes}}<option value="{{this}}" {{#if (eq this ../v.theme)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</label>
<label>Areas
<input type="number" name="areas" value="{{v.areas}}" min="3" max="12" step="1">
</label>
<label>Depth
<select name="depth">
{{#each picks.depth}}<option value="{{this}}" {{#if (eq this ../v.depth)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</label>
<label>Heroes
<select name="party">
{{#each picks.party}}<option value="{{this}}" {{#if (eq this ../v.party)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</label>
<label>Deadliness
<select name="deadliness">
{{#each picks.deadliness}}<option value="{{this}}" {{#if (eq this ../v.deadliness)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</label>
<label>Density
<select name="density">
{{#each picks.density}}<option value="{{this}}" {{#if (eq this ../v.density)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</label>
<label>Greed
<select name="greed">
{{#each picks.greed}}<option value="{{this}}" {{#if (eq this ../v.greed)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</label>
<label>Clock
<select name="clock">
{{#each picks.clock}}<option value="{{this}}" {{#if (eq this ../v.clock)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</label>
<label>Ending
<select name="ending">
{{#each picks.ending}}<option value="{{this}}" {{#if (eq this ../v.ending)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</label>
<label>Seed <span class="sub">blank = random</span>
<input type="text" name="seed" value="{{v.seed}}" placeholder="gilded-court-404">
</label>
</div>
<div class="delve-toggles">
<label class="checkbox"><input type="checkbox" name="maps" {{#if v.maps}}checked{{/if}}> Build the maps <span class="sub">(slower — about a second per area)</span></label>
<label class="checkbox"><input type="checkbox" name="populate" {{#if v.populate}}checked{{/if}}> Roll the encounters</label>
</div>
<footer class="delve-actions">
<button type="submit" class="bright"><i class="fas fa-mountain"></i> Raise the dungeon</button>
</footer>
</div>