210 lines
9.6 KiB
JavaScript
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);
|