orbit/js/galaxy/JumpNetwork.js

267 lines
12 KiB
JavaScript

/**
* 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).
* MAZE — with shortcuts OFF (the current config, data/gates.json →
* shortcuts:false) the graph is a PURE SPANNING TREE: exactly
* one route between any two systems, no closed loops. The
* galaxy reads as a maze of dead ends and long hauls — and
* every barren system (the `barren` set, gate-only dead ends)
* is a LEAF: exactly one gate, in and out the same way.
* 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. A BARREN node never adopts children
* (it keeps its single gate — the maze's dead end).
* 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.
* Every jump therefore has a RETURN gate (the destination's gate
* pointing back), so the player can always jump back the way they
* came.
* 4. Optional SHORTCUTS (OFF in the current config): 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 this scale), and it
* still respects the degree budget (preferring non-barren attach targets
* so a dead end stays a leaf when it can).
*/
/**
* 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 {Set<string>|null} [o.barren] — the gate-only dead-end systems
* (objectCount → 0, from the composition roll): a barren node never
* adopts children, so it ends up a tree LEAF — exactly one gate, in and
* out the same way (the maze's dead ends). The repair pass prefers to
* attach leftovers to non-barren nodes.
* @param {string|null} [o.rootId] — grow the tree from this system (the home
* system) — it then keeps the lowest possible degree.
* @returns {{ gates: Map<string, string[]>, repaired: number }}
* gates: system id → ordered list of gate destinations (parent edge
* first — "the road home" — then children/shortcuts).
* repaired: systems that needed the defensive attach (0 on real data).
*/
export function buildJumpNetwork({
records,
knn,
minGates = 1,
maxGates = 3,
shortcuts = true,
barren = null,
rootId = null,
}) {
const n = records.length;
const isBarren = (i) => barren instanceof Set && barren.has(records[i].id);
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++];
if (isBarren(u)) continue; // a dead end never adopts children (it keeps its single gate)
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 — prefer a non-barren target (a dead end should
// keep its single gate when the graph lets us).
let u = -1;
for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates && !isBarren(c)) { u = c; break; }
if (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 (prefer non-barren)
let bestD = Infinity;
for (const pass of [1, 0]) {
for (let c = 0; c < n; c++) {
if (!visited[c] || c === w) continue;
if (Boolean(isBarren(c)) !== (pass === 1)) continue;
const d = dist2(w, c);
if (d < bestD) { bestD = d; u = c; }
}
if (u !== -1) break;
}
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);