/** * 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).`);