267 lines
12 KiB
JavaScript
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);
|