feat(docs): assign real GJ catalog stars to all 300 systems

Replaced fabricated designations with real Gliese-Jahreiss entries
sourced from HYG Database v4.1. Selection: 215 G/K/F + 75 M + 10
unusual (white dwarfs, flare stars, subdwarfs) = 300 systems.

Habitable star types (G/K/F) are preferred at each distance shell,
spreading usable planets wider through the network. M-dwarfs fill
remaining slots — station economies, mining, frontier outposts.

Distance range: 4.2-46.8 ly. 51 named stars including Tau Ceti
(Gateway), Alpha Centauri, Delta Pavonis, Barnard's Star, Sirius,
Procyon, Arcturus, Pollux, Vega, Fomalhaut.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 23:38:55 +01:00
co-authored by Claude Opus 4.6
parent 90a1f56504
commit 983d8037d1
320 changed files with 7972 additions and 4725 deletions
+207 -254
View File
@@ -1,320 +1,273 @@
#!/usr/bin/env python3
"""
assign-astro-ids.py
Assigns astronomical_id and proper_name fields to all 300 systems in star-map.json.
Assigns real astronomical_id values 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.
All 300 systems get REAL Gliese-Jahreiss designations from the nearest
300 GJ-cataloged stars (sourced from HYG Database v4.1).
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)
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
import hashlib
import random
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"
# ─── 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),
]
# Pinned assignments: system_id -> gj_id
PINNED = {
"S-001": "GJ 71", # Tau Ceti — Gateway
}
# 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
# 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:
"""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 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"]
# ─── Step 1: Sort systems for real-star matching ──────────────────────────
sortable = sorted(nodes, key=lambda n: (
# 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"]
))
# ─── 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])
# Build assignments
assignments: dict[str, dict] = {}
# ─── 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")]
# 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!")
# Sort normal stars by distance from Sol
normal_stars_sorted = sorted(normal_stars, key=lambda s: s[3])
# 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 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")
# 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]
# 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
def habitability_penalty(star: dict) -> float:
"""Small distance penalty for less habitable star types.
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.")
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.
# Remove already-assigned stars from normal pool
normal_pool = [s for s in normal_stars_sorted if s[0] not in assigned_catalog_ids]
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
# ─── Step 5: Assign normal real stars by proximity ────────────────────────
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 sortable:
if pool_idx >= len(normal_pool):
break
for node in sorted_nodes:
sid = node["system_id"]
if sid in assignments:
continue
if pool_idx >= len(remaining_pool):
break
star = normal_pool[pool_idx]
assignments[sid] = (star[0], star[1])
assigned_catalog_ids.add(star[0])
assignments[sid] = remaining_pool[pool_idx]
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))
)
# 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 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])
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
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
# Step 5: Apply to nodes
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
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 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 ───────────────────────────────────────────────────
# Step 6: Write back
with open(STAR_MAP_PATH, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
# ─── Step 10: Summary report ──────────────────────────────────────────────
# 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 star assigned: {real_count}")
print(f"Fabricated: {fabricated_count}")
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")
# 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}
# 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}")
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}")
# 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}%)")
# 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}")
# 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(unique_ids)} astronomical_ids unique.")
print(f"\nCollision check: PASSED — all {len(set(all_ids))} unique.")
print(f"\nWritten to: {STAR_MAP_PATH}")
File diff suppressed because it is too large Load Diff