orbit/dev/star.test.mjs

180 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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