332 lines
13 KiB
JavaScript
332 lines
13 KiB
JavaScript
/**
|
|
* 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, stops facing
|
|
* the direction it was traveling, can be re-targeted mid-flight, coasts
|
|
* to rest, hard-brakes on stop() — and the Shift+click THROTTLE LOCK:
|
|
* thrustToward() commits to a heading and keeps flying it (past the
|
|
* point, full throttle) until a plain setTarget/stop steers the ship
|
|
* out of its 'thrust' state.
|
|
*
|
|
* Heading assertions account for the ship's art render offset
|
|
* (ship.artOffset — data/ship.json → artFacing), which is a presentation
|
|
* concern, not flight behavior.
|
|
*
|
|
* 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 0: base stats (data/ship.json → stats) ---------------------------
|
|
{
|
|
const ship = new Ship(scene, 0, 0);
|
|
const s = config.section('ship.stats');
|
|
check('base stats loaded from config (hull/shields/cargo/minerals)',
|
|
ship.stats.hullIntegrity === s.hullIntegrity &&
|
|
ship.stats.shields === s.shields &&
|
|
ship.stats.cargoHold === s.cargoHold &&
|
|
ship.stats.mineralStorage === s.mineralStorage);
|
|
check('base stats are the shipped values (100 / 0 / 100 / 250)',
|
|
ship.stats.hullIntegrity === 100 &&
|
|
ship.stats.shields === 0 &&
|
|
ship.stats.cargoHold === 100 &&
|
|
ship.stats.mineralStorage === 250);
|
|
}
|
|
|
|
// --- 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 + (ship.artOffset || 0); // start facing the wrong way (heading space)
|
|
ship.setTarget(600, 200); // fly straight right
|
|
for (let t = 0; t < 60; t++) { ship.update(t * dt, dt); integrate(ship); }
|
|
const hd0 = wrap(ship.rotation - (ship.artOffset || 0));
|
|
let hd = hd0 > Math.PI ? hd0 - TAU : hd0;
|
|
check(`rotates toward direction of travel (heading=${hd.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);
|
|
}
|
|
|
|
// --- Test 6: arrives facing the direction of travel ------------------------
|
|
{
|
|
const angDiff = (a, b) => {
|
|
let d = wrap(a - b);
|
|
if (d > Math.PI) d -= TAU;
|
|
return Math.abs(d);
|
|
};
|
|
|
|
// Straight trip from rest: must end facing the way it flew.
|
|
{
|
|
const ship = new Ship(scene, 100, 100);
|
|
const target = { x: 900, y: 100 };
|
|
ship.setTarget(target.x, target.y);
|
|
for (let t = 0; t < 60 * 30; t++) {
|
|
ship.update(t * dt, dt);
|
|
integrate(ship);
|
|
if (ship.target === null) break;
|
|
}
|
|
const want = Math.atan2(target.y - 100, target.x - 100);
|
|
check(`straight trip: stops facing direction of travel (heading=${wrap(ship.rotation - (ship.artOffset || 0)).toFixed(3)} rad, want ${want.toFixed(3)})`,
|
|
ship.target === null && angDiff(ship.rotation - (ship.artOffset || 0), want) < 0.25);
|
|
}
|
|
|
|
// Diagonal trip from rest: must end facing the diagonal it flew.
|
|
{
|
|
const ship = new Ship(scene, 100, 100);
|
|
const target = { x: 700, y: 500 };
|
|
ship.setTarget(target.x, target.y);
|
|
for (let t = 0; t < 60 * 30; t++) {
|
|
ship.update(t * dt, dt);
|
|
integrate(ship);
|
|
if (ship.target === null) break;
|
|
}
|
|
const want = Math.atan2(target.y - 100, target.x - 100);
|
|
check(`diagonal trip: stops facing direction of travel (heading=${wrap(ship.rotation - (ship.artOffset || 0)).toFixed(3)} rad, want ${want.toFixed(3)})`,
|
|
ship.target === null && angDiff(ship.rotation - (ship.artOffset || 0), want) < 0.25);
|
|
}
|
|
|
|
// Fast ship re-targeted close ahead: still ends facing its travel direction.
|
|
{
|
|
const ship = new Ship(scene, 100, 100);
|
|
ship.setTarget(2000, 100);
|
|
for (let t = 0; t < 40; t++) { ship.update(t * dt, dt); integrate(ship); }
|
|
const target = { x: 400, y: 100 };
|
|
ship.setTarget(target.x, target.y);
|
|
for (let t = 40; t < 40 + 60 * 30; t++) {
|
|
ship.update(t * dt, dt);
|
|
integrate(ship);
|
|
if (ship.target === null) break;
|
|
}
|
|
const want = Math.atan2(target.y - 100, target.x - 100);
|
|
const d = Math.hypot(ship.x - target.x, ship.y - target.y);
|
|
check(`fast re-target: arrives (dist=${d.toFixed(2)}) facing direction of travel (heading=${wrap(ship.rotation - (ship.artOffset || 0)).toFixed(3)} rad, want ${want.toFixed(3)})`,
|
|
ship.target === null && d <= 8.5 && angDiff(ship.rotation - (ship.artOffset || 0), want) < 0.25);
|
|
}
|
|
}
|
|
|
|
// --- Test 7: the Shift+click THROTTLE LOCK (thrustToward) --------------
|
|
{
|
|
// Commits to a heading and keeps flying it — PAST the click point,
|
|
// full throttle — with no stop in sight.
|
|
const ship = new Ship(scene, 100, 100);
|
|
ship.thrustToward(400, 100); // straight right
|
|
check('thrustToward enters its thrust state with a unit heading',
|
|
ship.state === 'thrust' && ship.target === null &&
|
|
Math.abs(Math.hypot(ship.thrustDir.x, ship.thrustDir.y) - 1) < 1e-9 &&
|
|
ship.thrustDir.x > 0.999 && Math.abs(ship.thrustDir.y) < 1e-9);
|
|
let maxSpeedSeen = 0;
|
|
for (let t = 0; t < 120; t++) { // ~2 s
|
|
ship.update(t * dt, dt);
|
|
integrate(ship);
|
|
maxSpeedSeen = Math.max(maxSpeedSeen, ship.body.velocity.length());
|
|
}
|
|
const passed = ship.x - 400;
|
|
check(`keeps flying the heading PAST the click point (x=${ship.x.toFixed(1)}, passed by ${passed.toFixed(1)}px)`,
|
|
passed > 100 && ship.y < 101 && ship.y > 99);
|
|
check(`throttle-locked speed is up and capped (max=${maxSpeedSeen.toFixed(1)} / cap ${ship.maxSpeed})`,
|
|
maxSpeedSeen > 200 && maxSpeedSeen <= ship.maxSpeed * 1.05 + 0.001);
|
|
const hd = wrap(ship.rotation - (ship.artOffset || 0));
|
|
check(`stays on the committed heading (heading=${hd.toFixed(3)} rad, want ~0)`, Math.abs(hd) < 0.1);
|
|
check('still holding the throttle (state=thrust, dir kept)',
|
|
ship.state === 'thrust' && ship.thrustDir !== null);
|
|
}
|
|
|
|
// --- Test 8: a plain click (setTarget) steers out of the hold ------------
|
|
{
|
|
const ship = new Ship(scene, 100, 100);
|
|
ship.thrustToward(2000, 100);
|
|
for (let t = 0; t < 30; t++) { ship.update(t * dt, dt); integrate(ship); }
|
|
const target = { x: 700, y: 500 };
|
|
ship.setTarget(target.x, target.y);
|
|
check('setTarget drops the thrust hold and takes the ship to the new point',
|
|
ship.state === 'normal' && ship.thrustDir === null && ship.target !== null);
|
|
let arrived = false;
|
|
for (let t = 0; t < 60 * 30; t++) {
|
|
ship.update(t * dt, dt);
|
|
integrate(ship);
|
|
if (ship.target === null && ship.body.velocity.length() === 0) { arrived = true; break; }
|
|
}
|
|
const d = Math.hypot(ship.x - target.x, ship.y - target.y);
|
|
check(`arrives at the steered-to point and stops (dist=${d.toFixed(2)})`, arrived && d <= 8.5);
|
|
}
|
|
|
|
// --- Test 9: stop() brakes the hold dead ---------------------------------
|
|
{
|
|
const ship = new Ship(scene, 300, 300);
|
|
ship.thrustToward(900, 300);
|
|
for (let t = 0; t < 30; t++) { ship.update(t * dt, dt); integrate(ship); }
|
|
ship.stop();
|
|
check('stop() clears the hold and kills the velocity',
|
|
ship.thrustDir === null && ship.target === null && ship.body.velocity.length() === 0);
|
|
for (let t = 0; t < 60; t++) { ship.update(t * dt, dt); integrate(ship); }
|
|
check('after stop() the ship coasts to rest (no re-thrust)',
|
|
ship.body.velocity.length() < 1);
|
|
}
|
|
|
|
// --- Test 10: thrusting out of 'mining' signals the scene -----------------
|
|
{
|
|
const ship = new Ship(scene, 0, 0);
|
|
const changes = [];
|
|
ship.onStateChange = (next, prev) => changes.push([next, prev]);
|
|
ship.setState('mining', 'mining');
|
|
const baseline = changes.length; // the setup call above already fired once
|
|
ship.thrustToward(500, 500);
|
|
check('thrustToward from the mining state fires the scene teardown signal',
|
|
ship.state === 'thrust' && changes.length === baseline + 1 &&
|
|
changes[changes.length - 1][0] === 'thrust' &&
|
|
changes[changes.length - 1][1] === 'mining');
|
|
}
|
|
|
|
console.log(failures === 0 ? '\nAll ship behavior tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
|
|
process.exit(failures === 0 ? 0 : 1);
|