orbit/dev/combat.test.mjs

1305 lines
61 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.

/**
* Combat rules test (dev tool, run with Node — no browser needed):
*
* node dev/combat.test.mjs
*
* Stubs just enough of Phaser + a scene to run the REAL combat modules
* from js/combat/ — the pure rules (Combat.js: armor damage, render
* heading, orbit-ring step), the hostile craft (EnemyShip.js: config
* scaling + north-facing art offset, shields→hull damage, the
* standoff-ring AI, the lance decision), the tracer (Bullet.js:
* velocity from angle/speed, world radius, one-shot consume, life
* expiry), and the system (CombatSystem.js: the pack's lifecycle,
* the auto-fire, the on-screen rule, swept hits, rams, the player's
* armor + respawn, the energy) — then asserts the behavior the range
* (js/combat/CombatScene.js) and the main game (GameScene) are built on.
*
* All numbers come from the real data/*.json (data/combat.json included),
* so the assertions track the shipped config, not a private copy.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TAU = Math.PI * 2;
// Mirror of Phaser.Math.Angle.Wrap — the vendored build's Angle.Wrap is
// Wrap(value, -π, π): ((v - min) % range + range) % range + min.
const wrap = (a) => {
const min = -Math.PI;
const range = Math.PI * 2;
return (((a - min) % range) + range) % range + min;
};
class V2 {
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
set(x, y) { this.x = x; this.y = y; return this; }
length() { return Math.hypot(this.x, this.y); }
scale(s) { this.x *= s; this.y *= s; return this; }
}
class Sprite {
constructor(scene, x, y, key, frame) {
this.scene = scene; this.x = x; this.y = y; this.key = key; this.frame = frame;
this.rotation = 0; this.scale = 1; this.alpha = 1; this.visible = true;
this.active = true; this.body = null;
}
setScale(s) { this.scale = s; return this; }
setTint() { return this; }
setBlendMode() { return this; }
setDepth() { return this; }
setOrigin() { return this; }
setAlpha(a) { this.alpha = a; return this; }
setVisible(v) { this.visible = v; return this; }
destroy() { this.active = false; this.destroyed = true; }
}
function makeBody() {
return {
velocity: new V2(),
acceleration: new V2(),
enable: true,
setCircle() { return this; },
setAllowGravity() { return this; },
setVelocity(x, y) { this.velocity.set(x, y); return this; },
};
}
const PhaserStub = {
Physics: { Arcade: { Sprite } },
Math: {
Angle: {
Wrap: wrap,
RotateTo: (cur, tgt, step) => (Math.abs(wrap(tgt - cur)) <= step ? tgt : cur + Math.sign(wrap(tgt - cur)) * step),
},
},
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).replace('#', ''), 16) }) } },
BlendModes: { ADD: 'ADD' },
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
function makeImg(x = 0, y = 0, key = '__dot') {
const o = {
x, y, key, scale: 1, alpha: 1, visible: true, active: true,
setScale(s) { this.scale = s; return this; },
setAlpha(a) { this.alpha = a; return this; },
setTint() { return this; },
setBlendMode() { return this; },
setDepth() { return this; },
setAngle() { return this; },
setVisible(v) { this.visible = v; return this; },
destroy() { this.active = false; this.destroyed = true; },
};
return o;
}
// The readability layer's seams (js/combat/CombatHud.js) — just enough
// of the display list for it to run headless and be asserted against.
function makeGraphics() {
return {
visible: true,
clear() { return this; },
lineStyle() { return this; },
fillStyle() { return this; },
lineBetween() { return this; },
fillCircle() { return this; },
fillTriangle() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setVisible(v) { this.visible = v; return this; },
destroy() { this.destroyed = true; },
};
}
function makeTextObj() {
return {
text: '', alpha: 0, visible: true,
setText(t) { this.text = String(t); return this; },
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
setOrigin() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
destroy() { this.destroyed = true; },
};
}
function makeRectObj(x = 0, y = 0, w = 0, h = 0, alpha = 0) {
return {
x, y, width: w, height: h, alpha, visible: true,
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
setOrigin() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
destroy() { this.destroyed = true; },
};
}
function makeScene({ withSheet = true } = {}) {
return {
textures: { exists: () => withSheet },
make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) },
add: {
existing: (o) => o,
image: (x, y, key) => makeImg(x, y, key),
graphics: () => makeGraphics(),
text: (x, y, s) => makeTextObj(),
rectangle: (x, y, w, h, c, a) => makeRectObj(x, y, w, h, a ?? 0),
},
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
scale: { width: 1280, height: 720 },
time: { now: 0 },
};
}
// The real data/*.json (combat numbers come from data/combat.json).
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
const {
applyDamage, headingOf, orbitStep, handleFeel, pickTarget, feedPush, offscreenArrow, inView,
wrapAngle, fanAngles, ringAngles, homingStep,
} = await import(
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
);
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
const { enemyTypeConfig, enemyTypeIds, enemySheetList } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href
);
const { normalizeWaves, resolveRoster, WaveRunner } = await import(
pathToFileURL(join(__dirname, '../js/combat/Waves.js')).href
);
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
const { Missile } = await import(pathToFileURL(join(__dirname, '../js/combat/Missile.js')).href);
const { firePattern, patternOf, stepMissiles, missilesHitPlayer } = await import(
pathToFileURL(join(__dirname, '../js/combat/Weapons.js')).href
);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
const { CombatHud } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatHud.js')).href);
const {
COMBAT_SFX, combatSfx, combatSfxEnemyHit,
} = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSfx.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const E = (k, d) => config.get(`combat.enemy.${k}`, d);
// ------------------------------------------------------------------ armor
{
const armor = { shields: 40, hull: 100 };
const r1 = applyDamage(armor, 30);
check('damage: 30 hits shields only (40→10, hull 100)', armor.shields === 10 && armor.hull === 100 && !r1.dead && r1.shieldHit === 30);
const r2 = applyDamage(armor, 50);
check('damage: 50 drains shields then hull (0/60)', armor.shields === 0 && armor.hull === 60 && !r2.dead);
const r3 = applyDamage(armor, 999);
check('damage: overkill kills (hull 0, dead)', armor.hull === 0 && r3.dead);
applyDamage(armor, 5);
check('damage: dead armor takes no more', armor.hull === 0 && armor.shields === 0);
const a2 = { shields: 0, hull: 25 };
applyDamage(a2, 9);
check('damage: zero shields → hull straight', a2.hull === 16 && !a2.dead);
const a3 = { shields: 10, hull: 10 };
const r4 = applyDamage(a3, 'nope');
check('damage: non-numeric = 0', a3.shields === 10 && a3.hull === 10 && !r4.dead);
}
{
check('headingOf: rotation minus artOffset', approx(headingOf({ rotation: Math.PI / 2, artOffset: Math.PI / 2 }), 0));
check('headingOf: no offset', approx(headingOf({ rotation: 1.2 }), 1.2));
}
{
// player at origin, orbiting at radius 100
const t = orbitStep(0, 0, 0, 1, 1, 1, 100);
check('orbitStep: angle advances dir*speed*dt', approx(t.angle, 1));
check('orbitStep: target on the ring', approx(t.x, Math.cos(1) * 100) && approx(t.y, Math.sin(1) * 100));
const t2 = orbitStep(0, 0, 0, -1, 2, 0.5, 100);
check('orbitStep: counter-direction', approx(t2.angle, -1));
}
// ------------------------------------------------------------------ feel
{
const P = (k, d) => config.get(`combat.player.${k}`, d);
const cfg = {
rotSpeed: P('rotSpeed', 5.6),
rotApproach: P('rotApproach', 16),
thrustRamp: P('thrustRamp', 14),
thrustDecay: P('thrustDecay', 26),
brake: P('brake', 0.8),
};
check('feel cfg ships: all five knobs present + sane',
cfg.rotSpeed > 0 && cfg.rotApproach > 0 && cfg.thrustRamp > 0 &&
cfg.thrustDecay > 0 && cfg.brake > 0 && cfg.brake <= 1);
// Turn: a key BUILDS the turn rate (a tap is a nudge, a hold a spin) —
// no instant full rate on the first frame, and it settles at ±rotSpeed.
const st = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(st, { right: true }, 1 / 60, cfg);
check('feel: first frame builds turn (no instant full rate)',
st.turnVel > 0 && st.turnVel < cfg.rotSpeed && st.rot > 0);
for (let i = 0; i < 60; i++) handleFeel(st, { right: true }, 1 / 60, cfg);
check('feel: turn settles at rotSpeed (no overshoot)',
st.turnVel <= cfg.rotSpeed && Math.abs(st.turnVel - cfg.rotSpeed) < 0.05);
const sFlip = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 };
for (let i = 0; i < 3; i++) handleFeel(sFlip, { left: true }, 1 / 60, cfg);
check('feel: left flips the turn (eases toward -rotSpeed)', sFlip.turnVel < 0);
const s0 = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 };
for (let i = 0; i < 120; i++) handleFeel(s0, {}, 1 / 60, cfg);
check('feel: released turn decays to 0', s0.turnVel === 0);
// Throttle: ramps up toward 1 (bounded), cuts to 0 when released.
const s1 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
for (let i = 0; i < 30; i++) handleFeel(s1, { up: true }, 1 / 60, cfg);
check('feel: throttle ramps up, bounded by 1', s1.throttle > 0.5 && s1.throttle <= 1);
for (let i = 0; i < 60; i++) handleFeel(s1, {}, 1 / 60, cfg);
check('feel: throttle cuts to 0 on release', s1.throttle === 0);
// Brake: instant while down (a fraction of full thrust), gone on release.
const s2 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(s2, { down: true }, 1 / 60, cfg);
check('feel: brake is instant (no ramp)', s2.brake === cfg.brake);
handleFeel(s2, {}, 1 / 60, cfg);
check('feel: brake drops when released', s2.brake === 0);
// Frame-rate independence: two half-steps ≈ one full step.
const a = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(a, { right: true, up: true }, 1 / 60, cfg);
const b = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(b, { right: true, up: true }, 1 / 120, cfg);
handleFeel(b, { right: true, up: true }, 1 / 120, cfg);
check('feel: frame-rate independent (≈)',
Math.abs(a.rot - b.rot) < 1e-3 && Math.abs(a.throttle - b.throttle) < 1e-3);
// dt clamp: a backgrounded tab (dt = 5 s) can't fast-forward the feel.
const c = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(c, { right: true, up: true }, 5, cfg);
const d = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
handleFeel(d, { right: true, up: true }, 0.1, cfg);
check('feel: dt clamped at 0.1 s (no tab-switch jump)',
Math.abs(c.rot - d.rot) < 1e-9 && Math.abs(c.throttle - d.throttle) < 1e-9);
}
// ---------------------------------------------------------------- enemy
{
const scene = makeScene({ withSheet: true });
const e = new EnemyShip(scene, 200, 360, 0);
const fw = E('frameWidth', 128);
check('enemy: scale = size / frameWidth', approx(e.scale, E('size', 54) / fw));
check('enemy: north-facing art → offset +π/2', approx(e.artOffset, Math.PI / 2));
check('enemy: hitRadius from config', e.hitRadius === E('hitRadius', 20));
check('enemy: armor from config', e.maxHull === E('hull', 60) && e.maxShields === E('shields', 0));
// Damage model (shields → hull → dead).
e.shields = 30;
check('enemy.damage: shields first', e.damage(40) === false && e.shields === 0 && e.hull === E('hull', 60) - 10);
check('enemy.damage: killing blow', e.damage(999) === true && e.dead && e.hull === 0);
check('enemy.damage: dead is a wall', e.damage(10) === false);
e.revive();
check('enemy: revive restores armor + liveness', !e.dead && e.hull === e.maxHull && e.shields === e.maxShields);
// The AI steers toward its point on the standoff ring around the player.
const player = { x: 640, y: 360 };
e.x = 200; e.y = 360;
e.body.velocity.set(0, 0);
e.orbitAngle = Math.PI; // "behind" the enemy relative to the player (west side)
e.update(0, 16, player);
check('enemy AI: velocity heads for the ring (toward the player side)', e.body.velocity.x > 0 && Math.abs(e.body.velocity.y) < Math.abs(e.body.velocity.x));
// The lance: in range + aimed + off cooldown → fires once.
e.nextFireAt = 0;
e.x = 840; e.y = 360; // 200 px EAST of the player (range is 560)
const toPlayer = Math.PI; // facing west, straight at the player
e.rotation = toPlayer + e.artOffset;
const fired = e.update(0, 16, player);
check('enemy AI: fires when in range + aimed + armed', fired === true);
check('enemy AI: cooldown set after firing', e.nextFireAt > 0);
const firedAgain = e.update(0, 16, player);
check('enemy AI: no instant re-fire', firedAgain === false);
// Out of range → no fire (even aimed + armed).
e.nextFireAt = 0;
e.x = 1500; e.y = 360;
e.rotation = Math.atan2(360 - 360, 640 - 1500) + e.artOffset;
check('enemy AI: out of range, no fire', e.update(0, 16, player) === false);
// Player down → no fire, keeps orbiting the last position.
e.nextFireAt = 0;
e.x = 840; e.y = 360;
e.rotation = toPlayer + e.artOffset;
check('enemy AI: player down, no fire', e.update(0, 16, null) === false);
}
{
// No spritesheet → the procedural wedge (east-facing, offset 0).
const scene = makeScene({ withSheet: false });
const e = new EnemyShip(scene, 100, 100, 1);
check('enemy fallback: east-facing wedge, offset 0', e.artOffset === 0);
check('enemy fallback: scale from the wedge width', approx(e.scale, E('size', 54) / 76));
}
// ---------------------------------------------------------------- bullet
{
const scene = makeScene({ withSheet: true });
const b = new Bullet(scene, { x: 100, y: 100, angle: 0, speed: 680, damage: 10, life: 1.2, color: '#00e5ff', size: 16, owner: 'player' });
check('bullet: velocity = speed along angle', approx(b.body.velocity.x, 680) && approx(b.body.velocity.y, 0));
check('bullet: world radius > 0', b.radius > 0 && b.radius < 32);
check('bullet: owner + damage carried', b.owner === 'player' && b.damage === 10);
check('bullet: consume is one-shot', b.consume() === true && b.consume() === false);
check('bullet: consume destroys', b.active === false);
const b2 = new Bullet(scene, { x: 0, y: 0, angle: Math.PI / 2, speed: 330, damage: 8, life: 1.0, color: '#ff2d6f', size: 18, owner: 'enemy' });
check('bullet: down-angle velocity', approx(b2.body.velocity.x, 0) && approx(b2.body.velocity.y, 330));
b2.update(1000);
check('bullet: life expiry consumes', b2.dead === true);
}
// ------------------------------------------------------------- combat system
{
const scene = makeScene({ withSheet: true });
// A stand-in player ship: position + radius + body (the system reads
// exactly that — Ship's full API isn't needed for the rules).
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {}; // Ship API the system leans on (brake + hide on death)
const events = { down: 0, up: 0, kills: 0, active: [] };
const combat = new CombatSystem(scene, {
ship,
onPlayerDown: () => events.down++,
onPlayerUp: () => events.up++,
onKill: () => events.kills++,
onActiveChange: (a) => events.active.push(a),
});
check('system: starts with full armor + full energy',
combat.hull === 100 && combat.shields === 40 && combat.energy === 100 && combat.maxEnergy === 100);
// The pack: game.spawnCount hostiles around the ship, combat live.
combat.spawnPack(0, 0);
check('system: pack spawns (game.spawnCount = 3)', combat.enemies.length === 3);
check('system: combat is active with the pack up', combat.active === true);
check('system: onActiveChange fired true', events.active[0] === true);
// AUTO-FIRE: the nearest hostile is within game.autoFire.range → a
// player tracer is born (the scene's world steps it in the real game).
let t = 1000;
combat.update(t, 16);
const playerShots = combat.bullets.filter((b) => b.owner === 'player');
check('system: auto-fires at a hostile in range', playerShots.length >= 1);
t += 16;
const before = playerShots.length;
combat.update(t, 16);
check('system: auto-fire honors the weapon cooldown',
combat.bullets.filter((b) => b.owner === 'player').length === before);
// Out of range → no auto-fire (the cannon waits for contact).
for (const e of combat.enemies) { e.x = 5000; e.y = 5000; }
combat.nextFireAt = 0;
combat.update(t + 16, 16);
check('system: no auto-fire out of range',
combat.bullets.filter((b) => b.owner === 'player').length === before);
for (const e of combat.enemies) { e.x = 100; e.y = 0; }
// Swept hit: a player tracer on the enemy's segment kills it (damage
// over the enemy's hull) → the kill bookkeeping + kill event fire.
const target = combat.nearestEnemy(ship.x, ship.y);
combat.spawnBullet(target.x - 18, 0, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'player');
combat.update(t + 32, 16);
check('system: player tracer kills (swept hit)', target.dead === true && combat.kills === 1 && events.kills === 1);
// The enemy's respawn (enemy.respawn = true): down, then back.
check('system: killed enemy is on its respawn clock', target._respawnPending === true && target.respawnAt > 0);
combat.update(target.respawnAt + 10, 16);
check('system: enemy reforms (armor full, alive)', !target.dead && target.hull === target.maxHull);
// CURRENT TARGET (the readability layer's lock): the auto-fire target —
// the nearest live hostile WITHIN auto-fire range (the shared rule).
check('system: currentTarget = the nearest live hostile in range',
combat.currentTarget() === combat.nearestEnemy(ship.x, ship.y) && combat.currentTarget() !== null);
combat.autoFire.range = 5;
check('system: currentTarget honors the auto-fire range (none in 5px)', combat.currentTarget() === null);
combat.autoFire.range = 800;
// RE-ANCHOR (the main game): the ship's start position settles AFTER
// create (the tether barrier) — the pack gets pulled around it.
ship.x = 1200; ship.y = 300;
const moved = combat.resyncAroundShip();
let cx = 0, cy = 0, n = 0;
for (const e of combat.enemies) { if (!e.dead) { cx += e.x; cy += e.y; n++; } }
cx /= n; cy /= n;
check('system: resync pulls the pack around the settled ship',
moved === true && Math.abs(cx - 1200) < 1e-6 && Math.abs(cy - 300) < 1e-6);
check('system: resync is a no-op once anchored', combat.resyncAroundShip() === false);
check('system: resync with no ship is a no-op', (combat.ship = null, combat.resyncAroundShip()) === false);
combat.ship = ship;
ship.x = 0; ship.y = 0;
// RAM: the ship inside the enemy's combined radius → both take damage.
const ram = combat.enemies[0];
ship.x = 0; ship.y = 0;
ram.x = 20; ram.y = 0;
const armorBefore = combat.hull + combat.shields;
combat.update(t + 64, 16);
check('system: ram damages the player (shields first)',
combat.hull + combat.shields < armorBefore);
// PLAYER DEATH + RESPAWN (playerShip.respawn = true, 2 s).
combat.damagePlayer(9999);
check('system: hull broke → player down', combat.playerDown === true && events.down === 1);
const respawnAt = combat.respawnAt;
combat.update(respawnAt + 10, 16);
check('system: player reformed (full armor)', !combat.playerDown && combat.hull === 100 && combat.shields === 40 && events.up === 1);
// cast(): the skill stub — no crash, no energy change (skills land later).
const energyBefore = combat.energy;
combat.cast(3);
check('system: cast is a safe no-op for now', combat.energy === energyBefore);
// Active flips off when the pack is gone (respawn clocks cleared).
for (const e of combat.enemies) { e.dead = true; e._respawnPending = false; e.respawnAt = 0; }
combat._setActive();
check('system: combat over → active off + event', combat.active === false && events.active[events.active.length - 1] === false);
// destroy(): the pack + tracers are gone.
combat.destroy();
check('system: destroy clears pack + tracers', combat.enemies.length === 0 && combat.bullets.length === 0);
}
// ------------------------------------------------- off-screen spawning
{
// MAIN-GAME SPAWNS: with a camera, hostiles materialize PAST the
// visible edge (the worldView half-diagonal + game.spawnLead) and fly
// in on their chase AI — nothing pops into view.
const VW = 1280, VH = 720;
const scene = makeScene({ withSheet: true });
scene.cameras = { main: { worldView: { width: VW, height: VH } } };
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
combat.autoFire.enabled = false;
const lead = Math.max(0, Number(config.get('game.spawnLead', 140)) || 0);
const halfDiag = Math.hypot(VW, VH) / 2;
const radius = combat._offscreenRadius();
check('spawn: the materialization radius is the camera edge + lead (≥ the band)',
approx(radius, Math.max(Number(config.get('game.spawnRadius', 340)), halfDiag + lead)));
const pack = combat.spawnPack(0, 0);
check('spawn: the pack materializes OFF-SCREEN (past the camera edge)',
pack.length === 3 && pack.every((e) => Math.hypot(e.x, e.y) > halfDiag));
check('spawn: the pack is still the spawn band wide (0.9–1.1 × radius)',
pack.every((e) => {
const d = Math.hypot(e.x, e.y);
return d >= radius * 0.9 - 1e-9 && d <= radius * 1.1 + 1e-9;
}));
// Wave ships get the same off-screen treatment.
combat.startWaves({ waves: [{ kind: 'fixed', roster: { raider: 1 }, total: 2 }], lead: 0, gap: 0 });
combat.update(1000, 16);
const wave = combat.enemies.filter((e) => e.wave && !e.dead);
check('spawn: wave ships materialize OFF-SCREEN too',
wave.length === 2 && wave.every((e) => Math.hypot(e.x, e.y) > halfDiag));
// The fly-in: one step of the chase AI closes in on the player side
// (velocity toward the ring point — the ships cross the screen).
const e0 = pack[0];
e0.update(1000, 16, { x: 0, y: 0 });
const closing = e0.body.velocity.x * -e0.x + e0.body.velocity.y * -e0.y; // v · toward-player
check('spawn: the fly-in closes in on the player (v · toward > 0)', closing > 0);
combat.destroy();
check('spawn: destroy still clears everything', combat.enemies.length === 0);
}
// ------------------------------------------------- the on-screen rule
{
// ON-SCREEN RULE (the main game): a hostile must be at least PARTLY
// in the camera's view to fire or be fired upon — off-screen craft
// hold their lances, the cannon won't engage them, and their tracers
// pass through (they re-enter the fight as they fly in).
const VW = 1280, VH = 720;
const scene = makeScene({ withSheet: true });
scene.cameras = { main: { worldView: { x: 0, y: 0, width: VW, height: VH } } };
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 640, 360, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
// One on-screen (below the player), one off-screen (above the top
// edge) — both within lance range (560), the off-screen one NEARER,
// so "nearest" alone would pick the wrong one.
const on = combat.spawnEnemy(640, 600, 0);
const off = combat.spawnEnemy(640, -50, 1);
const aim = (e) => {
e.rotation = Math.atan2(ship.y - e.y, ship.x - e.x) + e.artOffset;
e.nextFireAt = 0; // lift the spawn grace — armed for this frame
};
aim(on);
aim(off);
check('view: the on-screen craft is visible, the off-screen one is not',
combat.craftVisible(on) === true && combat.craftVisible(off) === false);
// ENEMY FIRE: the on-screen one lances; the off-screen one holds.
combat.update(1000, 16);
const eShots = combat.bullets.filter((b) => b.owner === 'enemy');
check('view: an off-screen craft holds its fire (no lance born off-screen)',
eShots.length >= 1 && eShots.every((b) => b.y > 0));
check('view: the on-screen craft lances (its shot is born in view)',
eShots.some((b) => b.y > 0 && b.y < 600));
// AUTO-FIRE + LOCK: the on-screen hostile is engaged even though the
// off-screen one is NEARER (the gun and the brackets track the same).
const pShots = combat.bullets.filter((b) => b.owner === 'player');
check('view: auto-fire targets the on-screen hostile (not the nearer off-screen one)',
pShots.length >= 1 && pShots.every((b) => b.y > 360));
check('view: currentTarget = the on-screen hostile (the lock follows the guns)',
combat.currentTarget() === on);
// BE HIT: a tracer on the off-screen hostile's path passes straight
// through (it cannot be shot while off-screen) …
const clearAll = () => { for (const b of combat.bullets) b.consume(); combat.bullets.length = 0; };
clearAll();
const offArmor = { hull: off.hull, shields: off.shields };
combat.spawnBullet(off.x - 18, off.y, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'player');
combat.update(1016, 16);
check('view: an off-screen hostile takes no hits (the tracer passes through)',
off.dead === false && off.hull === offArmor.hull && off.shields === offArmor.shields);
// …and the same path on the on-screen one connects (killing over the hull).
clearAll();
combat.spawnBullet(on.x - 18, on.y, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'player');
combat.update(1032, 16);
check('view: an on-screen hostile takes the hit (and goes down)', on.dead === true && combat.kills === 1);
combat.destroy();
check('view: destroy still clears everything', combat.enemies.length === 0 && combat.bullets.length === 0);
}
// ------------------------------------------------------------------ SFX
{
// A scene with just enough of a sound manager for the shared guards to
// pass (js/utils/Sfx.js) — every play lands in `played`.
const makeSfxScene = () => {
const played = [];
return {
played,
sound: { play: (key, o) => played.push({ key, volume: o.volume }) },
cache: { audio: { has: () => true } },
};
};
const sfxNames = config.get('sfx') ?? {};
check('sfx: the eleven combat events, each mapped',
Object.keys(COMBAT_SFX).sort().join(',') ===
'cannon_fire,cannon_hit,down,fire,hull,impact,kill,levelup,missile_fire,respawn,shield');
check('sfx: every mapped voice exists in data/sfx.json',
Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string'));
const sets = Object.values(COMBAT_SFX).map((ns) => new Set(ns));
check('sfx: the events\u2019 voices are pairwise distinct',
sets.every((a, i) => sets.every((b, j) => i === j || ![...a].some((n) => b.has(n)))));
const s1 = makeSfxScene();
check('sfx: fire → the laser voice, base volume',
combatSfx(s1, 'fire') === 'fire' &&
s1.played.length === 1 &&
s1.played[0].key === 'sfx_laser' &&
s1.played[0].volume === Number(config.get('sfx.volume', 0.55)));
const sC = makeSfxScene();
check('sfx: cannon_fire → the mass-cannon voice',
combatSfx(sC, 'cannon_fire') === 'cannon_fire' &&
sC.played.length === 1 && sC.played[0].key === 'sfx_mass_cannon');
const sCh = makeSfxScene();
check('sfx: cannon_hit → the mass-cannon hit voice',
combatSfx(sCh, 'cannon_hit') === 'cannon_hit' &&
sCh.played.length === 1 && sCh.played[0].key === 'sfx_mass_cannon_hit');
const s2 = makeSfxScene();
combatSfx(s2, 'fire', { vol: 0.45 }); // the vol knob scales the volume
check('sfx: vol scales the volume',
Math.abs(s2.played[0].volume - Number(config.get('sfx.volume', 0.55)) * 0.45) < 1e-9);
const killKeys = new Set();
for (let i = 0; i < 60; i++) {
const sc = makeSfxScene();
combatSfx(sc, 'kill');
killKeys.add(sc.played[0]?.key);
}
check('sfx: kill picks one of the thunder set',
killKeys.size >= 2 && [...killKeys].every((k) => /^sfx_thunder_0[1-4]$/.test(k)));
const sU = makeSfxScene();
check('sfx: unknown event is a no-op', combatSfx(sU, 'nope') === null && sU.played.length === 0);
// The shield/hull decision (one shared helper, both contexts):
// in front of the hit the craft has `preShields` shield points.
const hit = (pre, dmg) => {
const sc = makeSfxScene();
combatSfxEnemyHit(sc, pre, dmg);
return sc.played.map((p) => p.key.replace('sfx_', ''));
};
check('sfx: full shields → the shield voice only',
JSON.stringify(hit(40, 10)) === JSON.stringify(['solar_buzz']));
check('sfx: no shields → the hull voice only',
JSON.stringify(hit(0, 10)) === JSON.stringify(['mining_split']));
check('sfx: shield breaks through → BOTH (the pop reads)',
JSON.stringify(hit(5, 10)) === JSON.stringify(['solar_buzz', 'mining_split']));
// The dev log: every call is remembered (capped) + mirrored to window.
combatSfx(makeSfxScene(), 'down');
const after = window.__COMBAT_SFX__ ?? null;
check('sfx: dev log records the call (window.__COMBAT_SFX__)',
after && after.last === 'down' &&
after.log.length >= 1 && after.log.length <= 64 &&
after.log[after.log.length - 1].event === 'down');
// The master switch: off → nothing plays, but the log still records
// the call (headless verification depends on it).
const saved = config.data.sfx.enabled;
config.data.sfx.enabled = false;
const sOff = makeSfxScene();
combatSfx(sOff, 'kill');
config.data.sfx.enabled = saved;
check('sfx: master switch off → silent, still logged',
sOff.played.length === 0 && (window.__COMBAT_SFX__?.last === 'kill'));
}
// ------------------------------------------------------- readability layer
{
// pickTarget — the shared "current target" rule (the range's lock and
// the main game's auto-fire both run it).
const A = { x: 10, y: 0, dead: false, hitRadius: 20 };
const B = { x: 3, y: 0, dead: true, hitRadius: 20 }; // dead — never qualifies
const C = { x: 100, y: 0, dead: false, hitRadius: 20 };
check('read: pickTarget = the nearest LIVE hostile', pickTarget([A, B, C], 0, 0) === A);
check('read: pickTarget skips the dead (even when nearest)', pickTarget([A, B, C], 2, 0) === A);
check('read: pickTarget honors the range (and nulls outside it)',
pickTarget([A, C], 0, 0, 50) === A && pickTarget([A, C], 0, 0, 5) === null);
check('read: pickTarget empty / all-dead → null',
pickTarget([], 0, 0) === null && pickTarget([B], 0, 0) === null);
check('read: pickTarget tolerates missing entries', pickTarget([null, undefined, A], 0, 0) === A);
check('read: pickTarget honors the qualifies predicate (the on-screen seam)',
pickTarget([A, C], 0, 0, Infinity, (e) => e.x < 50) === A &&
pickTarget([C], 0, 0, Infinity, (e) => e.x < 50) === null);
// inView — the ON-SCREEN RULE's pure geometry (a craft's visual
// circle × the camera's visible rect): a craft is in view while ANY
// part of it is; it is off-screen only once fully out. A null/zero
// view (headless) = no constraint.
const view = { x: 0, y: 0, width: 1280, height: 720 };
check('view: fully inside → in view', inView(640, 360, 20, view) === true);
check('view: half off the edge → still in view (still visible)',
inView(1270, 360, 20, view) === true && inView(640, -10, 20, view) === true);
check('view: fully off the edge → out of view',
inView(1310, 360, 20, view) === false && inView(640, -100, 20, view) === false);
check('view: corner contact counts (the circle touches the rect)',
inView(1290, 710, 20, view) === true && inView(1305, 730, 10, view) === false);
check('view: no view (headless) → everything is in view',
inView(5000, 5000, 20, null) === true && inView(5000, 5000, 20, { width: 0, height: 0 }) === true);
// feedPush — the kill feed's pure state step.
const f1 = feedPush([], 'KILL A', 1000, 5, 3000);
const f2 = feedPush(f1, 'KILL B', 2000, 5, 3000);
check('read: feed lands the newest first (and keeps the rest)',
f2.length === 2 && f2[0].label === 'KILL B' && f2[1].label === 'KILL A');
check('read: feed caps at the entry count',
feedPush(feedPush(feedPush([], 'a', 0, 2, 3000), 'b', 1, 2, 3000), 'c', 2, 2, 3000).length === 2);
const f3 = feedPush(feedPush([], 'KILL OLD', 0, 5, 3000), 'KILL NEW', 4000, 5, 3000);
check('read: feed drops the expired (life 3000ms)', f3.length === 1 && f3[0].label === 'KILL NEW');
// offscreenArrow — the edge-arrow geometry (pure).
const VW = 1280, VH = 720, PAD = 28;
check('read: on-screen target → no arrow', offscreenArrow(VW, VH, 640, 360, PAD) === null);
const right = offscreenArrow(VW, VH, 2000, 360, PAD);
check('read: off-screen right → arrow on the right edge, aimed +x',
right && Math.abs(right.x - (VW - PAD)) < 1e-9 && Math.abs(right.angle) < 1e-9);
const up = offscreenArrow(VW, VH, 640, -500, PAD);
check('read: off-screen up → arrow on the top edge, aimed -y',
up && Math.abs(up.y - PAD) < 1e-9 && approx(up.angle, -Math.PI / 2, 1e-9));
const diag = offscreenArrow(VW, VH, 2000, -500, PAD);
check('read: diagonal → the point sits on the inset edge, aimed NE',
diag &&
(Math.abs(diag.x - (VW - PAD)) < 1e-9 || Math.abs(diag.y - PAD) < 1e-9) &&
diag.angle < 0 && diag.angle > -Math.PI / 2);
// CombatHud — the shared layer running headless on the stub scene.
const scene = makeScene({ withSheet: true });
const enemies = [
{ x: 300, y: 300, dead: false, hitRadius: 20 },
{ x: 900, y: 400, dead: true, hitRadius: 20 }, // the threat cue skips it
];
const rects = { shield: { x: 44, y: 75, w: 210, h: 9 }, hull: { x: 44, y: 35, w: 210, h: 9 } };
const hud = new CombatHud(scene, {
getEnemies: () => enemies,
getTarget: () => enemies[0],
gaugeRect: (k) => rects[k] ?? null,
});
check('read: hud.kill lands the feed entry (the pure step)',
hud.kill('HOSTILE DESTROYED')[0].label === 'HOSTILE DESTROYED');
const overlay = hud.flash('hull');
check('read: hud.flash records the layer + the gauge rect',
overlay && overlay.width === 210 && overlay.height === 9 && hud.lastFlash.kind === 'hull');
hud.update(200); // the draw pass (reticle/threat/arrows/feed) + flash step
check('read: the draw pass runs; the flash is fading',
overlay.alpha > 0 && overlay.alpha < 0.85);
hud.update(400); // past the 340ms life → the overlay retires
check('read: the flash retires after its life',
hud.flashes.length === 0 && overlay.destroyed === true);
hud.destroy();
check('read: hud.destroy clears the bookkeeping',
hud.feed.length === 0 && hud.feedTexts.length === 0 && hud.flashes.length === 0);
// No gauge rects (a context without a bar) → the flash is a safe no-op
// that still records the layer (the dev-log contract).
const hud2 = new CombatHud(makeScene({ withSheet: true }), { getEnemies: () => [] });
check('read: flash without a gauge is a recorded no-op',
hud2.flash('shield') === null && hud2.lastFlash.kind === 'shield');
}
// ------------------------------------------------------------- enemy types
{
const base = enemyTypeConfig();
check('types: the base craft = combat.enemy (shipped)', base.hull === E('hull', 60) && base.frame === 0 && base.weapon.range === E('weapon.range', 560));
check('types: the shipped roster = raider + the seven new types (frames 0–7)',
enemyTypeIds().join(',') === 'raider,bomber,spitter,stinger,hunter,gunner,barrage,warden');
const rosterOrder = ['bomber', 'spitter', 'stinger', 'hunter', 'gunner', 'barrage', 'warden'];
check('types: frames 1–7 are distinct (one per sheet frame)',
rosterOrder.every((id, i) => enemyTypeConfig(id).frame === i + 1));
check('types: each new type declares a distinct weapon pattern',
JSON.stringify(rosterOrder.map((id) => enemyTypeConfig(id).weapon.pattern)) ===
JSON.stringify(['lance', 'spray', 'burst', 'missile', 'rapid', 'ring', 'combo']));
const shotDmg = (t) => Math.max(t.weapon.damage ?? 0, t.weapon.missileDamage ?? 0);
const climbs = (get) =>
rosterOrder.slice(1).every((id, i) => get(enemyTypeConfig(id)) > get(enemyTypeConfig(rosterOrder[i])));
check('types: difficulty climbs monotonically bomber → warden (hull, shields, per-shot damage)',
climbs((t) => t.hull) && climbs((t) => t.shields) && climbs(shotDmg));
const raider = enemyTypeConfig('raider');
check('types: raider = the base craft under a named id', raider.frame === 0 && raider.hull === base.hull && raider.weapon.range === base.weapon.range);
const bomber = enemyTypeConfig('bomber');
check('types: bomber overrides merge onto the base',
bomber.frame === 1 && bomber.speed === 105 && bomber.hull === 80 && bomber.shields === 10 &&
bomber.weapon.pattern === 'lance' && bomber.weapon.range === 760 &&
bomber.weapon.cooldown === 2.0 && bomber.weapon.damage === 10);
check('types: bomber inherits what it does not set',
bomber.weapon.color === base.weapon.color && bomber.weapon.size === base.weapon.size &&
bomber.weapon.boltLength === base.weapon.boltLength && bomber.weapon.boltWidth === base.weapon.boltWidth &&
bomber.ramDamage === base.ramDamage && bomber.hitRadius === base.hitRadius && bomber.texture === base.texture);
const unknown = enemyTypeConfig('nope');
check('types: unknown id → the base craft (warned, not thrown)', unknown === base);
const sheets = enemySheetList();
check('sheets: base first, one entry per source file (bomber shares the sheet)',
sheets.length === 1 && sheets[0].src === base.texture && sheets[0].frameWidth === 128 && sheets[0].frameHeight === 128);
const scene = makeScene({ withSheet: true });
const b = new EnemyShip(scene, 0, 0, 0, bomber);
check('enemy type: a bomber flies with its own stats (frame 1, hull 80, same scale)',
b.frame === 1 && b.maxHull === 80 && b.maxShields === 10 && approx(b.scale, E('size', 54) / 128));
}
// ------------------------------------------------------------------- waves
{
check('waves: the shipped scripts (probe/skirmish/raid)',
['probe', 'skirmish', 'raid'].every((n) => Array.isArray(config.get(`combat.waves.scripts.${n}`))));
check('waves: the pirate_ambush script ships (Pirate Problems — one rolling wave of six frame-0 raiders)', (() => {
const w = config.get('combat.waves.scripts.pirate_ambush');
return Array.isArray(w) && w.length === 1 && w[0]?.kind === 'rolling' && w[0]?.type === 'raider' && w[0]?.count === 6 && w[0]?.maxConcurrent === 3 && w[0]?.replacePerKill === 1;
})());
check('waves: the script defaults ship',
config.get('combat.waves.defaults.maxConcurrent', 0) >= 1 && config.get('combat.waves.defaults.replacePerKill', 0) >= 0);
const bad = [normalizeWaves([]), normalizeWaves({ waves: [] }), normalizeWaves('nope'), normalizeWaves([{ kind: 'rolling' }])];
check('waves: empty / unknown / shapeless scripts → rejected with an error',
bad.every((b) => !b.ok && typeof b.error === 'string'));
const inline = normalizeWaves([{ kind: 'rolling', roster: { raider: 2, bomber: 1 } }]);
check('waves: the roster weight sum = the default force', inline.ok && inline.waves[0].total === 3 && inline.waves[0].kind === 'rolling');
check('waves: the script lead/gap ship (seconds → ms)',
inline.lead === Number(config.get('combat.waves.lead', 1.5)) * 1000 && inline.waves[0].gapMs === Number(config.get('combat.waves.gap', 2.0)) * 1000);
const shorthand = normalizeWaves([{ kind: 'fixed', type: 'bomber', count: 4 }]);
check('waves: type/count shorthand → a single-type roster', shorthand.ok && shorthand.waves[0].total === 4 && Object.keys(shorthand.waves[0].roster).join() === 'bomber');
const perGap = normalizeWaves([
{ kind: 'fixed', roster: { raider: 1 }, total: 2 },
{ kind: 'fixed', roster: { raider: 1 }, total: 2, gap: 0.5 },
]);
check('waves: a per-wave gap (s) overrides the script gap', perGap.ok && perGap.waves[1].gapMs === 500 && perGap.waves[0].gapMs === perGap.gap);
const named = normalizeWaves('raid');
check('waves: the named "raid" script (fixed 6 → rolling 12 at cap 4)',
named.ok && named.waves.length === 2 &&
named.waves[0].kind === 'fixed' && named.waves[0].total === 6 &&
named.waves[1].kind === 'rolling' && named.waves[1].total === 12 && named.waves[1].maxConcurrent === 4);
}
{
// ROLLING: the cap holds; every kill sends its replacement in; the force is spent.
const roll = new WaveRunner(normalizeWaves([{ kind: 'rolling', roster: { raider: 1 }, total: 6, maxConcurrent: 2, replacePerKill: 1 }]));
let spawned = 0, killed = 0, peak = 0;
spawned += roll.begin().length;
peak = Math.max(peak, roll.current.alive);
check('runner: rolling begins at the cap (min(maxConcurrent, force))', roll.current.alive === 2);
while (!roll.waveCleared()) {
killed += 1;
spawned += roll.onKill().length;
peak = Math.max(peak, roll.current.alive);
if (killed > 20) throw new Error('rolling wave never cleared');
}
check('runner: rolling spends exactly its force (6 kills, 6 deployed)', killed === 6 && spawned === 6);
check('runner: the cap is never exceeded (2 on screen)', peak <= 2);
// FIXED: the whole force at once, no replacements until exhausted.
const fixed = new WaveRunner(normalizeWaves([{ kind: 'fixed', roster: { raider: 3, bomber: 1 }, total: 4 }]));
check('runner: fixed deploys the whole force at once', fixed.begin().length === 4);
let fKills = 0;
while (!fixed.waveCleared()) { fixed.onKill(); fKills += 1; if (fKills > 10) break; }
check('runner: fixed sends no replacements until exhausted', fKills === 4);
// MULTI-WAVE: next() hands over, then the run is done.
const two = new WaveRunner(normalizeWaves([
{ kind: 'fixed', roster: { raider: 1 }, total: 1 },
{ kind: 'fixed', roster: { raider: 1 }, total: 1 },
]));
two.begin();
two.onKill();
check('runner: wave 1 cleared, wave 2 follows', two.waveCleared() === true && two.hasNext === true);
check('runner: next() deploys wave 2, then the run is done', two.next().length === 1 && two.next() === null && two.done === true);
const st = two.status();
check('runner: status reports the spent run (wave 2 of 2)', st.wave === 1 && st.waves === 2 && st.state === 'done' && st.total === 1);
}
{
// The roster's weighted draws — deterministic given the rng.
const wave = { total: 4, roster: { raider: 1, bomber: 3 } };
check('roster: a draw at 0 takes the first entry', resolveRoster(wave, () => 0).every((t) => t === 'raider'));
check('roster: a draw near 1 takes the last entry', resolveRoster(wave, () => 0.999999).every((t) => t === 'bomber'));
const mixedSeq = [0.2, 0.5, 0.2, 0.5];
let mi = 0;
const mixed = resolveRoster(wave, () => mixedSeq[mi++ % mixedSeq.length]);
check('roster: draws split by weight (low → raider, mid → bomber)',
mixed.length === 4 && mixed.filter((t) => t === 'bomber').length === 2 && mixed.filter((t) => t === 'raider').length === 2);
}
{
// The RUN, driven through CombatSystem (the quest hook's exact path):
// lead → wave → gap → wave → done, kills feeding the rolling cap.
const scene = makeScene({ withSheet: true });
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const events = { start: [], clear: [], complete: null, cancel: 0 };
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
combat.autoFire.enabled = false; // the test asserts the RUN, not the guns
const script = {
waves: [
{ kind: 'rolling', roster: { raider: 1 }, total: 3, maxConcurrent: 2, replacePerKill: 1 },
{ kind: 'fixed', roster: { bomber: 1 }, total: 2 },
],
lead: 0,
gap: 0,
};
const run = combat.startWaves(script, {
onWaveStart: (i) => events.start.push(i),
onWaveClear: (i) => events.clear.push(i),
onComplete: (s) => (events.complete = s),
onCancel: () => events.cancel++,
});
check('waves/system: a run is live (the bar comes up in the lead)',
run !== null && combat.active === true && combat.waveStatus().state === 'lead');
let t = 1000;
combat.update(t, 16);
const wave1 = combat.enemies.filter((e) => e.wave && !e.dead);
check('waves/system: wave 1 deploys at the cap (rolling 2 of 3)', wave1.length === 2 && events.start.length === 1);
check('waves/system: wave ships are run-owned (no respawn clock)', wave1.every((e) => e._respawnPending !== true));
t += 16;
combat.update(t, 16);
check('waves/system: the live wave holds its cap (no unforced spawns)',
combat.enemies.filter((e) => e.wave && !e.dead).length === 2);
const killOne = () => {
const e = combat.enemies.find((x) => x.wave && !x.dead);
e.damage(999); // the real path is a tracer sweep → damage() → killEnemy
combat.killEnemy(e, t);
};
killOne();
check('waves/system: a rolling kill sends its replacement in (cap holds)',
combat.enemies.filter((e) => e.wave && !e.dead).length === 2);
killOne();
killOne();
check('waves/system: the rolling force is spent (3 kills)',
combat.enemies.filter((e) => e.wave && !e.dead).length === 0 && combat.kills === 3);
combat.update(t + 16, 16);
check('waves/system: wave 1 clears (the clear event fires)', events.clear.length === 1);
combat.update(t + 32, 16);
const bombers = combat.enemies.filter((e) => e.wave && !e.dead);
check('waves/system: wave 2 deploys whole (fixed: the force at once)', bombers.length === 2);
check('waves/system: the roster type flies (bomber stats, not the base)',
bombers.every((e) => e.maxHull === 80 && e.frame === 1));
for (const e of [...bombers]) { e.damage(999); combat.killEnemy(e, t + 48); }
combat.update(t + 64, 16);
check('waves/system: the run completes (5 spent = 3 + 2, onComplete fires)',
combat.waveStatus().state === 'done' && combat.kills === 5 &&
events.complete !== null && events.start.length === 2 && events.clear.length === 2);
check('waves/system: combat over → the bar drops (no live ships, no run)', combat.active === false);
// The NAMED script (the shape the quests reference) + cancel.
const run2 = combat.startWaves('probe', { onCancel: () => events.cancel++ });
check('waves/system: the named "probe" script starts (lead in flight)',
run2 !== null && combat.waveStatus().state === 'lead' && combat.active === true);
combat.update(t + 1000, 16);
check('waves/system: nothing deploys during the lead',
combat.enemies.filter((e) => e.wave && !e.dead).length === 0);
combat.update(t + 3200, 16);
const probe = combat.enemies.filter((e) => e.wave && !e.dead);
check('waves/system: the lead elapses → "probe" deploys (fixed, 3 base raiders)',
probe.length === 3 && probe.every((e) => e.maxHull === 60));
combat.cancelWaves(true);
check('waves/system: cancel fires the event + pulls the run ships out',
events.cancel === 1 && combat.enemies.every((e) => !e.wave));
combat.destroy();
check('waves/system: destroy clears the run', combat.waveRun === null);
}
// ------------------------------------------------- fx: white-flash integrity
{
const { Fx } = await import(pathToFileURL(join(__dirname, '../js/combat/Fx.js')).href);
const fxScene = makeScene();
const fx = new Fx(fxScene);
// A craft with the real tint seams (the stub Sprite lacks setTintMode).
const craft = {
x: 100, y: 100, hitRadius: 12, tint: 16777215, tintMode: 0,
setTint(t) { this.tint = t; return this; },
setTintMode(m) { this.tintMode = m; return this; },
};
fx.craftFlash(craft, 120);
check('fx: the craft flash goes white (tintMode 1)', craft.tintMode === 1);
check('fx: the craft is tracked for the whiteness purge', fx.crafts.has(craft));
// OVERLAPPING HITS (the "turns white and stays white" bug): a second
// hit inside the flash window must EXTEND, never stack — a stacked
// item captures the in-flash state (mode 1) as its restore value.
fx.update(60);
fx.craftFlash(craft, 120);
check('fx: overlapping hits do not stack a second flash',
fx.items.filter((i) => i.kind === 'tintFlash').length === 1);
fx.update(200); // past the extended expiry
check('fx: after expiry the craft is back in normal colors (no white stick)',
craft.tintMode === 0 && craft.tint === 16777215);
// The kill seam settles the flash immediately.
fx.craftFlash(craft, 120);
fx.releaseCraft(craft);
check('fx: releaseCraft settles the flash at the kill seam',
craft.tintMode === 0 && fx.items.filter((i) => i.kind === 'tintFlash').length === 0);
// THE PERIODIC PURGE (the backstop): a stuck white craft is reset,
// a live flash is left alone, dead crafts are pruned.
craft.tintMode = 1; // simulate a leak no seam caught
fx.purgeCraftTints();
check('fx: the purge resets a stuck white craft', craft.tintMode === 0);
fx.craftFlash(craft, 400);
fx.purgeCraftTints();
check('fx: the purge leaves a live flash alone', craft.tintMode === 1);
fx.update(500); // expiry inside this step
check('fx: the live flash still restores itself on expiry', craft.tintMode === 0);
const dead = {
x: 0, y: 0, hitRadius: 10, tint: 16777215, tintMode: 0, destroyed: true,
setTint(t) { this.tint = t; return this; },
setTintMode(m) { this.tintMode = m; return this; },
};
fx.crafts.add(dead);
fx.purgeCraftTints();
check('fx: the purge prunes destroyed crafts (no leaked references)', !fx.crafts.has(dead));
// The roster callback (both contexts wire it): a craft that NEVER took
// a flash but somehow holds the white state is still re-asserted —
// the purge covers the whole pack, not just Fx's own flashes.
const straggler = {
x: 0, y: 0, tint: 16777215, tintMode: 1,
setTint(t) { this.tint = t; return this; },
setTintMode(m) { this.tintMode = m; return this; },
};
fx.purgeRoster = () => [straggler];
fx.purgeCraftTints();
check('fx: the purge covers the roster (not just tracked flashes)', straggler.tintMode === 0);
}
// ------------------------------------------------- combat geometry (pure)
{
check('geometry: wrapAngle folds into (-π, π]',
Math.abs(wrapAngle(3 * Math.PI) + Math.PI) < 1e-9 &&
Math.abs(wrapAngle(-3 * Math.PI) + Math.PI) < 1e-9 &&
wrapAngle(0.5) === 0.5);
check('geometry: fanAngles(1 shot) is the aim itself',
fanAngles(0, 1, 1.0).length === 1 && fanAngles(0, 1, 1.0)[0] === 0);
const f5 = fanAngles(0, 5, 1.0);
check('geometry: fanAngles(5) spans the arc, ends inclusive, evenly',
f5.length === 5 && f5.every((a, i) => Math.abs(a - (-0.5 + i * 0.25)) < 1e-9));
const r8 = ringAngles(8, 0);
check('geometry: ringAngles(8) = 8 shots evenly around 2π',
r8.length === 8 && r8.every((a, i) => Math.abs(a - i * Math.PI / 4) < 1e-9));
check('geometry: homingStep with no turn budget holds the heading',
homingStep(0, 0, 0, 100, 0, 1, 0) === 0);
check('geometry: the homing turn is capped at turnRate×dt (the dodge rule)',
Math.abs(homingStep(0, 0, 0, 0, 100, 2.0, 0.05) - 0.1) < 1e-9);
check('geometry: a close target is reached exactly (no overshoot)',
homingStep(0, 0, 0, 10, 0.5, 2.0, 0.05) === Math.atan2(0.5, 10));
}
// ------------------------------------------------- weapons: the seven patterns
{
const scene = makeScene({ withSheet: true });
scene.sound = { play: () => {} };
scene.cache = { audio: { has: () => true } };
const mkHost = () => ({
scene,
fx: { muzzle: () => {}, flash: () => {}, explode: () => {} },
missiles: [],
bolts: [],
spawnBullet(x, y, a, wpn, owner) { this.bolts.push({ x, y, a, wpn, owner }); },
hitsSwept: () => false,
playerHits: [],
damagePlayer(d, o) { this.playerHits.push({ d, o }); },
});
const ship = { x: 0, y: 0, hitRadius: 20 };
const player = { x: 100, y: 0 }; // sits straight along +x — the aim is 0
check('weapons: patternOf defaults to the base lance',
patternOf(undefined) === 'lance' && patternOf({}) === 'lance' &&
patternOf({ pattern: 'spray' }) === 'spray');
{
const h = mkHost();
const kind = firePattern(h, ship, player, { spread: 0 });
check('weapons: lance = one bolt, aimed exactly at the player (zero spread)',
kind === 'lance' && h.bolts.length === 1 && h.bolts[0].owner === 'enemy' &&
Math.abs(h.bolts[0].a) < 1e-9 && h.missiles.length === 0);
}
{
const h = mkHost();
const wpn = enemyTypeConfig('spitter').weapon;
const kind = firePattern(h, ship, player, wpn);
const span = Math.max(...h.bolts.map((b) => b.a)) - Math.min(...h.bolts.map((b) => b.a));
check('weapons: spray = a fan of five bolts (spitter)',
kind === 'spray' && h.bolts.length === 5 && h.missiles.length === 0);
check('weapons: the fan holds its width (± the aim jitter)',
span >= wpn.fan - 0.065 && span <= wpn.fan + 0.065);
}
{
const h = mkHost();
const wpn = enemyTypeConfig('stinger').weapon;
const kind = firePattern(h, ship, player, wpn);
const speeds = h.bolts.map((b) => b.wpn.bulletSpeed);
const span = Math.max(...h.bolts.map((b) => b.a)) - Math.min(...h.bolts.map((b) => b.a));
check('weapons: burst = a tight 3-shot stinger, staggered speeds (stinger)',
kind === 'burst' && h.bolts.length === 3 && new Set(speeds).size === 3 &&
span <= (wpn.burstFan ?? wpn.fan ?? 0.08) + 0.05);
}
{
const h = mkHost();
const wpn = enemyTypeConfig('barrage').weapon;
const kind = firePattern(h, ship, player, wpn);
const as = h.bolts.map((b) => b.a).sort((x, y) => x - y);
const even = as.length === 8 && as.every((a, i) => {
const next = i === 7 ? as[0] + Math.PI * 2 : as[i + 1];
return Math.abs(next - a - Math.PI / 4) < 1e-9;
});
check('weapons: ring = eight bolts evenly around the craft (barrage)', kind === 'ring' && even);
}
{
const h = mkHost();
const wpn = enemyTypeConfig('gunner').weapon;
const kind = firePattern(h, ship, player, wpn);
check('weapons: rapid = one bolt per trigger on a short cooldown (gunner)',
kind === 'rapid' && h.bolts.length === 1 && h.missiles.length === 0 && wpn.cooldown < 0.75);
}
{
const h = mkHost();
const wpn = enemyTypeConfig('hunter').weapon;
const kind = firePattern(h, ship, player, wpn);
const m = h.missiles[0];
check('weapons: missile = a heat-seeker, no bolts (hunter)',
kind === 'missile' && h.bolts.length === 0 && h.missiles.length === 1 && m !== undefined);
check('weapons: the seeker carries the dodge caps (turn < the player\'s 6.4 rad/s)',
m !== undefined && m.turnRate === wpn.missileTurn && m.turnRate < 6.4 &&
m.speed === wpn.missileSpeed && m.speed < 250 && m.damage === wpn.missileDamage);
}
{
const h = mkHost();
const wpn = enemyTypeConfig('warden').weapon;
const kind = firePattern(h, ship, player, wpn);
check('weapons: combo = the heavy bolt AND the missile (warden)',
kind === 'combo' && h.bolts.length === 1 && h.missiles.length === 1);
}
}
// ------------------------------------------------- weapons: the missile itself
{
const scene = makeScene({ withSheet: true });
const m = new Missile(scene, {
x: 0, y: 0, angle: 0, speed: 200, turnRate: 2.2, damage: 14, life: 4,
color: '#ff5d2d', size: 18,
});
check('missile: launches along its heading, world-sized, with a hit radius',
Math.abs(m.body.velocity.x - 200) < 1e-9 && Math.abs(m.body.velocity.y) < 1e-9 &&
m.radius >= 4 && m.radius <= 20);
const target = { x: 0, y: 100 }; // straight "down" — the desired heading is +π/2
const h0 = m.heading;
m.update(50, target); // dt 0.05 s → the turn budget is 0.11 rad
check('missile: the homing turn per step is CAPPED (dodgeable)',
m.heading > h0 && m.heading - h0 <= 2.2 * 0.05 + 1e-9);
const h1 = m.heading;
m.update(50, null); // the player is down — the seeker flies true
check('missile: no target → flies true on its heading', Math.abs(m.heading - h1) < 1e-9);
let steps = 0;
while (!m.dead && steps < 200) { m.update(50, null); steps += 1; }
check('missile: the fuse ends it after a few seconds of flight', m.dead === true && steps < 200);
check('missile: consume() is one-shot', m.consume() === false);
}
// ------------------------------------------------- weapons: step + hit passes
{
const scene = makeScene({ withSheet: true });
scene.sound = { play: () => {} };
scene.cache = { audio: { has: () => true } };
{
let flashes = 0;
const h = { scene, missiles: [], fx: { flash: () => { flashes += 1; }, explode: () => {} } };
h.missiles.push(new Missile(scene, {
x: 5, y: 100, angle: 0, speed: 100, turnRate: 1, damage: 10, life: 4, size: 16,
}));
stepMissiles(h, 16, null, { inset: 10, w: 400, h: 400 });
check('missiles: the arena wall eats a rocket (the range)', h.missiles.length === 0 && flashes === 1);
}
{
let booms = 0;
const h = {
scene, missiles: [],
fx: { flash: () => {}, explode: () => { booms += 1; } },
hitsSwept: () => true,
playerHits: [],
damagePlayer(d, o) { this.playerHits.push({ d, o }); },
};
h.missiles.push(new Missile(scene, {
x: 10, y: 10, angle: 0, speed: 100, turnRate: 1, damage: 14, life: 4, size: 16,
}));
missilesHitPlayer(h, { x: 12, y: 12 }, 12, 16);
check('missiles: a hit deals the missile damage, blooms, consumes',
h.playerHits.length === 1 && h.playerHits[0].d === 14 && booms === 1 && h.missiles.length === 0);
check('missiles: the hit bearing = the incoming direction',
Math.abs(h.playerHits[0].o.bearing - Math.PI) < 1e-9);
}
}
// ------------------------------------------------- the ?combat demo script
{
const r = normalizeWaves('combat_demo');
check('waves: the combat_demo script normalizes (7 rolling single-type waves)',
r.ok && r.waves.length === 7 &&
r.waves.every((w) =>
w.kind === 'rolling' && w.total === 6 && w.maxConcurrent === 3 && w.replacePerKill === 1));
const order = ['bomber', 'spitter', 'stinger', 'hunter', 'gunner', 'barrage', 'warden'];
check('waves: combat_demo order = difficulty (bomber → warden)',
r.ok && r.waves.map((w) => Object.keys(w.roster).join()).join(',') === order.join(','));
}
// ------------------------------------------------- the ?combat demo, end-to-end
{
const scene = makeScene({ withSheet: true });
const ship = scene.physics.add.existing(
new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const events = { start: 0, clear: 0, complete: null };
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
combat.autoFire.enabled = false; // the test asserts the RUN, not the guns
combat.startWaves('combat_demo', {
onWaveStart: () => { events.start += 1; },
onWaveClear: () => { events.clear += 1; },
onComplete: (s) => { events.complete = s; },
});
check('waves/system: the demo boot starts the 7-wave run (lead in flight)',
combat.waveStatus().state === 'lead' && combat.active === true);
let t = 0;
let okAll = true;
const liveNow = () => combat.enemies.filter((e) => e.wave && !e.dead);
for (let wave = 0; wave < 7; wave++) {
const frame = wave + 1;
for (let tries = 0; tries < 40 && liveNow().length < 3; tries++) {
t += 200;
combat.update(t, 16);
}
okAll = okAll && liveNow().length === 3 && liveNow().every((e) => e.frame === frame);
// 6 ships per wave, 3 on screen: the 3 replacements ride the first 3
// kills (replacePerKill = 1), then the force drains to zero.
const expectLen = [3, 3, 3, 2, 1, 0];
for (let k = 0; k < 6; k++) {
const e = liveNow().find((x) => x.frame === frame);
if (!e) { okAll = false; break; }
e.damage(999); // the real path is a tracer sweep → damage() → killEnemy
combat.killEnemy(e, t);
if (k < 5) {
combat.update(t, 16);
okAll = okAll && liveNow().length === expectLen[k]; // cap holds, then drains
}
}
t += 16;
combat.update(t, 16); // the clear bookkeeping
okAll = okAll && liveNow().length === 0; // the wave is spent
t += 16;
combat.update(t, 16); // the clear bookkeeping
}
check('waves/system: the demo runs all 7 waves in order, 3 on screen, a replacement per kill', okAll);
check('waves/system: the demo completes (42 kills = 7 × 6)',
combat.kills === 42 && combat.waveStatus().state === 'done' && events.complete !== null);
check('waves/system: the demo fires 7 starts + 7 clears', events.start === 7 && events.clear === 7);
}
// ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
process.exit(failures === 0 ? 0 : 1);