180 lines
7.6 KiB
JavaScript
180 lines
7.6 KiB
JavaScript
/**
|
||
* 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);
|