213 lines
8.0 KiB
JavaScript
213 lines
8.0 KiB
JavaScript
/**
|
||
* 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);
|