395 lines
19 KiB
JavaScript
395 lines
19 KiB
JavaScript
/**
|
||
* 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).`);
|