276 lines
10 KiB
JavaScript
276 lines
10 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 —,
|
||
* 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],
|
||
};
|
||
|
||
/**
|
||
* 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).
|
||
* @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, pirateTypes?:string[]}}
|
||
*/
|
||
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);
|
||
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, 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;
|
||
}
|
||
}
|