400 lines
23 KiB
JavaScript
400 lines
23 KiB
JavaScript
/**
|
||
* 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: 20–40 s rolls, 20–40 s quiet, the hostile floor)',
|
||
!!e.faction && e.faction.rollIntervalMin === 20 && e.faction.rollIntervalMax === 40 && e.faction.cooldownMin === 20 && e.faction.cooldownMax === 40 && 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');
|