/** * 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); * - 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 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, 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 -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 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);