orbit/js/combat/Waves.js

352 lines
12 KiB
JavaScript

/**
* WAVE SCRIPTS — the pure state machine behind quest-driven combat (and
* later, random encounters). A script is an ordered list of waves; this
* module knows nothing of Phaser or scenes — it hands back "spawn these
* type ids NOW" and js/combat/CombatSystem.js places them in the world
* (a ring around the ship). Runs in bare Node (dev/combat.test.mjs).
*
* Wave kinds (data/combat.json → waves):
* 'rolling' — maxConcurrent (X) on screen at a time; every kill sends in
* replacePerKill (Y) more until the wave's force is spent.
* 'fixed' — the whole force at once, no replacements until exhausted.
*
* A wave's composition: `roster` (type → weight, ids from
* combat.enemyTypes) and `total` (the units deployed; default = the
* roster's weight sum — so {raider: 2, bomber: 1} + total 6 is a 2:1 mix
* of six hostiles). `type` + `count` is the single-type shorthand.
*
* THE ELITE (`elite: { type, healthScale?, damageScale? }`) — one NAMED,
* BUFFED unit inside the wave (the bounty hunt's mark beside its escort).
* resolveRoster puts it at the HEAD of the spawn order, so a rolling wave
* always deploys it with the OPENING group, exactly once, and the rest of
* the force draws from the roster's remaining weights. A spawn order entry
* is then an OBJECT (`{ type, healthScale, damageScale }`) instead of a bare
* type id — js/combat/CombatSystem.js applies the buff (EnemyShip.applyEliteBuff).
*
* The runner's contract (CombatSystem drives it from update()):
* begin() → spawn entries to deploy (wave 1, once the lead elapses)
* onKill() → spawn entries to deploy (the rolling replacements)
* waveCleared() → the current wave's force is spent
* next() → spawn entries for the next wave (null when done)
* An entry is a type id, or an elite object (above).
*
* The runner owns no clock: the caller passes "now" and decides when the
* lead (norm.lead ms) / the gap (a wave's gapMs) has elapsed.
*/
import { config } from '../config/Config.js';
const KINDS = new Set(['rolling', 'fixed']);
const isPlain = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
const num = (v, fallback) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
/**
* The wave section's defaults (data/combat.json → waves). Seconds → ms.
* @returns {{maxConcurrent: number, replacePerKill: number, lead: number, gap: number}}
*/
export function waveDefaults() {
const w = config.section('combat.waves', {});
const d = w.defaults ?? {};
return {
maxConcurrent: Math.max(1, num(d.maxConcurrent, 3)),
replacePerKill: Math.max(0, num(d.replacePerKill, 1)),
lead: Math.max(0, num(w.lead, 1.5)) * 1000,
gap: Math.max(0, num(w.gap, 2.0)) * 1000,
};
}
/**
* One wave's fields (defaults from the wave section; a per-wave `gap`
* in SECONDS overrides the script gap).
* @returns {object|null} the normalized wave — null when the shape is bad
*/
function normalizeWave(w, defs, gapSec) {
if (!isPlain(w)) return null;
const kind = w.kind ?? 'rolling';
if (!KINDS.has(kind)) return null;
// The composition: the roster's weights (+ the type/count shorthand).
const roster = {};
if (isPlain(w.roster)) {
for (const [t, n] of Object.entries(w.roster)) {
if (typeof t !== 'string' || t === '') continue;
const weight = Number(n);
if (Number.isFinite(weight) && weight > 0) roster[t] = (roster[t] ?? 0) + weight;
}
}
if (w.type != null) {
const t = String(w.type);
roster[t] = (roster[t] ?? 0) + Math.max(0, num(w.count, 1));
}
if (Object.keys(roster).length === 0) return null; // nothing to send
// The force: the units deployed (default = the weight sum). An elite is
// ONE of the declared force, so the weight sum counts it (roster
// {raider: 8, gunner: 1} + elite gunner + total 9 is eight raiders and
// exactly one mark).
const weightSum = Object.values(roster).reduce((a, b) => a + b, 0);
const total = Math.floor(Math.max(1, num(w.total ?? w.totalForce, weightSum)));
if (total < 1) return null;
// The elite (optional): a declared type + the buff's scales (the data owns
// the numbers — combat.waves / a side-quest band; 1 = unbuffed).
let elite = null;
if (isPlain(w.elite)) {
const et = typeof w.elite.type === 'string' ? w.elite.type.trim() : '';
if (et !== '') {
elite = {
type: et,
healthScale: Math.max(0, num(w.elite.healthScale, 1)),
damageScale: Math.max(0, num(w.elite.damageScale, 1)),
// The name the kill is announced under (a hunt's wanted leader) — carried
// through to the ship so the fight, not just the quest log, can say who.
name: typeof w.elite.name === 'string' ? w.elite.name : '',
};
}
}
return {
kind,
roster,
elite,
total,
maxConcurrent: Math.max(1, num(w.maxConcurrent, defs.maxConcurrent)),
replacePerKill: Math.max(0, num(w.replacePerKill, defs.replacePerKill)),
gapMs: Math.max(0, num(w.gap, gapSec)) * 1000,
};
}
/**
* Normalize a wave script for the runner.
*
* `script` is an ordered list of waves, or `{ waves: [...], lead?, gap? }`
* (lead/gap in seconds), or a NAME under combat.waves.scripts (the shape
* the quests reference when their hook lands).
*
* @param {Array|object|string} script the script (above)
* @param {object} [defs] waveDefaults() — injectable for tests
* @returns {{ok: true, waves: object[], lead: number, gap: number} | {ok: false, error: string}}
* lead/gap in ms; each wave carries its own gapMs (the delay BEFORE it).
*/
export function normalizeWaves(script, defs = waveDefaults()) {
let waves;
let leadSec;
let gapSec;
if (typeof script === 'string') {
const named = config.section('combat.waves.scripts', {})[script];
if (!Array.isArray(named)) {
return { ok: false, error: `no wave script named "${script}" (combat.waves.scripts)` };
}
waves = named;
} else if (isPlain(script)) {
waves = script.waves;
leadSec = script.lead;
gapSec = script.gap;
} else {
waves = script;
}
if (!Array.isArray(waves) || waves.length === 0) {
return { ok: false, error: 'a wave script is a non-empty ordered list of waves' };
}
gapSec = num(gapSec, defs.gap / 1000);
const out = [];
for (let i = 0; i < waves.length; i++) {
const w = normalizeWave(waves[i], defs, gapSec);
if (!w) {
return {
ok: false,
error: `wave ${i + 1}: needs a kind (rolling/fixed) and a roster ({type: weight}) or the type/count shorthand`,
};
}
out.push(w);
}
return {
ok: true,
waves: out,
lead: Math.max(0, num(leadSec, defs.lead / 1000)) * 1000,
gap: Math.max(0, gapSec) * 1000,
};
}
/**
* Draw a wave's force from its roster: `total` weighted picks (with
* replacement) over the type weights — the spawn order the runner deploys.
* Deterministic given the same `rng` (tests seed one; the game uses
* Math.random).
*
* The elite rides at the HEAD of the order (deployed with the opening group)
* as an object entry; its own weight is taken OUT of the roster's draw so
* the force holds exactly one of it.
*
* @param {{total: number, roster: object, elite?: object|null}} wave a normalized wave
* @param {() => number} [rng] random source in [0, 1)
* @returns {Array<string|object>} the spawn entries (length = wave.total)
*/
export function resolveRoster(wave, rng = Math.random) {
const total = Math.max(0, Math.floor(num(wave.total, 0)));
if (total === 0) return [];
const draw = typeof rng === 'function' ? rng : Math.random;
const picks = [];
// The elite first, then the escort drawn from what the roster has left.
const el = isPlain(wave.elite) ? wave.elite : null;
let entries = Object.entries(wave.roster ?? {}).filter(
([t, n]) => typeof t === 'string' && t !== '' && num(n, 0) > 0,
);
if (el?.type) {
picks.push({ ...el });
entries = entries
.map(([t, n]) => (t === el.type ? [t, num(n, 0) - 1] : [t, n]))
.filter(([, n]) => n > 0);
}
if (entries.length === 0) {
// The roster held nothing but the mark (elite-only wave).
while (picks.length < total && el?.type) picks.push({ ...el });
}
const sum = entries.reduce((a, [, n]) => a + num(n, 0), 0);
if (entries.length === 0 || sum <= 0) return picks.slice(0, total);
for (let i = picks.length; i < total; i++) {
let r = draw() * sum;
let pick = entries[0][0];
for (const [t, n] of entries) {
r -= num(n, 0);
if (r <= 0) {
pick = t;
break;
}
}
picks.push(pick);
}
return picks;
}
/**
* One run of a normalized script: the wave-by-wave state machine.
* Clocks belong to the caller (CombatSystem passes the scene time); this
* only answers "who spawns now" and "is the force spent".
*/
export class WaveRunner {
/**
* @param {object} norm the ok result of normalizeWaves()
* @param {object} [o]
* resolve (wave, rng) → type ids (default resolveRoster)
* rng random source for the composition draws (tests seed it)
*/
constructor(norm, { resolve = resolveRoster, rng = Math.random } = {}) {
this.waves = (norm?.waves ?? []).map((w) => ({
...w,
queue: resolve(w, rng),
alive: 0,
deployed: 0,
}));
this.index = -1;
this.state = 'lead'; // 'lead' → 'live' → ('gap' → 'live')… → 'done'
}
/** How many waves the script holds. */
get totalWaves() {
return this.waves.length;
}
/** The current wave (its live state), or null before begin(). */
get current() {
return this.index >= 0 ? this.waves[this.index] : null;
}
/** True once the last wave's force is spent. */
get done() {
return this.state === 'done';
}
/** True while another wave follows the current one. */
get hasNext() {
return this.index + 1 < this.waves.length;
}
/**
* Start wave 1 (from 'lead').
* @returns {string[]} the type ids to spawn NOW (min(maxConcurrent, total)
* for a rolling wave, the whole force for a fixed one)
*/
begin() {
if (this.state !== 'lead' || this.waves.length === 0) return [];
this.index = 0;
this.state = 'live';
return this._deploy(this.waves[0]);
}
/**
* Start the next wave (after the caller let the gap elapse).
* @returns {string[]|null} the type ids to spawn — null when the script is spent
*/
next() {
if (this.state === 'done') return null;
if (!this.hasNext) {
this.state = 'done';
return null;
}
this.index += 1;
this.state = 'live';
return this._deploy(this.waves[this.index]);
}
/**
* A wave ship was killed (while 'live'). A rolling wave sends in its
* replacements here (capped by the seats and the remaining force); a
* fixed one sends nothing.
* @returns {string[]} the type ids to spawn NOW
*/
onKill() {
if (this.state !== 'live') return [];
const w = this.current;
w.alive = Math.max(0, w.alive - 1);
if (w.kind !== 'rolling') return [];
const room = w.maxConcurrent - w.alive;
const n = Math.max(0, Math.min(w.replacePerKill, room, w.queue.length));
if (n === 0) return [];
w.deployed += n;
w.alive += n;
return w.queue.splice(0, n);
}
/** The current wave's force is spent (every unit deployed, every one down). */
waveCleared() {
const w = this.current;
return this.state === 'live' && !!w && w.alive === 0 && w.queue.length === 0;
}
/**
* A status snapshot (the console's waves.status() / the quest hook's UI).
* @returns {{state: string, wave: number, waves: number, kind: string|null, alive: number, deployed: number, inQueue: number, total: number, killed: number}}
*/
status() {
const w = this.current;
return {
state: this.state,
wave: Math.max(0, this.index),
waves: this.totalWaves,
kind: w?.kind ?? null,
alive: w?.alive ?? 0,
deployed: w?.deployed ?? 0,
inQueue: w?.queue.length ?? 0,
total: w?.total ?? 0,
killed: w ? w.total - w.alive - w.queue.length : 0,
};
}
/** Deploy the wave's opening burst (seats for rolling, all for fixed). */
_deploy(w) {
const n = w.kind === 'fixed' ? w.queue.length : Math.min(w.maxConcurrent, w.queue.length);
if (n === 0) return [];
const out = w.queue.splice(0, n);
w.deployed += n;
w.alive += n;
return out;
}
}