orbit/js/combat/Encounters.js

214 lines
8.1 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), a
* ROLL 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;
}
}