/** * 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 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 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}`); } // ---------------------------------------------------------------------- // 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 -j 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);