orbit/dev/jumps.test.mjs

473 lines
22 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.

/**
* 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 network is a PURE SPANNING TREE (shortcuts OFF in the config):
* exactly N1 undirected edges — no loops, one route between any two
* systems (the maze) — and every BARREN system (objectCount → 0) is
* a LEAF: exactly one gate, in and out the same way (the dead end);
*
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
* - content.jumps matches the network (count + destinations);
* - ANCHORED systems (a planet — or the home world in the starting
* system): every gate is within level-1 tether (tether.level1Radius)
* of a planet anchor and FACES its destination star on the 2-D map
* (soft rule — the gate sits on the DESTINATION SIDE of the system
* center whenever some anchor can reach that side (maxProj + tether
* > 0), and from the anchoring object it is within 90° of the
* system→star bearing; when the only anchor sits farther than the
* tether opposite the destination — a single far planet — the gate
* takes the best aimed point instead); stations are NOT anchors (the
* player must be able to build out from a world), but they DO get
* clearance;
* - every non-barren system holds at least ONE planet (the gate's
* anchor is guaranteed to exist);
* - BARREN systems (objectCount → 0 — the network's dead-end leaves):
* the gate sits ON the ray toward its destination, in the radius
* band, facing it — and 12 asteroid clusters drift INSIDE the
* gate's level-1 tether (the stop's only payload, data/asteroids.json
* → barren);
* - gates stay gates.minRadius..gates.maxRadius from the center;
* - gates keep size+clearance from every solid disc (planets +
* stations + the home world) 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 no planets or stations — its payload is the gate's
* asteroid cluster;
*
* 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}`);
// HOP SCALE (the trade-economy property): with the even star field,
// hop counts track MAP distance — the journey to the far corner is a
// normal trip (a fraction of the roster), not a winding labyrinth.
const depth = new Map([[HOME, 0]]);
const qd = [HOME];
while (qd.length) {
const u = qd.pop();
for (const t of g.jumpGatesFor(u)) if (!depth.has(t.id)) depth.set(t.id, depth.get(u) + 1), qd.push(t.id);
}
const maxHops = Math.max(...depth.values());
check(
`hop scale: the farthest system is ${maxHops} hops from home (hops ≈ map distance)`,
maxHops < g.records.length / 2,
`max ${maxHops} of ${g.records.length} systems`,
);
// 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}`);
// TREE (shortcuts OFF in data/gates.json): exactly N1 undirected
// edges — no loops, no redundant routes (the maze's single path).
const pairs = new Set();
let treeEdges = 0;
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) {
const key = [r.id, t.id].sort().join('\u0000');
if (!pairs.has(key)) pairs.add(key), treeEdges++;
}
check(`spanning tree: exactly ${g.records.length - 1} undirected edges (no loops)`,
treeEdges === g.records.length - 1, `${treeEdges} edges over ${g.records.length} systems`);
// MAZE DEAD ENDS: every barren system (objectCount → 0) is a LEAF —
// exactly one gate, in and out the same way. (Home is exempt — it
// always holds planets.)
const leafBad = [];
for (const r of g.records) {
if (r.id === HOME) continue;
const c = g.ensureContent(r.id);
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
const isBarren = c.planets.length === 0 && spaceCount === 0;
if (!isBarren) continue;
const n = g.jumpGatesFor(r.id).length;
if (n !== 1) leafBad.push(`${r.id}:${n}`);
}
check('every barren system is a leaf — exactly one gate (the dead end)', leafBad.length === 0, leafBad.slice(0, 5).join(', '));
}
// ----------------------------------------------------------------------
// 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: the PLANETS and (home only) the home world — the bodies a
// gate's tether may hang from (stations are NOT anchors — the player
// must be able to build out from a world).
const anchors = [
...c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })),
];
if (isHome) anchors.push({ x: 0, y: 0, name: 'home world' });
// CLEARANCE DISCS: every solid body the gate keeps clear of — the
// anchors plus the free-space stations.
const discs = [
...anchors,
...((c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })) ?? []),
];
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 CENTER): the gate sits on the DESTINATION
// side of the system center — dot(gate, destDir) > 0 — whenever
// geometry allows it: some anchor's tether circle reaches that side
// (maxProj + tether > 0). When every anchor sits farther than the
// tether opposite the destination (a single far planet), the gate
// takes the best aimed point — the tether rule (hard) outranks the
// facing rule (soft).
const maxProj = Math.max(...anchors.map((a) => a.x * Math.cos(th) + a.y * Math.sin(th)));
if (maxProj + TETHER > 0 && j.x * Math.cos(th) + j.y * Math.sin(th) <= 1e-6) {
facing++;
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} faces AWAY from its destination (wrong side of the center)`;
}
// FACING (soft, from the anchor): 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 center.
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 center (band ${MIN_R}..${MAX_R})`;
}
// CLEARANCE from every solid disc (planets + stations + home world
// — every disc in this game is under 800 px across, so a size +
// 100 px floor is a fair check).
for (const a of discs) {
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 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, in the band ${MIN_R}..${MAX_R}, facing it`, barrenBad === 0, bWhy);
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the center`, radius === 0, rWhy);
check(`clearance: every gate keeps ${SIZE} + 100 px from solid discs (planets + stations + home)`, 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 dead end (the network's leaves). Every NON-barren
// system holds at least ONE PLANET (the gate's anchor — the player must
// be able to build out from a world), and a barren system's payload is
// 12 asteroid clusters INSIDE its single gate's level-1 tether.
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 = '';
let noPlanet = 0, whyPlanet = '', barrenCl = 0, whyCl = '';
const AC = config.section('asteroids', {});
const clLo = Math.max(0, Math.floor(AC.barren?.clusters?.[0] ?? 1));
const clHi = Math.max(clLo, Math.floor(AC.barren?.clusters?.[1] ?? 2));
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`;
}
// ≥ 1 PLANET in every non-barren system (the gate's anchor is real).
if (n > 0 && c.planets.length === 0) {
noPlanet++;
if (!whyPlanet) whyPlanet = `${r.id}: ${n} objects, 0 planets`;
}
// BARREN: the dead end's payload — clLo..clHi clusters, each inside
// its single gate's level-1 tether (center + extent ≤ tether rim).
if (n === 0) {
const cl = c.asteroids ?? [];
if (cl.length < clLo || cl.length > clHi) {
barrenCl++;
if (!whyCl) whyCl = `${r.id}: ${cl.length} clusters (want ${clLo}..${clHi})`;
continue;
}
const gate = (c.jumps ?? [])[0];
if (!gate) {
barrenCl++;
if (!whyCl) whyCl = `${r.id}: barren but no gate to hang the cluster on`;
continue;
}
for (const a of cl) {
const d = Math.hypot(a.x - gate.x, a.y - gate.y);
if (d - (a.bound ?? 0) > TETHER + 1e-6) {
barrenCl++;
if (!whyCl) whyCl = `${r.id}: cluster at ${Math.round(d)} px from the gate (+extent > ${TETHER})`;
}
}
}
}
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('every non-barren system holds ≥ 1 planet (the gate anchor exists)', noPlanet === 0, whyPlanet);
check(`barren dead ends carry ${clLo}${clHi} clusters INSIDE the gate's level-1 tether`, barrenCl === 0, whyCl);
}
// ----------------------------------------------------------------------
// 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);