The warning reaches every surface; commit and concurrency are covered

Still unreleased. Answering the three blockers.

The worksheet and play sheet never rendered the new field, and stripping
the prose meant they said nothing at all — the two surfaces the whole
two-surface design is about. All five renderers now go through one helper,
core/roster.mjs, which also recovers the warning for delve files generated
before the field existed and refuses to add a line the prose already
carries. Measured across four themes: 40 of 40 delves carrying a roster now
warn on worksheet, play sheet and markdown alike.

Twenty-three was the wrong set. The spelling was never the semantics:
barrow's nightmare Wraith, cave's nightmare Troll and forest's battle Troll
carry the same non-discoverable restriction without the word ONLY. Marked,
so 26. I audited all 64 rosters rather than trusting the count — the ones
left unmarked are discoverable by trying (an Ogre with 12 Grit, mooks that
keep coming until the necromancer stops), and damage works on all of them.

commit() was unprotected, so a failed settings write threw out of stageArea
with the scene already real. It is caught now and says plainly that a retry
will raise the area twice. enter() also has a single-flight guard: it is
bound to a button, it awaits the Forge for seconds, and two overlapping
calls both read the same index.

Also: packById checks the geometry is one VANITY can build rather than just
truthy; foeSection is exported and the suite holds the two renderers to
agreement on all four kinds; and the module README and the seams warning
said 0.10.5 where everything else says 0.10.4.

