Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d220a3428 |
+3
-2
@@ -104,8 +104,9 @@ export function renderMarkdown(d) {
|
||||
L.push('');
|
||||
|
||||
if (R) {
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
|
||||
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
|
||||
// No appended "say so before initiative" — the packs that need it already say it, and three
|
||||
// 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('| Foe | atk | def | Grit | Nerve | |');
|
||||
L.push('|---|---|---|---|---|---|');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "vanity-delve",
|
||||
"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.",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.365"
|
||||
|
||||
@@ -104,8 +104,9 @@ export function renderMarkdown(d) {
|
||||
L.push('');
|
||||
|
||||
if (R) {
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
|
||||
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
|
||||
// No appended "say so before initiative" — the packs that need it already say it, and three
|
||||
// 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('| Foe | atk | def | Grit | Nerve | |');
|
||||
L.push('|---|---|---|---|---|---|');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -12,39 +12,13 @@
|
||||
*/
|
||||
import { generateDelve } from './core/delve.mjs';
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
import { foeStats, classifyFoes } from './foes.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';
|
||||
|
||||
/**
|
||||
* 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' }];
|
||||
|
||||
export function setThemes(list) { THEMES = list; }
|
||||
@@ -193,6 +167,26 @@ export async function raiseDungeon(params = {}) {
|
||||
|
||||
/* ---------------------------------------------------------------- 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) {
|
||||
const sk = d.skeleton, ap = sk.appeasement;
|
||||
const pages = [];
|
||||
@@ -241,16 +235,7 @@ function buildPages(d, scenes) {
|
||||
${rv.failure ? `<li><b>Miss</b> → ${esc(rv.failure)}</li>` : ''}
|
||||
${rv.orElse ? `<li><b>Or</b> ${esc(rv.orElse)}</li>` : ''}
|
||||
</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>
|
||||
<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><i>DELVE planned ${esc(R.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>` : ''}`
|
||||
: R ? `<p><b>${esc(cap(a.encounter.heat))} — not cast.</b> Nothing was forged for this area, so the plan is the encounter. Cast it by hand:</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>
|
||||
<p>Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — <b>say so before initiative</b>' : ''}. <i>${esc(R.avoid)}.</i></p>` : ''}
|
||||
${foeSection(classifyFoes(a, a._foes ?? []))}
|
||||
${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>` : ''}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
*/
|
||||
import { coinSeed, Rng } from './core/rng.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 FLAG = 'state';
|
||||
@@ -42,7 +43,7 @@ const setState = async s => game.settings.set(MOD, FLAG, s);
|
||||
*/
|
||||
async function packById(id) {
|
||||
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);
|
||||
if (!p?.forgeStageType) return null;
|
||||
PACK = p;
|
||||
@@ -112,7 +113,7 @@ async function draft(params = {}) {
|
||||
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({ pack, ...params, seed });
|
||||
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);
|
||||
}
|
||||
@@ -130,37 +131,42 @@ async function enter() {
|
||||
if (!pack) return;
|
||||
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 stage = await game.vanity.forge.stage({
|
||||
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({
|
||||
heat: area.encounter.heat, forStage: name,
|
||||
...(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;
|
||||
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.
|
||||
await readAloudCard(name, w.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`);
|
||||
|
||||
// Then the GM, quietly.
|
||||
const R = area.encounter?.roster;
|
||||
const foes = (enc?.actors ?? []).map(foeStats);
|
||||
const rv = area.decision?.resolve ?? {};
|
||||
|
||||
/**
|
||||
* One rule, both surfaces: show what exists. The roster's tactical guidance — what harms it, how
|
||||
* to avoid it — describes the planned foes, so it travels with the plan and only when the plan
|
||||
* IS the encounter. With nothing forged (populate off, or the Forge failed) the plan is all
|
||||
* there is, and it must stay runnable.
|
||||
*/
|
||||
const foeBlock = foes.length
|
||||
? `<p><b>${cap(area.encounter.heat)} — in the world:</b></p>${list(foes.map(foeLine))}${
|
||||
R ? `<p><i>DELVE planned ${R.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`
|
||||
: R
|
||||
? `<p><b>${cap(area.encounter.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
|
||||
list(R.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 ${R.harmedBy}.</b> ${R.avoid}.</p>`
|
||||
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}`,
|
||||
`${area.situation ? `<p><b>Here:</b> ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.<br>
|
||||
@@ -218,7 +224,7 @@ Hooks.once('ready', async () => {
|
||||
if (index?.themes?.length) setThemes(index.themes);
|
||||
const packs = {};
|
||||
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 ?? ''));
|
||||
|
||||
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user