/** * The jump network — the galaxy's highway layer (config: data/gates.json). * * Given the roster (a 2-D map of star systems) this builds, for EVERY * system, the list of OTHER systems its jump gates jump to, subject to: * * LOCAL — a gate only ever jumps to a star in the system's own * neighbor pool (its `pool` nearest stars, or one that lists * the system among ITS nearest — the symmetric union). The * gate then sits on the side of the system facing that star * (SystemGenerator.layoutGates), so "upper-right gate" means * "a star to the upper right on the map". * COMPLETE — no closed systems, no trapped sets: the directed graph is * STRONGLY CONNECTED — from any star the player can reach * any other (dev/jumps.test.mjs verifies forward AND * backward reachability from the home system). * BOUNDED — minGates ≤ gates(system) ≤ maxGates (1..3 in practice). * PURE — no Math.random: every tie is broken by index. Same seed * ⇒ same network (deterministic across machines and runs). * * Construction: * 1. The undirected neighbor graph (u~v iff v ∈ nn(u) or u ∈ nn(v)). * 2. A spanning tree of it with every node's degree ≤ maxGates, grown * BFS-outward from the home system with a "keep the frontier open" * child heuristic: attach the unvisited neighbors with the MOST * unvisited neighbors first, so rim clusters are absorbed before * their degree budget is spent. * 3. Tree edges run BOTH ways. A bidirected tree is strongly connected * by construction (the unique tree path between any two systems can * be walked in either direction), and every node's gate count is its * tree degree — at least 1 (no isolated node) and at most maxGates. * 4. Optional SHORTCUTS: any spare degree budget (nodes under * maxGates) buys extra one-way local edges — the web, not just the * roads. Adding edges never removes reachability, so the strong * connectivity survives. * * The repair pass (below) is defensive: it fires only if the neighbor * graph is disconnected (effectively impossible at 40k points), and it * still respects the degree budget. */ /** * Build the gate network over the roster. * @param {object} o * @param {Array<{id:string, x:number, y:number}>} o.records — the star roster. * @param {(id:string) => Array<{id:string}>} o.knn — a system's nearest * neighbors (its neighbor pool; `pool` is the expected pool size, only * used to validate). * @param {number} [o.minGates=1] — validation bound. * @param {number} [o.maxGates=3] — hard degree budget per node. * @param {boolean} [o.shortcuts=true] — spend spare budget on extra local edges. * @param {string|null} [o.rootId] — grow the tree from this system (the home * system) — it then keeps the lowest possible degree. * @returns {{ gates: Map, repaired: number }} * gates: system id → ordered list of gate destinations (parent edge * first — "the road home" — then shortcuts). * repaired: systems that needed the defensive attach (0 on real data). */ export function buildJumpNetwork({ records, knn, minGates = 1, maxGates = 3, shortcuts = true, rootId = null, }) { const n = records.length; const idx = new Map(); records.forEach((r, i) => idx.set(r.id, i)); const iOf = (id) => { const i = idx.get(id); if (i === undefined) throw new Error(`Unknown system "${id}" in jump network`); return i; }; // Directed k-nearest per node, as indices. const out = new Array(n); for (let i = 0; i < n; i++) out[i] = knn(records[i].id).map((r) => iOf(r.id)); // Reverse links (who lists me) — the undirected neighborhood is the union. const rev = Array.from({ length: n }, () => []); for (let i = 0; i < n; i++) for (const j of out[i]) rev[j].push(i); const nbr = new Array(n); for (let i = 0; i < n; i++) { const seen = new Set(out[i]); nbr[i] = out[i].slice(); for (const j of rev[i]) { if (!seen.has(j)) { seen.add(j); nbr[i].push(j); } } } // A single-system "galaxy": no one to jump to (the minGates rule is // vacuous — there is no other star in existence). if (n <= 1) { const gates = new Map(); for (const r of records) gates.set(r.id, []); return { gates, repaired: 0 }; } const root = rootId ? iOf(rootId) : 0; const visited = new Uint8Array(n); const parent = new Int32Array(n).fill(-1); const deg = new Uint8Array(n); // --- The degree-limited spanning tree (BFS from the home system) ------ const queue = [root]; visited[root] = 1; let head = 0; while (head < queue.length) { const u = queue[head++]; const budget = maxGates - deg[u]; if (budget <= 0) continue; const cands = nbr[u].filter((v) => !visited[v]); if (cands.length === 0) continue; // "Keep the frontier open": candidates with the most unvisited // neighbors grow the tree for others; ties by index (determinism). const scored = cands.map((v) => { let unv = 0; for (const w of nbr[v]) if (!visited[w]) unv++; return { v, unv }; }); scored.sort((a, b) => b.unv - a.unv || a.v - b.v); for (const { v } of scored.slice(0, budget)) { visited[v] = 1; parent[v] = u; deg[u]++; deg[v]++; queue.push(v); } } // --- Repair (defensive): attach anything the tree left behind --------- // A leftover node has no visited neighbor with spare degree (its whole // neighborhood sat in a disconnected pocket). Repair, in order of // preference (all deterministic — index order, strict comparisons): // 1. Attach to a visited neighbor with spare degree (local). // 2. SWAP: take one of a visited node u's tree children x, re-home x // onto one of x's OWN visited neighbors that has spare degree, and // use the freed budget for w. The tree stays a tree; locality is // preserved (x stays inside its own neighborhood). // 3. Last resort (should never fire on a kNN graph): attach w to the // nearest visited node and accept one over-budget degree — a working // network beats a broken one. const dist2 = (a, b) => { const dx = records[a].x - records[b].x; const dy = records[a].y - records[b].y; return dx * dx + dy * dy; }; let repaired = 0; for (let w = 0; w < n; w++) { if (visited[w]) continue; // (1) local attach let u = -1; for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates) { u = c; break; } if (u === -1) { // (2) swap: best (u, x) pair by dist(w, u), then indices let bestU = -1, bestX = -1, bestNew = -1, bestD = Infinity; for (let c = 0; c < n; c++) { if (!visited[c] || c === w || deg[c] < 2) continue; // needs a child to free const d = dist2(w, c); if (d > bestD) continue; // children of c in the tree (visited nodes whose parent is c) for (let x = 0; x < n; x++) { if (parent[x] !== c) continue; for (const nn of nbr[x]) { if (nn === c || nn === w || !visited[nn] || deg[nn] >= maxGates) continue; if (d < bestD || (d === bestD && (c < bestU || (c === bestU && x < bestX)))) { bestD = d; bestU = c; bestX = x; bestNew = nn; } } } } if (bestU !== -1) { parent[bestX] = bestNew; // re-home the child deg[bestU]--; deg[bestNew]++; u = bestU; } } if (u === -1) { // (3) nearest visited node, budget be damned let bestD = Infinity; for (let c = 0; c < n; c++) { if (!visited[c] || c === w) continue; const d = dist2(w, c); if (d < bestD) { bestD = d; u = c; } } if (u === -1) continue; // nothing to attach to (n === 1 handled above) console.warn(`[orbit] jump network: forced attach of ${records[w].id} (degree budget exceeded)`); } visited[w] = 1; parent[w] = u; deg[u]++; deg[w]++; repaired++; } // --- Shortcuts: the spare budget buys extra one-way local edges -------- const shortcutsOf = Array.from({ length: n }, () => []); if (shortcuts) { const linked = new Set(); for (let i = 0; i < n; i++) if (parent[i] >= 0) linked.add(key(i, parent[i])); for (let u = 0; u < n; u++) { for (const v of out[u]) { if (deg[u] >= maxGates) break; if (linked.has(key(u, v))) continue; // already linked, either way linked.add(key(u, v)); shortcutsOf[u].push(v); deg[u]++; } } } // --- Assemble ---------------------------------------------------------- // Each node's gates = the tree edges touching it — its parent first ("the // road home"), then its tree children in index order — plus its // shortcuts. A bidirected spanning tree is strongly connected by // construction (the unique tree path between any two systems is walkable // in both directions), so every system both reaches and is reachable; // every node's gate count is its tree degree (≥ 1 for n > 1, ≤ maxGates) // plus any shortcuts it bought. const children = Array.from({ length: n }, () => []); for (let i = 0; i < n; i++) if (parent[i] >= 0) children[parent[i]].push(i); const gates = new Map(); for (let i = 0; i < n; i++) { const targets = []; if (parent[i] >= 0) targets.push(records[parent[i]].id); for (const c of children[i].sort((a, b) => a - b)) targets.push(records[c].id); for (const v of shortcutsOf[i]) targets.push(records[v].id); if (targets.length < minGates && n > 1) { // Can't happen (tree degree ≥ 1), but never emit a closed system. throw new Error(`Jump network left system ${records[i].id} with ${targets.length} gate(s) < minGates ${minGates}`); } gates.set(records[i].id, targets); } return { gates, repaired }; } const key = (a, b) => (a < b ? a : b) + '\u0000' + (a < b ? b : a);