Still unreleased, but module.json is at 0.6.5 now. initiativeWarning checked the field before the prose, so a hand-edited file carrying both printed the warning twice — the exact fault the migration was meant to end. Prose is checked first now and always wins. The play sheet was the only surface that did not render `avoid`, so three legacy rosters whose warning lived there — church, forest and temple at nightmare — lost it entirely: the helper suppressed its fallback on finding prose the play sheet never showed. It renders `avoid` now, which it should have anyway; it is how the table gets past a fight without having one, which is table-facing by definition. All five surfaces now render both prose fields, so "the prose already says it" means the same thing everywhere. Replayed all 64 rosters in their pre-field form through every surface: 26 need a warning, 130 of 130 checks say it exactly once. The current packs give the same result. commit() no longer mutates st before the write — it passes an incremented copy, so a failed write really does leave the state as it was, which is what the retry warning promises. The single-flight guard moved into stage.mjs as singleFlight() and has four tests: overlapping calls never run together, the second is refused rather than queued, the guard clears afterwards, and a throw does not wedge it. It closes the window within one client only; two GM browsers still race, and that needs a world-level lock rather than a module variable. 41 core tests, 60 adapter tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
36 lines
2.0 KiB
JavaScript
36 lines
2.0 KiB
JavaScript
/**
|
|
* 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;
|
|
// Prose wins, and is checked first. A hand-edited file can carry both the field and the old
|
|
// sentence; returning the field before looking would print it twice, which is the fault this
|
|
// whole migration exists to remove.
|
|
const prose = `${roster.harmedBy ?? ''} ${roster.avoid ?? ''}`;
|
|
if (/say so before initiative/i.test(prose)) return null;
|
|
if (roster.beforeInitiative) return roster.beforeInitiative;
|
|
return RESTRICTS.test(roster.harmedBy ?? '') ? 'Say so before initiative.' : null;
|
|
}
|