orbit/js/combat/Encounters.js

276 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}
}