From 1b2f9aa385b51542471901afb06428182347968a Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:35:15 +0100 Subject: [PATCH] The warning reaches every surface; commit and concurrency are covered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/content/barrow.json | 3 +- core/content/cave.json | 5 +- core/content/forest.json | 3 +- core/render-authoring.mjs | 6 ++- core/render.mjs | 4 +- core/roster.mjs | 32 ++++++++++++ core/validate-pack.mjs | 3 +- foundry-module/README.md | 2 +- .../module/core/content/barrow.json | 3 +- foundry-module/module/core/content/cave.json | 5 +- .../module/core/content/forest.json | 3 +- .../module/core/render-authoring.mjs | 6 ++- foundry-module/module/core/render.mjs | 4 +- foundry-module/module/core/roster.mjs | 32 ++++++++++++ foundry-module/module/forge-app.mjs | 7 +-- foundry-module/module/stage.mjs | 12 +++-- foundry-module/module/vanity-delve.mjs | 17 ++++++- foundry-module/test.mjs | 49 +++++++++++++++++++ 18 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 core/roster.mjs create mode 100644 foundry-module/module/core/roster.mjs 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);