orbit/dev/jumps.test.mjs

360 lines
16 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);
* - ANCHORED systems (a planet, a free-space station, or the home world):
* every gate is within level-1 tether (tether.level1Radius) of an
* anchor and FACES its destination star on the 2-D map (soft rule —
* within 90° of the system→star bearing from the anchoring object);
* - BARREN systems (objectCount → 0 — a jump-gate-only stop): the gate
* sits ON the ray toward its destination, ≥ gates.barrenDistance from
* the star, within the radius band, facing it;
* - 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;
* - every gate record carries active: false (data/gates.json → ACTIVITY);
* - the OBJECT COMPOSITION (data/systems.json → objectCount): every
* non-home system holds 0, 2, 3, 4, or 5 objects (planets +
* free-space stations) in the configured proportions; a barren
* system holds nothing else — no asteroid clusters either;
*
* 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 BARN_D = GATES.barrenDistance ?? 8192;
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, barrenBad = 0, active = 0;
let tWhy = '', fWhy = '', rWhy = '', cWhy = '', gWhy = '', mWhy = '', bWhy = '';
let netMismatch = false;
for (const sys of sample) {
const c = g.ensureContent(sys.id);
const isHome = sys.id === HOME;
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
const isBarren = c.planets.length === 0 && spaceCount === 0;
// 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`;
}
// ACTIVITY (data/gates.json → ACTIVITY): every gate starts inert.
if (c.jumps.some((j) => j.active !== false)) {
active++;
if (!bWhy) bWhy = `${sys.id}: a gate is not active:false`;
}
// 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);
const d0 = Math.hypot(j.x, j.y);
if (isBarren) {
// BARREN: on the ray toward the destination, ≥ barrenDistance from
// the star (stepped outward within the band if the gap forced it),
// facing it (the bearing nudge, if any, stays within 90°).
const dev = Math.abs(norm(Math.atan2(j.y, j.x) - th));
if (d0 < BARN_D - 1e-6 || d0 > MAX_R + 1e-6 || dev >= Math.PI / 2) {
barrenBad++;
if (!bWhy) bWhy = `${sys.id}: barren gate ${j.id} at ${Math.round(d0)} px, ${(dev * 57.3).toFixed(1)}° off the target ray`;
}
} else {
// 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.
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 anchored-system gate ≤ ${TETHER} px from a planet/station anchor`, tether === 0, tWhy);
check('facing (soft): every anchored-system gate is on the target side of its anchor (< 90°)', facing === 0, fWhy);
check(`barren gates sit on the target ray, ≥ ${BARN_D} px from the star, facing it`, barrenBad === 0, bWhy);
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);
check('every gate record is active:false (inert until activation)', active === 0, bWhy);
// The OBJECT COMPOSITION (data/systems.json → objectCount): every
// non-home system holds 0, 2, 3, 4, or 5 objects (planets +
// free-space stations) in the configured proportions — 0 = a barren,
// jump-gate-only stop (no asteroid clusters either).
const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } });
const expected = { 0: OC.barren ?? 0.1 };
for (const [k, w] of Object.entries(OC.objects ?? {})) expected[Number(k)] = w;
const counts = {};
let shapeBad = 0, whyShape = '', barrenClusters = 0;
for (const r of g.records) {
if (r.id === HOME) continue; // the home system is exempt (fixed 2 planets)
const c = g.ensureContent(r.id);
const n = c.planets.length + (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
counts[n] = (counts[n] ?? 0) + 1;
if (!(n in expected)) {
shapeBad++;
if (!whyShape) whyShape = `${r.id}: ${n} objects`;
}
if (n === 0 && (c.asteroids ?? []).length > 0) barrenClusters++;
}
const nN = g.records.length - 1;
let distOk = true;
for (const [n, p] of Object.entries(expected)) {
const obs = (counts[Number(n)] ?? 0) / nN;
const sd = Math.sqrt(p * (1 - p) / nN);
if (Math.abs(obs - p) > 4 * sd + 0.004) {
distOk = false;
console.log(` ${n} objects: observed ${(obs * 100).toFixed(1)}% vs expected ${(p * 100).toFixed(1)}%`);
}
}
check(`composition: every non-home system holds 0/2/3/4/5 objects — ${g.records.length} systems`, shapeBad === 0, whyShape);
check('composition: the object counts match the configured proportions (±4σ)', distOk);
check('barren systems are truly barren — no asteroid clusters', barrenClusters === 0, `${barrenClusters} with clusters`);
}
// ----------------------------------------------------------------------
// 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.length !== b.planets.length ||
a.planets.some((p, k) => p.x !== b.planets[k].x || p.y !== b.planets[k].y || p.frame !== b.planets[k].frame) ||
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);