304 lines
10 KiB
JavaScript
304 lines
10 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 runner's contract (CombatSystem drives it from update()):
|
|
* begin() → type ids to spawn (wave 1, once the lead elapses)
|
|
* onKill() → type ids to spawn (the rolling replacements)
|
|
* waveCleared() → the current wave's force is spent
|
|
* next() → type ids to spawn (the next wave — null when done)
|
|
*
|
|
* 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).
|
|
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;
|
|
|
|
return {
|
|
kind,
|
|
roster,
|
|
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).
|
|
*
|
|
* @param {{total: number, roster: object}} wave a normalized wave
|
|
* @param {() => number} [rng] random source in [0, 1)
|
|
* @returns {string[]} the type ids (length = wave.total)
|
|
*/
|
|
export function resolveRoster(wave, rng = Math.random) {
|
|
const entries = Object.entries(wave.roster ?? {}).filter(
|
|
([t, n]) => typeof t === 'string' && t !== '' && num(n, 0) > 0,
|
|
);
|
|
const sum = entries.reduce((a, [, n]) => a + num(n, 0), 0);
|
|
const total = Math.max(0, Math.floor(num(wave.total, 0)));
|
|
if (entries.length === 0 || total === 0) return [];
|
|
const draw = typeof rng === 'function' ? rng : Math.random;
|
|
const picks = [];
|
|
for (let i = 0; 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;
|
|
}
|
|
}
|