/** * 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 * system's worlds sit at finite, in-band positions around the origin, * at least minEdgeGap px apart edge-to-edge (incl. the origin's home * world), 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 { minOrbit, maxOrbit, minEdgeGap } = band; const baseR = (config.get('planets.frameWidth', 1024) * config.get('planets.scale', 1)) / 2; const g = Galaxy.create('discovery-layout-test'); let layoutOk = true; let layoutWhy = ''; const probe = (systems) => { for (const rec of systems) { const c = g.ensureContent(rec.id); const placed = [{ x: 0, y: 0, r: baseR }]; // the origin's home world for (const p of c.planets) { const r = baseR * p.scale; const d0 = Math.hypot(p.x, p.y); const want = config.get(`planets.classScale.${p.class}`, 1); if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) { layoutOk = false; layoutWhy = `${rec.id}: non-finite layout`; continue; } if (Math.abs(p.scale - want) > 1e-9) { layoutOk = false; layoutWhy = `${rec.id}: ${p.class} world scaled ${p.scale} ≠ classScale ${want}`; } if (d0 < minOrbit - 1e-6) { layoutOk = false; layoutWhy = `${rec.id}: world at ${d0.toFixed(1)} px < minOrbit ${minOrbit}`; } for (const q of placed) { const gap = Math.hypot(p.x - q.x, p.y - q.y) - q.r - r; if (gap < minEdgeGap - 1e-6) { layoutOk = false; layoutWhy = `${rec.id}: worlds ${minEdgeGap - gap}px closer than minEdgeGap ${minEdgeGap}`; } } placed.push({ x: p.x, y: p.y, r }); } } }; probe(g.records.slice(0, 500)); check( `layout: 500 systems' worlds finite, scaled by class, in-band (≥${minOrbit}px), ≥${minEdgeGap}px apart edge-to-edge${layoutOk ? '' : ' — ' + layoutWhy}`, layoutOk, ); const rec0 = g.records[0].id; const la = Galaxy.create('discovery-layout-test').ensureContent(rec0); const lb = Galaxy.create('discovery-layout-test').ensureContent(rec0); 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); check('layout is deterministic (same seed ⇒ same 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); 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).`);