Files
slaguru666andClaude Opus 5 c1e467c91b Prose wins over the field; the play sheet carries avoid; the guard is testable
Still unreleased, but module.json is at 0.6.5 now.

initiativeWarning checked the field before the prose, so a hand-edited file
carrying both printed the warning twice — the exact fault the migration was
meant to end. Prose is checked first now and always wins.

The play sheet was the only surface that did not render `avoid`, so three
legacy rosters whose warning lived there — church, forest and temple at
nightmare — lost it entirely: the helper suppressed its fallback on finding
prose the play sheet never showed. It renders `avoid` now, which it should
have anyway; it is how the table gets past a fight without having one, which
is table-facing by definition. All five surfaces now render both prose
fields, so "the prose already says it" means the same thing everywhere.

Replayed all 64 rosters in their pre-field form through every surface: 26
need a warning, 130 of 130 checks say it exactly once. The current packs
give the same result.

commit() no longer mutates st before the write — it passes an incremented
copy, so a failed write really does leave the state as it was, which is what
the retry warning promises.

The single-flight guard moved into stage.mjs as singleFlight() and has four
tests: overlapping calls never run together, the second is refused rather
than queued, the guard clears afterwards, and a throw does not wedge it. It
closes the window within one client only; two GM browsers still race, and
that needs a world-level lock rather than a module variable.

41 core tests, 60 adapter tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:46:43 +01:00

244 lines
12 KiB
JavaScript

