/** * 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 } = await import( pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href ); const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href); const { enemyTypeConfig, enemyTypeIds, enemySheetList } = await import( pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href ); const { normalizeWaves, resolveRoster, WaveRunner } = await import( pathToFileURL(join(__dirname, '../js/combat/Waves.js')).href ); const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href); const { 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 ten combat events, each mapped', Object.keys(COMBAT_SFX).sort().join(',') === 'cannon_fire,cannon_hit,down,fire,hull,impact,kill,levelup,respawn,shield'); check('sfx: every mapped voice exists in data/sfx.json', Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string')); const sets = Object.values(COMBAT_SFX).map((ns) => new Set(ns)); check('sfx: the events\u2019 voices are pairwise distinct', sets.every((a, i) => sets.every((b, j) => i === j || ![...a].some((n) => b.has(n))))); const s1 = makeSfxScene(); check('sfx: fire → the laser voice, base volume', combatSfx(s1, 'fire') === 'fire' && s1.played.length === 1 && s1.played[0].key === 'sfx_laser' && s1.played[0].volume === Number(config.get('sfx.volume', 0.55))); const sC = makeSfxScene(); check('sfx: cannon_fire → the mass-cannon voice', combatSfx(sC, 'cannon_fire') === 'cannon_fire' && sC.played.length === 1 && sC.played[0].key === 'sfx_mass_cannon'); const sCh = makeSfxScene(); check('sfx: cannon_hit → the mass-cannon hit voice', combatSfx(sCh, 'cannon_hit') === 'cannon_hit' && sCh.played.length === 1 && sCh.played[0].key === 'sfx_mass_cannon_hit'); const s2 = makeSfxScene(); combatSfx(s2, 'fire', { vol: 0.45 }); // the vol knob scales the volume check('sfx: vol scales the volume', Math.abs(s2.played[0].volume - Number(config.get('sfx.volume', 0.55)) * 0.45) < 1e-9); const killKeys = new Set(); for (let i = 0; i < 60; i++) { const sc = makeSfxScene(); combatSfx(sc, 'kill'); killKeys.add(sc.played[0]?.key); } check('sfx: kill picks one of the thunder set', killKeys.size >= 2 && [...killKeys].every((k) => /^sfx_thunder_0[1-4]$/.test(k))); const sU = makeSfxScene(); check('sfx: unknown event is a no-op', combatSfx(sU, 'nope') === null && sU.played.length === 0); // The shield/hull decision (one shared helper, both contexts): // in front of the hit the craft has `preShields` shield points. const hit = (pre, dmg) => { const sc = makeSfxScene(); combatSfxEnemyHit(sc, pre, dmg); return sc.played.map((p) => p.key.replace('sfx_', '')); }; check('sfx: full shields → the shield voice only', JSON.stringify(hit(40, 10)) === JSON.stringify(['solar_buzz'])); check('sfx: no shields → the hull voice only', JSON.stringify(hit(0, 10)) === JSON.stringify(['mining_split'])); check('sfx: shield breaks through → BOTH (the pop reads)', JSON.stringify(hit(5, 10)) === JSON.stringify(['solar_buzz', 'mining_split'])); // The dev log: every call is remembered (capped) + mirrored to window. combatSfx(makeSfxScene(), 'down'); const after = window.__COMBAT_SFX__ ?? null; check('sfx: dev log records the call (window.__COMBAT_SFX__)', after && after.last === 'down' && after.log.length >= 1 && after.log.length <= 64 && after.log[after.log.length - 1].event === 'down'); // The master switch: off → nothing plays, but the log still records // the call (headless verification depends on it). const saved = config.data.sfx.enabled; config.data.sfx.enabled = false; const sOff = makeSfxScene(); combatSfx(sOff, 'kill'); config.data.sfx.enabled = saved; check('sfx: master switch off → silent, still logged', sOff.played.length === 0 && (window.__COMBAT_SFX__?.last === 'kill')); } // ------------------------------------------------------- readability layer { // pickTarget — the shared "current target" rule (the range's lock and // the main game's auto-fire both run it). const A = { x: 10, y: 0, dead: false, hitRadius: 20 }; const B = { x: 3, y: 0, dead: true, hitRadius: 20 }; // dead — never qualifies const C = { x: 100, y: 0, dead: false, hitRadius: 20 }; check('read: pickTarget = the nearest LIVE hostile', pickTarget([A, B, C], 0, 0) === A); check('read: pickTarget skips the dead (even when nearest)', pickTarget([A, B, C], 2, 0) === A); check('read: pickTarget honors the range (and nulls outside it)', pickTarget([A, C], 0, 0, 50) === A && pickTarget([A, C], 0, 0, 5) === null); check('read: pickTarget empty / all-dead → null', pickTarget([], 0, 0) === null && pickTarget([B], 0, 0) === null); check('read: pickTarget tolerates missing entries', pickTarget([null, undefined, A], 0, 0) === A); check('read: pickTarget honors the qualifies predicate (the on-screen seam)', pickTarget([A, C], 0, 0, Infinity, (e) => e.x < 50) === A && pickTarget([C], 0, 0, Infinity, (e) => e.x < 50) === null); // inView — the ON-SCREEN RULE's pure geometry (a craft's visual // circle × the camera's visible rect): a craft is in view while ANY // part of it is; it is off-screen only once fully out. A null/zero // view (headless) = no constraint. const view = { x: 0, y: 0, width: 1280, height: 720 }; check('view: fully inside → in view', inView(640, 360, 20, view) === true); check('view: half off the edge → still in view (still visible)', inView(1270, 360, 20, view) === true && inView(640, -10, 20, view) === true); check('view: fully off the edge → out of view', inView(1310, 360, 20, view) === false && inView(640, -100, 20, view) === false); check('view: corner contact counts (the circle touches the rect)', inView(1290, 710, 20, view) === true && inView(1305, 730, 10, view) === false); check('view: no view (headless) → everything is in view', inView(5000, 5000, 20, null) === true && inView(5000, 5000, 20, { width: 0, height: 0 }) === true); // feedPush — the kill feed's pure state step. const f1 = feedPush([], 'KILL A', 1000, 5, 3000); const f2 = feedPush(f1, 'KILL B', 2000, 5, 3000); check('read: feed lands the newest first (and keeps the rest)', f2.length === 2 && f2[0].label === 'KILL B' && f2[1].label === 'KILL A'); check('read: feed caps at the entry count', feedPush(feedPush(feedPush([], 'a', 0, 2, 3000), 'b', 1, 2, 3000), 'c', 2, 2, 3000).length === 2); const f3 = feedPush(feedPush([], 'KILL OLD', 0, 5, 3000), 'KILL NEW', 4000, 5, 3000); check('read: feed drops the expired (life 3000ms)', f3.length === 1 && f3[0].label === 'KILL NEW'); // offscreenArrow — the edge-arrow geometry (pure). const VW = 1280, VH = 720, PAD = 28; check('read: on-screen target → no arrow', offscreenArrow(VW, VH, 640, 360, PAD) === null); const right = offscreenArrow(VW, VH, 2000, 360, PAD); check('read: off-screen right → arrow on the right edge, aimed +x', right && Math.abs(right.x - (VW - PAD)) < 1e-9 && Math.abs(right.angle) < 1e-9); const up = offscreenArrow(VW, VH, 640, -500, PAD); check('read: off-screen up → arrow on the top edge, aimed -y', up && Math.abs(up.y - PAD) < 1e-9 && approx(up.angle, -Math.PI / 2, 1e-9)); const diag = offscreenArrow(VW, VH, 2000, -500, PAD); check('read: diagonal → the point sits on the inset edge, aimed NE', diag && (Math.abs(diag.x - (VW - PAD)) < 1e-9 || Math.abs(diag.y - PAD) < 1e-9) && diag.angle < 0 && diag.angle > -Math.PI / 2); // CombatHud — the shared layer running headless on the stub scene. const scene = makeScene({ withSheet: true }); const enemies = [ { x: 300, y: 300, dead: false, hitRadius: 20 }, { x: 900, y: 400, dead: true, hitRadius: 20 }, // the threat cue skips it ]; const rects = { shield: { x: 44, y: 75, w: 210, h: 9 }, hull: { x: 44, y: 35, w: 210, h: 9 } }; const hud = new CombatHud(scene, { getEnemies: () => enemies, getTarget: () => enemies[0], gaugeRect: (k) => rects[k] ?? null, }); check('read: hud.kill lands the feed entry (the pure step)', hud.kill('HOSTILE DESTROYED')[0].label === 'HOSTILE DESTROYED'); const overlay = hud.flash('hull'); check('read: hud.flash records the layer + the gauge rect', overlay && overlay.width === 210 && overlay.height === 9 && hud.lastFlash.kind === 'hull'); hud.update(200); // the draw pass (reticle/threat/arrows/feed) + flash step check('read: the draw pass runs; the flash is fading', overlay.alpha > 0 && overlay.alpha < 0.85); hud.update(400); // past the 340ms life → the overlay retires check('read: the flash retires after its life', hud.flashes.length === 0 && overlay.destroyed === true); hud.destroy(); check('read: hud.destroy clears the bookkeeping', hud.feed.length === 0 && hud.feedTexts.length === 0 && hud.flashes.length === 0); // No gauge rects (a context without a bar) → the flash is a safe no-op // that still records the layer (the dev-log contract). const hud2 = new CombatHud(makeScene({ withSheet: true }), { getEnemies: () => [] }); check('read: flash without a gauge is a recorded no-op', hud2.flash('shield') === null && hud2.lastFlash.kind === 'shield'); } // ------------------------------------------------------------- enemy types { const base = enemyTypeConfig(); check('types: the base craft = combat.enemy (shipped)', base.hull === E('hull', 60) && base.frame === 0 && base.weapon.range === E('weapon.range', 560)); check('types: the shipped roster is raider + bomber', enemyTypeIds().join(',') === 'raider,bomber'); 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 === 110 && bomber.hull === 90 && bomber.shields === 20 && bomber.weapon.range === 720 && bomber.weapon.cooldown === 1.8 && bomber.weapon.damage === 12); check('types: bomber inherits what it does not set', bomber.weapon.spread === base.weapon.spread && bomber.weapon.bulletLife === base.weapon.bulletLife && 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 90, same scale)', b.frame === 1 && b.maxHull === 90 && b.maxShields === 20 && approx(b.scale, E('size', 54) / 128)); } // ------------------------------------------------------------------- waves { check('waves: the shipped scripts (probe/skirmish/raid)', ['probe', 'skirmish', 'raid'].every((n) => Array.isArray(config.get(`combat.waves.scripts.${n}`)))); check('waves: the pirate_ambush script ships (Pirate Problems — one rolling wave of six frame-0 raiders)', (() => { const w = config.get('combat.waves.scripts.pirate_ambush'); return Array.isArray(w) && w.length === 1 && w[0]?.kind === 'rolling' && w[0]?.type === 'raider' && w[0]?.count === 6 && w[0]?.maxConcurrent === 3 && w[0]?.replacePerKill === 1; })()); check('waves: the script defaults ship', config.get('combat.waves.defaults.maxConcurrent', 0) >= 1 && config.get('combat.waves.defaults.replacePerKill', 0) >= 0); const bad = [normalizeWaves([]), normalizeWaves({ waves: [] }), normalizeWaves('nope'), normalizeWaves([{ kind: 'rolling' }])]; check('waves: empty / unknown / shapeless scripts → rejected with an error', bad.every((b) => !b.ok && typeof b.error === 'string')); const inline = normalizeWaves([{ kind: 'rolling', roster: { raider: 2, bomber: 1 } }]); check('waves: the roster weight sum = the default force', inline.ok && inline.waves[0].total === 3 && inline.waves[0].kind === 'rolling'); check('waves: the script lead/gap ship (seconds → ms)', inline.lead === Number(config.get('combat.waves.lead', 1.5)) * 1000 && inline.waves[0].gapMs === Number(config.get('combat.waves.gap', 2.0)) * 1000); const shorthand = normalizeWaves([{ kind: 'fixed', type: 'bomber', count: 4 }]); check('waves: type/count shorthand → a single-type roster', shorthand.ok && shorthand.waves[0].total === 4 && Object.keys(shorthand.waves[0].roster).join() === 'bomber'); const perGap = normalizeWaves([ { kind: 'fixed', roster: { raider: 1 }, total: 2 }, { kind: 'fixed', roster: { raider: 1 }, total: 2, gap: 0.5 }, ]); check('waves: a per-wave gap (s) overrides the script gap', perGap.ok && perGap.waves[1].gapMs === 500 && perGap.waves[0].gapMs === perGap.gap); const named = normalizeWaves('raid'); check('waves: the named "raid" script (fixed 6 → rolling 12 at cap 4)', named.ok && named.waves.length === 2 && named.waves[0].kind === 'fixed' && named.waves[0].total === 6 && named.waves[1].kind === 'rolling' && named.waves[1].total === 12 && named.waves[1].maxConcurrent === 4); } { // ROLLING: the cap holds; every kill sends its replacement in; the force is spent. const roll = new WaveRunner(normalizeWaves([{ kind: 'rolling', roster: { raider: 1 }, total: 6, maxConcurrent: 2, replacePerKill: 1 }])); let spawned = 0, killed = 0, peak = 0; spawned += roll.begin().length; peak = Math.max(peak, roll.current.alive); check('runner: rolling begins at the cap (min(maxConcurrent, force))', roll.current.alive === 2); while (!roll.waveCleared()) { killed += 1; spawned += roll.onKill().length; peak = Math.max(peak, roll.current.alive); if (killed > 20) throw new Error('rolling wave never cleared'); } check('runner: rolling spends exactly its force (6 kills, 6 deployed)', killed === 6 && spawned === 6); check('runner: the cap is never exceeded (2 on screen)', peak <= 2); // FIXED: the whole force at once, no replacements until exhausted. const fixed = new WaveRunner(normalizeWaves([{ kind: 'fixed', roster: { raider: 3, bomber: 1 }, total: 4 }])); check('runner: fixed deploys the whole force at once', fixed.begin().length === 4); let fKills = 0; while (!fixed.waveCleared()) { fixed.onKill(); fKills += 1; if (fKills > 10) break; } check('runner: fixed sends no replacements until exhausted', fKills === 4); // MULTI-WAVE: next() hands over, then the run is done. const two = new WaveRunner(normalizeWaves([ { kind: 'fixed', roster: { raider: 1 }, total: 1 }, { kind: 'fixed', roster: { raider: 1 }, total: 1 }, ])); two.begin(); two.onKill(); check('runner: wave 1 cleared, wave 2 follows', two.waveCleared() === true && two.hasNext === true); check('runner: next() deploys wave 2, then the run is done', two.next().length === 1 && two.next() === null && two.done === true); const st = two.status(); check('runner: status reports the spent run (wave 2 of 2)', st.wave === 1 && st.waves === 2 && st.state === 'done' && st.total === 1); } { // The roster's weighted draws — deterministic given the rng. const wave = { total: 4, roster: { raider: 1, bomber: 3 } }; check('roster: a draw at 0 takes the first entry', resolveRoster(wave, () => 0).every((t) => t === 'raider')); check('roster: a draw near 1 takes the last entry', resolveRoster(wave, () => 0.999999).every((t) => t === 'bomber')); const mixedSeq = [0.2, 0.5, 0.2, 0.5]; let mi = 0; const mixed = resolveRoster(wave, () => mixedSeq[mi++ % mixedSeq.length]); check('roster: draws split by weight (low → raider, mid → bomber)', mixed.length === 4 && mixed.filter((t) => t === 'bomber').length === 2 && mixed.filter((t) => t === 'raider').length === 2); } { // The RUN, driven through CombatSystem (the quest hook's exact path): // lead → wave → gap → wave → done, kills feeding the rolling cap. const scene = makeScene({ withSheet: true }); const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0)); ship.radius = 12; ship.stop = () => {}; const events = { start: [], clear: [], complete: null, cancel: 0 }; const combat = new CombatSystem(scene, { ship, onKill: () => {} }); combat.autoFire.enabled = false; // the test asserts the RUN, not the guns const script = { waves: [ { kind: 'rolling', roster: { raider: 1 }, total: 3, maxConcurrent: 2, replacePerKill: 1 }, { kind: 'fixed', roster: { bomber: 1 }, total: 2 }, ], lead: 0, gap: 0, }; const run = combat.startWaves(script, { onWaveStart: (i) => events.start.push(i), onWaveClear: (i) => events.clear.push(i), onComplete: (s) => (events.complete = s), onCancel: () => events.cancel++, }); check('waves/system: a run is live (the bar comes up in the lead)', run !== null && combat.active === true && combat.waveStatus().state === 'lead'); let t = 1000; combat.update(t, 16); const wave1 = combat.enemies.filter((e) => e.wave && !e.dead); check('waves/system: wave 1 deploys at the cap (rolling 2 of 3)', wave1.length === 2 && events.start.length === 1); check('waves/system: wave ships are run-owned (no respawn clock)', wave1.every((e) => e._respawnPending !== true)); t += 16; combat.update(t, 16); check('waves/system: the live wave holds its cap (no unforced spawns)', combat.enemies.filter((e) => e.wave && !e.dead).length === 2); const killOne = () => { const e = combat.enemies.find((x) => x.wave && !x.dead); e.damage(999); // the real path is a tracer sweep → damage() → killEnemy combat.killEnemy(e, t); }; killOne(); check('waves/system: a rolling kill sends its replacement in (cap holds)', combat.enemies.filter((e) => e.wave && !e.dead).length === 2); killOne(); killOne(); check('waves/system: the rolling force is spent (3 kills)', combat.enemies.filter((e) => e.wave && !e.dead).length === 0 && combat.kills === 3); combat.update(t + 16, 16); check('waves/system: wave 1 clears (the clear event fires)', events.clear.length === 1); combat.update(t + 32, 16); const bombers = combat.enemies.filter((e) => e.wave && !e.dead); check('waves/system: wave 2 deploys whole (fixed: the force at once)', bombers.length === 2); check('waves/system: the roster type flies (bomber stats, not the base)', bombers.every((e) => e.maxHull === 90 && e.frame === 1)); for (const e of [...bombers]) { e.damage(999); combat.killEnemy(e, t + 48); } combat.update(t + 64, 16); check('waves/system: the run completes (5 spent = 3 + 2, onComplete fires)', combat.waveStatus().state === 'done' && combat.kills === 5 && events.complete !== null && events.start.length === 2 && events.clear.length === 2); check('waves/system: combat over → the bar drops (no live ships, no run)', combat.active === false); // The NAMED script (the shape the quests reference) + cancel. const run2 = combat.startWaves('probe', { onCancel: () => events.cancel++ }); check('waves/system: the named "probe" script starts (lead in flight)', run2 !== null && combat.waveStatus().state === 'lead' && combat.active === true); combat.update(t + 1000, 16); check('waves/system: nothing deploys during the lead', combat.enemies.filter((e) => e.wave && !e.dead).length === 0); combat.update(t + 3200, 16); const probe = combat.enemies.filter((e) => e.wave && !e.dead); check('waves/system: the lead elapses → "probe" deploys (fixed, 3 base raiders)', probe.length === 3 && probe.every((e) => e.maxHull === 60)); combat.cancelWaves(true); check('waves/system: cancel fires the event + pulls the run ships out', events.cancel === 1 && combat.enemies.every((e) => !e.wave)); combat.destroy(); check('waves/system: destroy clears the run', combat.waveRun === null); } // ------------------------------------------------- fx: white-flash integrity { const { Fx } = await import(pathToFileURL(join(__dirname, '../js/combat/Fx.js')).href); const fxScene = makeScene(); const fx = new Fx(fxScene); // A craft with the real tint seams (the stub Sprite lacks setTintMode). const craft = { x: 100, y: 100, hitRadius: 12, tint: 16777215, tintMode: 0, setTint(t) { this.tint = t; return this; }, setTintMode(m) { this.tintMode = m; return this; }, }; fx.craftFlash(craft, 120); check('fx: the craft flash goes white (tintMode 1)', craft.tintMode === 1); check('fx: the craft is tracked for the whiteness purge', fx.crafts.has(craft)); // OVERLAPPING HITS (the "turns white and stays white" bug): a second // hit inside the flash window must EXTEND, never stack — a stacked // item captures the in-flash state (mode 1) as its restore value. fx.update(60); fx.craftFlash(craft, 120); check('fx: overlapping hits do not stack a second flash', fx.items.filter((i) => i.kind === 'tintFlash').length === 1); fx.update(200); // past the extended expiry check('fx: after expiry the craft is back in normal colors (no white stick)', craft.tintMode === 0 && craft.tint === 16777215); // The kill seam settles the flash immediately. fx.craftFlash(craft, 120); fx.releaseCraft(craft); check('fx: releaseCraft settles the flash at the kill seam', craft.tintMode === 0 && fx.items.filter((i) => i.kind === 'tintFlash').length === 0); // THE PERIODIC PURGE (the backstop): a stuck white craft is reset, // a live flash is left alone, dead crafts are pruned. craft.tintMode = 1; // simulate a leak no seam caught fx.purgeCraftTints(); check('fx: the purge resets a stuck white craft', craft.tintMode === 0); fx.craftFlash(craft, 400); fx.purgeCraftTints(); check('fx: the purge leaves a live flash alone', craft.tintMode === 1); fx.update(500); // expiry inside this step check('fx: the live flash still restores itself on expiry', craft.tintMode === 0); const dead = { x: 0, y: 0, hitRadius: 10, tint: 16777215, tintMode: 0, destroyed: true, setTint(t) { this.tint = t; return this; }, setTintMode(m) { this.tintMode = m; return this; }, }; fx.crafts.add(dead); fx.purgeCraftTints(); check('fx: the purge prunes destroyed crafts (no leaked references)', !fx.crafts.has(dead)); // The roster callback (both contexts wire it): a craft that NEVER took // a flash but somehow holds the white state is still re-asserted — // the purge covers the whole pack, not just Fx's own flashes. const straggler = { x: 0, y: 0, tint: 16777215, tintMode: 1, setTint(t) { this.tint = t; return this; }, setTintMode(m) { this.tintMode = m; return this; }, }; fx.purgeRoster = () => [straggler]; fx.purgeCraftTints(); check('fx: the purge covers the roster (not just tracked flashes)', straggler.tintMode === 0); } // ------------------------------------------------------------------ done console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`); process.exit(failures === 0 ? 0 : 1);