orbit/dev/jumps.test.mjs

307 lines
13 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.

/**
* Jump gates test (dev tool, run with Node — no browser needed):
*
* node dev/jumps.test.mjs
*
* Asserts the GATE NETWORK (data/gates.json, js/galaxy/JumpNetwork.js,
* exposed as Galaxy.jumpNetwork / jumpGatesFor):
* - every system holds minGates..maxGates gates (13 in data);
* - every gate is LOCAL — its destination is in the system's
* nearest-star pool (symmetric union, pool = gates.neighborPool);
* - the network is STRONGLY CONNECTED: from the home system every
* other system is reachable (forward BFS) AND every system can
* reach home (reverse BFS) — no closed systems, no trapped sets;
*
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
* - content.jumps matches the network (count + destinations);
* - every gate is within level-1 tether (tether.level1Radius) of an
* ANCHOR — a planet, a free-space station, or the home world in the
* starting system;
* - every gate FACES its destination star on the 2-D map: the bearing
* from the anchor to the gate is within 90° of the system→star
* bearing (soft rule — same side, never opposite);
* - gates stay gates.minRadius..gates.maxRadius from the star;
* - gates keep size+clearance from anchor discs and 2·size+gateGap
* from each other;
* - gate ids/names are unique per system;
*
* and DETERMINISM: same seed ⇒ same network, same gates, same layout;
* different seed ⇒ different network (spot check).
*/
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++;
};
const GATES = config.section('gates', {});
const MIN_GATES = GATES.minGates ?? 1;
const MAX_GATES = GATES.maxGates ?? 3;
const POOL = Math.max(1, GATES.neighborPool ?? 8);
const TETHER = config.get('tether.level1Radius', 5120);
const SIZE = GATES.size ?? 96;
const CLEAR = GATES.clearance ?? 256;
const GAP = GATES.gateGap ?? 192;
const MIN_R = GATES.minRadius ?? 2048;
const MAX_R = GATES.maxRadius ?? 20480;
const SEED = 'jumps-test-seed';
const g = Galaxy.create(SEED);
const HOME = g.currentSystemId;
const recOf = new Map(g.records.map((r) => [r.id, r]));
const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.PI;
// ----------------------------------------------------------------------
// 1. The network: counts, locality, strong connectivity
// ----------------------------------------------------------------------
{
const bad = [];
for (const r of g.records) {
const n = g.jumpGatesFor(r.id).length;
if (n < MIN_GATES || n > MAX_GATES) bad.push(`${r.id}:${n}`);
}
check(
`every system holds ${MIN_GATES}${MAX_GATES} gates (${g.records.length} systems)`,
bad.length === 0,
bad.slice(0, 5).join(', '),
);
// Locality: every destination sits in the system's nearest-star pool
// (the symmetric union — the gate network's `neighborsOf` contract).
let local = true;
let why = '';
outer: for (const r of g.records) {
for (const t of g.jumpGatesFor(r.id)) {
if (t.id === r.id) {
local = false;
why = `${r.id} jumps to itself`;
break outer;
}
const inPool =
g.neighborsOf(r.id, POOL).some((x) => x.id === t.id) ||
g.neighborsOf(t.id, POOL).some((x) => x.id === r.id);
if (!inPool) {
local = false;
why = `${r.id}${t.id} outside the ${POOL}-nearest pool`;
break outer;
}
}
}
check('every gate jumps to a star in the systems nearest-star pool (local jumps)', local, why);
// Strong connectivity, forward: from home, every system is reachable.
const fwd = new Set([HOME]);
const q = [HOME];
while (q.length) {
const u = q.pop();
for (const t of g.jumpGatesFor(u)) if (!fwd.has(t.id)) fwd.add(t.id), q.push(t.id);
}
check('reachable from home: every system (no forward dead ends)', fwd.size === g.records.length, `${fwd.size}/${g.records.length}`);
// Strong connectivity, backward: every system can reach home
// (no closed systems, no trapped sets).
const radj = new Map(g.records.map((r) => [r.id, []]));
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) radj.get(t.id).push(r.id);
const rev = new Set([HOME]);
const qr = [HOME];
while (qr.length) {
const u = qr.pop();
for (const s of radj.get(u)) if (!rev.has(s)) rev.add(s), qr.push(s);
}
check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`);
}
// ----------------------------------------------------------------------
// 2. Placement: tether, facing, radius band, clearances, uniqueness
// ----------------------------------------------------------------------
{
const sample = [recOf.get(HOME), ...g.records.slice(0, 3000).filter((r) => r.id !== HOME)];
let tether = 0, facing = 0, radius = 0, clearance = 0, gap = 0, unique = 0;
let tWhy = '', fWhy = '', rWhy = '', cWhy = '', gWhy = '', mWhy = '';
let netMismatch = false;
for (const sys of sample) {
const c = g.ensureContent(sys.id);
const isHome = sys.id === HOME;
// content.jumps matches the network (count + destinations, in order).
const net = g.jumpGatesFor(sys.id);
if (
c.jumps.length !== net.length ||
c.jumps.some((j, i) => j.to !== net[i].id || j.toName !== net[i].name)
) {
netMismatch = true;
if (!mWhy) mWhy = `${sys.id}: content.jumps ≠ jumpGatesFor`;
}
// Anchors: planets, free-space stations, and (home) the home world.
const anchors = [
...c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })),
...(c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })),
];
if (isHome) anchors.push({ x: 0, y: 0, name: 'home world' });
c.jumps.forEach((j, gi) => {
const t = recOf.get(j.to);
if (!t || t.id === sys.id) {
tether++;
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} has no valid destination`;
return;
}
const th = Math.atan2(t.y - sys.y, t.x - sys.x);
// TETHER (hard): within level-1 range of some anchor.
let bestD = Infinity;
for (const a of anchors) bestD = Math.min(bestD, Math.hypot(j.x - a.x, j.y - a.y));
if (bestD > TETHER + 1e-6) {
tether++;
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`;
}
// FACING (soft): from the anchoring object, the gate is on the
// target side — within 90° of the system→star bearing (some anchor
// must satisfy BOTH the tether and the facing).
let onSide = false;
for (const a of anchors) {
const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d > TETHER + 1e-6) continue;
if (Math.abs(norm(Math.atan2(j.y - a.y, j.x - a.x) - th)) < Math.PI / 2) onSide = true;
}
if (!onSide) {
facing++;
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`;
}
// RADIUS band from the star.
const d0 = Math.hypot(j.x, j.y);
if (d0 < MIN_R - 1e-6 || d0 > MAX_R + 1e-6) {
radius++;
if (!rWhy) rWhy = `${sys.id}: gate ${j.id} at ${Math.round(d0)} px from the star (band ${MIN_R}..${MAX_R})`;
}
// CLEARANCE from anchor discs (every disc in this game is under
// 800 px across, so a size + 100 px floor is a fair check).
for (const a of anchors) {
const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d < SIZE + 100) {
clearance++;
if (!cWhy) cWhy = `${sys.id}: gate ${j.id} is ${Math.round(d)} px from ${a.name}`;
break;
}
}
// GAP to the other gates of this system.
c.jumps.forEach((k, ki) => {
if (ki <= gi) return;
const d = Math.hypot(j.x - k.x, j.y - k.y);
if (d < 2 * SIZE + GAP) {
gap++;
if (!gWhy) gWhy = `${sys.id}: gates ${j.id}/${k.id} are ${Math.round(d)} px apart (< ${2 * SIZE + GAP})`;
}
});
});
const ids = new Set(c.jumps.map((j) => j.id));
const names = new Set(c.jumps.map((j) => j.name));
const idShape = c.jumps.every((j, i) => j.id === `${sys.id}-j${i + 1}`);
if (ids.size !== c.jumps.length || names.size !== c.jumps.length || !idShape) {
unique++;
}
}
check('content.jumps matches the gate network (count, destinations, order)', !netMismatch, mWhy);
check(`tether (hard): every gate ≤ ${TETHER} px from a planet/station anchor`, tether === 0, tWhy);
check('facing (soft): every gate is on the target side of its anchor (< 90°)', facing === 0, fWhy);
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the star`, radius === 0, rWhy);
check(`clearance: every gate keeps ${SIZE} + 100 px from anchor discs`, clearance === 0, cWhy);
check(`gate gap: gates of a system are ≥ ${2 * SIZE + GAP} px apart`, gap === 0, gWhy);
check('gate ids are <systemId>-j<n> and names are unique per system', unique === 0);
// The anchor guarantee: every system holds a planet or space station
// (a gate must be tether-reachable from one).
let noAnchor = 0;
for (const r of g.records) {
const c = g.ensureContent(r.id);
if (c.planets.length === 0 && !(c.settlements ?? []).some((s) => s.anchor?.type === 'space')) noAnchor++;
}
check(`anchor guarantee: every one of the ${g.records.length} systems holds a planet or space station`, noAnchor === 0, `${noAnchor} without`);
}
// ----------------------------------------------------------------------
// 3. Determinism — same seed ⇒ same network + gates + layout
// ----------------------------------------------------------------------
{
const g2 = Galaxy.create(SEED);
let net = true;
let placement = true;
for (let i = 0; i < 40 && net; i++) {
if (JSON.stringify(g.jumpGatesFor(g.records[i].id).map((t) => t.id)) !== JSON.stringify(g2.jumpGatesFor(g2.records[i].id).map((t) => t.id))) net = false;
}
for (let i = 0; i < 40 && placement; i++) {
const a = g.ensureContent(g.records[i].id);
const b = g2.ensureContent(g2.records[i].id);
if (JSON.stringify(a.jumps) !== JSON.stringify(b.jumps)) placement = false;
if (
a.planets.some((p, k) => p.x !== b.planets[k].x || p.y !== b.planets[k].y) ||
JSON.stringify((a.settlements ?? []).map((s) => [s.x, s.y])) !== JSON.stringify((b.settlements ?? []).map((s) => [s.x, s.y]))
)
placement = false;
}
check('same seed ⇒ same gate network', net);
check('same seed ⇒ same gate placements + object layout', placement);
const g3 = Galaxy.create('jumps-test-OTHER');
let diff = false;
for (let i = 0; i < 50 && !diff; i++) {
const idA = g.records[i].id;
const idB = g3.records[i].id;
if (idA !== idB) continue;
diff =
JSON.stringify(g.jumpGatesFor(idA).map((t) => t.id)) !== JSON.stringify(g3.jumpGatesFor(idB).map((t) => t.id));
}
check('different seed ⇒ different gate network (spot check)', diff);
}
// ----------------------------------------------------------------------
// 4. The home system
// ----------------------------------------------------------------------
{
const c = g.ensureContent(HOME);
const objects = c.planets.length + (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
check('home system holds ≤ 3 objects (2 planets + ≤ 1 free-space station)', objects <= 3 && c.planets.length === 2, `${objects} objects, ${c.planets.length} planets`);
check('home system holds jump gates', c.jumps.length >= MIN_GATES && c.jumps.length <= MAX_GATES, `${c.jumps.length} gates`);
const home = recOf.get(HOME);
let homeOk = true;
for (const j of c.jumps) {
const t = recOf.get(j.to);
const th = Math.atan2(t.y - home.y, t.x - home.x);
// From a home anchor (the home world, or — when the gate gap pushes
// a pair of close targets apart — a home planet) the gate is within
// level-1 tether and on the target side.
const anchors = [...c.planets.map((p) => [p.x, p.y]), [0, 0]];
let ok = false;
for (const [ax, ay] of anchors) {
const d = Math.hypot(j.x - ax, j.y - ay);
const a = Math.abs(norm(Math.atan2(j.y - ay, j.x - ax) - th));
if (d <= TETHER + 1e-6 && a < Math.PI / 2) ok = true;
}
if (!ok) homeOk = false;
}
check('home gates sit within level-1 tether of a home anchor, facing their star', homeOk);
}
console.log(failures === 0 ? '\nAll jump-gate tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);