/** * 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 —, * or a FACTION system with a war context (the local faction + one of * its rivals — never pirates/neutrals)), 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 — in FACTION space `ctx.faction === true` and the tighter * encounters.faction.rollIntervalMin..rollIntervalMax stand), 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 (or * encounters.faction.cooldownMin..cooldownMax in faction space), 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). FACTION SPACE: both groups are sized * off the band's pirate count, one per faction. * * 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', 'faction']; /** * 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], }; /** * A band's pirate ROSTER as type → weight entries: a `{ type: weight }` * object (the tiers' weighted mixes) or an array of type ids (equal * weight). Unknown ids are left to the spawn side (a roster typo degrades * to the base craft there — the EnemyTypes.js rule); a roster that * empties yields null (the scene then picks its own way). * @returns {?Array<[string, number]>} | null */ function rosterEntries(band) { const raw = band?.roster; const out = []; if (Array.isArray(raw)) { for (const t of raw) { const k = String(t).trim(); if (k !== '') out.push([k, 1]); } } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) { for (const [t, w] of Object.entries(raw)) { const k = String(t).trim(); const wt = Number(w); if (k !== '' && Number.isFinite(wt) && wt > 0) out.push([k, wt]); } } return out.length > 0 ? out : null; } /** Draw `n` type ids from the band's roster (weighted, with replacement). Null when the band carries none. */ function drawRoster(band, n, rng) { const entries = rosterEntries(band); if (!entries) return null; const sum = entries.reduce((a, [, w]) => a + w, 0); const out = []; for (let i = 0; i < n; i++) { let x = rng() * sum; let pick = entries[0][0]; for (const [t, w] of entries) { x -= w; if (x <= 0) { pick = t; break; } } out.push(pick); } return out; } /** * 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. `pirateTypes` (new) * — the band's weighted ROSTER drawn out to the pirate count (the tiers * mix their types — a band without a roster leaves it unset and the scene * picks as it always did). * * FACTION SPACE (`context`, new — the war-zones): `context` of * `{ mode: 'faction', local: { id, types: [...] }, enemies: [{ id, * types: [...] }] }` (the scene builds it — this module stays * roster-free): the roll is ALWAYS a FACTION kind — the LOCAL faction * (the system's controller) AND one of the OTHER factions (never * pirates or neutrals), EACH group sized off the band's pirate count * (independent rolls) with its types drawn from THAT faction's roster * (repeated for the count — the scene spawns `types[i % types.length]`). * A context with no local roster or no rostered rival degrades to the * neutral roll (defensive). * * @param {object} cfg the encounters section (data/combat.json) * @param {number} xpLevel the player's combat level (a fresh run: 0) * @param {() => number} rng * @param {object} [context] the space's encounter context (see above) * @returns {{kind:string, pirates:number, neutrals:number, pirateTypes?:string[], * faction?:string, groups?:Array<{id:string, count:number, types:string[]}>}} */ export function compose(cfg, xpLevel, rng, context = null) { const rostered = (e) => e && Array.isArray(e.types) && e.types.length > 0; if ( context && context.mode === 'faction' && rostered(context.local) && Array.isArray(context.enemies) && context.enemies.some(rostered) ) { const live = context.enemies.filter(rostered); const pick = live[Math.floor(rng() * live.length) % live.length]; const band = bandFor(cfg && cfg.bands, xpLevel) || DEFAULT_BAND; const group = (roster) => { const b = Array.isArray(band.pirates) ? band.pirates : [1, 1]; const n = randInt(rng, b[0], b[1]); const types = []; for (let i = 0; i < n; i++) types.push(roster.types[i % roster.types.length]); return { id: String(roster.id), count: n, types }; }; const home = group(context.local); const rival = group(pick); return { kind: 'faction', faction: home.id, groups: [home, rival], pirates: home.count + rival.count, neutrals: 0, }; } const cfg2 = cfg && typeof cfg === 'object' ? cfg : {}; const band = bandFor(cfg2.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(cfg2.weights, rng); const types = (n) => drawRoster(band, n, rng); if (kind === 'pirates') { const n = ri('pirates', [1, 2]); const pt = types(n); return { kind, pirates: n, neutrals: 0, ...(pt ? { pirateTypes: pt } : {}) }; } if (kind === 'neutrals') return { kind, pirates: 0, neutrals: ri('neutrals', [1, 2]) }; const np = ri('mixedPirates', [1, 1]); const nt = ri('mixedNeutrals', [1, 1]); const pt = types(np); return { kind: 'mixed', pirates: np, neutrals: nt, ...(pt ? { pirateTypes: pt } : {}) }; } /** * The pure gate (GameScene passes its state; the test passes fixtures): * encounters host in NEUTRAL (faction == null) non-HOME space — or in a * FACTION system that carries a war context (the scene's * `_encounterContext` — the controller's rivals with rosters; the roll * there is always an opposing faction, never pirates/neutrals) — 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, factionContext, waveRunLive, playerDown } * @returns {boolean} */ export function encounterAllowed(gate = {}) { return ( !!gate.ship && gate.isHomeSystem !== true && (gate.faction == null || gate.factionContext != 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) * ctx.faction — true in FACTION space (the roll takes the * encounters.faction interval + quiet; the scene passes * its war context on ctx.context for compose) * * `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(src) { // 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). // `src` — the FACTION SPACE's own schedule (data/combat.json → // encounters.faction) when the roll lands in faction territory; // absent, the neutral-space schedule stands. const c = src && typeof src === 'object' ? src : this.cfg; const minS = Math.max(0, Number(c.rollIntervalMin) || 45); const maxS = Math.max(minS, Number(c.rollIntervalMax) || 90); return rand(this.rng, minS * 1000, maxS * 1000); } _cooldownMs(src) { const c = src && typeof src === 'object' ? src : this.cfg; const minS = Math.max(0, Number(c.cooldownMin) || 30); const maxS = Math.max(minS, Number(c.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). `ctx.faction` * true = the roll lands in FACTION space — the encounters.faction * schedule (its own interval + quiet) stands instead of the * neutral-space one. */ update(time, ctx = {}) { if (this.cfg.enabled === false) return null; // the data can hold it off time = Number(time) || 0; // The schedule source (faction space's tighter timers, or the // neutral-space one) — sampled per tick, so a scene that swaps // systems flips the cadence with it. const fac = ctx.faction === true && this.cfg.faction && typeof this.cfg.faction === 'object' ? this.cfg.faction : null; if (this.state === 'engaged') { if (ctx.live !== true) { this.state = 'cooldown'; this.quietUntil = time + this._cooldownMs(fac); } 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(fac); return null; } if (ctx.allowed !== true) { // Out of allowed space (home / 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(fac); return null; } if (time < this.nextRollAt) return null; this.nextRollAt = time + this._intervalMs(fac); if (this.rng() < this._chance()) { this.state = 'engaged'; return compose(this.cfg, ctx.xpLevel ?? 0, this.rng, ctx.context ?? null); } return null; } }