// 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) ✔`);