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:
@@ -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);
|
||||
}
|
||||
@@ -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.')),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user