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