orbit/dev/discovery.test.mjs

395 lines
19 KiB
JavaScript
Raw Permalink 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, the
* OBJECT COMPOSITION holding (data/systems.json → objectCount: every
* non-home system 0/2/3/4/5 objects, in the configured proportions)
* — 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 FAR display (compact chip + shrunken arrow beyond
* game.discovery.compass.farDistance, hover-expanding back to the full
* readout — with the hover INTENT: the expand waits for the pointer to
* rest, the fold-back waits for it to leave) — isFarTarget,
* syncEntryMode, and the chip's mode switch;
* - 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 {
constructor(scene) { this.scene = scene; }
setScrollFactor() { return this; }
setDepth() { return this; }
},
Sprite: class {},
},
Geom: {
Rectangle: class {
constructor(x = 0, y = 0, width = 0, height = 0) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
static Contains(r, px, py) {
return px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height;
}
},
},
},
};
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, isFarTarget, syncEntryMode } = 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 = 5 maximum (3 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). A BARREN system (objectCount → 0)
// holds none — its only objects are the jump gates (not layout
// objects here; the band check degenerates to nothing).
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 === 5) sawMaxObjects = true;
if (!isHome && !(nObjects === 0 || (nObjects >= 2 && nObjects <= 5))) {
layoutOk = false;
if (!layoutWhy) layoutWhy = `${rec.id}: holds ${nObjects} objects (composition wants 0/2/3/4/5)`;
}
// 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);
const nProbe = g.records.length;
check(
`layout: ${nProbe} 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 5-object maximum (3 planets + 2 stations) — max in galaxy: ${maxObjectsSeen}`, maxObjectsSeen <= 5);
check('layout: the 5-object maximum occurs in the galaxy', 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 FAR display (compact chip + shrunken arrow, hover expands) -----
{
const fc = config.get('game.discovery.compass');
check(
'far display is configured (farDistance 5120 / arrow 0.6 / smallBox 26 / intent delays)',
fc.farDistance === 5120 && fc.farArrowScale === 0.6 && fc.smallBox === 26 && fc.smallHitSlack === 16
&& fc.expandDelay === 300 && fc.collapseDelay === 2000,
);
check('isFarTarget: beyond farDistance ⇒ compact', isFarTarget({ x: 6000, y: 0 }, { x: 0, y: 0 }, 5120) === true);
check('isFarTarget: exactly at farDistance ⇒ full', isFarTarget({ x: 5120, y: 0 }, { x: 0, y: 0 }, 5120) === false);
check('isFarTarget: one px past ⇒ compact', isFarTarget({ x: 5121, y: 0 }, { x: 0, y: 0 }, 5120) === true);
check('isFarTarget: no ship ⇒ full (standalone path)', isFarTarget({ x: 99999, y: 0 }, null, 5120) === false);
check('isFarTarget: farDistance off (0) ⇒ full', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 0) === false);
check('isFarTarget: alwaysFull overrides distance (route waypoint)', isFarTarget({ x: 99999, y: 0, alwaysFull: true }, { x: 0, y: 0 }, 5120) === false);
check('isFarTarget: alwaysFull absent ⇒ distance rule holds', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 5120) === true);
const fakeScene = {
add: { existing() {} },
tweens: { add() {} },
time: { now: 0 },
};
const compass = new DiscoveryCompass(fakeScene);
check('compass far defaults match the config (incl. intent delays)',
compass.farDistance === 5120 && compass.smallBox === 26 && compass.farArrowScale === 0.6
&& compass.expandDelay === 300 && compass.collapseDelay === 2000);
const noop = () => {};
const entry = {
mode: 'full',
baseW: 120, baseH: 44, w: 120, h: 44,
far: true, hovered: false,
expandAt: null, collapseAt: null,
arrowScale: 1,
arrow: { setScale(a) { this.scale = a; } },
chip: { clear: noop, fillStyle: noop, fillPoints: noop, lineStyle: noop, strokePoints: noop, setInteractive: noop },
chipRoot: { setScale: noop },
typeText: { setAlpha(a) { this.alpha = a; } },
nameText: { setAlpha(a) { this.alpha = a; } },
neon: 0xffffff, fill: 0x0a1120,
};
compass.entries.set('f', entry);
compass.setMode(entry, 'small', false);
check('setMode(small): chip folds to smallBox, text hidden, arrow shrunk',
entry.w === 26 && entry.h === 26
&& entry.typeText.alpha === 0 && entry.nameText.alpha === 0
&& entry.arrowScale === 0.6 && entry.arrow.scale === 0.6,
);
check('setMode(small): hit slack keeps the small box an easy target', entry.hitHalfX === 13 + 16 + 6 && entry.hitHalfY === 35);
compass.setMode(entry, 'full', false);
check('setMode(full): the full readout restores (hover-expand)',
entry.w === 120 && entry.h === 44
&& entry.typeText.alpha === 1 && entry.nameText.alpha === 1
&& entry.arrowScale === 1 && entry.arrow.scale === 1 && entry.hitHalfX === 66,
);
// The scene's click guard covers the small chip's generous slack but not
// a click well off it (that is a fly-here again).
const smallEntry = { chipRoot: { x: 100, y: 100 }, w: 26, h: 26, hitHalfX: 35, hitHalfY: 35 };
compass.entries.set('s', smallEntry);
check('contains: inside the small chip\'s slack ⇒ true', compass.contains(100 + 30, 100) === true);
check('contains: well off the small chip ⇒ false', compass.contains(100 + 40, 100) === false);
// --- Hover INTENT (the expand waits, the fold-back has a grace) ------
compass.setMode(entry, 'small', false);
fakeScene.time.now = 1000;
compass.onChipOver(entry);
check('hover IN: brightens now; the expand waits the intent delay',
entry.hovered === true && entry.mode === 'small' && entry.expandAt === 1000 + compass.expandDelay);
check('within the expand window: the chip holds small',
syncEntryMode(entry, entry.expandAt - 1, true) === null && entry.mode === 'small');
compass.onChipOut(entry);
check('a flick over (out before the deadline) never pops it',
entry.expandAt === null && entry.mode === 'small');
fakeScene.time.now = 2000;
compass.onChipOver(entry);
check('after the rest: the far chip expands',
syncEntryMode(entry, 2000 + compass.expandDelay + 1, true) === 'expand');
compass.setMode(entry, 'full', false);
check('expanded: the full readout is back', entry.mode === 'full' && entry.typeText.alpha === 1);
fakeScene.time.now = 3000;
compass.onChipOut(entry);
check('hover OUT: the fold-back waits the grace (stays expanded)',
entry.collapseAt === 3000 + compass.collapseDelay && entry.mode === 'full' && entry.hovered === false);
check('within the grace: no collapse',
syncEntryMode(entry, entry.collapseAt - 1, true) === null && entry.mode === 'full');
compass.onChipOver(entry); // pointer back within the grace
check('hover back within the grace: fold cancelled, chip stays up',
entry.collapseAt === null && entry.hovered === true && entry.mode === 'full');
check('settles to full while hovered (far)', syncEntryMode(entry, 3500, true) === null && entry.mode === 'full');
fakeScene.time.now = 4000;
compass.onChipOut(entry);
const verdict = syncEntryMode(entry, entry.collapseAt + 1, true); // past the grace
compass.setMode(entry, verdict === 'collapse' ? 'small' : 'full', false);
check('grace runs out (pointer gone): the chip folds back to small',
verdict === 'collapse' && entry.mode === 'small');
// A NEAR chip is never far-folded: out just repaints it, no grace armed.
compass.setMode(entry, 'full', false);
entry.far = false;
compass.onChipOut(entry);
check('a NEAR chip never arms a fold-back',
entry.collapseAt === null && entry.mode === 'full');
}
// --- 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).`);