orbit/dev/discovery.test.mjs

258 lines
12 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): the
* system's planets + free-space stations (+ the origin's home world)
* sit on orbits around the origin, no pair closer than minSpacing
* (center-to-center) and — whenever a system holds more than one
* object — every object within maxNeighbor of at least one other,
* planets 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 MIN_SEP = band.minSpacing;
const MAX_NBR = band.maxNeighbor;
const g = Galaxy.create('discovery-layout-test');
let layoutOk = true;
let layoutWhy = '';
let sawMaxObjects = false; // the N = 6 maximum (4 planets + 2 stations)
let maxObjectsSeen = 0;
const probe = (systems) => {
for (const rec of systems) {
const c = g.ensureContent(rec.id);
for (const p of c.planets) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
layoutOk = false;
layoutWhy = `${rec.id}: non-finite layout`;
continue;
}
const want = config.get(`planets.classScale.${p.class}`, 1);
if (Math.abs(p.scale - want) > 1e-9) {
layoutOk = false;
layoutWhy = `${rec.id}: ${p.class} world scaled ${p.scale} ≠ classScale ${want}`;
}
}
// Layout objects: the home world (origin) + planets + free-space
// stations (planet-bound settlements sit ON their planet — not
// layout objects of their own).
const objs = [{ x: 0, y: 0 }];
for (const p of c.planets) objs.push({ x: p.x, y: p.y });
for (const s of c.settlements ?? []) {
if (s.anchor?.type !== 'space') continue;
if (typeof s.x !== 'number' || typeof s.y !== 'number' || !Number.isFinite(s.x) || !Number.isFinite(s.y)) {
layoutOk = false;
layoutWhy = `${rec.id}: free-space station "${s.name}" has no valid position`;
continue;
}
objs.push({ x: s.x, y: s.y });
}
const nObjects = objs.length - 1;
if (nObjects > maxObjectsSeen) maxObjectsSeen = nObjects;
if (nObjects === 6) sawMaxObjects = true;
// 1) No two objects closer than minSpacing (center to center).
for (let i = 0; i < objs.length && layoutOk; i++) {
for (let j = i + 1; j < objs.length; j++) {
const d = Math.hypot(objs[i].x - objs[j].x, objs[i].y - objs[j].y);
if (d < MIN_SEP - 1e-6) {
layoutOk = false;
layoutWhy = `${rec.id}: two objects ${Math.round(MIN_SEP - d)} px closer than minSpacing ${MIN_SEP}`;
}
}
}
// 2) Every object has a neighbor within maxNeighbor (the home world is
// always present, so "more than one object" ⇔ objs.length ≥ 2).
if (layoutOk && objs.length >= 2) {
for (let i = 0; i < objs.length; i++) {
const near = objs.some(
(o, j) => j !== i && Math.hypot(objs[i].x - o.x, objs[i].y - o.y) <= MAX_NBR + 1e-6,
);
if (!near) {
layoutOk = false;
layoutWhy = `${rec.id}: an object with no neighbor within maxNeighbor ${MAX_NBR}`;
break;
}
}
}
}
};
probe(g.records.slice(0, 5000));
check(
`layout: 5000 systems obey minSpacing (no pair <${MIN_SEP}px center-to-center) & maxNeighbor (every object ≤${MAX_NBR}px from a neighbor)${layoutOk ? '' : ' — ' + layoutWhy}`,
layoutOk,
);
check(`layout: no system exceeds the 6-object maximum (4 planets + 2 stations) — max in sample: ${maxObjectsSeen}`, maxObjectsSeen <= 6);
check('layout: the 6-object maximum (4 planets + 2 stations) occurs in the sample', sawMaxObjects);
// Determinism on a system that has BOTH planet and station objects.
const rec0 = g.records.find((r) =>
g.ensureContent(r.id).settlements.some((s) => s.anchor?.type === 'space'),
).id;
const la = Galaxy.create('discovery-layout-test').ensureContent(rec0);
const lb = Galaxy.create('discovery-layout-test').ensureContent(rec0);
const stationXY = (c) => (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => [s.x, s.y]);
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) &&
JSON.stringify(stationXY(la)) === JSON.stringify(stationXY(lb));
check('layout is deterministic (same seed ⇒ same planet + station 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) ||
JSON.stringify(stationXY(lc)) !== JSON.stringify(stationXY(la));
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).`);