Drop unused hashlib, math, and os imports from assign-astro-ids, generate-star-map, and test_quaternius_raw to pass ruff clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
9.6 KiB
Python
276 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
assign-astro-ids.py
|
|
Assigns real astronomical_id values to all 300 systems in star-map.json.
|
|
|
|
All 300 systems get REAL Gliese-Jahreiss designations from the nearest
|
|
300 GJ-cataloged stars (sourced from HYG Database v4.1).
|
|
|
|
Assignment logic:
|
|
- Gateway (S-001) pinned to GJ 71 (Tau Ceti)
|
|
- Systems sorted by hop distance from Gateway + topology rank
|
|
- Real stars sorted by distance from Sol
|
|
- Closest stars → most central systems
|
|
- Binary/companion entries (A/B suffixes) handled: one system per star system
|
|
|
|
Also writes spectral class from the real catalog into each node.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
STAR_MAP_PATH = Path(__file__).parent.parent / "docs/design/star-map.json"
|
|
CATALOG_PATH = Path(__file__).parent / "wiki/gj-catalog-real.json"
|
|
|
|
# Pinned assignments: system_id -> gj_id
|
|
PINNED = {
|
|
"S-001": "GJ 71", # Tau Ceti — Gateway
|
|
}
|
|
|
|
# Stars to skip (companions that share a system with their primary)
|
|
SKIP_COMPANIONS = {"GJ 244B", "GJ 65B"} # Sirius B, Luyten 726-8 B
|
|
|
|
|
|
def topology_rank(node: dict) -> int:
|
|
scores = {"hub": 5, "junction": 4, "loop_member": 3, "through_route": 2, "spur_end": 1, "dead_end": 0}
|
|
return scores.get(node.get("gate_topology", ""), 0)
|
|
|
|
|
|
def simplify_spectral(spect: str) -> str:
|
|
"""Convert detailed spectral class to simplified star_type for the game.
|
|
|
|
Returns: G, K, M, F, A, binary, unusual
|
|
"""
|
|
if not spect:
|
|
return "M" # default for unknowns (most nearby stars are M-dwarfs)
|
|
|
|
s = spect.strip()
|
|
# Check first character for main spectral class
|
|
first = s[0].upper() if s else "?"
|
|
|
|
if first == "D":
|
|
# White dwarf — unusual
|
|
return "unusual"
|
|
if first == "S" and s.startswith("sd"):
|
|
# Subdwarf — map to the next character
|
|
first = s[2].upper() if len(s) > 2 else "?"
|
|
if first in ("M", "m"):
|
|
return "M"
|
|
if first == "K":
|
|
return "K"
|
|
if first == "G":
|
|
return "G"
|
|
if first == "F":
|
|
return "F"
|
|
if first == "A":
|
|
return "F" # A-types are rare; group with F for game purposes
|
|
if first in ("O", "B"):
|
|
return "unusual"
|
|
|
|
return "M" # default for unrecognized
|
|
|
|
|
|
def main():
|
|
# Load star map
|
|
with open(STAR_MAP_PATH, "r") as f:
|
|
data = json.load(f)
|
|
nodes = data["nodes"]
|
|
|
|
# Load real GJ catalog
|
|
with open(CATALOG_PATH, "r") as f:
|
|
catalog = json.load(f)
|
|
real_stars = catalog["stars"]
|
|
|
|
# Filter out companions and build available pool
|
|
pool = []
|
|
seen_gj = set()
|
|
for star in real_stars:
|
|
gj = star["gj_id"]
|
|
if gj in SKIP_COMPANIONS:
|
|
continue
|
|
if gj in seen_gj:
|
|
continue
|
|
seen_gj.add(gj)
|
|
pool.append(star)
|
|
|
|
print(f"Real GJ catalog: {len(real_stars)} entries, {len(pool)} unique after dedup")
|
|
print(f"Systems to assign: {len(nodes)}")
|
|
|
|
if len(pool) < len(nodes):
|
|
print(f"WARNING: Not enough real stars ({len(pool)}) for {len(nodes)} systems!")
|
|
print(f"Will need {len(nodes) - len(pool)} fabricated entries.")
|
|
|
|
# Sort pool by distance (already sorted, but be explicit)
|
|
pool.sort(key=lambda s: s["dist_pc"])
|
|
|
|
# Sort systems: hop distance ascending, topology rank descending
|
|
sorted_nodes = sorted(nodes, key=lambda n: (
|
|
n.get("hop_distance_from_gateway", 999),
|
|
-topology_rank(n),
|
|
n["system_id"]
|
|
))
|
|
|
|
# Build assignments
|
|
assignments: dict[str, dict] = {}
|
|
|
|
# Step 1: Pin fixed assignments
|
|
for sid, gj_id in PINNED.items():
|
|
star = next((s for s in pool if s["gj_id"] == gj_id), None)
|
|
if star:
|
|
assignments[sid] = star
|
|
else:
|
|
print(f"WARNING: Pinned star {gj_id} not found in catalog!")
|
|
|
|
# Step 2: Find a binary-type system for Alpha Centauri (GJ 559A)
|
|
alpha_a = next((s for s in pool if s["gj_id"] == "GJ 559A"), None)
|
|
if alpha_a:
|
|
binary_systems = [n for n in sorted_nodes
|
|
if n.get("star_type") == "binary"
|
|
and n["system_id"] not in assignments]
|
|
if binary_systems:
|
|
bs = binary_systems[0]
|
|
assignments[bs["system_id"]] = alpha_a
|
|
print(f" Binary: {bs['system_id']} -> GJ 559A (Alpha Centauri)")
|
|
|
|
# Step 3: Distance-first with habitability preference
|
|
#
|
|
# The 300 nearest GJ stars are our pool. We want distance to drive
|
|
# centrality (closest stars = core systems), but within each distance
|
|
# band, prefer G/K/F stars over M-dwarfs. This means the habitable
|
|
# stars get picked first at each distance layer, pushing the M-dwarfs
|
|
# to fill later (more peripheral) slots. The net effect: the web of
|
|
# systems with usable planets spreads wider through the network because
|
|
# we're cherry-picking the good ones from each distance shell.
|
|
#
|
|
# Implementation: sort the full pool by distance, but with a small
|
|
# penalty for M-dwarfs that pushes them down within their distance
|
|
# neighborhood without disrupting the overall distance ordering.
|
|
used_gj = {a["gj_id"] for a in assignments.values()}
|
|
remaining_pool = [s for s in pool if s["gj_id"] not in used_gj]
|
|
|
|
def habitability_penalty(star: dict) -> float:
|
|
"""Small distance penalty for less habitable star types.
|
|
|
|
G/K: no penalty (0 pc). These get picked first at their distance.
|
|
F: tiny penalty (0.5 pc). Still good, slight deprioritization.
|
|
M: moderate penalty (3 pc). Pushed down ~3 pc worth of slots.
|
|
unusual/WD: larger penalty (5 pc). Pushed further down.
|
|
|
|
At the scale of our pool (4-33 ly / 1.3-10 pc), a 3 pc penalty
|
|
means an M-dwarf at 5 pc sorts like it's at 8 pc — it gets
|
|
overtaken by G/K stars up to ~8 pc but stays ahead of G/K stars
|
|
at 10+ pc. This is a gentle preference, not a hard filter.
|
|
"""
|
|
sp = simplify_spectral(star.get("spect", ""))
|
|
if sp in ("G", "K"):
|
|
return 0.0
|
|
if sp == "F":
|
|
return 0.5
|
|
if sp == "M":
|
|
return 3.0
|
|
return 5.0 # unusual, white dwarfs
|
|
|
|
remaining_pool.sort(key=lambda s: s["dist_pc"] + habitability_penalty(s))
|
|
|
|
# Assign unassigned systems (sorted by importance) from the reordered pool
|
|
pool_idx = 0
|
|
for node in sorted_nodes:
|
|
sid = node["system_id"]
|
|
if sid in assignments:
|
|
continue
|
|
if pool_idx >= len(remaining_pool):
|
|
break
|
|
|
|
assignments[sid] = remaining_pool[pool_idx]
|
|
pool_idx += 1
|
|
|
|
# Step 4: If we ran out of real stars, fabricate the remainder
|
|
fabricated = 0
|
|
if len(assignments) < len(nodes):
|
|
# Generate fabricated GJ numbers in the 5000+ range
|
|
import random
|
|
rng = random.Random(20260313)
|
|
used_nums = set()
|
|
for a in assignments.values():
|
|
gj = a["gj_id"]
|
|
try:
|
|
num = int(gj.replace("GJ ", "").rstrip("ABC"))
|
|
used_nums.add(num)
|
|
except ValueError:
|
|
pass
|
|
|
|
for node in sorted_nodes:
|
|
sid = node["system_id"]
|
|
if sid in assignments:
|
|
continue
|
|
# Fabricate in 5000-8000 range
|
|
while True:
|
|
n = rng.randint(5001, 8000)
|
|
if n not in used_nums:
|
|
used_nums.add(n)
|
|
break
|
|
assignments[sid] = {
|
|
"gj_id": f"GJ {n}",
|
|
"proper_name": None,
|
|
"dist_pc": 0,
|
|
"dist_ly": 0,
|
|
"spect": "",
|
|
"fabricated": True,
|
|
}
|
|
fabricated += 1
|
|
|
|
# Step 5: Apply to nodes
|
|
for node in nodes:
|
|
sid = node["system_id"]
|
|
star = assignments[sid]
|
|
node["astronomical_id"] = star["gj_id"]
|
|
node["proper_name"] = star.get("proper_name")
|
|
node["dist_ly"] = round(star.get("dist_ly", 0), 1)
|
|
node["spectral_class"] = star.get("spect", "")
|
|
|
|
# Step 6: Write back
|
|
with open(STAR_MAP_PATH, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
f.write("\n")
|
|
|
|
# Step 7: Summary
|
|
real_count = len(assignments) - fabricated
|
|
print(f"\n{'='*60}")
|
|
print("ASTRONOMICAL ID ASSIGNMENT COMPLETE")
|
|
print(f"{'='*60}")
|
|
print(f"Total systems: {len(nodes)}")
|
|
print(f"Real GJ assigned: {real_count}")
|
|
print(f"Fabricated: {fabricated}")
|
|
print(f"Distance range: {pool[0]['dist_ly']:.1f} — {pool[min(len(nodes)-1, len(pool)-1)]['dist_ly']:.1f} ly")
|
|
|
|
# Named stars
|
|
named = [(sid, a) for sid, a in assignments.items() if a.get("proper_name")]
|
|
print(f"\nNamed stars ({len(named)}):")
|
|
node_by_id = {n["system_id"]: n for n in nodes}
|
|
for sid, star in sorted(named, key=lambda x: x[1].get("dist_ly", 0)):
|
|
n = node_by_id[sid]
|
|
print(f" {star['gj_id']:12s} {star['proper_name']:25s} {star['dist_ly']:5.1f} ly {n['gate_topology']:14s} {sid}")
|
|
|
|
# Spectral type distribution
|
|
type_counts: dict[str, int] = {}
|
|
for node in nodes:
|
|
sp = simplify_spectral(node.get("spectral_class", ""))
|
|
type_counts[sp] = type_counts.get(sp, 0) + 1
|
|
print(f"\nSimplified spectral distribution:")
|
|
for t in ["G", "K", "M", "F", "unusual"]:
|
|
print(f" {t:8s} {type_counts.get(t, 0):3d} ({type_counts.get(t, 0)/len(nodes)*100:.0f}%)")
|
|
|
|
# Collision check
|
|
all_ids = [n["astronomical_id"] for n in nodes]
|
|
if len(set(all_ids)) != len(all_ids):
|
|
dupes = [x for x in all_ids if all_ids.count(x) > 1]
|
|
print(f"\nWARNING: Duplicates found: {set(dupes)}")
|
|
else:
|
|
print(f"\nCollision check: PASSED — all {len(set(all_ids))} unique.")
|
|
|
|
print(f"\nWritten to: {STAR_MAP_PATH}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|