From 47778fdbf6dc052833a43cd04958042fbe58a261 Mon Sep 17 00:00:00 2001 From: slaguru666 Date: Wed, 5 Aug 2026 09:27:23 +0100 Subject: [PATCH] The Forge: one button, a whole dungeon in the world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/my.json | 535 +++++++++++++++++++++++++ foundry-module/README.md | 25 ++ foundry-module/module/forge-app.mjs | 303 ++++++++++++++ foundry-module/module/vanity-delve.mjs | 22 +- foundry-module/styles/vanity-delve.css | 9 + foundry-module/templates/forge.hbs | 62 +++ 6 files changed, 951 insertions(+), 5 deletions(-) create mode 100644 core/my.json create mode 100644 foundry-module/README.md create mode 100644 foundry-module/module/forge-app.mjs create mode 100644 foundry-module/templates/forge.hbs diff --git a/core/my.json b/core/my.json new file mode 100644 index 0000000..993a5d7 --- /dev/null +++ b/core/my.json @@ -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": [] + } + } +} \ No newline at end of file diff --git a/foundry-module/README.md b/foundry-module/README.md new file mode 100644 index 0000000..7806fb9 --- /dev/null +++ b/foundry-module/README.md @@ -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. diff --git a/foundry-module/module/forge-app.mjs b/foundry-module/module/forge-app.mjs new file mode 100644 index 0000000..5a4ec4e --- /dev/null +++ b/foundry-module/module/forge-app.mjs @@ -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: `
+
⛏ ${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 */ + +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)}

+ + ${R ? `

${esc(cap(a.encounter.heat))} — ${esc(R.line)}. Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — say so before initiative' : ''}. ${esc(R.avoid)}.

+ + ${R.foes.map(f => ``).join('')} +
FoeatkdefGritNerve
${f.n}× ${esc(f.name)}${f.atk}${f.def}${f.grit}${f.nerve}${esc(f.note)}
` : ''} + ${a._foes?.length ? `

Rolled for you: ${a._foes.map(f => `@UUID[${f.uuid}]{${esc(f.name)}}`).join(' · ')}

` : ''} + ${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); +} diff --git a/foundry-module/module/vanity-delve.mjs b/foundry-module/module/vanity-delve.mjs index b901506..e2f8003 100644 --- a/foundry-module/module/vanity-delve.mjs +++ b/foundry-module/module/vanity-delve.mjs @@ -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.')), }); }); diff --git a/foundry-module/styles/vanity-delve.css b/foundry-module/styles/vanity-delve.css index abec09d..e676e41 100644 --- a/foundry-module/styles/vanity-delve.css +++ b/foundry-module/styles/vanity-delve.css @@ -7,3 +7,12 @@ .delve-read { border-left: 3px solid #e8c96a; font-size: 1.02em; } .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%; } diff --git a/foundry-module/templates/forge.hbs b/foundry-module/templates/forge.hbs new file mode 100644 index 0000000..ba16492 --- /dev/null +++ b/foundry-module/templates/forge.hbs @@ -0,0 +1,62 @@ +
+

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.

+ +
+ + + + + + + + + + +
+ +
+ + +
+ + +