/** * System LAYOUT test (dev tool, run with Node — no browser needed): * * node dev/system-layout.test.mjs * * Asserts the tether-discovery layout rules (data/planets.json → * solarSystem + data/tether.json, implemented by * js/galaxy/SystemGenerator.js → layoutSystem) across every system of a * fresh galaxy: * * 1. LEVEL-1 DISCOVERY: every planet and free-space station has at * least one OTHER object (planet/station) within a level-1 tether — * clearly inside (d ≤ L1 − edgeMargin). A chain of level-1 tethers * therefore walks the whole system. (The home world — the bootstrap * anchor the player starts on — sits minSpacing away, which is the * level-2 zone by design; its connection is covered by rule 2.) * 2. LEVEL-2 GUARANTEE: the level-2 adjacency graph over the objects * (and the home world in the starting system) is CONNECTED — a * level-2 tether DEFINITELY discovers an object while undiscovered * objects remain. * 3. NO LEVEL-3 NEEDED: the level-1 chain above implies level 3 is * never required for discovery (checked as: the whole system is * reachable from any object by a path of ≤ level-1 steps). * 4. EDGE MARGIN: NO anchorable pair (planets, stations, home world) * sits in the no-man's-land band around a tether rim — * (L1 ± edgeMargin) or (L2 ± edgeMargin) — objects are clearly * inside or clearly outside each tether region, never at the edge * (the scan edge case). The invisible star (non-home systems) is not * a tether anchor, so its distances are exempt. * 5. ORBITAL BAND: the center-to-object distance sits in * [minSpacing, maxSpacing] (6400–15360 px), and in the home system * additionally ≤ level-2 − edgeMargin (the home world IS an anchor). * * and DETERMINISM: same seed ⇒ same layout (the lazy/eager content * cache returns identical coordinates). */ 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)); 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); const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); let failures = 0; const check = (label, cond, extra = '') => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`); if (!cond) failures++; }; // --- The bands (from the same config the generator reads) ------------- const L1 = config.get('tether.level1Radius', 5120); const L2 = L1 * (config.get('tether.radiusGrowth', 2.0)); const M = config.get('tether.edgeMargin', 512); const L1in = L1 - M; const L1out = L1 + M; const L2in = L2 - M; const L2out = L2 + M; const MIN = config.get('planets.solarSystem.minSpacing', 6400); const MAX = config.get('planets.solarSystem.maxSpacing', 15360); const HOME_MAX = config.get('planets.solarSystem.homeMaxSpacing', 10240); const inRim = (d) => (d > L1in + 1e-9 && d < L1out - 1e-9) || (d > L2in + 1e-9 && d < L2out - 1e-9); const SEED = 'layout-test-seed'; const g = Galaxy.create(SEED); const HOME = g.currentSystemId; // One anchorable node per object: the planets, the free-space stations, // and (home only) the home world at the origin. const nodesOf = (c, isHome) => { const nodes = c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })); for (const s of c.settlements ?? []) { if (s.anchor?.type === 'space') nodes.push({ x: s.x, y: s.y, name: s.name }); } if (isHome) nodes.push({ x: 0, y: 0, name: 'home' }); return nodes; }; let systems = 0; let withObjects = 0; let l1Bad = 0, connBad = 0, rimBad = 0, orbBad = 0; let whyL1 = '', whyConn = '', whyRim = '', whyOrb = ''; let l1Pairs = 0, l2Pairs = 0; for (const r of g.records) { const c = g.ensureContent(r.id); const isHome = r.id === HOME; const planets = c.planets.length; const space = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length; if (planets === 0 && space === 0) continue; // barren — no anchorable objects systems++; withObjects++; const nodes = nodesOf(c, isHome); const N = nodes.length; const dist = (i, j) => Math.hypot(nodes[i].x - nodes[j].x, nodes[i].y - nodes[j].y); // 1 + 2 + 3: the adjacency graphs (level 1 = the chain, level 2 = the // guarantee) and BFS connectivity over level 2. const adj1 = Array.from({ length: N }, () => []); const adj2 = Array.from({ length: N }, () => []); for (let i = 0; i < N; i++) { for (let j = i + 1; j < N; j++) { const d = dist(i, j); if (d <= L1in + 1e-9) { adj1[i].push(j); adj1[j].push(i); l1Pairs++; } if (d <= L2in + 1e-9) { adj2[i].push(j); adj2[j].push(i); l2Pairs++; } // 4: the edge margin — no anchorable pair in a rim band. if (inRim(d)) { rimBad++; if (!whyRim) whyRim = `${r.id}: ${nodes[i].name}↔${nodes[j].name} @ ${d.toFixed(1)} px`; } } } // 1: every planet/station has a level-1 neighbor (the chain walks the // system). The home world is exempt: it is the BOOTSTRAP anchor — // the player starts there, and minSpacing (6400 > L1out) puts its // objects in the level-2 zone by design (the L2 check below covers // its connection). Once the first planet is reached, L1 finds the // rest. if (N >= 2) { const lonely = nodes .map((n, i) => (n.name === 'home' ? null : adj1[i].length === 0 ? n.name : null)) .filter(Boolean); if (lonely.length) { l1Bad++; if (!whyL1) whyL1 = `${r.id}: no level-1 neighbor — ${lonely.join(', ')}`; } // 2: level-2 graph connected. const seen = new Set([0]); const queue = [0]; while (queue.length) { const i = queue.shift(); for (const j of adj2[i]) if (!seen.has(j)) (seen.add(j), queue.push(j)); } if (seen.size !== N) { connBad++; if (!whyConn) whyConn = `${r.id}: level-2 graph splits ${seen.size}/${N}`; } } // 5: the orbital band (center-to-object distances). for (const n of nodes) { if (n.name === 'home') continue; // the home world sits at the origin const d = Math.hypot(n.x, n.y); const hi = isHome ? Math.min(HOME_MAX, L2in) : MAX; if (d < MIN - 1e-9 || d > hi + 1e-9) { orbBad++; if (!whyOrb) whyOrb = `${r.id}: ${n.name} @ ${d.toFixed(1)} px (band ${MIN}..${hi.toFixed(0)})`; } } } check( `level-1 discovery: every planet/station has a level-1 neighbor — ${withObjects} systems`, l1Bad === 0, whyL1, ); check('level-2 guarantee: the level-2 graph is connected (every system)', connBad === 0, whyConn); check( `edge margin: no anchorable pair in (L1±${M}) or (L2±${M})`, rimBad === 0, whyRim, ); check( `orbital band: center-to-object in [${MIN}, ${MAX}] (home ≤ ${Math.min(HOME_MAX, L2in)})`, orbBad === 0, whyOrb, ); console.log(` ${systems} systems with objects checked (${l1Pairs} level-1 pairs, ${l2Pairs} level-2 pairs)`); // --- Determinism: same seed ⇒ identical coordinates ------------------- { const g2 = Galaxy.create(SEED); let same = true; let why = ''; for (const r of g.records) { const a = g.ensureContent(r.id); const b = g2.ensureContent(r.id); for (let i = 0; i < a.planets.length && same; i++) { if (Math.abs(a.planets[i].x - b.planets[i].x) > 1e-9 || Math.abs(a.planets[i].y - b.planets[i].y) > 1e-9) { same = false; why = `${r.id}: planet ${i} differs`; } } } check('same seed ⇒ same layout (two fresh galaxies agree)', same, why); } console.log(failures === 0 ? '\nAll system-layout tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);