2 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 9d220a3428 v0.6.4 — one classifier for both surfaces, and tests for it
The foe block broke twice in two releases, both times because each
surface decided for itself, in its own hand-written ternary, which of
the cases it was in. classifyFoes() in the new module/foes.mjs makes that
choice once — forged, planned, unavailable, none — and both surfaces
switch on it. foes.mjs is Foundry-free on purpose, so foundry-module/
test.mjs can run it under plain node with no shim. Fourteen tests, each
one a case that shipped broken or nearly did.

enter() now survives a Forge that throws. It had no catch, so a failure
left the scene raised, no cards posted and the turn not advanced, and a
retry would stage the area twice — while the comment above it claimed
the branch handled exactly that. A failed stage stops before the turn
advances, since there is nothing to run; a failed population keeps going,
because the scene is up and the planned roster stands in.

An area with combat heat but neither actors nor a roster used to render
nothing at all. It now says so and points at the decision and fallback.

The "say so before initiative" addendum is gone from all three renderers.
It was gated on the literal word ONLY, and the palace, port and village
battle rosters already end with that exact phrase, so it printed twice.
The packs that need the instruction carry it themselves.

Also: draft() spreads params before the pack, like raiseDungeon; and
packById validates the cached pack rather than trusting it, so the
fail-closed rule holds at boot too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:05:26 +01:00
slaguru666andClaude Opus 5 3a180479cb v0.6.3 — the plan stays runnable when nothing is forged
0.6.2 fixed the foe block by showing the actors the Forge created, and in
doing so broke the populate-off path: with nothing forged there were no
combat numbers at all, and the caption still pointed the GM at "the table
above" when no table had rendered. Turning population off used to leave
the planned roster runnable. It does again.

One rule now governs both surfaces: show what exists. The roster's
tactical guidance describes the planned foes, so it travels with the plan
and only when the plan IS the encounter. With foes forged, the plan is a
one-line note saying its tactics do not describe them; with nothing
forged, the full planned roster renders and its guidance applies, because
there it is the encounter. The previous wording claimed the guidance
applied either way, which asserted exactly what the fix existed to deny,
and gating the caveat on the literal word ONLY missed the Troll that
regenerates unless burned, the Ogre's 12 Grit and the Skeletons that
return until their Necromancer stops.

packFor no longer falls back to barrow. Generated files always record a
theme, but load() takes hand-edited JSON too, and guessing the geometry
is the bug it was written to prevent — it now fails closed, and also
rejects a pack with no forgeStageType. draft() keeps a default because
drafting chooses a theme rather than being told one.