41 core tests, 54 adapter tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-06 00:35:15 +01:00
co-authored by Claude Opus 5
parent 0bbf57b693
commit 1b2f9aa385
18 changed files with 171 additions and 25 deletions
+2 -1
View File
@@ -2145,7 +2145,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"triggerGlossary": {
+3 -2
View File
@@ -346,7 +346,7 @@
"note": "douses open flames on a hit"
}
],
"harmedBy": "anything, but the Troll only stays down if fire touches it, and note the swarm is putting your fires out",
"harmedBy": "anything, but the Troll only stays down if fire touches it; the swarm douses open flames",
"avoid": "it will not leave the water; stay off the streamway and it stays in",
"beforeInitiative": "Say so before initiative."
},
@@ -382,7 +382,8 @@
}
],
"harmedBy": "anything; fire for the Troll",
"avoid": "do not run this without an appeasement route open and a way out at your back"
"avoid": "do not run this without an appeasement route open and a way out at your back",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
+2 -1
View File
@@ -349,7 +349,8 @@
}
],
"harmedBy": "anything, but the Troll only stays down if fire closes the wound",
"avoid": "it is territorial and will not follow past the treeline"
"avoid": "it is territorial and will not follow past the treeline",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "the Hunt: 1 Wraith mounted, 1 Troll and 4 Dire Wolves",
+4 -2
View File
@@ -9,6 +9,7 @@
* GM what matters faster" — and the only way to be fast is to carry less.
*/
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
import { initiativeWarning } from './roster.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const cost = c => {
@@ -76,7 +77,7 @@ export function renderWorksheet(d) {
if (a.encounter?.roster) {
const R = a.encounter.roster;
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
L.push(` · harmed by ${R.harmedBy} · ${R.avoid}`);
L.push(` · harmed by ${R.harmedBy}${initiativeWarning(R) ? ` · ${initiativeWarning(R)}` : ''} · ${R.avoid}`);
}
if (a.temptation) {
const t = a.temptation;
@@ -143,7 +144,8 @@ export function renderPlay(d) {
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
if (a.encounter?.roster) {
const R = a.encounter.roster;
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`);
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`
+ `${initiativeWarning(R) ? ` **${initiativeWarning(R)}**` : ''}`);
}
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
if (w.notes) L.push(`**Note:** ${w.notes}`);
+3 -1
View File
@@ -1,5 +1,6 @@
/** Render a delve to GM-readable markdown, in the house style. */
import { skeletonBlurb } from './skeleton.mjs';
import { initiativeWarning } from './roster.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
@@ -104,8 +105,9 @@ export function renderMarkdown(d) {
L.push('');
if (R) {
const warn = initiativeWarning(R);
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
+ `${R.beforeInitiative ? ` **${R.beforeInitiative}**` : ''} *${R.avoid}.*`);
+ `${warn ? ` **${warn}**` : ''} *${R.avoid}.*`);
L.push('');
L.push('| Foe | atk | def | Grit | Nerve | |');
L.push('|---|---|---|---|---|---|');
+32
View File
@@ -0,0 +1,32 @@
/**
* The one place that decides whether a fight needs a warning before initiative.
*
* Some rosters carry a restriction a party cannot discover safely: a Wraith that only blessed,
* silvered or magical weapons touch, a Troll that only stays down if fire closes the wound. Finding
* that out by swinging and missing is how playtest 1 died, so the GM says it aloud first.
*
* This was a renderer heuristic — synthesise the line whenever `harmedBy` contained the word ONLY.
* It printed twice on the rosters that already said it in prose, and when the heuristic was deleted
* the warning vanished from twelve rosters at once. Worse, the spelling was never the semantics:
* barrow's nightmare Wraith and forest's battle Troll carry the same restriction without the word.
*
* So it is authored data now — `roster.beforeInitiative` — and this module is the only thing that
* reads it. RESTRICTS remains, but only as a tripwire: it tells the validator that new content
* *looks* like it needs the field, and it recovers the warning for delve files generated before the
* field existed. It is not the source of truth; the packs are.
*/
/** Vocabulary the packs actually use for "your weapon may not work". A tripwire, not an oracle. */
export const RESTRICTS = /\bONLY\b|blessed|silvered|magical|only stays down|fire for the/i;
/**
* What to say before initiative, or null. Never returns a line the prose already carries, so it
* cannot print twice.
*/
export function initiativeWarning(roster) {
if (!roster) return null;
if (roster.beforeInitiative) return roster.beforeInitiative;
const prose = `${roster.harmedBy ?? ''} ${roster.avoid ?? ''}`;
if (/say so before initiative/i.test(prose)) return null; // a legacy file that says it itself
return RESTRICTS.test(roster.harmedBy ?? '') ? 'Say so before initiative.' : null;
}
+2 -1
View File
@@ -8,6 +8,7 @@
* Run: node validate-pack.mjs [name ...] (default: every pack in content/)
*/
import { readFileSync, readdirSync } from 'fs';
import { RESTRICTS } from './roster.mjs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
@@ -112,7 +113,7 @@ export function validatePack(pack, name = pack?.id ?? '?') {
*/
if (/say so before initiative/i.test(`${r.harmedBy} ${r.avoid ?? ''}`))
E(`rosters.${h}: put the initiative warning in beforeInitiative, not in the prose — it renders twice otherwise`);
if (r.harmedBy.includes('ONLY') && !r.beforeInitiative)
if (RESTRICTS.test(r.harmedBy) && !r.beforeInitiative)
E(`rosters.${h}: harmedBy restricts what works, so it needs beforeInitiative — the party cannot discover this safely`);
for (const f of r.foes ?? [])
for (const k of ['n', 'name', 'atk', 'def', 'grit', 'nerve'])
+1 -1
View File
@@ -21,5 +21,5 @@ dungeon**, and it builds — in one folder per document type:
Every document is stamped with a `dungeonId`, so the 🗑 button removes a whole dungeon and
nothing else.
Requires the VANITY system **0.10.5+** for quiet output; on older versions the Forge posts its
Requires the VANITY system **0.10.4+** for quiet output; on older versions the Forge posts its
own cards over DELVE's and the module says so on startup.
@@ -2145,7 +2145,8 @@
}
],
"harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest",
"avoid": "do not run this without an appeasement route open"
"avoid": "do not run this without an appeasement route open",
"beforeInitiative": "Say so before initiative."
}
},
"triggerGlossary": {
+3 -2
View File
@@ -346,7 +346,7 @@
"note": "douses open flames on a hit"
}
],
"harmedBy": "anything, but the Troll only stays down if fire touches it, and note the swarm is putting your fires out",
"harmedBy": "anything, but the Troll only stays down if fire touches it; the swarm douses open flames",
"avoid": "it will not leave the water; stay off the streamway and it stays in",
"beforeInitiative": "Say so before initiative."
},
@@ -382,7 +382,8 @@
}
],
"harmedBy": "anything; fire for the Troll",
"avoid": "do not run this without an appeasement route open and a way out at your back"
"avoid": "do not run this without an appeasement route open and a way out at your back",
"beforeInitiative": "Say so before initiative."
}
},
"motifs": {
@@ -349,7 +349,8 @@
}
],
"harmedBy": "anything, but the Troll only stays down if fire closes the wound",
"avoid": "it is territorial and will not follow past the treeline"
"avoid": "it is territorial and will not follow past the treeline",
"beforeInitiative": "Say so before initiative."
},
"nightmare": {
"line": "the Hunt: 1 Wraith mounted, 1 Troll and 4 Dire Wolves",
@@ -9,6 +9,7 @@
* GM what matters faster" — and the only way to be fast is to carry less.
*/
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
import { initiativeWarning } from './roster.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const cost = c => {
@@ -76,7 +77,7 @@ export function renderWorksheet(d) {
if (a.encounter?.roster) {
const R = a.encounter.roster;
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
L.push(` · harmed by ${R.harmedBy} · ${R.avoid}`);
L.push(` · harmed by ${R.harmedBy}${initiativeWarning(R) ? ` · ${initiativeWarning(R)}` : ''} · ${R.avoid}`);
}
if (a.temptation) {
const t = a.temptation;
@@ -143,7 +144,8 @@ export function renderPlay(d) {
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
if (a.encounter?.roster) {
const R = a.encounter.roster;
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`);
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`
+ `${initiativeWarning(R) ? ` **${initiativeWarning(R)}**` : ''}`);
}
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
if (w.notes) L.push(`**Note:** ${w.notes}`);
+3 -1
View File
@@ -1,5 +1,6 @@
/** Render a delve to GM-readable markdown, in the house style. */
import { skeletonBlurb } from './skeleton.mjs';
import { initiativeWarning } from './roster.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
@@ -104,8 +105,9 @@ export function renderMarkdown(d) {
L.push('');
if (R) {
const warn = initiativeWarning(R);
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
+ `${R.beforeInitiative ? ` **${R.beforeInitiative}**` : ''} *${R.avoid}.*`);
+ `${warn ? ` **${warn}**` : ''} *${R.avoid}.*`);
L.push('');
L.push('| Foe | atk | def | Grit | Nerve | |');
L.push('|---|---|---|---|---|---|');
+32
View File
@@ -0,0 +1,32 @@
/**
* The one place that decides whether a fight needs a warning before initiative.
*
* Some rosters carry a restriction a party cannot discover safely: a Wraith that only blessed,
* silvered or magical weapons touch, a Troll that only stays down if fire closes the wound. Finding
* that out by swinging and missing is how playtest 1 died, so the GM says it aloud first.
*
* This was a renderer heuristic — synthesise the line whenever `harmedBy` contained the word ONLY.
* It printed twice on the rosters that already said it in prose, and when the heuristic was deleted
* the warning vanished from twelve rosters at once. Worse, the spelling was never the semantics:
* barrow's nightmare Wraith and forest's battle Troll carry the same restriction without the word.
*
* So it is authored data now — `roster.beforeInitiative` — and this module is the only thing that
* reads it. RESTRICTS remains, but only as a tripwire: it tells the validator that new content
* *looks* like it needs the field, and it recovers the warning for delve files generated before the
* field existed. It is not the source of truth; the packs are.
*/
/** Vocabulary the packs actually use for "your weapon may not work". A tripwire, not an oracle. */
export const RESTRICTS = /\bONLY\b|blessed|silvered|magical|only stays down|fire for the/i;
/**
* What to say before initiative, or null. Never returns a line the prose already carries, so it
* cannot print twice.
*/
export function initiativeWarning(roster) {
if (!roster) return null;
if (roster.beforeInitiative) return roster.beforeInitiative;
const prose = `${roster.harmedBy ?? ''} ${roster.avoid ?? ''}`;
if (/say so before initiative/i.test(prose)) return null; // a legacy file that says it itself
return RESTRICTS.test(roster.harmedBy ?? '') ? 'Say so before initiative.' : null;
}
+4 -3
View File
@@ -13,6 +13,7 @@
import { generateDelve } from './core/delve.mjs';
import { coinSeed, Rng } from './core/rng.mjs';
import { foeStats, classifyFoes } from './foes.mjs';
import { initiativeWarning } from './core/roster.mjs';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
@@ -158,7 +159,7 @@ export async function raiseDungeon(params = {}) {
<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>'}
${seams ? '' : '<p><b>⚠ Forge seams missing</b> — update the VANITY system to 0.10.4 for quiet output.</p>'}
</div>`,
});
@@ -172,7 +173,7 @@ export async function raiseDungeon(params = {}) {
* The roster's tactical notes name particular monsters, so they print only where the plan is
* itself the encounter.
*/
function foeSection(c) {
export 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>
@@ -181,7 +182,7 @@ function foeSection(c) {
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)}.${c.roster.beforeInitiative ? ` <b>${esc(c.roster.beforeInitiative)}</b>` : ''} <i>${esc(c.roster.avoid)}.</i></p>`;
<p>Harmed by ${esc(c.roster.harmedBy)}.${initiativeWarning(c.roster) ? ` <b>${esc(initiativeWarning(c.roster))}</b>` : ''} <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 '';
+8 -4
View File
@@ -17,6 +17,7 @@
* invisible and the old population failure lie about a roster that did not exist.
*/
import { foeStats, foeLine, classifyFoes } from './foes.mjs';
import { initiativeWarning } from './core/roster.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const list = items => `<ul>${items.filter(Boolean).map(i => `<li>${i}</li>`).join('')}</ul>`;
@@ -37,7 +38,7 @@ export function foeBlock(c) {
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>` : ''}`;
if (c.kind === 'planned') return `<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.beforeInitiative ? ` <b>${c.roster.beforeInitiative}</b>` : ''} ${c.roster.avoid}.</p>`;
}<p><b>Harmed by ${c.roster.harmedBy}.</b>${initiativeWarning(c.roster) ? ` <b>${initiativeWarning(c.roster)}</b>` : ''} ${c.roster.avoid}.</p>`;
if (c.kind === 'unavailable') return `<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>`;
return '';
}
@@ -74,8 +75,11 @@ export async function stageArea(fx, { area, name, authored = {} }) {
}
// The scene exists, so this area has been entered. Commit before anything that may fail, or a
// retry restages it.
await fx.commit();
// retry restages it. If the commit itself fails the scene is still real and the delve is still
// playable, but the index no longer matches the world — say so, because the next press of ⏩
// would raise this area a second time.
const committed = await attempt(fx, () => fx.commit());
if (!committed.ok) fx.warn(`DELVE: ${name} is staged, but the delve's progress could not be saved. Pressing ⏩ again will raise it a second time — reload and check before continuing.`);
let forged = [];
if (area.encounter) {
@@ -99,5 +103,5 @@ export async function stageArea(fx, { area, name, authored = {} }) {
const told = (await attempt(fx, () => fx.gm(cards.gm.label, cards.gm.sub, cards.gm.body))).ok;
if (!said || !told) fx.warn(`DELVE: ${name} is staged but its ${!said && !told ? 'cards' : !said ? 'read-aloud' : 'GM card'} did not post. The turn has advanced; read from the worksheet.`);
return { entered: true, kind: classification.kind };
return { entered: true, committed: committed.ok, kind: classification.kind };
}
+15 -2
View File
@@ -24,8 +24,14 @@ const FLAG = 'state';
let PACK = null;
let seamsPresent = false;
// enter() is bound to a button a GM can double-press, and it awaits the Forge for seconds at a
// time. Two overlapping calls would both read the same st.at and stage the same area twice.
let staging = 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);
@@ -43,9 +49,10 @@ const setState = async s => game.settings.set(MOD, FLAG, s);
*/
async function packById(id) {
if (!id) return null;
if (PACK?.id === id && PACK.forgeStageType) return PACK; // validate the cache too
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 (!p?.forgeStageType) return null;
if (!usable(p)) return null;
PACK = p;
return p;
}
@@ -116,6 +123,12 @@ async function draft(params = {}) {
}
async function enter() {
if (staging) return ui.notifications.warn('DELVE: already raising an area — wait for it to finish.');
staging = true;
try { return await enterOnce(); } finally { staging = false; }
}
async function enterOnce() {
const st = getState();
if (!st) return ui.notifications.warn('DELVE: nothing loaded.');
const d = st.delve;
+49
View File
@@ -9,6 +9,7 @@
*/
import { foeStats, foeLine, classifyFoes } from './module/foes.mjs';
import { stageArea, areaCards, foeBlock } from './module/stage.mjs';
import { initiativeWarning } from '../core/roster.mjs';
let pass = 0, fail = 0;
const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
@@ -154,6 +155,54 @@ const ctx = (a = withEnc()) => ({ area: { ...a, hoard: 'cache' }, name: 'The Sai
const r = await stageArea(fx, ctx());
t('a Forge that returns nothing counts as a failed scene', !r.entered && log.committed === 0);
}
{
const { fx, log } = spy({ commit: () => { throw new Error('settings write failed'); } });
const r = await stageArea(fx, ctx());
t('a failed commit does not take down staging', r.entered && r.committed === false);
t('a failed commit warns that a retry will restage',
log.warns.some(w => /raise it a second time/.test(w)),
'the scene is real but the index does not know it');
t('a failed commit still posts the cards', log.cards === 2);
}
// ---------------------------------------------------------------- the warning
t('the field is used when present', initiativeWarning({ harmedBy: 'anything', beforeInitiative: 'Say so.' }) === 'Say so.');
t('a plain roster needs no warning', initiativeWarning({ harmedBy: 'anything' }) === null);
t('a legacy ONLY roster still gets one', initiativeWarning({ harmedBy: 'blessed weapons ONLY' }) === 'Say so before initiative.',
'delve files generated before the field existed must not lose it');
t('a legacy Wraith roster spelled without ONLY still gets one',
initiativeWarning({ harmedBy: 'blessed, silvered or magical weapons for the Wraith; anything for the rest' }) !== null,
'the spelling was never the semantics — barrow/nightmare had no ONLY');
t('a legacy roster that says it in prose is not given it twice',
initiativeWarning({ harmedBy: 'blessed weapons ONLY — say so before initiative' }) === null);
t('null roster is safe', initiativeWarning(null) === null);
// ---------------------------------------------------------------- surface parity
// forge-app destructures three Foundry symbols at import time. Everything under test is pure, so
// the smallest possible shim makes the journal renderer importable and its parity with the chat
// renderer checkable — the two drifted apart twice before they shared a classifier.
{
globalThis.foundry ??= {
applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: c => c } },
utils: { escapeHTML: s => String(s ?? '') },
};
const { foeSection } = await import('./module/forge-app.mjs').catch(() => ({ foeSection: null }));
if (!foeSection) { t('journal renderer importable for parity checks', false, 'needs a foundry shim'); }
else for (const [kind, c] of [
['forged', classifyFoes(withEnc(), [f])],
['planned', classifyFoes(withEnc(), [])],
['unavailable', classifyFoes(area({ encounter: { heat: 'fight' } }), [])],
['none', classifyFoes(area(), [])],
]) {
const chat = foeBlock(c), journal = foeSection(c);
t(`both surfaces agree on ${kind}: both render or both stay silent`,
(chat.trim() === '') === (journal.trim() === ''));
if (kind === 'planned') t('both surfaces carry the warning on planned',
/say so before initiative/i.test(chat) && /say so before initiative/i.test(journal));
if (kind === 'forged') t('neither surface asserts the plan on forged',
!/blessed, silvered/.test(chat) && !/blessed, silvered/.test(journal));
}
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);