/** * ENCOUNTER DIRECTOR — the periodic random ship encounters in neutral * wild space. Pure rules, no Phaser, no scene (the same contract as * js/combat/XpManager.js — it runs in bare Node; dev/encounters.test.mjs * drives it with a fake clock + an injected rng). * * WHAT IT DECIDES (GameScene does the spawning): * * WHEN — while the system allows encounters (NEUTRAL — faction null —, * not home, no wave run in flight, the player upright; the scene * passes `ctx.allowed`, `encounterAllowed` is the pure gate) AND the * scene's own quest hold stands off — GameScene ANDs in * `_encountersLockedByQuest`, the 'Special Delivery' gate: the rolls * stay off until that quest is completed and claimed (a scene-side * state, so it never crosses this module's pure contract). A roll then * lands on the interval schedule (data/combat.json → * encounters.rollIntervalMin..rollIntervalMax, seconds); each roll * fires at chanceMin..chanceMax probability. ONE AT A TIME: the * schedule holds while an encounter is engaged. * * QUIET — when the encounter ends (its ships all dead or off-screen → * the scene reports ctx.live = false) the next roll waits out * cooldownMin..cooldownMax seconds, and the cadence resumes from * there. * * WHAT — `rollKind` picks the kind at `weights` (shipped even — ~1/3 * each), and `compose` sizes the group off the player's combat * level band (shipped: 1–2 ships at low levels, 4–5 at high; mixed * keeps BOTH kinds present). * * IT OWNS NO SHIPS, NO AUDIO, NO SAVE STATE — scene-local, like the * 'Pirate Problems' ambush: a fresh GameScene starts a fresh director. */ /** Uniform float in [min, max] (a degenerate band just returns the value). */ function rand(rng, min, max) { const a = Math.min(min, max); const b = Math.max(min, max); return a + rng() * (b - a); } /** Uniform INT in [lo, hi] inclusive (a degenerate band is that value). */ function randInt(rng, lo, hi) { let a = Math.max(0, Math.floor(Number(lo) || 0)); let b = Math.max(0, Math.floor(Number(hi) || 0)); if (b < a) [a, b] = [b, a]; return Math.min(b, a + Math.floor(rng() * (b - a + 1))); } const KINDS = ['pirates', 'neutrals', 'mixed']; /** * The encounter kind at `weights` (data/combat.json → encounters.weights; * shipped even — ~1/3 each). A zero/absent weight is just never rolled. * @param {object} weights kind → weight * @param {() => number} rng * @returns {'pirates'|'neutrals'|'mixed'} */ export function rollKind(weights, rng) { const w = weights && typeof weights === 'object' ? weights : {}; const total = KINDS.reduce((s, k) => s + Math.max(0, Number(w[k]) || 0), 0); if (total <= 0) return KINDS[0]; let x = rng() * total; for (const k of KINDS) { x -= Math.max(0, Number(w[k]) || 0); if (x < 0) return k; } return KINDS[KINDS.length - 1]; } /** * The size band for a combat level (data/combat.json → * encounters.bands — minLevel..maxLevel, inclusive). A level past every * band takes the LAST one (the top of the roster). * @param {object[]} bands * @param {number} level * @returns {object|null} */ export function bandFor(bands, level) { const lv = Math.max(0, Math.floor(Number(level) || 0)); const list = Array.isArray(bands) ? bands : []; for (const b of list) { if (b && lv >= (Number(b.minLevel) || 0) && lv <= (Number(b.maxLevel) || 0)) return b; } return list.length ? list[list.length - 1] : null; } const DEFAULT_BAND = { pirates: [1, 2], neutrals: [1, 2], mixedPirates: [1, 1], mixedNeutrals: [1, 1], }; /** * The encounter's composition — the kind at `weights`, the group sized * off the level band. `pirates`/`neutrals` are the per-kind counts * (the OTHER kind is 0); `mixedPirates`/`mixedNeutrals` are the mixed * per-kind floors/ceilings — BOTH kinds stay present. * @param {object} cfg the encounters section (data/combat.json) * @param {number} xpLevel the player's combat level (a fresh run: 0) * @param {() => number} rng * @returns {{kind:string, pirates:number, neutrals:number}} */ export function compose(cfg, xpLevel, rng) { cfg = cfg && typeof cfg === 'object' ? cfg : {}; const band = bandFor(cfg.bands, xpLevel) || DEFAULT_BAND; const ri = (key, fb) => Array.isArray(band[key]) ? randInt(rng, band[key][0], band[key][1]) : randInt(rng, fb[0], fb[1]); const kind = rollKind(cfg.weights, rng); if (kind === 'pirates') return { kind, pirates: ri('pirates', [1, 2]), neutrals: 0 }; if (kind === 'neutrals') return { kind, pirates: 0, neutrals: ri('neutrals', [1, 2]) }; return { kind: 'mixed', pirates: ri('mixedPirates', [1, 1]), neutrals: ri('mixedNeutrals', [1, 1]) }; } /** * The pure gate (GameScene passes its state; the test passes fixtures): * encounters host in NEUTRAL (faction == null) non-HOME space, with no * wave run in flight (a run that is already done/cancelled is over — * its ships are spent), and the player upright (no spawn on a downed * ship). `ship` must stand — there is no flying without one. * @param {object} gate { ship, isHomeSystem, faction, waveRunLive, playerDown } * @returns {boolean} */ export function encounterAllowed(gate = {}) { return ( !!gate.ship && gate.isHomeSystem !== true && gate.faction == null && gate.waveRunLive !== true && gate.playerDown !== true ); } /** * The director's clock (one per GameScene — `this.encounters`). * * update(time, ctx) → { kind, pirates, neutrals } | null * * ctx.allowed — the pure gate above (the scene decides) * ctx.live — an encounter's ships are still up (CombatSystem.active) * ctx.xpLevel — the player's combat level (the size bands) * * `time` is the scene clock (ms — Phaser's `time`, the same ms clock the * combat's own nextFireAt/respawn clocks run on). */ export class EncounterDirector { constructor(cfg, opts = {}) { this.cfg = cfg && typeof cfg === 'object' ? cfg : {}; this.rng = typeof (opts && opts.rng) === 'function' ? opts.rng : Math.random; this.state = 'idle'; // 'idle' | 'engaged' | 'cooldown' this.nextRollAt = null; // ms clock; null = the first schedule is pending this.quietUntil = 0; // ms clock (the cooldown's end) } _intervalMs() { // BOTH bounds in the SAME unit (seconds) before scaling to ms: clamping // max to >= min must compare like-with-like, or the band's top collapses to // min*1000 (a 45–90 s roll becomes 45 s–12.5 h). Regression-guarded in // dev/encounters.test.mjs (rng=0 lower bound, rng=1 upper bound). const minS = Math.max(0, Number(this.cfg.rollIntervalMin) || 45); const maxS = Math.max(minS, Number(this.cfg.rollIntervalMax) || 90); return rand(this.rng, minS * 1000, maxS * 1000); } _cooldownMs() { const minS = Math.max(0, Number(this.cfg.cooldownMin) || 30); const maxS = Math.max(minS, Number(this.cfg.cooldownMax) || 60); return rand(this.rng, minS * 1000, maxS * 1000); } _chance() { const min = Math.min(1, Math.max(0, Number(this.cfg.chanceMin) ?? 0.5)); const max = Math.min(1, Math.max(min, Number(this.cfg.chanceMax) ?? 0.6)); return rand(this.rng, min, max); } /** One tick of the encounter clock (see the class doc). */ update(time, ctx = {}) { if (this.cfg.enabled === false) return null; // the data can hold it off time = Number(time) || 0; if (this.state === 'engaged') { if (ctx.live !== true) { this.state = 'cooldown'; this.quietUntil = time + this._cooldownMs(); } return null; // one at a time: the schedule holds while it's live } if (this.state === 'cooldown') { if (time < this.quietUntil) return null; this.state = 'idle'; this.nextRollAt = this.quietUntil; // the cadence resumes on the quiet's end } // 'idle' if (this.nextRollAt === null) { this.nextRollAt = time + this._intervalMs(); return null; } if (ctx.allowed !== true) { // Out of allowed space (home / a faction system / a wave run): // the clock SLIDES — the first roll here lands a full interval out, // never on the arrival frame. this.nextRollAt = time + this._intervalMs(); return null; } if (time < this.nextRollAt) return null; this.nextRollAt = time + this._intervalMs(); if (this.rng() < this._chance()) { this.state = 'engaged'; return compose(this.cfg, ctx.xpLevel ?? 0, this.rng); } return null; } }