raiseDungeon spreads caller params before the pack, so a programmatic
raise({pack}) can no longer generate from one pack while the maps are
staged from another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:53:13 +01:00
7 changed files with 191 additions and 58 deletions
+3 -2
View File
@@ -104,8 +104,9 @@ export function renderMarkdown(d) {
L.push(''); L.push('');
if (R) { if (R) {
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` + // No appended "say so before initiative" — the packs that need it already say it, and three
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`); // battle rosters carry the phrase verbatim, so the addendum printed it twice.
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${R.avoid}.*`);
L.push(''); L.push('');
L.push('| Foe | atk | def | Grit | Nerve | |'); L.push('| Foe | atk | def | Grit | Nerve | |');
L.push('|---|---|---|---|---|---|'); L.push('|---|---|---|---|---|---|');
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "vanity-delve", "id": "vanity-delve",
"title": "DELVE — a dungeon layer for VANITY", "title": "DELVE — a dungeon layer for VANITY",
"description": "Generates a coherent delve and sequences VANITY's Forge to stage it, one area at a time.", "description": "Generates a coherent delve and sequences VANITY's Forge to stage it, one area at a time.",
"version": "0.6.2", "version": "0.6.4",
"compatibility": { "compatibility": {
"minimum": "13", "minimum": "13",
"verified": "14.365" "verified": "14.365"
+3 -2
View File
@@ -104,8 +104,9 @@ export function renderMarkdown(d) {
L.push(''); L.push('');
if (R) { if (R) {
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` + // No appended "say so before initiative" — the packs that need it already say it, and three
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`); // battle rosters carry the phrase verbatim, so the addendum printed it twice.
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${R.avoid}.*`);
L.push(''); L.push('');
L.push('| Foe | atk | def | Grit | Nerve | |'); L.push('| Foe | atk | def | Grit | Nerve | |');
L.push('|---|---|---|---|---|---|'); L.push('|---|---|---|---|---|---|');
+48
View File
@@ -0,0 +1,48 @@
/**
* Who is actually in the room.
*
* A pack's roster is the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats and,
* in some themes, tactical notes naming specific monsters. The Forge takes no cast: it rolls its
* own from the heat. So the plan and the world are two different lists, and the rule both surfaces
* follow is **show what exists**:
*
* forged the Forge made actors — run those, and say the plan's tactics are not about them
* planned nothing was forged (population off, or the Forge failed) — the plan IS the
* encounter, so it renders in full and its guidance applies
* unavailable an area carries combat heat but has neither — say so rather than render nothing
* none no encounter here at all
*
* This file is deliberately Foundry-free so the decision can be tested without a VTT. The two
* surfaces disagreed about it twice — 0.6.2 lost the planned roster entirely when nothing was
* forged, and the caption claimed the plan's guidance applied either way — because each surface
* made the choice for itself in a ternary. They now both switch on `kind`.
*/
/** What a forged actor actually is, read off the document rather than off the plan. */
export const foeStats = a => ({
name: a.name,
uuid: a.uuid,
atk: a.system?.attack1?.pool ?? null,
def: a.system?.defence?.pool ?? null,
grit: a.system?.grit?.value ?? null,
nerve: a.system?.nerve ?? null,
trick: a.system?.trick ?? '',
});
/** One forged foe as a line of stats, in the roster's vocabulary so the two read alike. */
export const foeLine = f =>
`<b>@UUID[${f.uuid}]{${f.name}}</b> — ${f.atk ?? '?'}/${f.def ?? '?'}/${f.grit ?? '?'}, Nerve ${f.nerve ?? '?'}${f.trick ? `. <i>${f.trick}</i>` : ''}`;
/**
* Decide which of the four cases an area is in.
* @param {object} area a delve area
* @param {Array} forged actors the Forge created, already through foeStats — [] if it failed
*/
export function classifyFoes(area, forged = []) {
if (!area?.encounter) return { kind: 'none' };
const heat = area.encounter.heat;
const planned = area.encounter.roster ?? null;
if (forged.length) return { kind: 'forged', heat, foes: forged, planned };
if (planned) return { kind: 'planned', heat, roster: planned };
return { kind: 'unavailable', heat };
}
+25 -34
View File
@@ -12,39 +12,13 @@
*/ */
import { generateDelve } from './core/delve.mjs'; import { generateDelve } from './core/delve.mjs';
import { coinSeed, Rng } from './core/rng.mjs'; import { coinSeed, Rng } from './core/rng.mjs';
import { foeStats, classifyFoes } from './foes.mjs';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? ''); const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? '');
const MOD = 'vanity-delve'; const MOD = 'vanity-delve';
/**
* What a forged foe actually is.
*
* A pack's roster describes the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats
* and, in some themes, "blessed, silvered or magical weapons ONLY". The Forge does not take a
* cast: it rolls its own monsters from the heat. So the roster and the actors in the world are two
* different lists, and printing the roster's numbers beside the Forge's actors told the GM to run
* a fight against foes that were never created.
*
* Read the numbers off the documents that exist. The plan is still worth showing — it carries
* authored tactical guidance — but it has to be labelled as the plan.
*/
export const foeStats = a => ({
name: a.name,
uuid: a.uuid,
atk: a.system?.attack1?.pool ?? null,
def: a.system?.defence?.pool ?? null,
grit: a.system?.grit?.value ?? null,
nerve: a.system?.nerve ?? null,
trick: a.system?.trick ?? '',
});
/** One foe as a line of stats, in the roster's own vocabulary so the two read alike. */
export const foeLine = f =>
`<b>@UUID[${f.uuid}]{${f.name}}</b> — ${f.atk ?? '?'}/${f.def ?? '?'}/${f.grit ?? '?'}, Nerve ${f.nerve ?? '?'}${f.trick ? `. <i>${f.trick}</i>` : ''}`;
let THEMES = [{ id: 'barrow', label: 'Barrow' }]; let THEMES = [{ id: 'barrow', label: 'Barrow' }];
export function setThemes(list) { THEMES = list; } export function setThemes(list) { THEMES = list; }
@@ -109,7 +83,9 @@ export async function raiseDungeon(params = {}) {
const PACK = await game.delve.loadPack(params.theme ?? 'barrow'); const PACK = await game.delve.loadPack(params.theme ?? 'barrow');
if (!PACK) return ui.notifications.error(`DELVE: could not load the ${params.theme} theme.`); if (!PACK) return ui.notifications.error(`DELVE: could not load the ${params.theme} theme.`);
const seed = params.seed || coinSeed(new Rng(String(Date.now()))); const seed = params.seed || coinSeed(new Rng(String(Date.now())));
const d = generateDelve({ pack: PACK, ...params, seed }); // pack last: a programmatic caller passing params.pack would otherwise generate from one pack
// while the maps below are staged from the one actually loaded.
const d = generateDelve({ ...params, seed, pack: PACK });
const sk = d.skeleton; const sk = d.skeleton;
const title = sk.placeName; const title = sk.placeName;
@@ -191,6 +167,26 @@ export async function raiseDungeon(params = {}) {
/* ---------------------------------------------------------------- journal */ /* ---------------------------------------------------------------- journal */
/**
* The journal's foe section, driven by classifyFoes so it cannot disagree with the chat card.
* The roster's tactical notes name particular monsters, so they print only where the plan is
* itself the encounter.
*/
function foeSection(c) {
const table = rows => `<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
if (c.kind === 'forged') return `
<p><b>${esc(cap(c.heat))} — in the world.</b> These are the actors the Forge created; run the fight off these.</p>
${table(c.foes.map(f => `<tr><td>@UUID[${f.uuid}]{${esc(f.name)}}</td><td>${f.atk ?? '?'}</td><td>${f.def ?? '?'}</td><td>${f.grit ?? '?'}</td><td>${f.nerve ?? '?'}</td><td><i>${esc(f.trick)}</i></td></tr>`).join(''))}
${c.planned ? `<p><i>DELVE planned ${esc(c.planned.line)}. The Forge rolls its own cast, so the plan's foes are not these — its tactical notes describe monsters that were not created.</i></p>` : ''}`;
if (c.kind === 'planned') return `
<p><b>${esc(cap(c.heat))} — not cast.</b> Nothing was forged for this area, so the plan is the encounter. Cast it by hand:</p>
${table(c.roster.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(''))}
<p>Harmed by ${esc(c.roster.harmedBy)}. <i>${esc(c.roster.avoid)}.</i></p>`;
if (c.kind === 'unavailable') return `
<p><b>${esc(cap(c.heat))} — nothing to run.</b> No actors were created and this theme has no roster at this heat. Improvise the fight or skip it; the area's decision and fallback still stand.</p>`;
return '';
}
function buildPages(d, scenes) { function buildPages(d, scenes) {
const sk = d.skeleton, ap = sk.appeasement; const sk = d.skeleton, ap = sk.appeasement;
const pages = []; const pages = [];
@@ -239,12 +235,7 @@ function buildPages(d, scenes) {
${rv.failure ? `<li><b>Miss</b> → ${esc(rv.failure)}</li>` : ''} ${rv.failure ? `<li><b>Miss</b> → ${esc(rv.failure)}</li>` : ''}
${rv.orElse ? `<li><b>Or</b> ${esc(rv.orElse)}</li>` : ''} ${rv.orElse ? `<li><b>Or</b> ${esc(rv.orElse)}</li>` : ''}
</ul> </ul>
${a._foes?.length ? `<p><b>${esc(cap(a.encounter.heat))} — in the world.</b> These are the actors the Forge created; run the fight off these.</p> ${foeSection(classifyFoes(a, a._foes ?? []))}
<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>
${a._foes.map(f => `<tr><td>@UUID[${f.uuid}]{${esc(f.name)}}</td><td>${f.atk ?? '?'}</td><td>${f.def ?? '?'}</td><td>${f.grit ?? '?'}</td><td>${f.nerve ?? '?'}</td><td><i>${esc(f.trick)}</i></td></tr>`).join('')}
</tbody></table>` : ''}
${R ? `<p><b>DELVE planned ${esc(R.line)}</b> — the Forge rolls its own foes, so the plan and the table above are different lists.
Its guidance still applies to the scene: harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — <b>only true if you cast the fight yourself</b>' : ''}. <i>${esc(R.avoid)}.</i></p>` : ''}
${a.temptation ? `<p><b>${esc(cap(a.temptation.id))}</b> — ${esc(a.temptation.cue)}: ${esc(a.temptation.benefit)}.<br> ${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>` : ''} <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>` : ''} ${a._hoard?.length ? `<p><b>Hoard.</b></p><ul>${a._hoard.map(l => `<li>${l}</li>`).join('')}</ul>` : ''}
+50 -19
View File
@@ -16,7 +16,8 @@
*/ */
import { coinSeed, Rng } from './core/rng.mjs'; import { coinSeed, Rng } from './core/rng.mjs';
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs'; import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes, foeStats, foeLine } from './forge-app.mjs'; import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
import { foeStats, foeLine, classifyFoes } from './foes.mjs';
const MOD = 'vanity-delve'; const MOD = 'vanity-delve';
const FLAG = 'state'; const FLAG = 'state';
@@ -36,18 +37,28 @@ const setState = async s => game.settings.set(MOD, FLAG, s);
* says quayside and the geometry says burial chamber. Resolved per call rather than once at load, * 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. * 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 it is always present and always * `params.theme` is written from `pack.id` at generation time, so every delve the generator made
* right, whatever the caller passed. * 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 packFor(d) { async function packById(id) {
const id = d?.params?.theme ?? 'barrow'; if (!id) return null;
if (PACK?.id === id) return PACK; if (PACK?.id === id && PACK.forgeStageType) return PACK; // validate the cache too
const p = await loadPack(id); const p = await loadPack(id);
if (!p) { if (!p?.forgeStageType) return null;
ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`); 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; return null;
} }
PACK = p; 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; return p;
} }
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
@@ -99,9 +110,10 @@ async function loadFile(name) {
/** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */ /** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */
async function draft(params = {}) { async function draft(params = {}) {
const seed = params.seed || coinSeed(new Rng(String(game.world.id))); const seed = params.seed || coinSeed(new Rng(String(game.world.id)));
const pack = await packFor({ params }); const theme = params.theme ?? 'barrow'; // drafting picks a theme; staging must be told one
if (!pack) return; const pack = await packById(theme);
const d = newWorkingFile({ pack, ...params, seed }); 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.'); ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.');
return load(d); return load(d);
} }
@@ -119,29 +131,48 @@ async function enter() {
if (!pack) return; if (!pack) return;
ui.notifications.info(`DELVE: raising ${name}…`); ui.notifications.info(`DELVE: raising ${name}…`);
// A Forge failure must not leave the area half-raised. If the scene itself fails there is
// nothing to run, so stop before the turn advances and let the GM try again. If the population
// fails the scene is up and the delve is still playable — the planned roster stands in.
const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {}; const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {};
const stage = await game.vanity.forge.stage({ const stage = await game.vanity.forge.stage({
type: pack.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet, type: pack.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet,
}); }).catch(e => { console.error('DELVE | stage failed', e); return null; });
if (!stage) return ui.notifications.error(`DELVE: the Forge could not raise ${name}. Nothing staged; try again.`);
const enc = area.encounter ? await game.vanity.forge.encounter({ const enc = area.encounter ? await game.vanity.forge.encounter({
heat: area.encounter.heat, forStage: name, heat: area.encounter.heat, forStage: name,
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}), ...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
}).catch(e => {
console.error('DELVE | encounter failed', e);
ui.notifications.warn(`DELVE: could not populate ${name} — the planned roster stands in.`);
return null;
}) : null; }) : null;
if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) }); if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) })
.catch(e => { console.error('DELVE | hoard failed', e); return null; });
// Players first — the scene is up and this is what they came for. // Players first — the scene is up and this is what they came for.
await readAloudCard(name, w.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`); await readAloudCard(name, w.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`);
// Then the GM, quietly. // Then the GM, quietly.
const R = area.encounter?.roster;
const foes = (enc?.actors ?? []).map(foeStats);
const rv = area.decision?.resolve ?? {}; const rv = area.decision?.resolve ?? {};
const c = classifyFoes(area, (enc?.actors ?? []).map(foeStats));
const foeBlock =
c.kind === 'forged'
? `<p><b>${cap(c.heat)} — in the world:</b></p>${list(c.foes.map(foeLine))}${
c.planned ? `<p><i>DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`
: c.kind === 'planned'
? `<p><b>${cap(c.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
list(c.roster.foes.map(f => `${f.n}× <b>${f.name}</b> — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. <i>${f.note}</i>`))
}<p><b>Harmed by ${c.roster.harmedBy}.</b> ${c.roster.avoid}.</p>`
: c.kind === 'unavailable'
? `<p><b>${cap(c.heat)} — nothing to run.</b> No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.</p>`
: '';
await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`, await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`,
`${area.situation ? `<p><b>Here:</b> ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.<br> `${area.situation ? `<p><b>Here:</b> ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.<br>
<b>They can:</b> ${area.situation.offer}. <i>${cap(area.situation.because)}.</i></p>` : ''} <b>They can:</b> ${area.situation.offer}. <i>${cap(area.situation.because)}.</i></p>` : ''}
<p><b>${cap(area.decision.cue)}</b>${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `<br><b>Miss:</b> ${rv.failure}` : ''}${rv.orElse ? `<br><b>Or:</b> ${rv.orElse}` : ''}</p> <p><b>${cap(area.decision.cue)}</b>${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `<br><b>Miss:</b> ${rv.failure}` : ''}${rv.orElse ? `<br><b>Or:</b> ${rv.orElse}` : ''}</p>
${foes.length ? `<p><b>${cap(area.encounter.heat)} — in the world:</b></p>${list(foes.map(foeLine))}` : ''} ${foeBlock}
${R ? `<p><b>DELVE planned ${R.line}</b> — the Forge rolled its own, so run the block above. ${R.avoid}.</p>` : ''}
${area.temptation ? `<p><b>${cap(area.temptation.id)}:</b> ${area.temptation.benefit}. <i>Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.</i></p>` : ''} ${area.temptation ? `<p><b>${cap(area.temptation.id)}:</b> ${area.temptation.benefit}. <i>Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.</i></p>` : ''}
${w.notes ? `<p><b>Your note:</b> ${w.notes}</p>` : ''} ${w.notes ? `<p><b>Your note:</b> ${w.notes}</p>` : ''}
<p><code>${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}</code></p>`); <p><code>${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}</code></p>`);
@@ -193,7 +224,7 @@ Hooks.once('ready', async () => {
if (index?.themes?.length) setThemes(index.themes); if (index?.themes?.length) setThemes(index.themes);
const packs = {}; const packs = {};
loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`)); loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`));
PACK = await loadPack('barrow'); await packById('barrow'); // sets PACK, and validates it like any other
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? '')); seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon, game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
+61
View File
@@ -0,0 +1,61 @@
/**
* Adapter tests. No Foundry, no shim — foes.mjs is pure on purpose.
*
* These exist because the same decision broke twice in two releases. 0.6.2 showed the forged
* actors and silently dropped the planned roster when nothing had been forged; 0.6.3 fixed that
* but the choice still lived in two hand-written ternaries, one per surface. Every case below is
* a bug that shipped or nearly shipped.
*
* Run: node foundry-module/test.mjs
*/
import { foeStats, foeLine, classifyFoes } from './module/foes.mjs';
let pass = 0, fail = 0;
const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
const actor = {
name: 'Morthollow-born', uuid: 'Actor.abc',
system: { attack1: { pool: 4 }, defence: { pool: 3 }, grit: { value: 5 }, nerve: 6, trick: 'freezes on a hit' },
};
const roster = {
line: '1 Ghoul and 2 Skeletons',
foes: [{ n: 1, name: 'Ghoul', atk: 4, def: 3, grit: 4, nerve: 5, note: 'freezes' }],
harmedBy: 'blessed, silvered or magical weapons ONLY — say so before initiative',
avoid: 'the Ghoul goes for court dress first',
};
const withEnc = (extra = {}) => ({ encounter: { heat: 'fight', roster, ...extra } });
// --- reading a forged actor -------------------------------------------------
const f = foeStats(actor);
t('foeStats reads the stats off the document', f.atk === 4 && f.def === 3 && f.grit === 5 && f.nerve === 6);
t('foeStats keeps the uuid so the GM can open it', f.uuid === 'Actor.abc');
const bare = foeStats({ name: 'X', uuid: 'Actor.z', system: {} });
t('a missing stat degrades rather than throwing', bare.atk === null && foeLine(bare).includes('?/?/?'));
t('foeLine links the actor', foeLine(f).includes('@UUID[Actor.abc]{Morthollow-born}'));
t('foeLine omits an empty trick', !foeLine(bare).includes('<i>'));
// --- the four cases ---------------------------------------------------------
t('no encounter → none', classifyFoes({}, []).kind === 'none');
t('no encounter → none, even with stray actors', classifyFoes({}, [f]).kind === 'none');
const forged = classifyFoes(withEnc(), [f]);
t('actors forged → forged', forged.kind === 'forged' && forged.foes.length === 1);
t('forged keeps the plan, so it can be named as not-these', forged.planned === roster);
const planned = classifyFoes(withEnc(), []);
t('nothing forged but a roster → planned', planned.kind === 'planned' && planned.roster === roster,
'the 0.6.2 regression: population off left no numbers at all');
const nothing = classifyFoes({ encounter: { heat: 'fight' } }, []);
t('heat but neither actors nor roster → unavailable', nothing.kind === 'unavailable',
'must not render silence for an area labelled with combat');
t('unavailable still reports the heat', nothing.heat === 'fight');
// --- the invariant the two surfaces kept breaking ---------------------------
t('the plan is never the encounter while actors exist', forged.kind !== 'planned',
'roster guidance names monsters the Forge did not create');
t('a forged classification carries no roster field to render from',
forged.roster === undefined, 'so a surface cannot accidentally run the plan');
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);