From 2aeb0e567436a50f6128a19b66b9cfa21092f129 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Thu, 8 Oct 2026 10:42:03 +0200 Subject: [PATCH] Add tools/route.py: jump-distance routing from FIO's system graph PRUNplanner doesn't model travel, so nothing in the project could answer "how far is planet X" until now. Builds a graph from /systemstars (698 systems, real jump connections + 3D coordinates) and runs Dijkstra weighted by straight-line ly (what FTL fuel/time actually scale with), reporting jump count alongside. Co-Authored-By: Claude Sonnet 5 --- puga/route.py | 74 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_route.py | 28 +++++++++++++++++ tools/route.py | 33 ++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 puga/route.py create mode 100644 tests/test_route.py create mode 100644 tools/route.py diff --git a/puga/route.py b/puga/route.py new file mode 100644 index 0000000..10b476a --- /dev/null +++ b/puga/route.py @@ -0,0 +1,74 @@ +"""Jump-graph routing between systems/planets, built from FIO's /systemstars (698 systems, +3D coordinates + jump connections). Not in PRUNplanner's own source (it doesn't model travel) - +this is the project's own addition. Edge weight = straight-line distance between connected +systems (ly); PrUn's own FTL fuel/time scale with this, not raw jump count, so Dijkstra on +distance gives the right "cheapest" route, with jump count reported alongside for reference.""" +import heapq +import math +from . import fio + + +def _dist(a, b): + return math.dist((a["PositionX"], a["PositionY"], a["PositionZ"]), + (b["PositionX"], b["PositionY"], b["PositionZ"])) + + +def load_graph(): + """SystemId -> {name, natural_id, pos, neighbors: {SystemId: distance_ly}}""" + systems = {s["SystemId"]: s for s in fio.systems()} + g = {} + for sid, s in systems.items(): + neighbors = {} + for c in s.get("Connections", []): + other = systems.get(c["ConnectingId"]) + if other: + neighbors[c["ConnectingId"]] = _dist(s, other) + g[sid] = {"name": s["Name"], "natural_id": s["NaturalId"], "pos": s, "neighbors": neighbors} + return g + + +def resolve_system(query: str) -> str: + """Planet natural id (e.g. ZV-759c), system natural id (ZV-759), or a name -> SystemId.""" + q = query.strip().upper() + for p in fio.planets_full(): + if p.get("PlanetNaturalId", "").upper() == q or (p.get("PlanetName") or "").upper() == q: + return p["SystemId"] + for s in fio.systems(): + if s["NaturalId"].upper() == q or s["Name"].upper() == q: + return s["SystemId"] + raise ValueError(f"no planet or system matches {query!r}") + + +def shortest_path(src_system_id: str, dst_system_id: str, graph=None): + """Dijkstra by straight-line ly distance. Returns dict: jumps, distance_ly, path (list of system natural ids).""" + g = graph or load_graph() + if src_system_id not in g or dst_system_id not in g: + raise ValueError("unknown system id") + dist = {src_system_id: 0.0} + prev = {} + pq = [(0.0, src_system_id)] + seen = set() + while pq: + d, u = heapq.heappop(pq) + if u in seen: + continue + seen.add(u) + if u == dst_system_id: + break + for v, w in g[u]["neighbors"].items(): + nd = d + w + if nd < dist.get(v, float("inf")): + dist[v] = nd + prev[v] = u + heapq.heappush(pq, (nd, v)) + if dst_system_id not in dist: + return None + path = [dst_system_id] + while path[-1] != src_system_id: + path.append(prev[path[-1]]) + path.reverse() + return { + "jumps": len(path) - 1, + "distance_ly": dist[dst_system_id], + "path": [g[sid]["natural_id"] for sid in path], + } diff --git a/tests/test_route.py b/tests/test_route.py new file mode 100644 index 0000000..771a85f --- /dev/null +++ b/tests/test_route.py @@ -0,0 +1,28 @@ +from puga import route + +# Small synthetic graph: A-B-C in a line, A-D direct (shorter), D-C (closes a loop so Dijkstra has a choice) +GRAPH = { + "A": {"name": "A", "natural_id": "A", "pos": None, "neighbors": {"B": 10.0, "D": 3.0}}, + "B": {"name": "B", "natural_id": "B", "pos": None, "neighbors": {"A": 10.0, "C": 10.0}}, + "C": {"name": "C", "natural_id": "C", "pos": None, "neighbors": {"B": 10.0, "D": 10.0}}, + "D": {"name": "D", "natural_id": "D", "pos": None, "neighbors": {"A": 3.0, "C": 10.0}}, +} + + +def test_shortest_path_picks_lower_total_distance_not_fewer_jumps(): + r = route.shortest_path("A", "C", graph=GRAPH) + assert r["path"] == ["A", "D", "C"] # 3+10=13, beats A-B-C's 10+10=20 despite same jump count + assert r["jumps"] == 2 + assert r["distance_ly"] == 13.0 + + +def test_same_system_is_zero_jumps(): + r = route.shortest_path("A", "A", graph=GRAPH) + assert r["jumps"] == 0 + assert r["distance_ly"] == 0.0 + + +def test_no_route_returns_none(): + graph = dict(GRAPH) + graph["E"] = {"name": "E", "natural_id": "E", "pos": None, "neighbors": {}} + assert route.shortest_path("A", "E", graph=graph) is None diff --git a/tools/route.py b/tools/route.py new file mode 100644 index 0000000..c6b21e5 --- /dev/null +++ b/tools/route.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Jump-distance route between two planets/systems, from FIO's system graph (not PRUNplanner, +which doesn't model travel). Straight-line ly is the Dijkstra weight (scales with FTL fuel/time); +jump count shown alongside. + + tools/route.py ZV-759c ZV-194a # Deimos -> Nike + tools/route.py Deimos "Ironforge" # names also work +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import route + + +def main(): + if len(sys.argv) != 3: + sys.exit("usage: tools/route.py ") + a, b = sys.argv[1], sys.argv[2] + src = route.resolve_system(a) + dst = route.resolve_system(b) + if src == dst: + print(f"{a} and {b} are in the same system - 0 jumps") + return + r = route.shortest_path(src, dst) + if r is None: + print(f"no connected route found between {a} and {b}") + return + print(f"{a} -> {b}: {r['jumps']} jumps, {r['distance_ly']:.1f} ly (straight-line sum along the jump path)") + print("path: " + " -> ".join(r["path"])) + + +if __name__ == "__main__": + main()