orbit/dev/contact.test.mjs

241 lines
9.4 KiB
JavaScript

// Headless test for the contact seam that stops a THRUSTING ship (the
// Shift+click throttle lock) when it runs into the world:
// - Planet / AsteroidCluster constrainShip report CONTACT (true) exactly
// when they did something to the ship this frame (pushed it out of the
// keep-out, or stripped inward velocity/acceleration) and false when
// the ship is untouched — the scene's stop rule (GameScene.onPostUpdate:
// `if (touched && ship.state === 'thrust') ship.stop()`) reads those,
// - a thrusting ship driven into a planet stops dead ON its rim,
// - a coasting (non-thrust) ship is untouched by the rule.
// Run: node --import ./dev/phaser-loader.mjs dev/contact.test.mjs
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
const { config } = await import('../js/config/Config.js');
const data = {};
for (const f of readdirSync(new URL('../data', import.meta.url))) {
if (f.endsWith('.json')) data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8'));
}
config.init(data);
const { GameObject } = await import('./phaser-stub.mjs').then((m) => ({ GameObject: m.default.GameObjects.Container }));
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; }
}
function makeImage(x, y, key) {
const g = new GameObject(null, x, y);
g.key = key;
return g;
}
function makeScene() {
return {
time: { now: 0, delayedCall: () => ({ destroy() {} }) },
add: {
existing: (o) => o,
image: (x, y, key, frame) => makeImage(x, y, key),
graphics: () => ({ clear() {}, destroy() {} }),
circle: (x, y, r, fill, fa) => makeImage(x, y, 'circle'),
text: (x, y, s, st) => makeImage(x, y, 'text'),
},
make: { graphics: () => ({ clear() {}, destroy() {} }) },
tweens: { add: (o) => { if (o && typeof o.onComplete === 'function') o.onComplete(); return { destroy() {} }; }, killTweensOf: () => {} },
textures: { exists: () => true },
physics: {
add: {
existing: (o) => {
o.body = {
x: 0, y: 0,
velocity: { x: 0, y: 0, set() {}, length: () => 0, scale() {} },
acceleration: { set() {}, x: 0, y: 0 },
};
return o;
},
},
},
ship: null,
};
}
const { Ship } = await import('../js/entities/Ship.js');
const { Planet } = await import('../js/entities/Planet.js');
const { AsteroidCluster } = await import('../js/entities/AsteroidCluster.js');
// The stub scene hands out a body whose vectors don't do arithmetic —
// give the ship a REAL one so update() / stop() behave.
function realShip(scene, x, y) {
const ship = new Ship(scene, x, y);
ship.body = { x, y, velocity: new V2(), acceleration: new V2() };
return ship;
}
const dist = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
let n = 0;
const ok = (label) => { n += 1; console.log(` ${String(n).padStart(2)}. ${label}`); };
// ---------------------------------------------------------------- planet
{
const scene = makeScene();
const planet = new Planet(scene, 1000, 0, 0, 'Test World', {});
scene.ship = null;
const ship = realShip(scene, 0, 0);
scene.ship = ship;
const rim = planet.minCenterDistance(ship.radius);
// Outside the keep-out, moving inward: untouched this frame (the ship
// has not reached the rim yet) — NO contact.
ship.x = 1000 + rim + 50;
ship.y = 0;
ship.body.velocity.x = -200;
let touched = planet.constrainShip(ship, ship.radius);
assert.equal(touched, false, 'a ship outside the keep-out is untouched');
assert.equal(dist(ship.x, ship.y, 1000, 0), rim + 50, 'position unchanged');
assert.equal(ship.body.velocity.x, -200, 'velocity unchanged');
ok('Planet.constrainShip: no contact outside the keep-out');
// INSIDE the keep-out, moving inward: pushed out to the rim, inward
// velocity stripped — CONTACT reported.
ship.x = 1000 + rim - 5;
ship.y = 0;
ship.body.velocity.x = -200;
ship.body.acceleration.x = -900;
touched = planet.constrainShip(ship, ship.radius);
assert.equal(touched, true, 'contact is reported');
assert.ok(dist(ship.x, ship.y, 1000, 0) > rim - 0.01, 'ship pushed out to the rim');
assert.equal(ship.body.velocity.x, 0, 'inward velocity stripped');
assert.equal(ship.body.acceleration.x, 0, 'inward acceleration stripped');
ok('Planet.constrainShip: contact on approach (pushed out, inward motion stripped)');
// Riding the rim, moving OUTWARD (already sliding away): untouched —
// no spurious contact (no in/out jitter).
ship.x = 1000 + rim;
ship.y = 0;
ship.body.velocity.x = 300;
ship.body.acceleration.x = 0;
touched = planet.constrainShip(ship, ship.radius);
assert.equal(touched, false, 'a ship sliding outward off the rim is untouched');
ok('Planet.constrainShip: no contact while sliding off the rim');
}
// ------------------------------------------------------- the stop rule
{
// The full throttle-lock contact, as the scene drives it each frame
// (Ship.update → constraints → the scene's stop rule): thrust straight
// at a planet and the ship must stop dead ON its rim.
const scene = makeScene();
const planet = new Planet(scene, 1000, 0, 0, 'Test World', {});
const ship = realShip(scene, 0, 0);
scene.ship = ship;
const rim = planet.minCenterDistance(ship.radius);
ship.setPosition(1000 + rim + 600, 0);
// The stub has no physics world, so integrate the way Arcade does:
// acceleration → velocity → position, after each ship.update().
const integrate = (ship) => {
const s = 16.67 / 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;
};
ship.thrustToward(1000, 0); // shift+click ON the planet: committed heading is -x
assert.equal(ship.state, 'thrust');
let contact = false;
let frames = 0;
do {
ship.update(16.67, 16.67); // the ship's own frame
integrate(ship); // the world's integration
contact = planet.constrainShip(ship, ship.radius); // the world's constraint
frames += 1;
// GameScene.onPostUpdate's rule:
if (contact && ship.state === 'thrust') {
ship.stop();
ship.setState('normal', 'contact');
}
assert.ok(frames < 600, 'the ship never reaches the planet (it stops at the rim)');
} while (ship.state === 'thrust' && !contact);
assert.equal(ship.state, 'normal', 'contact ended the hold');
assert.equal(ship.thrustDir, null, 'the committed heading is cleared');
assert.equal(ship.body.velocity.x, 0, 'stopped dead');
assert.ok(dist(ship.x, ship.y, 1000, 0) > rim - 0.01, 'resting on the rim');
ok(`thrusting into a planet stops it dead on the rim (${frames} frames, x=${ship.x.toFixed(1)})`);
}
// ------------------------------------------------------- asteroid field
{
const scene = makeScene();
const ship = realShip(scene, 0, 0);
scene.ship = ship;
const cluster = new AsteroidCluster(scene, {
id: 'c-contact',
name: 'Contact Field',
x: 1000,
y: 0,
bound: 110,
tint: null,
groupSpin: 0,
groupPhase: 0,
debrisPhase: 0,
debrisSpin: 0,
debris: [],
asteroids: [{ frame: 2, x: 0, y: 0, size: 200, spin: 0, phase: 0 }],
}, {});
const rock = cluster.members[0];
const rim = rock.radius + cluster.clearance + ship.radius;
// Outside the rock's keep-out: no contact.
ship.x = 1000 + rim + 40;
ship.y = 0;
ship.body.velocity.x = -150;
let touched = cluster.constrainShip(ship, ship.radius);
assert.equal(touched, false, 'outside the rock keep-out: untouched');
ok('AsteroidCluster.constrainShip: no contact outside the keep-out');
// Inside, closing in: pushed out, inward motion stripped — CONTACT.
ship.x = 1000 + rim - 4;
ship.y = 0;
ship.body.velocity.x = -150;
ship.body.acceleration.x = -500;
touched = cluster.constrainShip(ship, ship.radius);
assert.equal(touched, true, 'contact is reported');
assert.ok(dist(ship.x, ship.y, 1000, 0) > rim - 0.01, 'pushed out to the rock rim');
assert.equal(ship.body.velocity.x, 0, 'inward velocity stripped');
assert.equal(ship.body.acceleration.x, 0, 'inward acceleration stripped');
ok('AsteroidCluster.constrainShip: contact on approach');
}
// ----------------------------------------------------------------- rule
{
// The same contact, but the ship is NOT thrusting (a plain
// click-to-fly arrival): the scene's rule must leave it alone — its
// arrival/brake logic owns that stop.
const scene = makeScene();
const planet = new Planet(scene, 1000, 0, 0, 'Test World', {});
const ship = realShip(scene, 0, 0);
scene.ship = ship;
const rim = planet.minCenterDistance(ship.radius);
ship.setTarget(1000 + rim, 0); // normal mode, flying in
ship.x = 1000 + rim - 3;
ship.y = 0;
ship.body.velocity.x = -120;
const touched = planet.constrainShip(ship, ship.radius);
assert.equal(touched, true, 'contact is reported');
// The rule (GameScene.onPostUpdate) only fires in the thrust state:
if (touched && ship.state === 'thrust') {
ship.stop();
ship.setState('normal', 'contact');
}
assert.equal(ship.state, 'normal', 'a non-thrust ship is not force-stopped by contact');
ok('the stop rule only fires in the thrust state');
}
console.log(`\nAll contact tests passed (${n} groups) ✔`);