/** * Ship behavior test (dev tool, run with Node — no browser needed): * * node dev/ship-behavior.test.mjs * * Stubs just enough of Phaser + a scene to run the REAL Ship.update() * loop from js/entities/Ship.js, then asserts the flight feel: * arrives and stops, respects maxSpeed, tracks its heading, can be * re-targeted mid-flight, coasts to rest, hard-brakes on stop(). * * The harness integrates acceleration→velocity→position the way the * Arcade physics world does (after the scene's update). */ 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; const wrap = (a) => ((a % TAU) + TAU) % TAU; 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) { this.scene = scene; this.x = x; this.y = y; this.key = key; this.rotation = 0; this.scaleX = 1; this.scaleY = 1; this.body = null; } setCollideWorldBounds() { return this; } setScale(s) { this.scaleX = this.scaleY = s; return this; } } const graphicsStub = { fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {}, }; // Mirror of Phaser.Math.Angle.RotateTo (radians, step-based, shortest path). const rotateTo = (cur, tgt, step) => { let diff = wrap(tgt - cur); if (diff > Math.PI) diff -= TAU; if (Math.abs(diff) <= step) return tgt; return cur + Math.sign(diff) * step; }; const PhaserStub = { Physics: { Arcade: { Sprite } }, Math: { Clamp: (v, min, max) => Math.max(min, Math.min(max, v)), Angle: { Wrap: wrap, RotateTo: rotateTo }, }, Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } }, }; globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this const scene = { textures: { exists: () => true }, make: { graphics: () => graphicsStub }, add: { existing: (o) => o }, physics: { add: { existing: (o) => { o.body = { velocity: new V2(), acceleration: new V2() }; return o; }, }, }, scale: { width: 1280, height: 720 }, }; const { Ship } = await import( pathToFileURL(join(__dirname, '../js/entities/Ship.js')).href ); // Use the real data/*.json config (ship feel comes from data/ship.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(', ')); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; const dt = 16.67; // Physics-world step: acceleration → velocity → position. const integrate = (ship) => { const s = dt / 1000; ship.body.velocity.x += ship.body.acceleration.x * s; ship.body.velocity.y += ship.body.acceleration.y * s; ship.x += ship.body.velocity.x * s; ship.y += ship.body.velocity.y * s; }; // --- Test 1: fly to a point, arrive and stop -------------------------------- { const ship = new Ship(scene, 640, 360); const target = { x: 1000, y: 300 }; ship.setTarget(target.x, target.y); let maxSpeedSeen = 0; let arrived = false; for (let t = 0; t < 60 * 30; t++) { ship.update(t * dt, dt); integrate(ship); maxSpeedSeen = Math.max(maxSpeedSeen, ship.body.velocity.length()); if ( Math.hypot(ship.x - target.x, ship.y - target.y) <= 8 && ship.body.velocity.length() <= 50 && ship.target === null ) { arrived = true; break; } } const distTo = Math.hypot(ship.x - target.x, ship.y - target.y); check('arrives at target and stops (target cleared)', arrived); check(`final position within arrive radius (dist=${distTo.toFixed(2)})`, distTo <= 8.5); check('velocity is exactly zero on arrival', ship.body.velocity.length() === 0); check(`speed never exceeded maxSpeed (max=${maxSpeedSeen.toFixed(1)} / cap ${ship.maxSpeed})`, maxSpeedSeen <= ship.maxSpeed * 1.05 + 0.001); } // --- Test 2: heading tracks the direction of travel ------------------------- { const ship = new Ship(scene, 200, 200); ship.rotation = 2.5; // start facing the wrong way ship.setTarget(600, 200); // fly straight right for (let t = 0; t < 60; t++) { ship.update(t * dt, dt); integrate(ship); } let hd = wrap(ship.rotation); if (hd > Math.PI) hd -= TAU; check(`rotates toward direction of travel (rotation=${ship.rotation.toFixed(3)} rad, want ~0)`, Math.abs(hd) < 0.3); } // --- Test 3: re-target mid-flight ------------------------------------------- { const ship = new Ship(scene, 100, 100); ship.setTarget(900, 100); for (let t = 0; t < 30; t++) { ship.update(t * dt, dt); integrate(ship); } ship.setTarget(100, 500); for (let t = 30; t < 30 + 60 * 20; t++) { ship.update(t * dt, dt); integrate(ship); } const d = Math.hypot(ship.x - 100, ship.y - 500); check(`re-target mid-flight reaches new point (dist=${d.toFixed(2)})`, d <= 8.5); } // --- Test 4: coasting drift decays to rest ---------------------------------- { const ship = new Ship(scene, 300, 300); ship.body.velocity.set(200, 0); let stopped = false; for (let t = 0; t < 60 * 10; t++) { ship.update(t * dt, dt); integrate(ship); if (ship.body.velocity.length() < 1) { stopped = true; break; } } check('coasting drift decays to rest', stopped); } // --- Test 5: stop() hard-brakes --------------------------------------------- { const ship = new Ship(scene, 300, 300); ship.setTarget(800, 300); for (let t = 0; t < 10; t++) { ship.update(t * dt, dt); integrate(ship); } ship.stop(); check('stop() clears target and zeroes velocity', ship.target === null && ship.body.velocity.length() === 0); } console.log(failures === 0 ? '\nAll ship behavior tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);