orbit/dev/planet.test.mjs

210 lines
9.6 KiB
JavaScript

/**
* Planet test (dev tool, run with Node — no browser needed):
*
* node dev/planet.test.mjs
*
* Stubs just enough of Phaser to construct the REAL Planet from
* js/entities/Planet.js against the real data/planets.json + ship.json
* config, then asserts the home-world rules:
* - the Terran world is 1024 px across at 1:1 (rim 512 px from center);
* - the ship can come to shipClearance px (edge-to-edge) from the rim,
* but never closer, and never moves through the planet;
* - a graze keeps its tangential velocity (slides along the rim);
* - edgePoint() places the spawn exactly spawnDistanceFromEdge off.
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Phaser stub: just the Sprite base class -----------------------------
class Sprite {
constructor(scene, x, y, key, frame) {
this.scene = scene; this.x = x; this.y = y;
this.key = key; this.frame = frame;
this.scaleX = 1; this.scaleY = 1;
}
setScale(s) { this.scaleX = s; this.scaleY = s; return this; }
}
globalThis.window = { Phaser: { GameObjects: { Sprite } } }; // js/vendor/phaser.js reads this
// --- Load the real config (data/*.json) into the config singleton --------
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 { Planet } = await import(pathToFileURL(join(__dirname, '../js/entities/Planet.js')).href);
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
// Off-origin on purpose: proves nothing is baked to (0, 0).
const planet = new Planet({ add: { existing: (o) => o } }, 100, -50, 0);
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
const clearance = planet.clearance;
const minDist = planet.minCenterDistance(shipRadius);
const ship = (x, y, vx = 0, vy = 0) => ({ x, y, body: { velocity: { x: vx, y: vy }, acceleration: { x: 0, y: 0 } } });
const dist = (p, c = planet) => Math.hypot(p.x - c.x, p.y - c.y);
// --- 1. Real config: 1:1 Terran world + keep-out distances ---------------
check(`1:1 world: planet radius is 512 px (got ${planet.radius})`, planet.radius === 512);
check(`frame 0 is the Terran world (got frame ${planet.frame})`, planet.frame === 0);
check(`texture key is the spritesheet (got "${planet.key}")`, planet.key === 'planets');
check(`keep-out = radius + clearance + ship radius (got ${minDist}, want ${512 + clearance + shipRadius})`,
minDist === 512 + clearance + shipRadius);
// --- 1b. Frame pools: terran has 3 faces, pick is seed-deterministic ------
{
const pool = config.get('planets.frames.terran', []);
check('terran frame pool is [0, 1, 2] (got ' + JSON.stringify(pool) + ')',
JSON.stringify(pool) === JSON.stringify([0, 1, 2]));
// Same seed ⇒ same pick (worldgen determinism), and every pick is a
// member of the pool.
const a = Planet.frameFor('terran', Rng.derive('seed-x', 'planet', 'home'));
const b = Planet.frameFor('terran', Rng.derive('seed-x', 'planet', 'home'));
check(`frameFor is deterministic per seed (got ${a} and ${b})`, a === b && pool.includes(a));
// Across seeds, all three faces actually get picked (uniform pool).
const seen = new Set();
for (let i = 0; i < 90; i++) {
const f = Planet.frameFor('terran', Rng.derive('seed' + i, 'planet', 'home'));
if (!pool.includes(f)) { check(`frameFor stays in the pool (seed${i} got ${f})`, false); break; }
seen.add(f);
}
check(`frameFor covers the whole pool over seeds (got ${[...seen].sort().join(',')})`,
seen.size === pool.length);
// Unknown kinds / bad pools fall back to frame 0.
const fallback = Planet.frameFor('gasGiant', Rng.derive('seed-x', 'planet', 'home'));
check(`frameFor unknown kind falls back to 0 (got ${fallback})`, fallback === 0);
}
// --- 2. Outside the keep-out circle: untouched ----------------------------
{
const s = ship(planet.x + minDist + 10, planet.y);
planet.constrainShip(s, shipRadius);
check('outside the keep-out circle: ship untouched',
s.x === planet.x + minDist + 10 && s.y === planet.y);
}
// Exactly on the boundary: allowed ("within 50 px" includes 50 px).
{
const s = ship(planet.x + minDist, planet.y, -100, 0); // on the line, moving in
planet.constrainShip(s, shipRadius);
const vn = s.body.velocity.x; // normal here is +x
check('on the boundary with inward velocity: position held, inward velocity removed',
dist(s) === minDist && vn >= 0);
}
// --- 3. Inside: pushed out to exactly the keep-out line -------------------
{
const s = ship(planet.x + 100, planet.y);
planet.constrainShip(s, shipRadius);
check(`inside: pushed out to the keep-out line (dist=${dist(s).toFixed(2)}, want ${minDist})`,
Math.abs(dist(s) - minDist) < 1e-9);
}
// --- 4. Inward velocity removed, tangential kept (a graze slides) ---------
{
const s = ship(planet.x + 200, planet.y, -100, -300); // inside, heading into the planet
planet.constrainShip(s, shipRadius);
check('inside + inward velocity: pushed out, inward component removed (vx=0)',
Math.abs(dist(s) - minDist) < 1e-9 && s.body.velocity.x === 0);
check('tangential velocity kept (vy=-300)', s.body.velocity.y === -300);
}
// --- 4b. Inward ACCELERATION removed too (the world integrates it after) --
{
// The arcade world steps AFTER this constraint runs, so an inward
// acceleration would push the ship back inside a fraction of a pixel on
// the very next step. It must be stripped, tangential kept.
const s = ship(planet.x + 200, planet.y, 0, 0);
s.body.acceleration.x = -500; // throttling into the planet
s.body.acceleration.y = -300; // …and a tangential component
planet.constrainShip(s, shipRadius);
check('inward acceleration stripped (ax=0), tangential kept (ay=-300)',
s.body.acceleration.x === 0 && s.body.acceleration.y === -300);
// …so the next physics step can only carry the ship along the rim.
const step = 16.67 / 1000;
s.x += s.body.velocity.x * step + s.body.acceleration.x * step * step;
s.y += s.body.velocity.y * step + s.body.acceleration.y * step * step;
check('next physics step keeps the ship at the clearance or farther',
dist(s) >= minDist - 1e-9);
}
// --- 4c. aimPoint(): clicks inside the planet clamp to the keep-out rim ---
{
// Inside → projected out onto the rim, along the ray from the center.
const inAim = planet.aimPoint(planet.x + 100, planet.y + 300, shipRadius);
check('aimPoint inside: lands exactly on the keep-out line',
Math.abs(dist(inAim) - minDist) < 1e-9);
const dirOk = Math.abs((inAim.x - planet.x) / 100 - (inAim.y - planet.y) / 300) < 1e-12;
check('aimPoint inside: stays on the ray from the center', dirOk);
// Outside → unchanged.
const outAim = planet.aimPoint(planet.x + minDist + 5, planet.y - 20, shipRadius);
check('aimPoint outside: passes through unchanged',
outAim.x === planet.x + minDist + 5 && outAim.y === planet.y - 20);
// Dead center → pushed out along +x, no NaN.
const cAim = planet.aimPoint(planet.x, planet.y, shipRadius);
check('aimPoint at the center: +x on the rim, no NaN',
Number.isFinite(cAim.x) && Math.abs(dist(cAim) - minDist) < 1e-9 && cAim.x === planet.x + minDist);
}
// --- 5. Full approach at max ship speed, frame by frame ------------------
{
// One 60 fps frame at the ship's maxSpeed (data/ship.json), closing in
// dead-on: the ship must never end a frame closer than the clearance,
// and must come to rest exactly on the keep-out line.
let s = ship(planet.x + 1500, planet.y, -480, 0);
let worst = Infinity;
for (let i = 0; i < 200 && s.body.velocity.x < 0; i++) {
s.x += s.body.velocity.x * (16.67 / 1000); // world integration
s.y += s.body.velocity.y * (16.67 / 1000);
planet.constrainShip(s, shipRadius);
worst = Math.min(worst, dist(s) - planet.radius - shipRadius);
}
check(`frame-by-frame approach at max speed: never closer than the clearance (worst edge gap ${worst.toFixed(2)} px)`,
worst >= clearance - 1e-9);
check('...and comes to rest on the keep-out line with no inward speed',
Math.abs(dist(s) - minDist) < 1e-9 && s.body.velocity.x === 0);
}
// --- 6. Degenerate: ship dead at the planet's center ----------------------
{
const s = ship(planet.x, planet.y);
planet.constrainShip(s, shipRadius);
check('at the center: pushed out along +x, no NaN',
Number.isFinite(s.x) && Number.isFinite(s.y) && Math.abs(dist(s) - minDist) < 1e-9
&& s.x === planet.x + minDist && s.y === planet.y);
}
// --- 7. edgePoint(): spawn is exactly spawnDistanceFromEdge off the rim ---
{
const gap = config.get('planets.spawnDistanceFromEdge', 150);
let ok = true;
for (const a of [0, 0.7, 2.4, 4.2]) {
const p = planet.edgePoint(a, gap, shipRadius);
const edgeGap = dist(p) - planet.radius - shipRadius;
if (Math.abs(edgeGap - gap) > 1e-9) ok = false;
}
check(`edgePoint puts the ship exactly ${gap} px (edge-to-edge) off the rim, any direction`, ok);
}
console.log(failures === 0 ? '\nAll planet tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);