diff --git a/core/content/barrow.json b/core/content/barrow.json index 9a1d821..bdda586 100644 --- a/core/content/barrow.json +++ b/core/content/barrow.json @@ -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": { diff --git a/core/content/cave.json b/core/content/cave.json index 08c9507..4d63ab9 100644 --- a/core/content/cave.json +++ b/core/content/cave.json @@ -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": { diff --git a/core/content/forest.json b/core/content/forest.json index fbd2312..4bb1bcc 100644 --- a/core/content/forest.json +++ b/core/content/forest.json @@ -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", diff --git a/core/render-authoring.mjs b/core/render-authoring.mjs index 6cb97bb..8f351f0 100644 --- a/core/render-authoring.mjs +++ b/core/render-authoring.mjs @@ -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}`); diff --git a/core/render.mjs b/core/render.mjs index a39d6bb..6ad29b9 100644 --- a/core/render.mjs +++ b/core/render.mjs @@ -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('|---|---|---|---|---|---|'); diff --git a/core/roster.mjs b/core/roster.mjs new file mode 100644 index 0000000..e077502 --- /dev/null +++ b/core/roster.mjs @@ -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; +} diff --git a/core/validate-pack.mjs b/core/validate-pack.mjs index 6ee7c7f..19b60ff 100644 --- a/core/validate-pack.mjs +++ b/core/validate-pack.mjs @@ -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']) diff --git a/foundry-module/README.md b/foundry-module/README.md index 7806fb9..0a7c083 100644 --- a/foundry-module/README.md +++ b/foundry-module/README.md @@ -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. diff --git a/foundry-module/module/core/content/barrow.json b/foundry-module/module/core/content/barrow.json index 9a1d821..bdda586 100644 --- a/foundry-module/module/core/content/barrow.json +++ b/foundry-module/module/core/content/barrow.json @@ -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": { diff --git a/foundry-module/module/core/content/cave.json b/foundry-module/module/core/content/cave.json index 08c9507..4d63ab9 100644 --- a/foundry-module/module/core/content/cave.json +++ b/foundry-module/module/core/content/cave.json @@ -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": { diff --git a/foundry-module/module/core/content/forest.json b/foundry-module/module/core/content/forest.json index fbd2312..4bb1bcc 100644 --- a/foundry-module/module/core/content/forest.json +++ b/foundry-module/module/core/content/forest.json @@ -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", diff --git a/foundry-module/module/core/render-authoring.mjs b/foundry-module/module/core/render-authoring.mjs index 6cb97bb..8f351f0 100644 --- a/foundry-module/module/core/render-authoring.mjs +++ b/foundry-module/module/core/render-authoring.mjs @@ -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}`); diff --git a/foundry-module/module/core/render.mjs b/foundry-module/module/core/render.mjs index a39d6bb..6ad29b9 100644 --- a/foundry-module/module/core/render.mjs +++ b/foundry-module/module/core/render.mjs @@ -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('|---|---|---|---|---|---|'); diff --git a/foundry-module/module/core/roster.mjs b/foundry-module/module/core/roster.mjs new file mode 100644 index 0000000..e077502 --- /dev/null +++ b/foundry-module/module/core/roster.mjs @@ -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; +} diff --git a/foundry-module/module/forge-app.mjs b/foundry-module/module/forge-app.mjs index 5dd7bd4..8eef594 100644 --- a/foundry-module/module/forge-app.mjs +++ b/foundry-module/module/forge-app.mjs @@ -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 = {}) { ${d.areas.length} areas · ${esc(d.params.theme)} · depth ${d.params.depth} · seed ${esc(seed)}

${scenes.filter(Boolean).length} scenes, ${game.actors.filter(a => a.folder?.id === folders.Actor.id).length} actors and a journal, all in the folder ${esc(title)}.

@UUID[${journal.uuid}]{Open the adventure}

- ${seams ? '' : '

⚠ Forge seams missing — update the VANITY system to 0.10.5 for quiet output.

'} + ${seams ? '' : '

⚠ Forge seams missing — update the VANITY system to 0.10.4 for quiet output.

'} `, }); @@ -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 => `${rows}
FoeatkdefGritNerve
`; if (c.kind === 'forged') return `

${esc(cap(c.heat))} — in the world. These are the actors the Forge created; run the fight off these.

@@ -181,7 +182,7 @@ function foeSection(c) { if (c.kind === 'planned') return `

${esc(cap(c.heat))} — not cast. Nothing was forged for this area, so the plan is the encounter. Cast it by hand:

${table(c.roster.foes.map(f => `${f.n}× ${esc(f.name)}${f.atk}${f.def}${f.grit}${f.nerve}${esc(f.note)}`).join(''))} -

Harmed by ${esc(c.roster.harmedBy)}.${c.roster.beforeInitiative ? ` ${esc(c.roster.beforeInitiative)}` : ''} ${esc(c.roster.avoid)}.

`; +

Harmed by ${esc(c.roster.harmedBy)}.${initiativeWarning(c.roster) ? ` ${esc(initiativeWarning(c.roster))}` : ''} ${esc(c.roster.avoid)}.

`; if (c.kind === 'unavailable') return `

${esc(cap(c.heat))} — nothing to run. 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.

`; return ''; diff --git a/foundry-module/module/stage.mjs b/foundry-module/module/stage.mjs index 556b055..f248e08 100644 --- a/foundry-module/module/stage.mjs +++ b/foundry-module/module/stage.mjs @@ -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 => ``; @@ -37,7 +38,7 @@ export function foeBlock(c) { c.planned ? `

DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.

` : ''}`; if (c.kind === 'planned') return `

${cap(c.heat)} — not cast. Nothing was forged; run the plan by hand:

${ list(c.roster.foes.map(f => `${f.n}× ${f.name} — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. ${f.note}`)) - }

Harmed by ${c.roster.harmedBy}.${c.roster.beforeInitiative ? ` ${c.roster.beforeInitiative}` : ''} ${c.roster.avoid}.

`; + }

Harmed by ${c.roster.harmedBy}.${initiativeWarning(c.roster) ? ` ${initiativeWarning(c.roster)}` : ''} ${c.roster.avoid}.

`; if (c.kind === 'unavailable') return `

${cap(c.heat)} — nothing to run. No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.

`; 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 }; } diff --git a/foundry-module/module/vanity-delve.mjs b/foundry-module/module/vanity-delve.mjs index 0a80c79..4aaf73d 100644 --- a/foundry-module/module/vanity-delve.mjs +++ b/foundry-module/module/vanity-delve.mjs @@ -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; diff --git a/foundry-module/test.mjs b/foundry-module/test.mjs index 30e5e6c..3d51954 100644 --- a/foundry-module/test.mjs +++ b/foundry-module/test.mjs @@ -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);