feat(docs): assign GJ catalog IDs to all 300 star systems

All systems use Gliese-Jahreiss (GJ) designations as primary catalog.
35 real nearby stars assigned by proximity to Gateway; 265 fabricated
across GJ sub-catalogs (main, southern, supplement, post-settlement).

Notable assignments:
- Gateway (S-001): GJ 71 / Tau Ceti
- Alpha Centauri A/B (S-090): GJ 559A, binary junction at hop 3
- Delta Pavonis (S-265): GJ 780, core dead_end at hop 2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 23:21:26 +01:00
co-authored by Claude Opus 4.6
parent 5f266ae002
commit 26276a59f4
2 changed files with 1224 additions and 301 deletions
+901 -301
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
#!/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()