The foe block broke twice in two releases, both times because each surface decided for itself, in its own hand-written ternary, which of the cases it was in. classifyFoes() in the new module/foes.mjs makes that choice once — forged, planned, unavailable, none — and both surfaces switch on it. foes.mjs is Foundry-free on purpose, so foundry-module/ test.mjs can run it under plain node with no shim. Fourteen tests, each one a case that shipped broken or nearly did. enter() now survives a Forge that throws. It had no catch, so a failure left the scene raised, no cards posted and the turn not advanced, and a retry would stage the area twice — while the comment above it claimed the branch handled exactly that. A failed stage stops before the turn advances, since there is nothing to run; a failed population keeps going, because the scene is up and the planned roster stands in. An area with combat heat but neither actors nor a roster used to render nothing at all. It now says so and points at the decision and fallback. The "say so before initiative" addendum is gone from all three renderers. It was gated on the literal word ONLY, and the palace, port and village battle rosters already end with that exact phrase, so it printed twice. The packs that need the instruction carry it themselves. Also: draft() spreads params before the pack, like raiseDungeon; and packById validates the cached pack rather than trusting it, so the fail-closed rule holds at boot too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
62 lines
3.2 KiB
JavaScript
62 lines
3.2 KiB
JavaScript
/**
|
|
* Adapter tests. No Foundry, no shim — foes.mjs is pure on purpose.
|
|
*
|
|
* These exist because the same decision broke twice in two releases. 0.6.2 showed the forged
|
|
* actors and silently dropped the planned roster when nothing had been forged; 0.6.3 fixed that
|
|
* but the choice still lived in two hand-written ternaries, one per surface. Every case below is
|
|
* a bug that shipped or nearly shipped.
|
|
*
|
|
* Run: node foundry-module/test.mjs
|
|
*/
|
|
import { foeStats, foeLine, classifyFoes } from './module/foes.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 — say so before initiative',
|
|
avoid: 'the Ghoul goes for court dress first',
|
|
};
|
|
const withEnc = (extra = {}) => ({ encounter: { heat: 'fight', roster, ...extra } });
|
|
|
|
// --- reading a forged actor -------------------------------------------------
|
|
const f = foeStats(actor);
|
|
t('foeStats reads the 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}'));
|
|
t('foeLine omits an empty trick', !foeLine(bare).includes('<i>'));
|
|
|
|
// --- the four cases ---------------------------------------------------------
|
|
t('no encounter → none', classifyFoes({}, []).kind === 'none');
|
|
t('no encounter → none, even with stray actors', classifyFoes({}, [f]).kind === 'none');
|
|
|
|
const forged = classifyFoes(withEnc(), [f]);
|
|
t('actors forged → forged', forged.kind === 'forged' && forged.foes.length === 1);
|
|
t('forged keeps the plan, so it can be named as not-these', forged.planned === roster);
|
|
|
|
const planned = classifyFoes(withEnc(), []);
|
|
t('nothing forged but a roster → planned', planned.kind === 'planned' && planned.roster === roster,
|
|
'the 0.6.2 regression: population off left no numbers at all');
|
|
|
|
const nothing = classifyFoes({ encounter: { heat: 'fight' } }, []);
|
|
t('heat but neither actors nor roster → unavailable', nothing.kind === 'unavailable',
|
|
'must not render silence for an area labelled with combat');
|
|
t('unavailable still reports the heat', nothing.heat === 'fight');
|
|
|
|
// --- the invariant the two surfaces kept breaking ---------------------------
|
|
t('the plan is never the encounter while actors exist', forged.kind !== 'planned',
|
|
'roster guidance names monsters the Forge did not create');
|
|
t('a forged classification carries no roster field to render from',
|
|
forged.roster === undefined, 'so a surface cannot accidentally run the plan');
|
|
|
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
process.exit(fail ? 1 : 0);
|