/** * 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 // THE FACTION CROSSFIRE (the war-zones — both fleets shoot and kill each // other, and the player's disposition is the only thing that decides who // may fight the PLAYER): the crossfire collide passes (a faction's bolts / // seekers drop a hostile-faction craft — never their own wing, never the // classic pack), the SILENT npc kill (no kill count — the bounty is the // player's, the war isn't), and the peel-off (the last survivor of a // neutral-to-the-player side flies away once its rivals are spent, and the // cull clears the sky for the director's cadence). { 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: () => {} }); const tag = (e, fid, hostileToPlayer) => { e.factionId = fid; e.hostileToPlayer = hostileToPlayer; e.respawn = false; e.nextFireAt = 999999999; // hold their own lances — the test drives the volleys return e; }; const zen = tag(combat.spawnEnemy(500, 360, 0), 'zenith', false); const apx = tag(combat.spawnEnemy(780, 360, 1), 'apex', false); const zen2 = tag(combat.spawnEnemy(500, 300, 2), 'zenith', false); const apx2 = tag(combat.spawnEnemy(780, 420, 3), 'apex', false); // TARGETING: each ship's target is the RIVAL faction — the player is not // in the fight (both sides stand neutral to it). check('crossfire: a faction ship targets the rival faction', combat.pickEnemyTarget(zen, ship) === apx && combat.pickEnemyTarget(apx, ship) === zen); check('crossfire: neutral-to-the-player — the player pool is empty (no auto-fire)', combat._playerPool().length === 0 && combat.playerTargetable(zen) === false && combat.playerTargetable(apx) === false); // CROSSFIRE SEEKERS: a faction's missile drops a hostile-faction craft // (the same one-kill read; the seeker's own faction is the friendly-fire // rule — and its neutral disposition keeps it off the player's hull). // A minimal seeker stub, held still on the victim's line — the pass // reads exactly this shape. const seeker = { x: 780 - 5, y: 360, radius: 12, dead: false, owner: 'enemy', factionId: 'zenith', hostileToPlayer: false, targetRef: apx, damage: 999, heading: 0, body: { velocity: { x: 0, y: 0 } }, update: () => {}, // held on the line — the hit pass settles it consume: () => { seeker.dead = true; return true; }, }; combat.missiles.push(seeker); combat.update(1000, 16); check('crossfire: a faction seeker drops the rival (and is spent)', seeker.dead === true && apx.dead === true); check('crossfire: the npc kill banks no kill count (not a player kill)', combat.kills === 0); check('crossfire: the neutral seeker fizzles past the ship (no player threat)', ship.dead !== true); // CROSSFIRE BOLTS: zenith's lance drops the other apex hull. combat.spawnBullet(780 - 18, 420, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16, factionId: 'zenith' }, 'enemy'); combat.update(1016, 16); check('crossfire: the rival hull takes the bolt and goes down', apx2.dead === true); // FRIENDLY FIRE: the same volley passes through the shooter's own wing. combat.spawnBullet(500 - 18, 300, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16, factionId: 'zenith' }, 'enemy'); combat.update(1032, 16); check('crossfire: a faction volley never touches its own wing', zen2.dead === false); // CLASSIC PACK: a bolt with no faction stamp never shoots the pack // (pirates don't fire on pirates — the seam holds). combat.spawnBullet(500 - 18, 300, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'enemy'); combat.update(1048, 16); check('crossfire: a classic bolt passes through the pack', zen2.dead === false); // PEEL-OFF: the apex side is all spent — the neutral zenith survivors // disengage (they steer AWAY from the ship, then the cull collects them // off-screen — the sky clears for the director's cadence). combat.update(1064, 16); check("peel: the survivors' disengage arms (peeling)", zen.peeling === true && zen2.peeling === true); check('peel: the velocity runs away from the player', zen.body && zen.body.velocity.x < 0 && zen2.body && zen2.body.velocity.x < 0); let frames = 0; while ((combat.enemies.includes(zen) || combat.enemies.includes(zen2)) && frames < 240) { if (zen.body) { zen.x += zen.body.velocity.x * 0.016; zen.y += zen.body.velocity.y * 0.016; } if (zen2.body) { zen2.x += zen2.body.velocity.x * 0.016; zen2.y += zen2.body.velocity.y * 0.016; } combat.update(1064 + (frames + 1) * 16, 16); frames++; } check('peel: off-screen, the survivors are culled (the sky clears)', frames < 240 && !combat.enemies.includes(zen) && !combat.enemies.includes(zen2) && zen._peelGone === true && zen2._peelGone === true); // HOSTILE DISPOSITION: a faction ship the player HAS heat on keeps the // classic fight — with no rival up, the player is its target (and the // player's guns may engage it in return). const hot = tag(combat.spawnEnemy(500, 360, 3), 'apex', true); check('disposition: hostile-to-the-player — the player is the fallback target', combat.pickEnemyTarget(hot, ship) === ship); check('disposition: the player guns may engage a hostile faction (pool + lock)', combat._playerPool().includes(hot) && combat.nearestEnemy(640, 360) === hot); combat.destroy(); } console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`); process.exit(failures === 0 ? 0 : 1);