orbit/dev/system-layout.test.mjs

213 lines
8.0 KiB
JavaScript
Raw 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.

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