#!/usr/bin/env python3 """ assign-astro-ids.py Assigns astronomical_id and proper_name fields to all 300 systems in star-map.json. All systems use Gliese-Jahreiss (GJ) catalog designations as primary identifier. The GJ catalog is the catalog of nearby stars — a natural fit for the Settled Reach. Logic: - 36 real stars assigned by proximity/topology matching (closest to Gateway first) - ~264 remaining systems get fabricated GJ numbers - All assignments are deterministic (seeded RNG from system_id hash) - Gateway (S-001) gets GJ 71 (Tau Ceti) """ import json import hashlib import random from pathlib import Path STAR_MAP_PATH = Path(__file__).parent.parent / "docs/design/star-map.json" # ─── Real star pool ──────────────────────────────────────────────────────────── # All nearby stars have GJ designations. Fields: (gj_id, proper_name, star_class, distance_ly, notes) REAL_STARS = [ ("GJ 551", "Proxima Centauri", "M", 4.2, None), ("GJ 559A", "Alpha Centauri A", "G", 4.4, None), ("GJ 559B", "Alpha Centauri B", "K", 4.4, "binary_companion"), ("GJ 699", "Barnard's Star", "M", 5.9, None), ("GJ 244", "Sirius", "A", 8.6, "too_hot"), ("GJ 411", "Lalande 21185", "M", 8.3, None), ("GJ 144", "Epsilon Eridani", "K", 10.5, None), ("GJ 447", "Ross 128", "M", 10.9, None), ("GJ 887", "Lacaille 9352", "M", 10.7, None), ("GJ 280", "Procyon", "F", 11.4, None), ("GJ 820", "61 Cygni", "K", 11.4, None), ("GJ 845", "Epsilon Indi", "K", 11.8, None), ("GJ 71", "Tau Ceti", "G", 11.9, "gateway"), ("GJ 380", "Groombridge 1618", "K", 15.9, None), ("GJ 702", "70 Ophiuchi", "K", 16.6, None), ("GJ 166", "Omicron2 Eridani", "K", 16.4, None), ("GJ 768", "Altair", "A", 16.8, "too_hot"), ("GJ 764", "Sigma Draconis", "G", 18.8, None), ("GJ 570", "Gliese 570", "K", 19.2, None), ("GJ 139", "82 Eridani", "G", 19.8, None), ("GJ 780", "Delta Pavonis", "G", 19.9, None), ("GJ 667C", "Gliese 667 C", "M", 22.7, None), ("GJ 68", "107 Piscium", "K", 24.4, None), ("GJ 19", "Beta Hydri", "G", 24.4, None), ("GJ 34", "Eta Cassiopeiae", "G", 24.6, None), ("GJ 53", "Mu Cassiopeiae", "G", 24.6, None), ("GJ 881", "Fomalhaut", "A", 25.1, "too_hot"), ("GJ 721", "Vega", "A", 25.3, "too_hot"), ("GJ 178", "Pi3 Orionis", "F", 26.2, None), ("GJ 475", "Chara", "G", 27.4, None), ("GJ 506", "61 Virginis", "G", 27.8, None), ("GJ 250", "Gliese 250", "K", 28.4, None), ("GJ 183", "HR 1614", "K", 28.4, None), ("GJ 137", "Kappa1 Ceti", "G", 29.8, None), ("GJ 451", "Groombridge 1830", "G", 29.9, None), ("GJ 2046", "HD 40307", "K", 42.0, None), ] # Collect all reserved GJ numbers (as strings for consistent comparison) RESERVED_GJ = set() for cat_id, _, _, _, _ in REAL_STARS: RESERVED_GJ.add(cat_id) def system_rng(system_id: str, salt: str = "") -> random.Random: """Return a seeded RNG deterministic for this system_id + salt.""" h = hashlib.sha256(f"{system_id}:{salt}".encode()).digest() seed = int.from_bytes(h[:8], "big") return random.Random(seed) def fabricate_gj(system_id: str, used: set[str]) -> str: """ Fabricate a plausible GJ catalog designation. Uses the full range of GJ numbering styles for authenticity: - GJ 1-999: main catalog (sparse — most taken by real stars) - GJ 1001-1299: southern extension - GJ 2001-2159: supplement (Gliese & Jahreiss 1979) - GJ 3001-3999: supplement (CNS3, faint nearby stars) - GJ 4001-4383: supplement (CNS4) - GJ 5001+: our fabrication range for "post-settlement survey" We weight toward the supplementary catalogs (3xxx, 4xxx, 5xxx) since the main catalog numbers are more likely to collide with real stars. """ rng = system_rng(system_id, "GJ_fab") # Weighted range selection: # 10% main (500-999), 10% southern (1001-1299), 10% supplement2 (2001-2159), # 30% supplement3 (3001-3999), 15% supplement4 (4001-4383), 25% post-settlement (5001-6500) ranges = [ (500, 999, 0.10), (1001, 1299, 0.10), (2001, 2159, 0.10), (3001, 3999, 0.30), (4001, 4383, 0.15), (5001, 6500, 0.25), ] for _ in range(2000): # Pick a range by weight roll = rng.random() cumulative = 0.0 lo, hi = 3001, 3999 # default for r_lo, r_hi, weight in ranges: cumulative += weight if roll < cumulative: lo, hi = r_lo, r_hi break n = rng.randint(lo, hi) candidate = f"GJ {n}" if candidate not in RESERVED_GJ and candidate not in used: used.add(candidate) return candidate # Fallback: scan upward in post-settlement range n = 5001 while f"GJ {n}" in RESERVED_GJ or f"GJ {n}" in used: n += 1 candidate = f"GJ {n}" used.add(candidate) return candidate def topology_rank(node: dict) -> int: """Higher = more notable for real-star assignment priority.""" 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 main(): with open(STAR_MAP_PATH, "r") as f: data = json.load(f) nodes = data["nodes"] # ─── Step 1: Sort systems for real-star matching ────────────────────────── sortable = sorted(nodes, key=lambda n: ( n.get("hop_distance_from_gateway", 999), -topology_rank(n), n["system_id"] )) # ─── Step 2: Pin Gateway to Tau Ceti (GJ 71) ───────────────────────────── gateway_star = next(s for s in REAL_STARS if s[4] == "gateway") assigned_catalog_ids: set[str] = {gateway_star[0]} assignments: dict[str, tuple[str, str | None]] = {} assignments["S-001"] = (gateway_star[0], gateway_star[1]) # ─── Step 3: Separate hot A-type stars for special handling ─────────────── hot_stars = [s for s in REAL_STARS if s[4] == "too_hot"] normal_stars = [s for s in REAL_STARS if s[4] not in ("gateway", "too_hot", "binary_companion")] # Sort normal stars by distance from Sol normal_stars_sorted = sorted(normal_stars, key=lambda s: s[3]) # ─── Step 4: Handle Alpha Centauri binary ───────────────────────────────── alpha_a = next(s for s in REAL_STARS if s[0] == "GJ 559A") alpha_b = next(s for s in REAL_STARS if s[0] == "GJ 559B") # Find a binary-type system within hop 5 for Alpha Centauri binary_systems = [n for n in sortable if n.get("star_type") == "binary"] binary_assigned = False for bs in binary_systems: if bs["system_id"] == "S-001": continue if bs.get("hop_distance_from_gateway", 999) <= 5: assignments[bs["system_id"]] = (alpha_a[0], alpha_a[1]) assigned_catalog_ids.add(alpha_a[0]) assigned_catalog_ids.add(alpha_b[0]) # mark companion as used binary_assigned = True print(f" Binary: {bs['system_id']} -> {alpha_a[0]} (Alpha Centauri A/B)") break if not binary_assigned: # No binary found; Alpha A stays in normal pool, skip B assigned_catalog_ids.add(alpha_b[0]) print(" No binary system within hop 5; Alpha Centauri B skipped.") # Remove already-assigned stars from normal pool normal_pool = [s for s in normal_stars_sorted if s[0] not in assigned_catalog_ids] # ─── Step 5: Assign normal real stars by proximity ──────────────────────── pool_idx = 0 for node in sortable: if pool_idx >= len(normal_pool): break sid = node["system_id"] if sid in assignments: continue star = normal_pool[pool_idx] assignments[sid] = (star[0], star[1]) assigned_catalog_ids.add(star[0]) pool_idx += 1 # ─── Step 6: Assign hot A-type stars to notable hubs/junctions ──────────── hub_junction_candidates = sorted( [n for n in nodes if n["system_id"] not in assignments and n.get("gate_topology") in ("hub", "junction")], key=lambda n: (-topology_rank(n), n.get("hop_distance_from_gateway", 999)) ) for i, hot_star in enumerate(hot_stars): if i < len(hub_junction_candidates): sid = hub_junction_candidates[i]["system_id"] else: # Fallback: any unassigned system sid = next(n["system_id"] for n in sortable if n["system_id"] not in assignments) assignments[sid] = (hot_star[0], hot_star[1]) assigned_catalog_ids.add(hot_star[0]) real_count = len(assignments) print(f"\nReal star assignments: {real_count}") # ─── Step 7: Fabricate GJ IDs for remaining systems ─────────────────────── used_gj: set[str] = set(RESERVED_GJ) # protect all real GJ numbers fabricated_count = 0 for node in nodes: sid = node["system_id"] if sid not in assignments: fab_id = fabricate_gj(sid, used_gj) assignments[sid] = (fab_id, None) fabricated_count += 1 # ─── Step 8: Apply to nodes ─────────────────────────────────────────────── for node in nodes: sid = node["system_id"] cat_id, proper = assignments[sid] node["astronomical_id"] = cat_id node["proper_name"] = proper # ─── Step 9: Write back ─────────────────────────────────────────────────── with open(STAR_MAP_PATH, "w") as f: json.dump(data, f, indent=2) f.write("\n") # ─── Step 10: Summary report ────────────────────────────────────────────── print(f"\n{'='*60}") print("ASTRONOMICAL ID ASSIGNMENT COMPLETE") print(f"{'='*60}") print(f"Total systems: {len(nodes)}") print(f"Real star assigned: {real_count}") print(f"Fabricated: {fabricated_count}") # GJ range breakdown range_counts = {"main (1-999)": 0, "southern (1001-1299)": 0, "supp2 (2001-2159)": 0, "supp3 (3001-3999)": 0, "supp4 (4001-4383)": 0, "post-settlement (5001+)": 0, "special": 0} for node in nodes: aid = node["astronomical_id"] if not aid.startswith("GJ "): range_counts["special"] += 1 continue # Parse the number num_str = aid[3:].rstrip("ABC") try: num = int(num_str) except ValueError: range_counts["special"] += 1 continue if num <= 999: range_counts["main (1-999)"] += 1 elif num <= 1299: range_counts["southern (1001-1299)"] += 1 elif num <= 2159: range_counts["supp2 (2001-2159)"] += 1 elif num <= 3999: range_counts["supp3 (3001-3999)"] += 1 elif num <= 4383: range_counts["supp4 (4001-4383)"] += 1 else: range_counts["post-settlement (5001+)"] += 1 print("\nGJ range distribution:") for label, count in range_counts.items(): if count > 0: pct = count / len(nodes) * 100 print(f" {label:28s} {count:3d} ({pct:.1f}%)") # Real star assignment table print(f"\nReal star assignments by hop distance:") print(f" {'System':8s} {'Hop':4s} {'Topology':14s} {'Type':6s} {'GJ ID':10s} {'Proper Name'}") print(f" {'─'*8} {'─'*4} {'─'*14} {'─'*6} {'─'*10} {'─'*25}") real_sids = {sid for sid, (_, name) in assignments.items() if name is not None} node_by_id = {n["system_id"]: n for n in nodes} for node in sorted( [node_by_id[sid] for sid in real_sids], key=lambda n: (n.get("hop_distance_from_gateway", 999), n["system_id"]) ): sid = node["system_id"] hop = node.get("hop_distance_from_gateway", "?") topo = node.get("gate_topology", "?") stype = node.get("star_type", "?") cat = node["astronomical_id"] name = node["proper_name"] print(f" {sid:8s} {str(hop):4s} {topo:14s} {stype:6s} {cat:10s} {name}") # Verify no duplicates all_astro_ids = [n["astronomical_id"] for n in nodes] unique_ids = set(all_astro_ids) if len(unique_ids) != len(all_astro_ids): dupes = [aid for aid in all_astro_ids if all_astro_ids.count(aid) > 1] print(f"\nWARNING: {len(all_astro_ids) - len(unique_ids)} duplicate astronomical_id(s)!") for d in sorted(set(dupes)): print(f" DUPE: {d}") else: print(f"\nCollision check: PASSED — all {len(unique_ids)} astronomical_ids unique.") print(f"\nWritten to: {STAR_MAP_PATH}") if __name__ == "__main__": main()