orbit/dev/encounters.test.mjs

400 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ENCOUNTER DIRECTOR test (dev tool, run with Node — no browser needed):
*
* node dev/encounters.test.mjs
*
* Exercises the pure clock (js/combat/Encounters.js) against the shipped
* data/combat.json → encounters defaults: the cadence (interval schedule
* + chance gate), the one-at-a-time + post-encounter QUIET, the
* allowed-space gate (neutral non-home, no wave run in flight, the
* player upright), the EVEN kind weights (~1/3 each), and the
* level-scaled group size (1–2 low → 4–5 high; mixed keeps both kinds).
*
* The deterministic blocks run the director on a degenerate config
* (min == max, so every roll lands on an exact value) with a fixed rng —
* the cadence math is testable to the millisecond.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fs = await import('node:fs');
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
const { EncounterDirector, encounterAllowed, rollKind, compose, bandFor } = await import(
pathToFileURL(join(__dirname, '../js/combat/Encounters.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const cfg = () => config.get('combat.encounters', {});
/** A deterministic LCG (Numerical Recipes') — the stats checks are reproducible. */
const lcg = (seed = 123456789) => {
let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return () => {
s = (s * 48271) % 2147483647;
return (s - 1) / 2147483646;
};
};
/** The degenerate config — min == max, so the rolls land on exact values. */
const fixed = {
enabled: true,
rollIntervalMin: 50,
rollIntervalMax: 50,
chanceMin: 1,
chanceMax: 1,
cooldownMin: 30,
cooldownMax: 30,
weights: { pirates: 1, neutrals: 1, mixed: 1 },
bands: cfg().bands,
};
// -------------------------------------------------------------------- data
{
const e = cfg();
check('data: the encounters section ships (data/combat.json)', !!e && typeof e === 'object');
check('data: enabled by default', e.enabled === true);
check('data: the roll interval is the ~45–90 s band', e.rollIntervalMin === 45 && e.rollIntervalMax === 90);
check(
'data: the chance sits inside the ~50–60% band',
e.chanceMin >= 0.5 && e.chanceMax <= 0.6 && e.chanceMin <= e.chanceMax,
);
check('data: the post-encounter quiet is the ~30–60 s band', e.cooldownMin === 30 && e.cooldownMax === 60);
check(
'data: the FACTION schedule ships (tighter rolls: 20–40 s, a 40–60 s quiet, the hostile floor)',
!!e.faction && e.faction.rollIntervalMin === 20 && e.faction.rollIntervalMax === 40 && e.faction.cooldownMin === 40 && e.faction.cooldownMax === 60 && e.faction.hostileBelow === -5,
JSON.stringify(e.faction),
);
check(
'data: the kind weights are EVEN (~1/3 each)',
e.weights.pirates === 1 && e.weights.neutrals === 1 && e.weights.mixed === 1,
);
check(
'data: the size bands cover the whole level range (0–99, exactly one each)',
Array.from({ length: 100 }, (_, lv) => lv).every((lv) => {
const list = e.bands.filter((b) => lv >= b.minLevel && lv <= b.maxLevel);
return list.length === 1;
}),
);
const lo = bandFor(e.bands, 1);
check('data: the low band is 1–2 ships', lo.pirates[0] === 1 && lo.pirates[1] === 2 && lo.neutrals[0] === 1 && lo.neutrals[1] === 2);
const hi = bandFor(e.bands, 80);
check(
'data: the top band caps at 9–12 ships',
hi.pirates[0] === 9 && hi.pirates[1] === 12 && hi.neutrals[0] === 9 && hi.neutrals[1] === 12,
);
const jump = bandFor(e.bands, 6);
check(
'data: the level-6 JUMP band steps 2–3 → 5–7 (a ~2× pack) and carries a weighted roster',
jump.pirates[0] === 5 && jump.pirates[1] === 7 && jump.neutrals[0] === 5 && jump.neutrals[1] === 7 &&
typeof jump.roster === 'object' && jump.roster.raider > 0,
);
check(
'data: the higher tiers add types (stinger/gunner at 9+, hunter/barrage at 13+)',
bandFor(e.bands, 9).roster.stinger > 0 && bandFor(e.bands, 13).roster.hunter > 0 && bandFor(e.bands, 13).roster.barrage > 0 &&
bandFor(e.bands, 3).roster === undefined,
);
check(
'data: mixed keeps BOTH kinds present (every band)',
e.bands.every((b) => b.mixedPirates[0] >= 1 && b.mixedNeutrals[0] >= 1),
);
check(
'data: the mixed top end is 4–5 pirates + 4–5 neutrals (the cap band)',
hi.mixedPirates[0] === 4 && hi.mixedPirates[1] === 5 && hi.mixedNeutrals[0] === 4 && hi.mixedNeutrals[1] === 5,
);
check(
'bands: 0–2 → low, 3–5 → mid, 6–8 → jump, 9–12, 13+ (and a level past the top stays on it)',
bandFor(e.bands, 0) === e.bands[0] &&
bandFor(e.bands, 5) === e.bands[1] &&
bandFor(e.bands, 6) === e.bands[2] &&
bandFor(e.bands, 10) === e.bands[3] &&
bandFor(e.bands, 120) === e.bands[4],
);
}
// -------------------------------------------------------------------- gate
{
const ok = { ship: { x: 0, y: 0 }, isHomeSystem: false, faction: null, waveRunLive: false, playerDown: false };
check('gate: neutral wild space, flying, no run, upright — ALLOWED', encounterAllowed(ok) === true);
check('gate: the HOME system is safe space (never)', encounterAllowed({ ...ok, isHomeSystem: true }) === false);
check('gate: a FACTION system without a war context is off-limits (the roster is unknown)', encounterAllowed({ ...ok, faction: 'apex', factionContext: null }) === false);
check('gate: a FACTION system WITH a war context is allowed (the war-zones)', encounterAllowed({ ...ok, faction: 'apex', factionContext: { mode: 'faction', faction: 'apex', enemies: [{ id: 'mindweave', types: ['x'] }] } }) === true);
check('gate: the HOME system is safe even in the war context', encounterAllowed({ ...ok, isHomeSystem: true, faction: 'apex', factionContext: { mode: 'faction', faction: 'apex', enemies: [{ id: 'mindweave', types: ['x'] }] } }) === false);
check('gate: a wave run IN FLIGHT holds the rolls', encounterAllowed({ ...ok, waveRunLive: true }) === false);
check('gate: a run that is already DONE holds no roll (its ships are spent)', encounterAllowed({ ...ok, waveRunLive: false }) === true);
check('gate: no spawn on a downed ship', encounterAllowed({ ...ok, playerDown: true }) === false);
check('gate: no ship, no encounter', encounterAllowed({ ...ok, ship: null }) === false);
}
// ------------------------------------------------------------------ cadence
{
// The full lifecycle, deterministic: first hit at exactly the interval,
// no rolls while live, the next hit exactly the cooldown after the end.
const d = new EncounterDirector(fixed, { rng: () => 0 });
const specs = [];
const tick = (t, live = false, allowed = true, level = 1) => {
const s = d.update(t, { allowed, live, xpLevel: level });
if (s) specs.push({ t, s });
return s;
};
for (let t = 0; t <= 49000; t += 1000) tick(t);
check('cadence: quiet until the first roll window (a full interval out)', specs.length === 0);
const s1 = tick(50000);
check('cadence: the first hit lands ON the interval (50 s here)', !!s1 && s1.pirates + s1.neutrals >= 1);
check('cadence: ONE AT A TIME — no roll while the encounter is live', tick(100000, true) === null && tick(199000, true) === null);
check('cadence: the fight ends → the quiet window holds (no early roll)', tick(200000) === null && tick(229000) === null);
const s2 = tick(230000);
check('cadence: the next hit lands exactly on the cooldown (30 s after the end; 229 s still null)', !!s2);
check('cadence: still one at a time on the second fight', tick(300000, true) === null);
tick(310000); // the second fight ends
const s3 = tick(340000); // 30 s later
check('cadence: the third hit keeps the same rhythm', !!s3);
check('cadence: the director ended its third fight ENGAGED (state bookkeeping)', d.state === 'engaged');
}
{
// REGRESSION: ms-scaling must clamp max to >= min in the SAME unit. The
// old code did `max = Math.max(min_ms, rawSeconds) * 1000`, which collapsed
// the band's TOP to min*1000 (a 45–90 s roll became 45 s–12.5 h, a 30–60 s
// cooldown became 30 s–8.3 h). rng=0 lands the LOWER bound, rng=1 the UPPER
// bound — both must sit inside the documented band. The deterministic
// cadence test above uses rng=() => 0 (always the lower bound) and so CANNOT
// see the corrupted upper bound; this block does.
const e = cfg();
const dLo = new EncounterDirector(e, { rng: () => 0 });
const dHi = new EncounterDirector(e, { rng: () => 1 });
const iLo = dLo._intervalMs(), iHi = dHi._intervalMs();
const cLo = dLo._cooldownMs(), cHi = dHi._cooldownMs();
check('cadence (regression): the roll interval spans 45–90 s (ms), NOT 45 s–12.5 h', iLo === 45000 && iHi === 90000);
check('cadence (regression): the cooldown spans 30–60 s (ms), NOT 30 s–8.3 h', cLo === 30000 && cHi === 60000);
}
{
// STATISTICAL: with the shipped config + a real rng, the FIRST encounter
// must land within a few minutes — periodic, not hours. The old upper-bound
// corruption made the median ~9 h (a player waits forever); a few-minutes
// median proves the cadence is genuinely "periodic-not-constant".
const firstHit = (seed) => {
const d = new EncounterDirector(cfg(), { rng: lcg(seed) });
let t = 0;
while (t < 3600000) {
if (d.update(t, { allowed: true, live: false, xpLevel: 1 })) return t;
t += 1000;
}
return null;
};
const hits = [];
for (let i = 0; i < 2000; i++) {
const h = firstHit(1000 + i);
if (h !== null) hits.push(h);
}
hits.sort((a, b) => a - b);
const median = hits.length ? hits[Math.floor(hits.length / 2)] : Infinity;
check(
'cadence (statistical): the median first encounter is a few minutes, not hours (got ' + Math.round(median / 1000) + ' s over ' + hits.length + '/2000 runs)',
hits.length > 1900 && median < 300000,
);
}
{
// The chance gate: a 0% chance never fires — but the schedule keeps
// ticking (the roll windows still pass, 50 s apart).
const d = new EncounterDirector({ ...fixed, chanceMin: 0, chanceMax: 0 }, { rng: () => 0 });
const all = [0, 50000, 100000, 150000].map((t) => d.update(t, { allowed: true, live: false, xpLevel: 1 }));
check('chance: a 0% chance never fires (four windows pass)', all.every((s) => s === null));
check('chance: ...and the schedule kept ticking (the next window is 200 s out)', d.nextRollAt === 200000);
}
{
// The allowed-space gate: out of allowed space the clock SLIDES — the
// first roll here lands a full interval AFTER allowed opens, never on
// the arrival frame.
const d = new EncounterDirector(fixed, { rng: () => 0 });
d.update(0, { allowed: false, live: false, xpLevel: 1 });
d.update(100000, { allowed: false, live: false, xpLevel: 1 });
d.update(190000, { allowed: false, live: false, xpLevel: 1 });
check('gate (director): out of allowed space, no roll on arrival', d.update(191000, { allowed: false, live: false, xpLevel: 1 }) === null);
check('gate (director): ...and no roll the moment allowed opens', d.update(192000, { allowed: true, live: false, xpLevel: 1 }) === null);
const s = d.update(242000, { allowed: true, live: false, xpLevel: 1 });
check('gate (director): the first roll here lands ~a full interval after the open', !!s);
}
{
// enabled=false (the data can hold the whole feature off).
const d = new EncounterDirector({ ...fixed, enabled: false }, { rng: () => 0 });
check(
'data: enabled=false holds the director off (every window)',
d.update(0, { allowed: true, live: false, xpLevel: 1 }) === null &&
d.update(50000, { allowed: true, live: false, xpLevel: 1 }) === null,
);
}
// -------------------------------------------------------------------- kinds
{
// EVEN weights: with a uniform rng each kind lands ~1/3 (a wide,
// deterministic tolerance — the LCG is fixed, so this is a repeat).
const n = 30000;
const rng = lcg();
const counts = { pirates: 0, neutrals: 0, mixed: 0 };
for (let i = 0; i < n; i++) counts[rollKind(cfg().weights, rng)]++;
for (const k of ['pirates', 'neutrals', 'mixed']) {
const share = counts[k] / n;
check(`kind: "${k}" rolls roughly 1/3 (got ${(share * 100).toFixed(1)}%)`, share > 0.28 && share < 0.37);
}
// A zero weight is never rolled.
check('kind: a zero weight is never rolled', Array.from({ length: 200 }, (_, i) => rollKind({ pirates: 0, neutrals: 1, mixed: 0 }, () => i / 200)).every((k) => k === 'neutrals'));
}
// -------------------------------------------------------------------- size
{
const e = cfg();
const only = (kind) => ({
...e,
weights: { pirates: kind === 'pirates' ? 1 : 0, neutrals: kind === 'neutrals' ? 1 : 0, mixed: kind === 'mixed' ? 1 : 0 },
});
const count = (kind, level, rng) => {
const c = compose(only(kind), level, rng);
return kind === 'pirates' ? c.pirates : kind === 'neutrals' ? c.neutrals : c.pirates + c.neutrals;
};
const r0 = () => 0;
const r1 = () => 0.999;
check('size: level 1 — 1–2 ships (the low band)', count('pirates', 1, r0) === 1 && count('pirates', 1, r1) === 2);
check('size: level 4 (the mid band) — 2–3 ships', count('neutrals', 4, r0) === 2 && count('neutrals', 4, r1) === 3);
check('size: level 8 (the jump band) — 5–7 ships', count('pirates', 8, r0) === 5 && count('pirates', 8, r1) === 7);
check('size: the group JUMPS at level 6 (jump min 5 > mid max 3)', count('pirates', 6, r0) === 5 && count('pirates', 6, r1) === 7);
check('size: the group GROWS with the level (top min > low max)', count('pirates', 13, r0) === 9 && count('pirates', 13, r1) === 12 && count('pirates', 13, r0) > count('pirates', 1, r1));
check('size: the cap holds past level 13 (9–12 at any higher level)', count('pirates', 40, r0) === 9 && count('pirates', 40, r1) === 12);
const mixLo = compose(only('mixed'), 1, () => 0.5);
check('size: mixed keeps BOTH kinds present (the low end)', mixLo.pirates >= 1 && mixLo.neutrals >= 1);
check(
'size: mixed at the jump band — 3–4 pirates + 2–3 neutrals',
compose(only('mixed'), 8, r0).pirates === 3 &&
compose(only('mixed'), 8, r0).neutrals === 2 &&
compose(only('mixed'), 8, r1).pirates === 4 &&
compose(only('mixed'), 8, r1).neutrals === 3,
);
check('size: a single kind leaves the OTHER at zero', compose(only('pirates'), 8, r0).neutrals === 0 && compose(only('neutrals'), 8, r0).pirates === 0);
// The band's ROSTER — the weighted mix drawn out to the pirate count (the
// types are the mix, not one type; a band without a roster leaves the
// scene's random pick standing — pirateTypes unset).
const c8 = compose(only('pirates'), 8, () => 0);
check('roster: the band’s mix draws out to the pirate count', !!c8.pirateTypes && c8.pirateTypes.length === c8.pirates, JSON.stringify(c8));
check('roster: the draw sits inside the band’s types (no foreign ids)', c8.pirateTypes.every((t) => t in e.bands[2].roster));
check('roster: a band WITHOUT a roster leaves pirateTypes unset (the scene picks as before)', compose(only('pirates'), 1, () => 0).pirateTypes === undefined);
const cHi = compose(only('pirates'), 13, () => 0.999);
check('roster: the top tier’s heavier types are reachable (barrage in the mix)', cHi.pirateTypes.includes('barrage') || cHi.pirateTypes.includes('hunter'), JSON.stringify(cHi.pirateTypes));
// The director composes at the caller's LEVEL (xpLevel rides the ctx).
const d = new EncounterDirector(fixed, { rng: () => 0 });
d.update(0, { allowed: true, live: false, xpLevel: 8 });
const s = d.update(50000, { allowed: true, live: false, xpLevel: 8 });
check('size (director): the level-8 hit deploys the jump band (5 pirates)', !!s && s.kind === 'pirates' && s.pirates === 5 && s.neutrals === 0);
// The degenerate-band fallback (a config without bands still composes).
const bare = compose({ weights: { pirates: 1, neutrals: 0, mixed: 0 } }, 1, r0);
check('size: a config without bands falls back to 1 ship', bare.kind === 'pirates' && bare.pirates === 1);
}
// ------------------------------------------------------------- faction space
{
const e = cfg();
const roster = { apex: ['ax_a', 'ax_b'], mindweave: ['mw_a', 'mw_b'], zenith: ['zn_a'] };
const ctx = (faction) => ({
mode: 'faction',
faction,
local: { id: faction, types: roster[faction] },
enemies: [
{ id: 'mindweave', types: ['mw_a', 'mw_b'] },
{ id: 'zenith', types: ['zn_a'] },
].filter((x) => x.id !== faction),
});
const c1 = compose(e, 1, () => 0, ctx('apex'));
check('faction: the roll is ALWAYS a faction kind (never pirates/neutrals)', c1.kind === 'faction' && c1.neutrals === 0 && Array.isArray(c1.groups), JSON.stringify(c1));
check('faction: TWO groups deploy — the LOCAL faction + one RIVAL (never the controller)',
c1.groups.length === 2 && c1.groups[0].id === 'apex' && c1.groups[1].id !== 'apex' && ['mindweave', 'zenith'].includes(c1.groups[1].id), JSON.stringify(c1));
check('faction: each group draws from ITS OWN faction\u2019s roster',
c1.groups.every((g) => g.types.every((t) => roster[g.id].includes(t))));
check('faction: each group\u2019s count sits in the level band (level 1: 1\u20132)',
c1.groups.every((g) => g.count === 1) && compose(e, 1, () => 0.999, ctx('apex')).groups.every((g) => g.count === 2));
check('faction: level 8 sizes BOTH groups off the band\u2019s pirate count (5\u20137)',
compose(e, 8, () => 0, ctx('apex')).groups.every((g) => g.count === 5) && compose(e, 8, () => 0.999, ctx('apex')).groups.every((g) => g.count === 7));
// Both rivals are reachable (the roll walks the rival list).
const seen = new Set(Array.from({ length: 50 }, (_, i) => compose(e, 1, () => i / 50, ctx('apex')).groups[1]?.id));
check('faction: BOTH opposing factions are reachable (the rival slot)', seen.has('mindweave') && seen.has('zenith'), [...seen].join(','));
// Determinism: the same seed deploys the same rival + groups.
const d1 = compose(e, 3, () => 0.37, ctx('zenith'));
const d2 = compose(e, 3, () => 0.37, ctx('zenith'));
check('faction: a fixed seed reproduces the roll (rival + groups)', JSON.stringify(d1) === JSON.stringify(d2));
// A rival without a roster is skipped (never deployed).
const ctxNoRoster = { mode: 'faction', faction: 'apex', local: ctx('apex').local, enemies: [{ id: 'mindweave', types: [] }, { id: 'zenith', types: ['zn_a'] }] };
const c3 = compose(e, 1, () => 0, ctxNoRoster);
check('faction: a roster-less rival is skipped (the rostered one stands)', c3.groups[1].id === 'zenith', JSON.stringify(c3));
// A roster-less LOCAL faction (defensive) degrades to the neutral roll.
const c4 = compose(e, 1, () => 0, { mode: 'faction', faction: 'apex', local: { id: 'apex', types: [] }, enemies: ctx('apex').enemies });
check('faction: a roster-less LOCAL faction \u2192 the neutral roll stands (defensive)', c4.kind !== 'faction');
// No rivals at all (a one-faction world) degrades to the neutral roll.
const c5 = compose(e, 1, () => 0, { mode: 'faction', faction: 'apex', local: ctx('apex').local, enemies: [] });
check('faction: no rival rosters \u2192 the neutral roll stands (defensive)', c5.kind !== 'faction');
// The director composes the faction roll when the context rides the ctx.
const dd = new EncounterDirector(fixed, { rng: () => 0 });
dd.update(0, { allowed: true, live: false, xpLevel: 1, context: ctx('apex'), faction: true });
const s = dd.update(50000, { allowed: true, live: false, xpLevel: 1, context: ctx('apex'), faction: true });
check('faction (director): the hit deploys the local + rival groups', !!s && s.kind === 'faction' && s.groups[0].id === 'apex' && s.groups[1].id !== 'apex', JSON.stringify(s));
// NEUTRAL SPACE IS UNCHANGED: no context \u2192 the classic roll.
const n1 = compose(e, 1, () => 0);
check('neutral: without a context the classic roll stands (unchanged)', n1.kind !== 'faction');
}
// ------------------------------------------------------------- faction timers
// The war-zones roll on the TIGHTER cadence (data/combat.json →
// encounters.faction): ctx.faction takes the faction schedule (the
// degenerate 20–40s roll / 20s quiet below) for BOTH the roll and the
// post-fight cooldown, NOT the neutral-space one (the degenerate
// 50s / 30s). A context WITHOUT ctx.faction (the escort director's
// shape) stays on the neutral schedule.
{
const eF = { ...fixed, faction: { rollIntervalMin: 20, rollIntervalMax: 40, cooldownMin: 20, cooldownMax: 20 } };
const ctx = { mode: 'faction', faction: 'apex', local: { id: 'apex', types: ['ax_a'] }, enemies: [{ id: 'zenith', types: ['zn_a'] }] };
// Roll schedule: faction space's next roll sits at the faction interval
// (20–40s → 30000 at rng 0.5), not the neutral one (50s).
const dFac = new EncounterDirector(eF, { rng: () => 0.5 });
dFac.update(0, { allowed: true, live: false, xpLevel: 1, context: ctx, faction: true }); // the first schedule
check('faction timers: the next roll takes the FACTION schedule (20–40s → 30000 at rng 0.5), not the neutral one (50s)', dFac.nextRollAt === 30000, String(dFac.nextRollAt));
const s = dFac.update(30000, { allowed: true, live: false, xpLevel: 1, context: ctx, faction: true });
check('faction timers: the scheduled roll deploys a faction fight (local + rival)', !!s && s.kind === 'faction' && s.groups.length === 2, JSON.stringify(s));
// Post-fight quiet: the cooldown is the faction schedule (20s), not
// the neutral one (30s).
const dCd = new EncounterDirector(eF, { rng: () => 0.5 });
dCd.update(0, { allowed: true, live: false, xpLevel: 1 }); // the first schedule (neutral: 50s)
dCd.update(50000, { allowed: true, live: false, xpLevel: 1, context: ctx, faction: true }); // the spawn
dCd.update(51000, { allowed: true, live: false, xpLevel: 1, context: ctx, faction: true }); // the fight ends → the quiet
check('faction timers: the post-fight wait takes the FACTION cooldown (20s → 71000), not the neutral one (30s → 81000)', dCd.quietUntil === 51000 + 20000, String(dCd.quietUntil));
// A context WITHOUT ctx.faction (the escort director's shape) stays on
// the neutral schedule.
const dNo = new EncounterDirector(eF, { rng: () => 0.5 });
dNo.update(0, { allowed: true, live: false, xpLevel: 1, context: ctx });
check('faction timers: without ctx.faction the neutral schedule stands (50s)', dNo.nextRollAt === 50000, String(dNo.nextRollAt));
}
// --------------------------------------------------------------------- done
if (failures) {
console.log(`\n✘ ${failures} ENCOUNTER-DIRECTOR CHECK(S) FAILED`);
process.exit(1);
}
console.log('\n✔ ALL ENCOUNTER-DIRECTOR CHECKS PASSED');