orbit/dev/combat.test.mjs

1806 lines
88 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,
neutralTypeConfig, neutralTypeIds, neutralSheetList,
} = 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 { factionMeta, factionTypeIds, factionIds } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href
);
const { behindPoint, offsetPoint, pushImpulse, stepAbility, stepTkwaves } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyAbilities.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 thirteen combat events, each mapped',
Object.keys(COMBAT_SFX).sort().join(',') ===
'blink,bola_snag,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);
const K = { x: 1, y: 0, dead: false, cloaked: true, hitRadius: 20 }; // cloaked — never qualifies
check('read: pickTarget skips cloaked hostiles (untargetable until reveal)',
pickTarget([K, A, C], 0, 0) === A && pickTarget([K], 0, 0) === null);
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 = the eight classics + the sixteen faction fighters',
enemyTypeIds().length === 24 &&
enemyTypeIds().slice(0, 8).join(',') === 'raider,bomber,spitter,stinger,hunter,gunner,barrage,warden' &&
enemyTypeIds().slice(8, 16).join(',') === 'apex_raptor,apex_mauler,apex_stalker,apex_screamer,apex_reaver,apex_fang,apex_tyrant,apex_alpha' &&
enemyTypeIds().slice(16, 24).join(',') === 'weave_mindblade,weave_telekine,weave_phantasm,weave_weaver,weave_lure,weave_dreamer,weave_serpent,weave_overmind');
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));
}
// ------------------------------------------------------------- neutral types
{
const base = neutralTypeConfig();
check('neutral: the base craft = combat.neutral (the frames 20–27 sheet, player-side)',
base.frame === 20 && base.side === 'player' && base.texture === E('texture', 'assets/images/ships-enemy.png') &&
base.respawn === false && base.ramDamage === 0);
const ramp = neutralTypeIds().slice().sort((a, b) => neutralTypeConfig(a).frame - neutralTypeConfig(b).frame);
check('neutral: the shipped roster = 8 distinct crafts, frames 20–27',
ramp.length === 8 && ramp.every((id, i) => neutralTypeConfig(id).frame === 20 + i));
check('neutral: each type declares a DISTINCT weapon pattern',
new Set(ramp.map((id) => neutralTypeConfig(id).weapon.pattern)).size === 8);
const climbs = (get) => ramp.slice(1).every((id, i) => get(neutralTypeConfig(id)) > get(neutralTypeConfig(ramp[i])));
check('neutral: the stats ramp climbs monotonically frame 20 → 27 (hull + shields)',
climbs((t) => t.hull) && climbs((t) => t.shields));
check('neutral: xp follows the (hull+shields)/5 rule',
ramp.every((id) => {
const t = neutralTypeConfig(id);
return t.xp === Math.round((t.hull + t.shields) / 5);
}));
check('neutral: the merge works (frame 21 = the rapid, inheriting base texture/size/hitRadius)', (() => {
const id = ramp.find((x) => neutralTypeConfig(x).frame === 21);
const t = neutralTypeConfig(id);
return t.weapon.pattern === 'rapid' && t.texture === base.texture && t.size === base.size && t.hitRadius === base.hitRadius;
})());
check('neutral: unknown id → the base craft (warned, not thrown)', neutralTypeConfig('nope') === base);
check('neutral: every type stays player-side (never hostile)',
ramp.every((id) => neutralTypeConfig(id).side === 'player'));
const sheets = neutralSheetList();
check('neutral: the sheet seam resolves (the shared enemy sheet, 128×128 frames)',
sheets.length === 1 && sheets[0].src === base.texture && sheets[0].frameWidth === 128 && sheets[0].frameHeight === 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);
}
// ------------------------------------------- waves: THE ELITE + the halt seam
{
// The elite (a bounty hunt's mark beside its escort): declared on the wave,
// deployed with the OPENING group, exactly once, buffed by the DATA's scales
// and TAGGED so a quest can recognise the kill.
const eNorm = normalizeWaves([
{ kind: 'rolling', roster: { raider: 8, gunner: 1 }, total: 9, maxConcurrent: 3, replacePerKill: 1, elite: { type: 'gunner', healthScale: 2, damageScale: 2 } },
]);
check('waves/elite: the wave keeps its elite block through normalizeWaves', eNorm.ok && eNorm.waves[0].elite?.type === 'gunner' && eNorm.waves[0].elite.healthScale === 2);
const order = resolveRoster(eNorm.waves[0], () => 0.5);
check('waves/elite: the force is still nine deep', order.length === 9);
const marks = order.filter((s) => typeof s === 'object' && s?.type === 'gunner');
check('waves/elite: exactly ONE mark in the force (the rest are the escort)', marks.length === 1);
check('waves/elite: the mark rides the HEAD of the spawn order (the opening group)', typeof order[0] === 'object' && order[0].type === 'gunner');
check('waves/elite: the escort draws the remaining types only', order.slice(1).every((s) => s === 'raider'));
const noElite = resolveRoster(normalizeWaves([{ kind: 'rolling', roster: { raider: 1 }, total: 6 }]).waves[0], () => 0.5);
check('waves/elite: a wave WITHOUT an elite is untouched (bare type ids)', noElite.length === 6 && noElite.every((s) => s === 'raider'));
// The RUN: the mark lands with the first burst, doubled by the data, and
// its kill HALTS the run — survivors stay hostile, nothing else deploys.
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 combat = new CombatSystem(scene, { ship, onKill: () => {} });
combat.autoFire.enabled = false;
combat.startWaves({ waves: [{ kind: 'rolling', roster: { raider: 8, gunner: 1 }, total: 9, maxConcurrent: 3, replacePerKill: 1, elite: { type: 'gunner', healthScale: 2, damageScale: 2, name: 'Ilse Vanturi' } }], lead: 0 });
combat.update(1000, 16);
const burst = combat.enemies.filter((e) => e.wave && !e.dead);
const mark = combat.enemies.find((e) => e.elite);
const gunnerCfg = (await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href)).enemyTypeConfig('gunner');
check('waves/elite: the mark deploys WITH the opening group (3 on screen, mark among them)', burst.length === 3 && !!mark);
check('waves/elite: the mark is tagged + named (the kill is identifiable)', mark?.wave === true && mark?.markName === 'Ilse Vanturi');
check('waves/elite: hull AND shields ride the health scale', mark.maxHull === gunnerCfg.hull * 2 && mark.shields === (gunnerCfg.shields || 0) * 2);
check('waves/elite: its weapon rides the damage scale', Math.round(mark.wpn.damage) >= Math.round((Number(gunnerCfg.weapon?.damage) || 8) * 2));
const rawGunner = config.section('combat').enemyTypes.gunner;
check('waves/elite: the TYPE’s own data is never mutated (a buffed craft, not a re-tuned frame)', Number(rawGunner.weapon?.damage ?? 8) <= Number(mark.wpn.damage) / 2 + 1);
combat.haltWaves();
check('waves/halt: the run halts without pulling the ships out', combat.waveStatus().state === 'halted' && combat.enemies.filter((e) => e.wave && !e.dead).length === burst.length);
const before = combat.enemies.filter((e) => e.wave && !e.dead).length;
for (let i = 0; i < 12; i++) combat.update(2000 + i * 500, 16);
check('waves/halt: nothing further deploys while it is halted', combat.enemies.filter((e) => e.wave && !e.dead).length === before);
const survivors = combat.enemies.filter((e) => e.wave && !e.dead);
for (const e of survivors) { e.damage(9999); combat.killEnemy(e, 9000); }
check('waves/halt: the survivors do NOT reform (hostile, spent — no respawn clock)', survivors.every((e) => e._respawnPending !== true && e.respawnAt === 0));
combat.update(9100, 16);
check('waves/halt: the last survivor down ends the run (no onComplete re-arm)', combat.waveStatus().state === 'done' && combat.active === false);
combat.destroy();
}
// ------------------------------------------------- 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 eight 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);
}
{
const h = mkHost();
const wpn = { pattern: 'volley', bolts: 7, fan: 1.5, stagger: 0.35, bulletSpeed: 280, spread: 0 };
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: volley = a WIDE 7-bolt fan with staggered speeds (the 8th pattern)',
kind === 'volley' && h.bolts.length === 7 && h.missiles.length === 0 && new Set(speeds).size === 7);
check('weapons: the volley fan holds its width (± the aim jitter)',
span >= wpn.fan - 0.065 && span <= wpn.fan + 0.065);
}
}
// ------------------------------------------------- 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);
}
// -------------------------------------------------------------- comm rolls
// The comming type's VOICE (data/combat.json → enemyTypes.raider.comms; the
// comms themselves are data/comms.json's pirate-attack / pirate-defeat —
// the clip + the random voice + the line): the 50% ON-SCREEN attack roll
// (once per pirate, on its first fly-in), the 20% ROLLING-ARRIVAL attack
// roll (a new pirate sent into a rolling wave), and the 20% DEFEAT roll
// on the kill. The dice are CombatSystem's, the seam is o.onComm (the
// scene's CommsHub trigger). The rolls ride Math.random, so the checks
// stub it (hit = always < chance, miss = always >= chance) and restore it.
{
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 comms = [];
const combat = new CombatSystem(scene, { ship, onComm: (trigger) => comms.push(trigger) });
const realRandom = Math.random;
const rollHit = () => { Math.random = () => 0; }; // every roll succeeds (0 < chance)
const rollMiss = () => { Math.random = () => 0.999; }; // every roll fails (>= chance)
const count = (name) => comms.filter((c) => c === name).length;
try {
// The config rides the TYPE (data/combat.json → enemyTypes): the raider
// (the pirate) carries the block; a type without one rides commless.
const pirate = combat.spawnEnemy(100, 100, 0, 'raider');
const bomber = combat.spawnEnemy(200, 200, 1, 'bomber');
check('comms: the raider carries its comm rolls (the pirate)',
pirate.comms?.attack?.trigger === 'pirateAttack' && pirate.comms?.defeat?.trigger === 'pirateDefeat');
check('comms: a type without a comms block rides commless (the rolls no-op)',
bomber.comms === null);
// ON-SCREEN (50%): headless has no camera, so inView is a no-op — the
// craft is "on screen" from its first frame, and the first update is
// its fly-in. A forced hit arms the attack trigger…
rollHit();
combat.update(1000, 16);
check('comms: the on-screen roll arms the attack comm (the 50% hit)',
count('pirateAttack') === 1);
// …and a pirate rolls it ONCE (the edge is spent — no second line).
combat.update(1016, 16);
check('comms: the on-screen roll fires once per pirate', count('pirateAttack') === 1);
// …while a forced miss arms nothing.
rollMiss();
const raider2 = combat.spawnEnemy(300, 300, 2, 'raider');
combat.update(1032, 16);
check('comms: the on-screen roll\'s miss arms nothing (the 50% miss)',
count('pirateAttack') === 1 && raider2._commedAttack === false);
// DEFEAT (20%): a downed pirate\'s line — the forced hit arms the defeat
// trigger; the commless bomber\'s death stays quiet.
rollHit();
pirate.damage(9999);
combat.killEnemy(pirate, 2000);
check('comms: the defeat roll arms the defeat comm (the 20% hit)',
count('pirateDefeat') === 1);
rollMiss();
bomber.damage(9999);
combat.killEnemy(bomber, 2016);
check('comms: a commless type\'s death arms no defeat comm', count('pirateDefeat') === 1);
// ROLLING ARRIVAL (20%): a NEW pirate sent into a rolling wave (the
// opening burst, then each kill\'s replacement) rolls its own attack
// comm on spawn — a hit marks the ship, so the on-screen roll (which
// the next update would otherwise run) can\'t stack on top.
const atkBefore = count('pirateAttack');
const defBefore = count('pirateDefeat');
rollHit();
combat.startWaves(
{ waves: [{ kind: 'rolling', roster: { raider: 1 }, total: 2, maxConcurrent: 1, replacePerKill: 1 }], lead: 0, gap: 0 },
{},
);
combat.update(3000, 16);
const live = combat.enemies.filter((e) => e.wave && !e.dead);
check('comms: the rolling opening burst comms on arrival (the 20% hit)',
live.length === 1 && count('pirateAttack') === atkBefore + 1);
check('comms: a hit arrival marks the ship (the on-screen roll can\'t stack)',
live.length === 1 && live[0]._commedAttack === true);
live[0].damage(9999);
combat.killEnemy(live[0], 3016);
combat.update(3032, 16);
const replaced = combat.enemies.filter((e) => e.wave && !e.dead);
check('comms: the rolling replacement deploys + comms on arrival',
replaced.length === 1 && replaced[0]._commedAttack === true &&
count('pirateAttack') === atkBefore + 2);
check('comms: the arrival pirate\'s defeat rides its 20% roll (hit)',
count('pirateDefeat') === defBefore + 1);
combat.cancelWaves(true);
} finally {
Math.random = realRandom;
}
}
// ------------------------------------------------- the faction duel arena (apex / mindweave)
{
const apexIds = factionTypeIds('apex');
const mwIds = factionTypeIds('mindweave');
const all = [...apexIds, ...mwIds];
check('factions: two rosters, eight fighters each (the duel arena)',
factionIds().includes('apex') && factionIds().includes('mindweave') &&
apexIds.length === 8 && mwIds.length === 8);
check('factions: every fighter is a registered enemyType with a name',
all.every((id) => {
const c = enemyTypeConfig(id);
return typeof c.name === 'string' && c.name !== '';
}));
check('factions: apex rides frames 30-37 (display order)',
apexIds.map((id) => enemyTypeConfig(id).frame).join(',') === '30,31,32,33,34,35,36,37');
check('factions: mindweave rides frames 40-47 (display order)',
mwIds.map((id) => enemyTypeConfig(id).frame).join(',') === '40,41,42,43,44,45,46,47');
check('factions: every fighter carries a weapon pattern AND at least one ability',
all.every((id) => {
const c = enemyTypeConfig(id);
return typeof (c.weapon ?? {}).pattern === 'string' &&
Array.isArray(c.abilities) && c.abilities.length >= 1 &&
c.abilities.every((a) => typeof a.id === 'string');
}));
// The balance rule (the user's numbers): BOTH FACTIONS = 2x the pirate's
// damage (16 = 8 x 2) and 3.5x its armor (175 + 35 = 210 = 60 x 3.5).
const raider = enemyTypeConfig('raider');
const rArmor = raider.hull + (raider.shields ?? 0);
const rDmg = raider.weapon.damage;
const armorOf = (id) => {
const c = enemyTypeConfig(id);
return c.hull + (c.shields ?? 0);
};
const dmgOf = (id) => {
const w = enemyTypeConfig(id).weapon;
return Math.max(Number(w.damage) || 0, Number(w.shellDamage) || 0, Number(w.missileDamage) || 0);
};
check('balance: every fighter armor is 3.5x the pirate (210 = 60 x 3.5)',
all.every((id) => armorOf(id) === 210 && armorOf(id) / rArmor >= 3.45 && armorOf(id) / rArmor <= 3.55));
check('balance: every fighter bolt/shell/seeker damage is 2x the pirate (16 = 8 x 2)',
all.every((id) => dmgOf(id) === 16 && dmgOf(id) / rDmg >= 1.95 && dmgOf(id) / rDmg <= 2.05));
check('balance: the XP rule of thumb holds ((hull+shields)/5 rounded = 42)',
all.every((id) => {
const c = enemyTypeConfig(id);
return c.xp === Math.round((c.hull + (c.shields ?? 0)) / 5);
}) && all.every((id) => enemyTypeConfig(id).xp === 42));
check('apex: the shared kit - the cloak on most, the flank cut on most',
apexIds.filter((id) => enemyTypeConfig(id).abilities.some((a) => a.id === 'cloaked_ambush')).length >= 4 &&
apexIds.filter((id) => enemyTypeConfig(id).abilities.some((a) => a.id === 'flank_strafe')).length >= 4);
check('mindweave: the shared kit - the blink or the phantoms on every fighter',
mwIds.every((id) => enemyTypeConfig(id).abilities.some((a) => a.id === 'blink_reposition' || a.id === 'illusion_decoy')));
check('apex: eight distinct attack styles (pattern + the full ability kit)',
new Set(apexIds.map((id) => JSON.stringify([enemyTypeConfig(id).weapon.pattern, enemyTypeConfig(id).abilities]))).size === 8);
check('mindweave: eight distinct attack styles (pattern + the full ability kit)',
new Set(mwIds.map((id) => JSON.stringify([enemyTypeConfig(id).weapon.pattern, enemyTypeConfig(id).abilities]))).size === 8);
}
// ------------------------------------------------- the faction weapon patterns (sweep / spiral / shell)
{
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,
damagePlayer() {},
});
const ship = { x: 0, y: 0, hitRadius: 20, heading: () => Math.PI / 2 };
const player = { x: 100, y: 0 };
const sweepWpn = enemyTypeConfig('apex_raptor').weapon;
const h = mkHost();
const kind = firePattern(h, ship, player, sweepWpn);
const as = h.bolts.map((b) => b.a);
const center = (Math.max(...as) + Math.min(...as)) / 2;
const span = Math.max(...as) - Math.min(...as);
check('weapons: sweep = a fan centered on the craft heading',
kind === 'sweep' && h.bolts.length === sweepWpn.bolts &&
Math.abs(center - Math.PI / 2) < 0.035 &&
span >= sweepWpn.fan - 0.07 && span <= sweepWpn.fan + 0.07);
const spiralWpn = enemyTypeConfig('weave_mindblade').weapon;
const h2 = mkHost();
const kind2 = firePattern(h2, ship, player, spiralWpn);
const sas = h2.bolts.map((b) => b.a).sort((x, y) => x - y);
const step = sas.slice(1).reduce((m, a2, i2) => Math.max(m, a2 - sas[i2]), 0);
check('weapons: spiral = the bolts stepped around the circle (the bloom)',
kind2 === 'spiral' && h2.bolts.length === spiralWpn.bolts && step > 0.45);
check('weapons: the spiral staggers its speeds (the arms open in flight)',
new Set(h2.bolts.map((b) => b.wpn.bulletSpeed)).size === spiralWpn.bolts);
const shellWpn = enemyTypeConfig('weave_dreamer').weapon;
const h3 = mkHost();
const kind3 = firePattern(h3, ship, player, shellWpn);
const s = h3.bolts[0] && h3.bolts[0].wpn;
check('weapons: shell = one large-radius slow orb with the heavy punch',
kind3 === 'shell' && h3.bolts.length === 1 && s && s.size >= 40 && s.bulletSpeed < 200 && s.damage >= 11);
const over = enemyTypeConfig('weave_overmind').weapon;
const h4 = mkHost();
check('weapons: the Overmind triple shells ride one volley (three orbs)',
firePattern(h4, ship, player, over) === 'shell' && h4.bolts.length === 3);
}
// ------------------------------------------------- the faction ability hands (cloak / blink / the tide)
{
const rMid = () => 0.5; // every injected draw = the band's midpoint
const p1 = behindPoint(0, 0, 0, 100, 0, rMid);
check('abilities: the ambush stand sits on the player REAR arc',
p1.x < 0 && Math.abs(p1.y) < 1e-9);
const p2 = offsetPoint(0, 0, 0, 50, 50, 1, rMid);
check('abilities: the blink destination honors the rear bias',
p2.x < 0 && Math.abs(p2.y) < 1e-9);
const pw = pushImpulse({ x: 0, y: 0, dir: 'push', power: 100 }, { x: 10, y: 0 });
const pl = pushImpulse({ x: 0, y: 0, dir: 'pull', power: 100 }, { x: 10, y: 0 });
check('abilities: the tide shoves away (push) or drags in (pull)',
pw.vx === 100 && pw.vy === 0 && pl.vx === -100 && pl.vy === 0);
const realRand = Math.random;
Math.random = () => 0.5;
const player = { x: 0, y: 0 };
const baseCtx = {
scene: null,
fx: { ring() {}, sparks() {} },
tkwaves: [], illusions: [],
playerFacing: () => 0,
teleportCraft(e, x, y) { e.x = x; e.y = y; },
};
try {
{
const ctx = { ...baseCtx, tkwaves: [], illusions: [] };
const e = {
x: 500, y: 0, dead: false, cloaked: false, _ab: {},
abilities: [{ id: 'cloaked_ambush', cloakMs: 3000 }],
setAlpha() {},
};
stepAbility(ctx, e, player, 0);
check('ability: the cloaked ambush phases the craft into a nearby stand',
e.cloaked === true && Math.hypot(e.x, e.y) < 400);
check('ability: the stand is BEHIND the player (x < 0)', e.x < 0);
stepAbility(ctx, e, player, 2999);
check('ability: the cloak holds until its clock runs', e.cloaked === true);
e.nextFireAt = 9999;
stepAbility(ctx, e, player, 3000);
check('ability: the reveal is the strike - cloak off, fire free',
e.cloaked === false && e.nextFireAt === 0);
}
{
const ctx = { ...baseCtx, tkwaves: [], illusions: [] };
const w = {
x: 40, y: 0, dead: false, _ab: {}, hitRadius: 20, scale: 1, sheetFrame: 43,
abilities: [{ id: 'blink_reposition', cooldown: 12 }],
setAlpha() {},
};
stepAbility(ctx, w, player, 0);
w._ab.blink_reposition.nextAt = 0;
stepAbility(ctx, w, player, 1);
const d = Math.hypot(w.x, w.y);
check('ability: the blink hops the weaver to a fresh stand (a screen away)',
(w.x !== 40 || w.y !== 0) && d >= 200 && d <= 330);
check('ability: the blink leaves a phantom AT THE OLD POSITION',
ctx.illusions.length === 1 && ctx.illusions[0].x === 40 && ctx.illusions[0].y === 0);
}
{
const ctx = { ...baseCtx, tkwaves: [], illusions: [], fx: { ring() {}, sparks() {}, flash() {} } };
const k = {
x: 300, y: 0, dead: false, _ab: {},
abilities: [{ id: 'telekinetic_wave', cooldown: 14, dir: 'push', radius: 290, power: 280 }],
};
const pl3 = { x: 100, y: 0, body: { velocity: { x: 0, y: 0 } } };
stepAbility(ctx, k, pl3, 0);
check('ability: the tide arms on first contact (silent until its clock)',
k._ab.telekinetic_wave && k._ab.telekinetic_wave.nextAt > 0 && ctx.tkwaves.length === 0);
stepAbility(ctx, k, pl3, 5600); // its clock runs (0.4 x 14 s)
check('ability: at its clock the tide BLOOMS (the wave is armed)',
ctx.tkwaves.length === 1 && ctx.tkwaves[0].power === 280);
stepTkwaves(ctx, 5600, pl3);
check('ability: the wave is silent before its arrival', pl3.body.velocity.x === 0);
stepTkwaves(ctx, 6251, pl3);
check('ability: at arrival the push tide shoves the player AWAY',
pl3.body.velocity.x < 0 && ctx.tkwaves.length === 0);
}
} finally {
Math.random = realRand;
}
}
// ------------------------------------------------- the faction duel's seam (spawn -> kill -> re-pick)
{
const scene = makeScene({ withSheet: true });
scene.sound = { play: () => {} };
scene.cache = { audio: { has: () => true } };
const ship = {
x: 0, y: 0, rotation: 0, artOffset: 0, body: { velocity: { x: 0, y: 0 } },
hitRadius: 16, _heading: () => 0, _headingRaw: () => 0,
shieldFrac: () => 1, _shieldFrac: 1, dead: false,
};
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
const d = combat.startDuel('apex');
check('duel: startDuel arms the arena state', d && d.faction === 'apex');
const e = combat.spawnDuel('apex_mauler');
check('duel: spawnDuel marks its fighter (no respawn, spent when down)',
e && e.duel === true && e.respawn === false && combat.duel.active === true &&
combat.enemies.includes(e));
const e2 = combat.spawnDuelPack('mindweave');
check('duel: the pack is the faction full roster (eight, replacing the single)',
e2.length === 8 && e2.every((x) => x.duel === true) &&
combat.enemies.length === 8 && !combat.enemies.includes(e));
for (const x of combat.enemies) x.damage(9999);
combat.update(100, 16);
let killed = 0;
const kill = combat.killEnemy.bind(combat);
for (const x of combat.enemies.slice()) { kill(x, 116); killed++; }
check('duel: the pack down-drop releases the arena (duel.active back to the picker)',
killed === 8 && combat.duel.active === false);
}
// THE SHEET ITSELF — the sixteen faction fighters' frames (30-37, 40-47)
// must carry ART: a blank cell renders as an invisible ship, which every
// other test would happily pass. A tiny PNG alpha scan (zlib inflate +
// unfilter) counts the opaque pixels per 128px cell. The shipped sheet is
// an 8-bit PALETTE PNG (PLTE + tRNS alpha); RGBA is also accepted.
{
const fs = await import('node:fs');
const zlib = await import('node:zlib');
const png = fs.readFileSync(join(__dirname, '../assets', 'images', 'ships-enemy.png'));
// Walk the chunks: IHDR (size, depth, color type) + PLTE/tRNS (palette
// alpha) + the IDAT payload.
let pos = 8, width = 0, height = 0, depth = 0, ctype = 0;
let palte = null, trns = null;
const idat = [];
while (pos < png.length) {
const len = png.readUInt32BE(pos);
const type = png.subarray(pos + 4, pos + 8).toString('latin1');
const body = png.subarray(pos + 8, pos + 8 + len);
if (type === 'IHDR') { width = body.readUInt32BE(0); height = body.readUInt32BE(4); depth = body[8]; ctype = body[9]; }
else if (type === 'PLTE') palte = body;
else if (type === 'tRNS') trns = body;
if (type === 'IDAT') idat.push(body);
pos += 12 + len;
}
const palette = ctype === 3;
const rgba = ctype === 6;
check('sheet: ships-enemy.png is a decodable 1280x1280 8-bit sheet (palette+tRNS, or RGBA)',
width === 1280 && height === 1280 && depth === 8 && (palette || rgba) && (!palette || palte));
const raw = zlib.inflateSync(Buffer.concat(idat));
const bpp = palette ? 1 : 4; // palette: one index byte per pixel; RGBA: four
const stride = width * bpp;
const px = Buffer.alloc(stride * height);
let off = 0;
for (let y = 0; y < height; y++) {
const f = raw[off]; off++;
for (let x = 0; x < stride; x++) {
const cur = raw[off + x]; off++;
if (f === 0) px[y * stride + x] = cur;
else if (f === 1) px[y * stride + x] = cur + (x >= bpp ? px[y * stride + x - bpp] : 0);
else if (f === 2) px[y * stride + x] = cur + (y > 0 ? px[(y - 1) * stride + x] : 0);
else if (f === 3) px[y * stride + x] = cur + ((x >= bpp ? px[y * stride + x - bpp] : 0) + (y > 0 ? px[(y - 1) * stride + x] : 0)) / 2;
else { // Paeth
const a = x >= bpp ? px[y * stride + x - bpp] : 0;
const b = y > 0 ? px[(y - 1) * stride + x] : 0;
const c = x >= bpp && y > 0 ? px[(y - 1) * stride + x - bpp] : 0;
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
const pr = pa <= pb && pa <= pc ? a : (pb <= pc ? b : c);
px[y * stride + x] = cur + pr;
}
}
}
// A pixel is OPAQUE when its alpha is > 0: the tRNS table for the
// palette (entries past the table are opaque by the PNG spec), the
// alpha channel for RGBA.
const opaqueAt = (i) => {
if (palette) {
const a = trns && i < trns.length ? trns[i] : 255;
return a > 0;
}
return px[i * 4 + 3] > 0;
};
const cellOpaque = (idx) => {
const r = Math.floor(idx / 10) * 128, c = (idx % 10) * 128;
let n = 0;
for (let y = r; y < r + 128; y += 4) for (let x = c; x < c + 128; x += 4) if (opaqueAt(y * width + x)) n++;
return n;
};
const blank = [];
for (const idx of [...Array(38).keys()].filter((i) => i >= 30).concat([...Array(48).keys()].filter((i) => i >= 40))) {
if (cellOpaque(idx) === 0) blank.push(idx);
}
check('sheet: every faction frame (30-37 the APEX, 40-47 the MINDWEAVE) carries art (no blank/invisible ships)',
blank.length === 0, blank.length ? `blank frames: ${blank.join(', ')}` : '16/16 painted');
const still = [0, 20].map((i) => cellOpaque(i));
check('sheet: the classic pirates\u2019 frames are untouched (still painted)', still.every((n) => n > 0), JSON.stringify(still));
}
// ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
process.exit(failures === 0 ? 0 : 1);