/** * 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');