168 lines
8.3 KiB
JavaScript
168 lines
8.3 KiB
JavaScript
/**
|
||
* 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}`);
|
||
}
|