Files
settled-reach/tooling/generate-star-map-data.py
T
jpmschweitzerandClaude Opus 4.6 8eb9e0a383 feat(ui): star map click-through with wiki/GTTR popup and edge visibility
Show system profile popup on select: name, star type, hop distance,
corridor, GTTR excerpt, body count, population, adjacent systems.
Edge rendering changed to show no edges by default, only selected
system's gate lines on click. Star map data extended with wiki fields
for all 301 systems.

Ticket: #780

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:18 +02:00

240 lines
8.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate client/data/star_map_data.json from star-map.json + systems.db + wiki.
Run from any directory — paths are resolved relative to this script's location:
python3 tooling/generate-star-map-data.py
python3 tooling/generate-star-map-data.py --check # exit 1 if committed JSON is stale
Sources:
docs/design/star-map.json — graph topology (nodes + edges)
server/server/data/systems.db — proper names, geographic sectors
wiki/star-systems/ — star type, bodies, population, GTTR excerpt
Output:
client/data/star_map_data.json — self-contained client data for the star map UI
"""
import json
import os
import re
import sqlite3
import sys
import tempfile
# Resolve project root from this script's location: tooling/ is one level below root.
# Works regardless of cwd — no fragile relative path guessing.
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_PROJECT_ROOT = os.path.dirname(_SCRIPT_DIR)
# Worktree layout: settled-reach/{client,server,main}/
# This script lives in client/tooling/, so _PROJECT_ROOT = client/.
# The parent of _PROJECT_ROOT is the worktree parent where sibling dirs live.
_WORKTREE_PARENT = os.path.dirname(_PROJECT_ROOT)
STAR_MAP_PATH = os.path.join(_PROJECT_ROOT, "docs", "design", "star-map.json")
SYSTEMS_DB_PATH = os.path.join(_WORKTREE_PARENT, "server", "server", "data", "systems.db")
WIKI_PATH = os.path.join(_PROJECT_ROOT, "wiki", "star-systems")
OUTPUT_PATH = os.path.join(_PROJECT_ROOT, "client", "data", "star_map_data.json")
def system_id_to_wiki_slug(system_id: str) -> str:
"""Convert system_id to wiki folder slug. 'GJ 71' → 'GJ-71'."""
return system_id.replace(" ", "-")
def parse_wiki_index(system_id: str) -> dict:
"""Extract star type, bodies summary, and population from index.md.
Returns dict with keys: star_type, bodies, population (all strings, may be empty).
"""
slug = system_id_to_wiki_slug(system_id)
path = os.path.join(WIKI_PATH, slug, "index.md")
result = {"star_type": "", "bodies": "", "population": ""}
if not os.path.exists(path):
return result
with open(path, encoding="utf-8") as f:
content = f.read()
# Star row: | **Star** | G8V · 11.9 ly |
m = re.search(r"\|\s*\*\*Star\*\*\s*\|\s*([^|]+?)\s*\|", content)
if m:
raw = m.group(1).strip()
# Extract spectral class — everything before " ·" or end of string
star_type = raw.split("·")[0].strip()
result["star_type"] = star_type
# Bodies row: | **Bodies** | 2 habitable · 3 inhabited |
m = re.search(r"\|\s*\*\*Bodies\*\*\s*\|\s*([^|]+?)\s*\|", content)
if m:
result["bodies"] = m.group(1).strip()
# Population row: | **Population** | 1,200,000,000 |
m = re.search(r"\|\s*\*\*Population\*\*\s*\|\s*([^|]+?)\s*\|", content)
if m:
result["population"] = m.group(1).strip()
return result
def parse_gttr_excerpt(system_id: str) -> str:
"""Extract the first body paragraph from gttr.md as the GTTR excerpt."""
slug = system_id_to_wiki_slug(system_id)
path = os.path.join(WIKI_PATH, slug, "gttr.md")
if not os.path.exists(path):
return ""
with open(path, encoding="utf-8") as f:
lines = f.readlines()
# Skip the H1 heading line, then find the first non-empty paragraph
in_content = False
paragraph_lines = []
for line in lines:
stripped = line.strip()
if not in_content:
# Skip heading and blank lines at start
if stripped.startswith("# "):
in_content = True
continue
if not stripped:
# Blank line ends the paragraph if we've collected lines
if paragraph_lines:
break
else:
if not stripped.startswith("#"):
paragraph_lines.append(stripped)
return " ".join(paragraph_lines)
def build_adjacency(edges: list) -> dict:
"""Build a map from system_id to list of adjacent system_ids from edges."""
adj: dict = {}
for edge in edges:
if len(edge) < 2:
continue
a, b = edge[0], edge[1]
adj.setdefault(a, [])
adj.setdefault(b, [])
if b not in adj[a]:
adj[a].append(b)
if a not in adj[b]:
adj[b].append(a)
return adj
def generate() -> dict:
"""Generate the enriched star map data dict."""
if not os.path.exists(STAR_MAP_PATH):
print(f"ERROR: star-map.json not found at {STAR_MAP_PATH}", file=sys.stderr)
sys.exit(1)
if not os.path.exists(SYSTEMS_DB_PATH):
print(f"ERROR: systems.db not found at {SYSTEMS_DB_PATH}", file=sys.stderr)
sys.exit(1)
with open(STAR_MAP_PATH) as f:
star_map = json.load(f)
conn = sqlite3.connect(SYSTEMS_DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"SELECT system_id, proper_name, geographic_sector, geographic_band "
"FROM star_systems"
)
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
conn.close()
adjacency = build_adjacency(star_map["edges"])
nodes = []
wiki_hits = 0
gttr_hits = 0
for n in star_map["nodes"]:
sid = n["system_id"]
db = db_lookup.get(sid, {})
wiki = parse_wiki_index(sid)
gttr = parse_gttr_excerpt(sid)
if wiki["star_type"]:
wiki_hits += 1
if gttr:
gttr_hits += 1
entry = {
"system_id": sid,
"proper_name": db.get("proper_name", ""),
"geographic_sector": db.get("geographic_sector", "unknown"),
"geographic_band": db.get("geographic_band", ""),
"gate_topology": n["gate_topology"],
"aperture_count": n["aperture_count"],
"gate_connections": n["gate_connections"],
"hop_distance": n["hop_distance_from_gateway"],
"adjacent_systems": adjacency.get(sid, []),
}
if wiki["star_type"]:
entry["star_type"] = wiki["star_type"]
if wiki["bodies"]:
entry["bodies"] = wiki["bodies"]
if wiki["population"]:
entry["population"] = wiki["population"]
if gttr:
entry["gttr_excerpt"] = gttr
if n.get("is_gateway"):
entry["is_gateway"] = True
nodes.append(entry)
nodes.sort(key=lambda x: (x["hop_distance"], x["system_id"]))
print(f" Wiki index data: {wiki_hits}/{len(nodes)} systems")
print(f" GTTR excerpts: {gttr_hits}/{len(nodes)} systems")
return {
"_meta": {
"generated_from": "star-map.json + systems.db + wiki/star-systems",
"system_count": len(nodes),
"edge_count": len(star_map["edges"]),
"note": "Client-side star map data. Regenerate with: tooling/generate-star-map-data.py",
},
"nodes": nodes,
"edges": star_map["edges"],
}
def write_output(data: dict, path: str) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def main() -> None:
check_mode = "--check" in sys.argv
data = generate()
if check_mode:
# Generate to temp file and compare against committed JSON
if not os.path.exists(OUTPUT_PATH):
print(f"STALE: {OUTPUT_PATH} does not exist — run without --check to generate")
sys.exit(1)
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json.dump(data, tmp, indent=2, ensure_ascii=False)
tmp.write("\n")
tmp_path = tmp.name
try:
with open(tmp_path) as a, open(OUTPUT_PATH) as b:
if a.read() != b.read():
print(f"STALE: {OUTPUT_PATH} differs from generated output")
print("Run: python3 tooling/generate-star-map-data.py")
sys.exit(1)
print(f"OK: {OUTPUT_PATH} is up to date")
finally:
os.unlink(tmp_path)
else:
write_output(data, OUTPUT_PATH)
print(f"Generated {OUTPUT_PATH}")
print(f" Nodes: {data['_meta']['system_count']}, Edges: {data['_meta']['edge_count']}")
if __name__ == "__main__":
main()