/** * 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 sawTwoOrbits = false; // the N = 11 case (9 planets + 2 stations) 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 }); } if (objs.length - 1 === 11) sawTwoOrbits = 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: the 11-object two-orbit case (9 planets + 2 stations) occurs in the sample', sawTwoOrbits); // 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).`);