Files
slaguru666andClaude Opus 5 c1e467c91b Prose wins over the field; the play sheet carries avoid; the guard is testable
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>
2026-08-06 00:46:43 +01:00

233 lines
13 KiB
JavaScript

/**
* Adapter tests. No Foundry, no shim — foes.mjs and stage.mjs are pure on purpose.
*
* These exist because the same area of code broke in three consecutive releases, every time on a
* path that only runs when something misbehaves, and every time I convinced myself by reading it.
* Each case below is a bug that shipped or nearly shipped.
*
* Run: node foundry-module/test.mjs
*/
import { foeStats, foeLine, classifyFoes } from './module/foes.mjs';
import { stageArea, areaCards, foeBlock, singleFlight } 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 : ''}`); };
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',
beforeInitiative: 'Say so before initiative.',
avoid: 'send someone plain',
};
const area = (extra = {}) => ({
index: 2, role: 'complication', facet: 'ritual', name: 'The Sail Loft',
cueFragments: ['a tide glass turned by the watch'],
decision: { cue: 'a bell rope still swinging', resolve: { roll: 'Poise 2', success: 'it comes to order', failure: 'the harbour turns early', orElse: 'leave it' } },
trigger: 'seenTwice', fallback: { route: 'the dead will receive a guest' },
...extra,
});
const withEnc = (r = roster) => area({ encounter: { heat: 'fight', roster: r } });
// ---------------------------------------------------------------- reading actors
const f = foeStats(actor);
t('foeStats reads 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}'));
// ---------------------------------------------------------------- the four kinds
t('no encounter → none', classifyFoes(area(), []).kind === 'none');
t('actors forged → forged', classifyFoes(withEnc(), [f]).kind === 'forged');
t('nothing forged but a roster → planned', classifyFoes(withEnc(), []).kind === 'planned',
'the 0.6.2 regression: population off left no numbers at all');
t('heat but neither → unavailable', classifyFoes(area({ encounter: { heat: 'fight' } }), []).kind === 'unavailable');
t('a forged classification exposes no roster to render from', classifyFoes(withEnc(), [f]).roster === undefined);
// ---------------------------------------------------------------- what reaches the card
const forgedBlock = foeBlock(classifyFoes(withEnc(), [f]));
t('forged block runs off the actors', forgedBlock.includes('Morthollow-born') && forgedBlock.includes('in the world'));
t('forged block never asserts the plan\'s tactics', !forgedBlock.includes('blessed, silvered'),
'roster guidance names monsters the Forge did not create');
t('forged block still names the plan', forgedBlock.includes('1 Ghoul and 2 Skeletons'));
const plannedBlock = foeBlock(classifyFoes(withEnc(), []));
t('planned block is runnable', plannedBlock.includes('Ghoul') && plannedBlock.includes('4/3/4'));
t('planned block carries the immunity', plannedBlock.includes('blessed, silvered'));
t('planned block warns before initiative', plannedBlock.includes('Say so before initiative.'),
'the 0.6.4 regression: deleting the ONLY gate stripped this from 12 rosters');
t('the warning is said exactly once',
(plannedBlock.match(/say so before initiative/gi) ?? []).length === 1);
t('unavailable says so rather than rendering nothing',
foeBlock(classifyFoes(area({ encounter: { heat: 'fight' } }), [])).includes('nothing to run'));
const cards = areaCards({ area: withEnc(), name: 'The Sail Loft', authored: {}, classification: classifyFoes(withEnc(), [f]) });
t('an unwritten read-aloud is marked as such', cards.readAloud.includes('(unwritten)'));
t('written prose outranks the fragments',
areaCards({ area: withEnc(), name: 'x', authored: { readAloud: 'They have been waiting.' }, classification: classifyFoes(withEnc(), [f]) })
.readAloud === 'They have been waiting.');
t('the GM card carries the decision and its miss', cards.gm.body.includes('Poise 2') && cards.gm.body.includes('Miss:'));
// ---------------------------------------------------------------- the failure matrix
const spy = (over = {}) => {
const log = { calls: [], warns: [], errors: [], committed: 0, cards: 0 };
const fx = {
stage: n => { log.calls.push('stage'); return { id: 'scn1', name: n }; },
encounter: () => { log.calls.push('encounter'); return { actors: [actor] }; },
hoard: () => { log.calls.push('hoard'); return { size: 'cache' }; },
commit: async () => { log.committed++; },
readAloud: () => { log.cards++; },
gm: () => { log.cards++; },
warn: m => log.warns.push(m),
error: m => log.errors.push(m),
...over,
};
return { fx, log };
};
const boom = () => { throw new Error('forge exploded'); };
const ctx = (a = withEnc()) => ({ area: { ...a, hoard: 'cache' }, name: 'The Sail Loft', authored: {} });
{
const { fx, log } = spy();
const r = await stageArea(fx, ctx());
t('happy path enters, commits once and posts both cards',
r.entered && log.committed === 1 && log.cards === 2 && !log.warns.length && !log.errors.length);
t('happy path reports the kind it rendered', r.kind === 'forged');
}
{
const { fx, log } = spy({ stage: boom });
const r = await stageArea(fx, ctx());
t('a failed scene enters nothing', !r.entered && r.failed === 'stage');
t('a failed scene does not commit — a retry is safe', log.committed === 0,
'the v0.6.3 bug: scene left behind, index unchanged, retry duplicated it');
t('a failed scene posts no cards and says why', log.cards === 0 && log.errors.length === 1);
t('a failed scene never calls the Forge again', !log.calls.includes('encounter'));
}
{
const { fx, log } = spy({ encounter: boom });
const r = await stageArea(fx, ctx());
t('a failed population still enters — the scene is up', r.entered && log.committed === 1);
t('a failed population falls back to the plan', r.kind === 'planned');
t('a failed population says the roster stands in', log.warns.some(w => /planned roster stands in/.test(w)));
t('a failed population still posts both cards', log.cards === 2);
}
{
const { fx, log } = spy({ encounter: boom });
const r = await stageArea(fx, ctx(area({ encounter: { heat: 'fight' } })));
t('a failed population with no roster does not claim one', !log.warns.some(w => /roster stands in/.test(w)),
'the 0.6.4 regression: it promised a roster that did not exist');
t('a failed population with no roster says what is actually lost',
log.warns.some(w => /no roster at fight/.test(w)) && r.kind === 'unavailable');
}
{
const { fx, log } = spy({ hoard: boom });
const r = await stageArea(fx, ctx());
t('a failed hoard still enters', r.entered && log.committed === 1);
t('a failed hoard is visible to the GM', log.warns.some(w => /hoard/.test(w)),
'the 0.6.4 regression: swallowed to console while the turn advanced');
}
{
const { fx, log } = spy({ gm: boom });
const r = await stageArea(fx, ctx());
t('a failed GM card does not roll back the turn', r.entered && log.committed === 1);
t('a failed GM card tells the GM to read from the worksheet',
log.warns.some(w => /GM card did not post/.test(w)));
}
{
const { fx, log } = spy({ readAloud: boom, gm: boom });
await stageArea(fx, ctx());
t('both cards failing reports once, not twice', log.warns.filter(w => /did not post/.test(w)).length === 1);
}
{
// async rejections, not just throws — the Forge returns promises
const { fx, log } = spy({ encounter: () => Promise.reject(new Error('async')) });
const r = await stageArea(fx, ctx());
t('a rejected promise is handled like a throw', r.entered && r.kind === 'planned' && log.warns.length === 1);
}
{
const { fx, log } = spy({ stage: () => null });
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('prose in avoid also suppresses the fallback',
initiativeWarning({ harmedBy: 'blessed weapons ONLY', avoid: 'burn it — say so before initiative' }) === null);
t('the field never overrides prose that already says it',
initiativeWarning({ harmedBy: 'blessed weapons ONLY — say so before initiative', beforeInitiative: 'Say so before initiative.' }) === null,
'a hand-edited file can carry both; returning the field first printed it twice');
t('null roster is safe', initiativeWarning(null) === null);
// ---------------------------------------------------------------- single flight
{
let running = 0, peak = 0, refused = 0;
const guarded = singleFlight(async () => {
running++; peak = Math.max(peak, running);
await new Promise(r => setTimeout(r, 5));
running--; return 'done';
}, () => { refused++; return 'busy'; });
const [a, b] = await Promise.all([guarded(), guarded()]);
t('two overlapping calls never run together', peak === 1, `peak concurrency ${peak}`);
t('the second is refused, not queued', a === 'done' && b === 'busy' && refused === 1);
t('the guard clears so a later call still runs', (await guarded()) === 'done');
}
{
const guarded = singleFlight(async () => { throw new Error('boom'); }, () => 'busy');
await guarded().catch(() => {});
t('a throw does not leave the guard stuck', (await guarded().catch(() => 'threw')) === 'threw');
}
// ---------------------------------------------------------------- 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);