orbit/dev/encounters.test.mjs

355 lines
19 KiB
JavaScript
Raw 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 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 ctx = (faction) => ({
mode: 'faction',
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);
check('faction: the enemy is an OPPOSING faction (never the controller)', c1.faction !== 'apex' && ['mindweave', 'zenith'].includes(c1.faction));
check('faction: the types come from the ENEMY faction\u2019s roster', c1.types.every((t) => (c1.faction === 'mindweave' ? ['mw_a', 'mw_b'] : ['zn_a']).includes(t)));
check('faction: the count sits in the level band (level 1: 1\u20132)', compose(e, 1, () => 0, ctx('apex')).count === 1 && compose(e, 1, () => 0.999, ctx('apex')).count === 2);
check('faction: level 8 sizes off the band\u2019s pirate count (5\u20137)', compose(e, 8, () => 0, ctx('apex')).count === 5 && compose(e, 8, () => 0.999, ctx('apex')).count === 7);
// Both rivals are reachable (the roll walks the enemy list).
const seen = new Set(Array.from({ length: 50 }, (_, i) => compose(e, 1, () => i / 50, ctx('apex')).faction));
check('faction: BOTH opposing factions are reachable', seen.has('mindweave') && seen.has('zenith'), [...seen].join(','));
// Determinism: the same seed walks the same enemy + the same types.
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 (enemy + types + count)', d1.faction === d2.faction && d1.count === d2.count && JSON.stringify(d1.types) === JSON.stringify(d2.types));
// A rival without a roster is skipped (never deployed).
const ctxNoRoster = { mode: 'faction', faction: 'apex', 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.faction === 'zenith');
// No enemies at all (a roster-less world) degrades to the neutral roll.
const c4 = compose(e, 1, () => 0, { mode: 'faction', faction: 'apex', enemies: [] });
check('faction: no enemies \u2192 the neutral roll stands (defensive)', c4.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') });
const s = dd.update(50000, { allowed: true, live: false, xpLevel: 1, context: ctx('apex') });
check('faction (director): the hit deploys a faction enemy (not pirates/neutrals)', !!s && s.kind === 'faction' && s.faction !== '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');
}
// --------------------------------------------------------------------- done
if (failures) {
console.log(`\n✘ ${failures} ENCOUNTER-DIRECTOR CHECK(S) FAILED`);
process.exit(1);
}
console.log('\n✔ ALL ENCOUNTER-DIRECTOR CHECKS PASSED');