#!/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()