orbit/js/combat/Encounters.js

344 lines
14 KiB
JavaScript
Raw Permalink 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 —,
* 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;
}
}