/** * 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);