113 lines
4.5 KiB
JavaScript
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;
|
|
}
|