Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d220a3428 | ||
|
|
3a180479cb |
+3
-2
@@ -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('|---|---|---|---|---|---|');
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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('|---|---|---|---|---|---|');
|
||||||
|
|||||||
@@ -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 { 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>` : ''}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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