1807 lines
88 KiB
JavaScript
1807 lines
88 KiB
JavaScript
/**
|
||
* 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 twenty-four faction fighters',
|
||
enemyTypeIds().length === 32 &&
|
||
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' &&
|
||
enemyTypeIds().slice(24, 32).join(',') === 'zenith_wingblade,zenith_divefang,zenith_kestrel,zenith_reefrunner,zenith_voltspire,zenith_stormweft,zenith_arcpulse,zenith_tempest');
|
||
|
||
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 twenty-four faction fighters' frames (30-37, 40-47,
|
||
// 50-57) 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), ...Array(8).keys().map((i) => i + 50))) {
|
||
if (cellOpaque(idx) === 0) blank.push(idx);
|
||
}
|
||
check('sheet: every faction frame (30-37 the APEX, 40-47 the MINDWEAVE, 50-57 the ZENITH) carries art (no blank/invisible ships)',
|
||
blank.length === 0, blank.length ? `blank frames: ${blank.join(', ')}` : '24/24 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);
|