orbit/js/galaxy/Route.js

113 lines
4.5 KiB
JavaScript

/**
* Route — a plotted course between two SYSTEMS of the jump network.
*
* The player sets a destination (SYSTEM tab of the MAP console) and the
* run plots the route it will take to get there. The compass then marks
* the NEXT point of that route — the jump gate in the current system that
* leads toward the destination — in orange, so the player always knows
* where to fly next.
*
* THE JUMP NETWORK IS A SPANNING TREE (data/gates.json → shortcuts:false),
* so there is EXACTLY ONE route between any two systems — the galaxy reads
* as a maze. planRoute still computes the CHEAPEST path (Dijkstra by travel
* distance over the gate graph), which is that unique route under the
* current config and degrades gracefully to "the fastest route" if
* shortcuts are ever enabled. Deterministic: same galaxy + endpoints ⇒
* same route.
*
* ROUTE STATE IS DERIVED. The run persists only the DESTINATION
* (systemId + objectId); the path from wherever the ship is NOW to that
* destination is computed on demand. That makes the follow/detour rules
* automatic, with no progress counter to desync:
* · jump to the route's next system → the remainder just shortens
* (re-plan from the new system, same destination);
* · jump anywhere else (a DETOUR) → the route re-plots from the detour
* (GameScene toasts the re-plot);
* · arrive at the destination system → the route is DONE and clears.
*
* Pure module (no Phaser) — Node-testable (dev/route.test.mjs).
*/
/** Euclidean distance between two roster points. */
function dist(ax, ay, bx, by) {
const dx = ax - bx;
const dy = ay - by;
return Math.hypot(dx, dy);
}
/**
* Plan the route between two systems.
*
* @param {object} galaxy — the run's Galaxy: needs `byId` (Map sysId →
* record with x/y) and `jumpNetwork.gates` (Map sysId → [sysId, …]).
* @param {string} fromId — the current system.
* @param {string} toId — the destination system.
* @returns {{path: string[], next: string|null, hops: number, distance: number}|null}
* `path` is [from, …, to]; `next` is the FIRST hop after `from` (null
* when from === to — already there); `hops` = path.length - 1. `null`
* when an endpoint is unknown or there is no route (should not happen —
* the network is strongly connected by construction).
*/
export function planRoute(galaxy, fromId, toId) {
if (typeof fromId !== 'string' || typeof toId !== 'string') return null;
if (fromId === toId) return { path: [toId], next: null, hops: 0, distance: 0 };
const byId = galaxy?.byId;
const gates = galaxy?.jumpNetwork?.gates;
if (!byId || !gates || !byId.has(fromId) || !byId.has(toId)) return null;
if (typeof byId.get(fromId)?.x !== 'number' || typeof byId.get(toId)?.x !== 'number') return null;
// Dijkstra by travel distance (edge cost = the hop's Euclidean span).
// The graph is small (a few hundred systems, degree ≤ maxGates), so a
// plain O(V²) select is plenty; ties break on system id (deterministic).
const best = new Map(); // sysId → best distance seen
best.set(fromId, 0);
const prev = new Map(); // sysId → predecessor on the best path
const done = new Set();
for (;;) {
let u = null;
let du = Infinity;
for (const [id, d] of best) {
if (done.has(id)) continue;
if (d < du || (d === du && (u === null || id < u))) { u = id; du = d; }
}
if (u === null) break; // nothing left to relax
if (u === toId) break; // reached the goal at its final cost
done.add(u);
const ru = byId.get(u);
for (const v of gates.get(u) ?? []) {
if (done.has(v) || !byId.has(v)) continue;
const rv = byId.get(v);
const nd = du + dist(ru.x, ru.y, rv.x, rv.y);
if (nd < (best.get(v) ?? Infinity)) {
best.set(v, nd);
prev.set(v, u);
}
}
}
if (!best.has(toId)) return null; // unreachable — defensive (a tree can't do this)
const path = [];
let cur = toId;
for (;;) {
path.push(cur);
if (cur === fromId) break;
cur = prev.get(cur);
if (cur === undefined) return null; // no chain back — defensive
}
path.reverse();
return {
path,
next: path.length > 1 ? path[1] : null,
hops: path.length - 1,
distance: best.get(toId),
};
}
/**
* The system the route leaves NEXT from `fromId` on the way to `toId`
* (planRoute's `next`), or null when already there / no route.
*/
export function nextSystem(galaxy, fromId, toId) {
return planRoute(galaxy, fromId, toId)?.next ?? null;
}