/** * Audit: do generated galaxies obey the tether-discovery design rules? * Rule 1 (gates): every jump gate strictly < 5000 px from a planet or * space station — its ANCHOR sits in the 3500–4608 px * placement band (data/gates.json → anchorMinDistance / * anchorMaxDistance — inside the level-1 tether with the * edgeMargin), the distance varying per gate — unless the * system is a dead end (a barren system: no planets/stations, * single gate). Also reports: no gate sits in the rim band * (± edgeMargin) around ANOTHER object's tether, and the * gate→anchor distance distribution (the variation). * Rule 2 (discovery chains — replaces the old "all objects within 2 * tether lengths of each other" rule): * a) LEVEL-1: every planet/station has another object within * level 1 — clearly inside (d ≤ L1 − edgeMargin); a chain * of level-1 tethers walks the whole system; * b) LEVEL-2: the level-2 adjacency graph over the objects * (and the home world) is CONNECTED — level 2 DEFINITELY * discovers an object while undiscovered ones remain; * c) LEVEL-3: never required (the level-1 chain above); * d) EDGE MARGIN: no anchorable pair (planets, stations, home * world) sits in the rim band (L1 ± edgeMargin) or * (L2 ± edgeMargin) — objects are clearly inside or * clearly outside each tether region, never at the edge. * * Run: node dev/tether-rules.audit.mjs */ 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); const T1 = config.get('tether.level1Radius', 5120); const T2 = T1 * (config.get('tether.radiusGrowth', 2.0)); const M = config.get('tether.edgeMargin', 512); const L1in = T1 - M; const L1out = T1 + M; const L2in = T2 - M; const L2out = T2 + M; const ANCHOR_MIN = config.get('gates.anchorMinDistance', 3500); const ANCHOR_MAX = Math.min(T1, config.get('gates.anchorMaxDistance', 4608)); const EPS = 1; const inRim = (d) => (d > L1in + 1e-9 && d < L1out - 1e-9) || (d > L2in + 1e-9 && d < L2out - 1e-9); for (const seed of ['audit-a', 'audit-b', 'audit-c']) { const g = Galaxy.create(seed); g.generateAll(); let r1Bad = 0, r1Checked = 0; const r1Examples = []; const anchorDs = []; // gate→anchor distances (to verify the variation) let gateRimBad = 0; const gateRimExamples = []; // Rule 2 let l1Bad = 0, connBad = 0, rimBad = 0, systems = 0; let whyL1 = '', whyConn = '', whyRim = ''; const r2ByN = new Map(); // N → [l1ok, connOk, rimOk, systems] for (const rec of g.records) { const c = g.contentOf(rec.id); const planets = c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })); const space = c.settlements.filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })); const isHome = rec.id === g.homeSystemId; const objects = [...planets, ...space]; if (isHome) objects.push({ x: 0, y: 0, name: 'home' }); // the home world (a world — a valid Rule-1 solid) const isBarren = objects.length === 0; // Rule 1 (gates < 5000 px from a planet/station; anchor band 3500–4608; barren exempt) for (const j of c.jumps) { r1Checked++; if (isBarren) continue; let best = Infinity; for (const o of objects) best = Math.min(best, Math.hypot(j.x - o.x, j.y - o.y)); if (best > 5000 + 1e-6) { r1Bad++; if (r1Examples.length < 3) r1Examples.push(`${rec.id} gate→${j.to} d=${Math.round(best)}`); } let aBand = -1; const anchors = isHome ? [...planets, { x: 0, y: 0, name: 'home' }] : planets; for (const a of anchors) { const d = Math.hypot(j.x - a.x, j.y - a.y); if (d >= ANCHOR_MIN - 1e-6 && d <= ANCHOR_MAX + 1e-6) { aBand = d; break; } } if (aBand >= 0) anchorDs.push(aBand); // the gate's anchoring planet // Rim band against the OTHER solids (the anchor's own distance is // on the step band (≤ L1in — clearly inside, by construction), so // it can never count here). const solids = [...planets, ...space]; if (isHome) solids.push({ x: 0, y: 0 }); for (const s of solids) { const d = Math.hypot(j.x - s.x, j.y - s.y); if (inRim(d)) { gateRimBad++; if (gateRimExamples.length < 3) gateRimExamples.push(`${rec.id} gate→${s.name ?? 'obj'} d=${Math.round(d)}`); } } } // Rule 2 (discovery chains + edge margin) if (objects.length >= 2) { systems++; const nodes = isHome ? [...objects, { x: 0, y: 0, name: 'home' }] : objects; const N = nodes.length; const adj1 = Array.from({ length: N }, () => []); const adj2 = Array.from({ length: N }, () => []); let sysRim = true; for (let i = 0; i < N; i++) { for (let j = i + 1; j < N; j++) { const d = Math.hypot(nodes[i].x - nodes[j].x, nodes[i].y - nodes[j].y); if (d <= L1in + 1e-9) { adj1[i].push(j); adj1[j].push(i); } if (d <= L2in + 1e-9) { adj2[i].push(j); adj2[j].push(i); } if (inRim(d)) { sysRim = false; if (!whyRim) whyRim = `${rec.id}: ${nodes[i].name}↔${nodes[j].name} @ ${Math.round(d)} px`; } } } // a) every planet/station (not the home world) has an L1 neighbor let l1ok = true; for (let i = 0; i < N; i++) { if (nodes[i].name === 'home') continue; if (adj1[i].length === 0) { l1ok = false; if (!whyL1) whyL1 = `${rec.id}: ${nodes[i].name} has no level-1 neighbor`; } } // b) level-2 graph connected let conn = true; 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) conn = false; if (!conn && !whyConn) whyConn = `${rec.id}: level-2 graph splits ${seen.size}/${N}`; if (!l1ok) l1Bad++; if (!conn) connBad++; if (!sysRim) rimBad++; const [c1, c2, c3, cc] = r2ByN.get(N) ?? [0, 0, 0, 0]; r2ByN.set(N, [c1 + (l1ok ? 1 : 0), c2 + (conn ? 1 : 0), c3 + (sysRim ? 1 : 0), cc + 1]); } } console.log(`\nseed="${seed}" systems=${g.records.length} (L1=${T1}px, L2=${T2}px, edgeMargin=${M}px)`); console.log(` Rule 1 (gates < 5000px from a planet/station, anchor band ${ANCHOR_MIN}–${ANCHOR_MAX}, barren exempt): ${r1Checked - r1Bad}/${r1Checked} OK${r1Bad ? ' — BAD: ' + r1Examples.join(', ') : ''}`); console.log(` Rule 1b (no gate in the rim band around another solid): ${gateRimBad ? 'BAD — ' + gateRimExamples.join(', ') : 'OK'}`); const anchorSorted = [...anchorDs].sort((a, b) => a - b); if (anchorSorted.length) { const distinct = new Set(anchorSorted.map((d) => Math.round(d / 50))).size; console.log(` gate→anchor distances: min=${Math.round(anchorSorted[0])} max=${Math.round(anchorSorted[anchorSorted.length - 1])}, ~${distinct} distinct radii (band ${ANCHOR_MIN}–${ANCHOR_MAX})`); } const byN = [...r2ByN.entries()].map(([n, [a, b, cc, tot]]) => `${n}-obj L1 ${a}/${tot}, L2-conn ${b}/${tot}, margin ${cc}/${tot}`).join('; '); console.log(` Rule 2 (L1 chain + L2 connectivity + edge margin, ${systems} systems): L1 ${systems - l1Bad}/${systems}${l1Bad ? ' — ' + whyL1 : ''}`); console.log(` L2 connectivity ${systems - connBad}/${systems}${connBad ? ' — ' + whyConn : ''}`); console.log(` edge margin ${systems - rimBad}/${systems}${rimBad ? ' — ' + whyRim : ''}`); console.log(` by system size: ${byN}`); }