orbit/dev/discovery.test.mjs

262 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): every
* pair of layout objects — the system's planets + free-space
* stations + the central body (the origin's home world, or the star
* elsewhere) — sits in the band [minSpacing, maxSpacing] px
* center-to-center (the home system's band tightens to
* [minSpacing, homeMaxSpacing] and it holds ≤ 3 objects — 5 points
* cannot sit 6400..10240 px apart), 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_SP = band.maxSpacing; // normal systems: the band's maximum
const HOME_MAX = band.homeMaxSpacing; // the home system's tighter maximum
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;
let homeOk = true;
const probe = (systems) => {
for (const rec of systems) {
const c = g.ensureContent(rec.id);
const isHome = rec.id === g.currentSystemId;
const MAX = isHome ? HOME_MAX : MAX_SP;
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 central body (origin — the home world in the
// starting system, the star elsewhere) + 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;
// The home system holds at most 3 objects (its 2 fixed planets + at
// most one free-space station) — 5 points cannot sit 6400..10240 px
// apart (the tightest 5-point spacing needs ratio ≥ φ > 1.6).
if (isHome) {
if (nObjects > 3 || c.planets.length !== 2) {
homeOk = false;
if (layoutOk) layoutWhy = `${rec.id}: home system holds ${nObjects} objects`;
}
}
// The SOLAR SYSTEM BAND: every pair of layout objects — incl. the
// central body — sits in [minSpacing, the system's maximum],
// 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 || d > MAX + 1e-6) {
layoutOk = false;
layoutWhy = `${rec.id}: a pair ${Math.round(d)} px outside the band [${MIN_SEP}, ${MAX}]`;
}
}
}
}
};
probe(g.records.slice(0, 5000));
check(
`layout: 5000 systems obey the band — every pair (incl. the central body) in [${MIN_SEP}, ${MAX_SP}] px, home in [${MIN_SEP}, ${HOME_MAX}] px${layoutOk ? '' : ' — ' + layoutWhy}`,
layoutOk && homeOk,
);
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).`);