/** * 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, 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; } 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), }, 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 } = await import( pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href ); const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.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 { 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); // 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); } // ------------------------------------------------------------------ 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 seven combat events, each mapped', Object.keys(COMBAT_SFX).sort().join(',') === 'down,fire,hull,impact,kill,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 scan ping, base volume', combatSfx(s1, 'fire') === 'fire' && s1.played.length === 1 && s1.played[0].key === 'sfx_scan' && s1.played[0].volume === Number(config.get('sfx.volume', 0.55))); const s2 = makeSfxScene(); combatSfx(s2, 'fire', { vol: 0.45 }); // enemy lances play quieter 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')); } // ------------------------------------------------------------------ done console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`); process.exit(failures === 0 ? 0 : 1);