orbit/dev/tether-rules.audit.mjs

168 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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}`);
}