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: `
${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.
'} +${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)}.
+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.
seed ${esc(d.seed)} · generator ${esc(d.generatorVersion)} · ${d.budget.plannedFights} planned fights
${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)}
+[${esc(rv.roll)}] → ${esc(rv.success)}${esc(cap(a.encounter.heat))} — ${esc(R.line)}. Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — say so before initiative' : ''}. ${esc(R.avoid)}.
+| Foe | atk | def | Grit | Nerve | |
|---|---|---|---|---|---|
| ${f.n}× ${esc(f.name)} | ${f.atk} | ${f.def} | ${f.grit} | ${f.nerve} | ${esc(f.note)} |
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)}.
Hoard.
trigger: ${esc(a.trigger)}${a.baneBeat ? ` · bane beat: ${esc(a.baneBeat)}` : ''} · fallback: ${esc(a.fallback.route)} · if skipped: ${esc(a.failureCase)}
+ ${e.authored ? '${esc(e.cueFragments.join('. '))}.
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 did | The audience |
|---|---|
| Appeased it, even once | It listens, and will trade ${esc(sk.prize.label)} for the real thing, said to its face. |
| Never appeased, tab under 4 | It offers a Vice — the shine now, the bill later. Refusing starts the fight. |
| Carrying the prize | It wants that more than it wants them. |
| Fighting | Winnable 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.
+ +