/**
* DELVE — the Foundry adapter. Stages a delve the GM has already finished at the desk.
*
* The core/ directory is copied verbatim from the standalone project; nothing in it knows what
* Foundry is. This file is the only part that does.
*
* DELVE is an authoring system: the GM writes the read-aloud and the climax in the worksheet,
* then brings the finished JSON here to be staged. So this module does not generate at the table.
* It raises the scenes, populates them, and puts the right text in front of the right people —
* two audiences, always:
*
* PLAYERS get the read-aloud. That is what it is for.
* THE GM gets the truth, the numbers and the leverage point, whispered.
*
* A draft can still be generated in-world, but it announces itself as unfinished.
*/
import { coinSeed, Rng } from './core/rng.mjs';
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
import { stageArea, singleFlight } from './stage.mjs';
const MOD = 'vanity-delve';
const FLAG = 'state';
let PACK = null;
let seamsPresent = false;
let loadPack = async () => null;
/** The geometries VANITY's Forge can actually build — mirrors validate-pack.mjs. */
const GEOMETRIES = ['barrow', 'cave', 'fen', 'village', 'forest'];
const getState = () => game.settings.get(MOD, FLAG) ?? null;
const setState = async s => game.settings.set(MOD, FLAG, s);
/**
* The pack a delve was actually authored against.
*
* PACK is only a boot-time default. A delve brought in from the desk may be any of the sixteen
* themes, and staging one with the wrong pack gives a harbour delve a barrow map — the fiction
* says quayside and the geometry says burial chamber. Resolved per call rather than once at load,
* because the global resets on a page reload while the staged delve in world state does not.
*
* `params.theme` is written from `pack.id` at generation time, so every delve the generator made
* carries it. `load()` also accepts hand-edited JSON, and there the field may be absent — which is
* why this fails closed rather than falling back to barrow. Guessing the geometry is the bug.
*/
async function packById(id) {
if (!id) return null;
const usable = p => GEOMETRIES.includes(p?.forgeStageType);
if (PACK?.id === id && usable(PACK)) return PACK; // validate the cache too
const p = await loadPack(id);
if (!usable(p)) return null;
PACK = p;
return p;
}
/** The pack a staged delve was authored against. Never guesses. */
async function packFor(d) {
const id = d?.params?.theme;
if (!id) {
ui.notifications.error('DELVE: that delve does not record a theme — refusing to stage, the geometry would be a guess.');
return null;
}
const p = await packById(id);
if (!p) ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`);
return p;
}
/** GM-only: truth, numbers, leverage. Never read aloud. */
const gmCard = (label, sub, body) => ChatMessage.create({
speaker: { alias: 'DELVE' },
whisper: ChatMessage.getWhisperRecipients('GM'),
content: `<div class="vanity-roll delve-card">
<header><span class="delve-label">${label}</span><span class="delve-sub">${sub}</span></header>${body}</div>`,
});
/** Everyone: the GM's own prose. This is what the players are here for. */
const readAloudCard = (title, prose) => ChatMessage.create({
speaker: { alias: title },
content: `<div class="vanity-roll delve-card delve-read"><p>${prose}</p></div>`,
});
/** Bring in a finished working file. This is the intended entry point. */
async function load(working) {
if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.');
const d = typeof working === 'string' ? JSON.parse(working) : working;
if (!d?.skeleton || !Array.isArray(d.areas)) return ui.notifications.error('DELVE: that is not a delve file.');
const todo = outstanding(d);
if (!await packFor(d)) return; // before anything is created in the world
const folder = await Folder.create({ name: `Delve — ${d.authored?.title ?? d.skeleton.placeName}`, type: 'Actor' });
await setState({ delve: d, folderId: folder.id, at: 0, turn: 0, tab: [], clock: [] });
const sk = d.skeleton, ap = sk.appeasement;
await gmCard('⛏ Delve loaded', `${d.authored?.title ?? sk.placeName} · ${d.areas.length} areas · seed ${d.seed}`,
`${todo.length ? `<p><b>⚠ ${todo.length} unfinished:</b> ${todo.map(t => t.where).join(', ')}. Fragments will show where nothing was written.</p>` : '<p><b>Finished.</b> Ready to stage.</p>'}
<p class="delve-truth"><b>Truth.</b> ${sk.knot.transgression} ${sk.bottomProblem.label} ${sk.bottomProblem.failureState}.</p>
<p><b>Prize:</b> ${sk.prize.label}. <b>It asks:</b> ${d.ending.question}</p>
<p><b>Appease:</b> ${ap.move} [${ap.attribute} ${ap.successes}] — +${ap.gain.vanity} Vanity now, +${ap.cost.bane} Bane later. Repeatable.</p>`);
ui.notifications.info(`DELVE: ${d.authored?.title ?? sk.placeName} loaded${todo.length ? ` (${todo.length} unfinished)` : ''}.`);
return d;
}
/** Load from the world's delves/ folder — where the CLI writes finished files. */
async function loadFile(name) {
const path = `worlds/${game.world.id}/delves/${String(name).replace(/\.json$/, '')}.json`;
const json = await foundry.utils.fetchJsonWithTimeout(path).catch(() => null);
if (!json) return ui.notifications.error(`DELVE: could not read ${path}`);
return load(json);
}
/** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */
async function draft(params = {}) {
const seed = params.seed || coinSeed(new Rng(String(game.world.id)));
const theme = params.theme ?? 'barrow'; // drafting picks a theme; staging must be told one
const pack = await packById(theme);
if (!pack) return ui.notifications.error(`DELVE: could not load the ${theme} theme.`);
const d = newWorkingFile({ ...params, seed, pack }); // pack last — see raiseDungeon
ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.');
return load(d);
}
const enter = singleFlight(
() => enterOnce(),
() => ui.notifications.warn('DELVE: already raising an area — wait for it to finish.'),
);
async function enterOnce() {
const st = getState();
if (!st) return ui.notifications.warn('DELVE: nothing loaded.');
const d = st.delve;
if (st.at >= d.areas.length) return ending();
const area = d.areas[st.at];
const w = d.authored?.areas?.[area.index] ?? {};
const name = w.nameOverride ?? area.name;
const pack = await packFor(d);
if (!pack) return;
ui.notifications.info(`DELVE: raising ${name}…`);
// The decisions live in stage.mjs so their failure paths can be tested; this is only the wiring.
const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {};
const fx = {
stage: n => game.vanity.forge.stage({
type: pack.forgeStageType, size: 'medium', name: n, populate: false, activate: true, ...quiet,
}),
encounter: heat => game.vanity.forge.encounter({
heat, forStage: name,
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
}),
hoard: size => game.vanity.forge.hoard({ size, ...(seamsPresent ? { post: false } : {}) }),
// An incremented copy, never a mutation of st: a failed write must leave the in-memory state
// exactly as it was, or the retry advice in the warning is wrong.
commit: () => setState({ ...st, at: st.at + 1, turn: st.turn + 1 }),
readAloud: readAloudCard,
gm: gmCard,
warn: m => ui.notifications.warn(m),
error: m => ui.notifications.error(m),
log: e => console.error('DELVE | staging', e),
};
return stageArea(fx, { area, name, authored: w });
}
async function ending() {
const st = getState();
const d = st.delve, sk = d.skeleton, we = d.authored?.ending ?? {};
await readAloudCard(d.ending.name, we.readAloud ?? `<i>(unwritten)</i> ${d.ending.cueFragments.join('. ')}.`);
await gmCard('⛏ The ending', d.ending.authored ? 'the authored slot' : 'generated',
`<p><b>It asks:</b> ${d.ending.question}</p>
${we.notes ? `<p>${we.notes}</p>` : '<p><b>⚠ The climax is unwritten.</b> DELVE leaves this to you on purpose — improvise, or stop and write it.</p>'}
<p><b>Appeased even once</b> → it listens, and will trade ${sk.prize.label}.</p>`);
return true;
}
async function bane(who = 'the party', why = 'a beat') {
const st = getState(); if (!st) return;
st.tab.push({ who, why }); await setState(st);
await gmCard('⛏ The tab grows', `${who} — ${why}`,
`<p>The tab reads <b>${st.tab.length}</b>.${st.tab.length >= 6 ? ' <b>THE RECKONING.</b>' : ''}</p>`);
}
/** The clock is a live table roll. The core decides when; it never rolls. */
async function clock(cause = 'timer') {
const st = getState(); if (!st) return;
const roll = await new Roll('1d6').evaluate();
const fired = roll.total <= 1;
st.clock.push({ cause, die: roll.total, fired }); await setState(st);
await roll.toMessage({ speaker: { alias: 'DELVE' }, flavor: `Wandering the Dark — ${cause}` });
if (fired) await gmCard('⛏ Something comes', `${cause} · rolled ${roll.total}`, '<p>Roll <b>Reaction (2d6)</b> for its mood.</p>');
return fired;
}
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 () => {
if (!game.user.isGM) return;
const fetchJson = p => foundry.utils.fetchJsonWithTimeout(p).catch(() => fetch(p).then(r => r.json()).catch(() => null));
const base = `modules/${MOD}/module/core/content`;
const index = await fetchJson(`${base}/index.json`);
if (index?.themes?.length) setThemes(index.themes);
const packs = {};
loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`));
await packById('barrow'); // sets PACK, and validates it like any other
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
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: [] }),
// the validating resolver, not the raw fetcher — raiseDungeon stages from this,
// so a pack with no forgeStageType must never reach forge.stage({type: undefined})
loadPack: id => packById(id),
get pack() { return PACK; } };
console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`);
if (!seamsPresent) ui.notifications.warn('DELVE: Forge seams not installed — see the delve-seams branch.');
});
Hooks.on('getSceneControlButtons', controls => {
if (!game.user.isGM) return;
// Foundry v13+ passes controls as a record keyed by name — the token group is `tokens`, plural —
// and each group's `tools` is a record too, not an array. Written for the old array shape, this
// hook silently added nothing and the button never appeared.
const group = controls.tokens ?? controls.token;
if (!group?.tools) return;
const tool = (name, title, icon, order, onClick) => {
const entry = { name, title, icon, order, button: true,
onChange: (event, active) => { if (active !== false) onClick(); },
onClick };
if (Array.isArray(group.tools)) group.tools.push(entry);
else group.tools[name] = entry;
};
tool('delve-forge', 'DELVE — raise a dungeon', 'fas fa-mountain', 90,
() => new DelveForgeApp().render(true));
tool('delve-remove', 'DELVE — remove a generated dungeon', 'fas fa-trash-can', 91,
() => removeDungeonDialog());
tool('delve-next', 'DELVE — stage the next area of a pre-authored delve', 'fas fa-forward', 92,
() => (getState() ? enter() : ui.notifications.info('DELVE: no staged delve — use ⛏ to raise one.')));
});