orbit/dev/discovery.test.mjs

220 lines
9.9 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.

/**
* Discovery & compass test (dev tool, run with Node — no browser needed):
*
* node dev/discovery.test.mjs
*
* Asserts:
* - the DISCOVERY RULE (data/game.json → discovery.distance): within that
* many px of an object's EDGE ⇒ discovered, exactly once, per system;
* state round-trips through toJSON/fromJSON (saves-ready);
* - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem): every
* system's worlds sit at finite, in-band positions around the origin,
* at least minEdgeGap px apart edge-to-edge (incl. the origin's home
* world), scaled by class — deterministically (same seed ⇒ same layout,
* different seed ⇒ different);
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
* the screen-edge rect (edges AND corners), circleInView is exact,
* lerpAngle always takes the short arc;
* - the compass's chip hit test (contains) — the scene's guard that keeps
* click-to-fly away from autopilot clicks on name tags;
* - the new planet class pools resolve to real sheet frames.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- 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);
// Minimal Phaser stub — enough to import the UI/entity modules below.
globalThis.window = {
Phaser: {
GameObjects: {
Container: class {
setScrollFactor() { return this; }
setDepth() { return this; }
},
Sprite: class {},
},
},
};
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { Discovery } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Discovery.js')).href);
const { Planet } = await import(pathToFileURL(join(__dirname, '../js/entities/Planet.js')).href);
const { DiscoveryCompass, edgeAnchor, circleInView, lerpAngle } = await import(
pathToFileURL(join(__dirname, '../js/ui/DiscoveryCompass.js')).href
);
let pass = 0;
function check(name, cond) {
if (!cond) {
console.error(`${name}`);
process.exit(1);
}
pass++;
console.log(`${name}`);
}
// --- The discovery rule ---------------------------------------------------
const D = config.get('game.discovery.distance');
check('discovery distance is configured (540 px)', D === 540);
const d = new Discovery(D);
const objs = [
{ id: 'near', x: 1000, y: 0, radius: 512 }, // rim at 488 px → within 540
{ id: 'far', x: 5000, y: 0, radius: 512 }, // rim at 4488 px → out of range
{ id: 'sideways', x: 0, y: 900, radius: 512 }, // rim at 388 px → within 540
];
let fresh = d.check('S1', 0, 0, objs);
check('within distance of the edge ⇒ discovered', fresh.length === 2 && d.isDiscovered('S1', 'near') && d.isDiscovered('S1', 'sideways'));
check('beyond the distance ⇒ not discovered', !d.isDiscovered('S1', 'far'));
check('already-discovered is never re-reported', d.check('S1', 0, 0, objs).length === 0);
check('discovery is tracked per system', d.check('S2', 0, 0, objs).length === 2 && !d.isDiscovered('S2', 'far'));
const dEdge = new Discovery(D);
check('exactly at radius+distance ⇒ discovered', dEdge.check('S1', 512 + D, 0, [{ id: 'a', x: 0, y: 0, radius: 512 }]).length === 1);
const dPast = new Discovery(D);
check('one px past radius+distance ⇒ not discovered', dPast.check('S1', 513 + D, 0, [{ id: 'a', x: 0, y: 0, radius: 512 }]).length === 0);
let rejected = false;
try {
new Discovery(-1);
} catch {
rejected = true;
}
check('rejects a negative distance', rejected);
const restored = Discovery.fromJSON(d.toJSON());
check('toJSON/fromJSON round-trips (saves-ready)', restored.distance === D && restored.isDiscovered('S1', 'near') && !restored.isDiscovered('S1', 'far') && restored.isDiscovered('S2', 'sideways'));
// --- The solar system layout ---------------------------------------------
const band = config.get('planets.solarSystem');
const { minOrbit, maxOrbit, minEdgeGap } = band;
const baseR = (config.get('planets.frameWidth', 1024) * config.get('planets.scale', 1)) / 2;
const g = Galaxy.create('discovery-layout-test');
let layoutOk = true;
let layoutWhy = '';
const probe = (systems) => {
for (const rec of systems) {
const c = g.ensureContent(rec.id);
const placed = [{ x: 0, y: 0, r: baseR }]; // the origin's home world
for (const p of c.planets) {
const r = baseR * p.scale;
const d0 = Math.hypot(p.x, p.y);
const want = config.get(`planets.classScale.${p.class}`, 1);
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
layoutOk = false;
layoutWhy = `${rec.id}: non-finite layout`;
continue;
}
if (Math.abs(p.scale - want) > 1e-9) {
layoutOk = false;
layoutWhy = `${rec.id}: ${p.class} world scaled ${p.scale} ≠ classScale ${want}`;
}
if (d0 < minOrbit - 1e-6) {
layoutOk = false;
layoutWhy = `${rec.id}: world at ${d0.toFixed(1)} px < minOrbit ${minOrbit}`;
}
for (const q of placed) {
const gap = Math.hypot(p.x - q.x, p.y - q.y) - q.r - r;
if (gap < minEdgeGap - 1e-6) {
layoutOk = false;
layoutWhy = `${rec.id}: worlds ${minEdgeGap - gap}px closer than minEdgeGap ${minEdgeGap}`;
}
}
placed.push({ x: p.x, y: p.y, r });
}
}
};
probe(g.records.slice(0, 500));
check(
`layout: 500 systems' worlds finite, scaled by class, in-band (≥${minOrbit}px), ≥${minEdgeGap}px apart edge-to-edge${layoutOk ? '' : ' — ' + layoutWhy}`,
layoutOk,
);
const rec0 = g.records[0].id;
const la = Galaxy.create('discovery-layout-test').ensureContent(rec0);
const lb = Galaxy.create('discovery-layout-test').ensureContent(rec0);
const same =
la.planets.length === lb.planets.length &&
la.planets.every((p, i) => p.x === lb.planets[i].x && p.y === lb.planets[i].y && p.scale === lb.planets[i].scale);
check('layout is deterministic (same seed ⇒ same x/y/scale)', same);
const lc = Galaxy.create('discovery-layout-OTHER').ensureContent(rec0);
const diff =
lc.planets.length !== la.planets.length ||
la.planets.some((p, i) => p.x !== lc.planets[i]?.x || p.y !== lc.planets[i]?.y);
check('different seed ⇒ different layout', diff);
// --- The compass geometry ---------------------------------------------------
const W = 1280;
const H = 720;
const INSET = config.get('game.discovery.compass.edgeInset', 26);
const eR = edgeAnchor(W, H, INSET, 0);
check('edgeAnchor(+x) lands on the right edge, inset', Math.abs(eR.x - (W - INSET)) < 1e-9 && Math.abs(eR.y - H / 2) < 1e-9);
const eT = edgeAnchor(W, H, INSET, -Math.PI / 2);
check('edgeAnchor(up) lands on the top edge, inset', Math.abs(eT.x - W / 2) < 1e-9 && Math.abs(eT.y - INSET) < 1e-9);
const cA = Math.atan2(H / 2 - INSET, W / 2 - INSET);
const eC = edgeAnchor(W, H, INSET, cA);
check('edgeAnchor(corner ray) lands exactly on the corner', Math.abs(eC.x - (W - INSET)) < 1e-6 && Math.abs(eC.y - (H - INSET)) < 1e-6);
const view = { left: 100, top: 200, w: 1280, h: 720 };
check('circleInView: rim overlapping the view ⇒ true', circleInView(view.left + view.w + 100, 560, 120, view) === true);
check('circleInView: fully outside ⇒ false', circleInView(view.left + view.w + 200, 560, 120, view) === false);
check('circleInView: fully inside ⇒ true', circleInView(700, 560, 512, view) === true);
check(
'lerpAngle(0→π, ½) lands midway (antipodal ⇒ either arc is shortest)',
Math.abs(Math.abs(lerpAngle(0, Math.PI, 0.5)) - Math.PI / 2) < 1e-9,
);
check('lerpAngle eases the short way (3→3, no long-way sweep)', Math.abs(lerpAngle(3.0, -3.0, 1) - (-3.0 + 2 * Math.PI)) < 1e-9);
// --- Autopilot seam: the chip hit test (the scene's click-to-fly guard) ----
{
const fakeScene = { add: { existing() {} } };
const compass = new DiscoveryCompass(fakeScene);
compass.entries.set('a', { chipRoot: { x: 100, y: 100 }, w: 50, h: 30 });
check('compass.contains: chip center ⇒ true', compass.contains(100, 100) === true);
check('compass.contains: inside the chip rect ⇒ true', compass.contains(124, 114) === true);
check('compass.contains: just outside, within hover slack ⇒ true', compass.contains(130, 100) === true);
check('compass.contains: outside ⇒ false', compass.contains(133, 100) === false);
check('compass.contains: no entries ⇒ false', new DiscoveryCompass(fakeScene).contains(1, 1) === false);
// Options: onSelect seam + deck reserve (layout input, clamped ≥ 0).
const c2 = new DiscoveryCompass(fakeScene, { onSelect: () => {}, reserveBottom: 104 });
check('compass options: onSelect kept, reserveBottom applied', typeof c2.onSelect === 'function' && c2.reserveBottom === 104);
check('compass options: negative reserve clamps to 0', new DiscoveryCompass(fakeScene, { reserveBottom: -50 }).reserveBottom === 0);
}
// --- The new planet class pools ---------------------------------------------
for (const k of ['rocky', 'gas', 'ice', 'lava']) {
const pool = config.get(`planets.frames.${k}`);
check(`frames pool for ${k} exists`, Array.isArray(pool) && pool.length > 0);
const f = Planet.frameFor(k, Rng.derive('test', 'planet', 'x'));
check(`frameFor(${k}) picks a frame from its pool`, pool.includes(f));
const s = config.get(`planets.classScale.${k}`, 1);
check(`classScale.${k} is sane (${s})`, s > 0 && s < 3);
}
console.log(`\nAll discovery & compass tests passed (${pass} checks).`);