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 <noreply@anthropic.com>
This commit is contained in:
@@ -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],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 <from planet/system> <to planet/system>")
|
||||
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()
|
||||
Reference in New Issue
Block a user