/** * Star test (dev tool, run with Node — no browser needed): * * node dev/star.test.mjs * * Stubs just enough of Phaser to construct the REAL Star from * js/entities/Star.js against the real data/planets.json config, then * asserts the central-body rules (the star is the central body of every * NON-HOME system — see js/scenes/GameScene.js): * - the solid rim is half the configured star.size (1024-class worlds, * a bigger star: 1536 px across by default); * - the ship can come to shipClearance px (edge-to-edge) from the rim, * but never closer, and never moves through the star; * - a graze keeps its tangential velocity (slides along the rim); * - edgePoint() places a point exactly `gap` off the rim; * - one texture per spectral class (generated once, keyed star-). */ import { pathToFileURL, fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); // --- Phaser stub: Image + Graphics + Display ------------------------------ class Image { 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; this.scale = 1; } setScale(s) { this.scaleX = s; this.scaleY = s; this.scale = s; return this; } } const Sprite = class extends Image {}; class Graphics { fillStyle() { return this; } fillCircle() { return this; } generateTexture(key, w, h) { made.push({ key, w, h }); return this; } destroy() { this.destroyed = true; } } function hexToRgbInt(v) { const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); return m ? parseInt(m[1], 16) : 0xffffff; } const made = []; // generateTexture calls globalThis.window = { Phaser: { GameObjects: { Image, Sprite }, Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } }, }, }; // --- 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 { Star } = await import(pathToFileURL(join(__dirname, '../js/entities/Star.js')).href); // A fake scene: textures.exists + make.graphics + add.existing. const scene = { add: { existing: (o) => o }, textures: { exists: (k) => made.some((t) => t.key === k) }, make: { graphics: ({ add } = {}) => new Graphics() }, }; // --- 1. Real config: solid rim + keep-out distances ----------------------- // Off-origin on purpose: proves nothing is baked to (0, 0). const star = new Star(scene, 100, -50, { name: 'Kestrel', class: 'k' }); const wantSize = config.get('planets.star.size', 1536); const wantRim = wantSize / 2; check(`star rim is half star.size (got ${star.radius}, want ${wantRim})`, star.radius === wantRim); check(`default star is 1536 px across (got ${star.size})`, star.size === 1536); check(`texture key is per-class (got "${star.key}", want "star-k")`, star.key === 'star-k'); check(`class is normalized to uppercase (got "${star.starClass}")`, star.starClass === 'K'); const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2; const clearance = star.clearance; const minDist = star.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) => Math.hypot(p.x - star.x, p.y - star.y); check(`keep-out = rim + clearance + ship radius (got ${minDist}, want ${wantRim + clearance + shipRadius})`, minDist === wantRim + clearance + shipRadius); // --- 2. Outside the keep-out circle: untouched ---------------------------- { const s = ship(star.x + minDist + 10, star.y); star.constrainShip(s, shipRadius); check('outside the keep-out circle: ship untouched', s.x === star.x + minDist + 10 && s.y === star.y); } // Exactly on the boundary: allowed. { const s = ship(star.x + minDist, star.y, -100, 0); // on the line, moving in star.constrainShip(s, shipRadius); check('on the boundary with inward velocity: position held, inward velocity removed', dist(s) === minDist && s.body.velocity.x >= 0); } // Deep inside: pushed out to the rim line. { const s = ship(star.x + 10, star.y, -200, 0); star.constrainShip(s, shipRadius); check('inside the keep-out circle: pushed out to the rim line', Math.abs(dist(s) - minDist) < 1e-9); } // A graze on the rim keeps its tangential velocity (slides along the rim), // and the inward part of any velocity is stripped. { const s = ship(star.x + minDist, star.y, -50, 300); // on the line, partly along the rim star.constrainShip(s, shipRadius); check('graze: tangential velocity kept, inward part stripped (got vx=' + s.body.velocity.x + ', vy=' + s.body.velocity.y + ')', s.body.velocity.x === 0 && s.body.velocity.y === 300 && dist(s) === minDist); } // Dead center: pushed out along +x (deterministic). { const s = ship(star.x, star.y, 0, 0); star.constrainShip(s, shipRadius); check('dead center: pushed out along +x', s.y === star.y && s.x > star.x); } // --- 3. edgePoint / aimPoint ---------------------------------------------- { const p = star.edgePoint(0.7, 150, shipRadius); check(`edgePoint sits rim + gap + ship off the rim (got ${dist(p).toFixed(3)})`, Math.abs(dist(p) - (wantRim + 150 + shipRadius)) < 1e-9); } { const inside = { x: star.x + 10, y: star.y }; const out = star.aimPoint(inside.x, inside.y, shipRadius); check(`aimPoint projects inside points onto the rim (got ${dist(out).toFixed(3)})`, Math.abs(dist(out) - minDist) < 1e-9); const pas = star.aimPoint(star.x + minDist + 5, star.y, shipRadius); check('aimPoint passes outside points through unchanged', pas.x === star.x + minDist + 5 && pas.y === star.y); } // --- 4. Textures: one per spectral class, generated once ------------------ { const before = made.length; new Star(scene, 0, 0, { name: 'Sol', class: 'G' }); // star-g: fresh check('new class generates its texture (star-g present)', made.some((t) => t.key === 'star-g')); new Star(scene, 0, 0, { name: 'Betelgeuse', class: 'M' }); // star-m: fresh new Star(scene, 0, 0, { name: 'Kestrel', class: 'k' }); // star-k: already made const k = made.filter((t) => t.key === 'star-k').length; check('existing class reuses its texture (star-k generated exactly once, got ' + k + ')', k === 1); check('texture size = rim × 2 × glowFactor', made.filter((t) => t.key === 'star-m')[0].w === Math.ceil(wantRim * config.get('planets.star.glow.radiusFactor', 1.55)) * 2); check(`texture generation calls total ${before + 2} (star-g + star-m)`, made.length === before + 2); } // Unknown spectral class: no crash, its own texture with the G fallback hue. { const s = new Star(scene, 0, 0, { name: 'X', class: 'zzz' }); check('unknown class gets its own texture (star-zzz, G fallback hue)', s.key === 'star-zzz' && made.some((t) => t.key === 'star-zzz')); const s2 = new Star(scene, 0, 0, { name: 'Y', class: undefined }); check('missing class defaults to G (got "' + s2.key + '")', s2.key === 'star-g'); } console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`); process.exit(failures === 0 ? 0 : 1);