/** * 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 (1–3 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 N−1 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 — within 90° of the system→star bearing from the * anchoring object); 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 1–2 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 system’s 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}`); // TREE (shortcuts OFF in data/gates.json): exactly N−1 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 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 -j 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 // 1–2 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);