344 lines
14 KiB
JavaScript
344 lines
14 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|