""" body_definition_parser.py ------------------------- Parses a system index.md file and produces one body_definition.json per renderable celestial body. Input: index.md (system wiki page, bodies table + system profile) Output: {body_id}_def.json per planet / moon / gas_giant Design principles: - "rand" sentinel means: derive from seed + planet class constraints - Explicit values in the bodies table or override dict always win - Every derivation is documented so the logic is auditable - No field is silently dropped — unknowns get a logged warning Field resolution order (highest wins): 1. override dict (per-body, hand-authored for special cases like Sol) 2. direct read (field exists verbatim in bodies table) 3. derived (computed from other fields — documented formula) 4. inferred (implied by combination of fields) 5. randomised (seeded, within planet-class constraints) Usage: python -m tooling.domains.atlas.planet.body_definition_parser path/to/index.md [--out-dir ./defs] # With overrides (e.g. Sol) python -m tooling.domains.atlas.planet.body_definition_parser sol/index.md --overrides sol_overrides.json Override file format: { "GJ0g": { "rings": true, "ring_color": [0.88, 0.78, 0.55] }, "GJ0f": { "rings": false }, "GJ0d": { "orbit": { "axial_tilt_deg": 23.4 } } } """ import argparse import hashlib import json import logging import math import os import re from pathlib import Path from typing import Optional import numpy as np from tooling.core import console from tooling.domains.atlas.planet.biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES logging.basicConfig(level=logging.INFO, format=" %(levelname)s %(message)s") log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Constants / lookup tables # --------------------------------------------------------------------------- # Spectral type → solar luminosity (approximate) STAR_LUMINOSITY = { "O": 100000.0, "B": 1000.0, "A": 10.0, "F": 2.5, "G": 1.0, "K": 0.4, "M": 0.04, } # Spectral type → colour temperature K (approximate midpoint) STAR_COLOUR_TEMP = { "O": 40000, "B": 20000, "A": 9000, "F": 7000, "G": 5800, "K": 4500, "M": 3200, } # Star type → UV index category STAR_UV = { "O": "extreme", "B": "extreme", "A": "high", "F": "high", "G": "moderate","K": "low", "M": "low", } # atmosphere field → density string # Wiki atmosphere value → pipeline atmosphere density. # Both legacy ("breathable") and current ("standard") wiki values mapped. ATMO_MAP = { "none": "none", "trace": "none", # too thin for weather/clouds "thin": "thin", "breathable": "standard", # legacy wiki value "standard": "standard", # current wiki value "dense": "thick", "thick": "thick", "toxic": "thick", # Venus-style reducing atmosphere "reducing": "thick", # same as toxic "hydrogen-helium": "thick", # gas giant "gas_giant": "thick", # gas giant atmosphere field } # hydrosphere → approximate land_fraction range [min, max] HYDRO_LAND = { "ocean": (0.28, 0.50), "liquid_water":(0.35, 0.65), "rivers": (0.50, 0.75), # Titan-style — surface liquid but mostly land "ice": (0.70, 0.90), # mostly frozen land "subsurface": (0.90, 0.99), # surface appears dry "none": (0.97, 1.00), } # biome → planet_class # Wiki planet class → pipeline planet class. # All 26 known wiki values mapped explicitly. # Unknown values default to "temperate" (safe for modders adding new classes). PLANET_CLASS_MAP = { # Direct matches "temperate": "temperate", "arid": "arid", "frozen": "frozen", "volcanic": "volcanic", "barren": "barren", "oceanic": "oceanic", # Temperate variants "temperate_terminator": "temperate_terminator", "temperate-cool": "temperate", "temperate_highland": "temperate", "temperate_maritime": "temperate", "temperate_savanna": "temperate", "temperate_coastal": "temperate", "temperate_riverine": "temperate", "temperate_ocean": "oceanic", "cold_temperate": "temperate", "floodplain_temperate": "temperate", # Arid variants "cold_arid": "cold_arid", "hot_arid": "hot_arid", # Tropical "tropical": "tropical", "tropical_ocean": "oceanic", "subtropical": "tropical", # Cold "ice": "frozen", "boreal": "boreal", # Warm ocean "warm_ocean": "oceanic", # Geothermal "geothermal": "volcanic", # Forest (from prototype) "forest": "forest", } # planet_class → axial tilt range [min, max] degrees # Tidal locking check overrides this for short-period bodies CLASS_TILT = { "temperate": (10, 35), "temperate_terminator": (0, 5), # tidally locked by definition "oceanic": (5, 25), "forest": (10, 40), "arid": (5, 30), "cold_arid": (10, 40), "hot_arid": (2, 15), "tropical": (5, 15), "boreal": (20, 50), "frozen": (15, 60), # high tilt → seasonal extremes → frozen "volcanic": (2, 20), "barren": (0, 45), } # planet_class → geothermal flux CLASS_GEOTHERMAL = { "volcanic": "extreme", "temperate": "low", "temperate_terminator": "low", "oceanic": "low", "forest": "low", "arid": "low", "cold_arid": "low", "hot_arid": "low", "tropical": "low", "boreal": "low", "frozen": "low", "barren": "low", } # planet_class → polar ice latitude (fraction of 0–1, where 1 = poles) # Lower = ice caps extend further toward equator CLASS_POLAR_ICE = { "temperate": (0.72, 0.85), "temperate_terminator": (0.75, 0.90), "oceanic": (0.80, 0.92), "forest": (0.75, 0.88), "arid": (0.90, 0.99), "cold_arid": (0.50, 0.70), "hot_arid": (0.95, 1.00), "tropical": (0.88, 0.96), "boreal": (0.45, 0.65), "frozen": (0.10, 0.40), "volcanic": (0.95, 1.00), "barren": (0.92, 1.00), } # planet_class → oblateness range CLASS_OBLATENESS = { "temperate": (0.001, 0.005), "temperate_terminator": (0.001, 0.004), "oceanic": (0.001, 0.004), "forest": (0.001, 0.005), "arid": (0.001, 0.004), "cold_arid": (0.001, 0.004), "hot_arid": (0.001, 0.004), "tropical": (0.001, 0.005), "boreal": (0.001, 0.004), "frozen": (0.001, 0.003), "volcanic": (0.002, 0.008), "barren": (0.000, 0.003), } # Gas giant band palettes available: GAS_PALETTES, imported at the top. # planet_class → cloud coverage base range CLASS_CLOUD = { "temperate": (0.35, 0.55), "temperate_terminator": (0.30, 0.50), "oceanic": (0.55, 0.75), "forest": (0.40, 0.60), "arid": (0.05, 0.20), "cold_arid": (0.10, 0.25), "hot_arid": (0.02, 0.10), "tropical": (0.60, 0.80), "boreal": (0.55, 0.75), "frozen": (0.20, 0.45), "volcanic": (0.60, 0.85), "barren": (0.00, 0.05), } # Atmosphere classes that allow clouds CLOUD_CAPABLE = {"standard", "thick", "thin"} # Render defaults RENDER_DEFAULTS = { "globe_light_angle_deg": 125, "specular_ocean": True, "night_side_ambient": 0.025, } # Ring probability for gas giants (if not overridden) RING_PROBABILITY = 0.40 # 40% chance of rings — Saturn is special # Ring colour palettes paired to band palettes RING_COLOURS = { "jovian": [0.55, 0.48, 0.35], # faint dark rings "neptunian": [0.72, 0.82, 0.95], # blue-tinted "saturnian": [0.88, 0.78, 0.55], # warm golden "icy": [0.85, 0.90, 0.95], # pale ice "sulfuric": [0.75, 0.70, 0.30], # sulphur-tinted "infernal": [0.60, 0.25, 0.15], # dark ember } # --------------------------------------------------------------------------- # Seeded RNG helpers # --------------------------------------------------------------------------- def _seed_from_id(body_id: str) -> int: """Deterministic integer seed from body ID string.""" h = hashlib.md5(body_id.encode()).digest() return int.from_bytes(h[:4], "little") def _rng(body_id: str, salt: str = "") -> np.random.Generator: """Seeded RNG for a specific body + context. Always reproducible.""" seed = _seed_from_id(body_id + salt) return np.random.default_rng(seed) def _rand_range(body_id: str, lo: float, hi: float, salt: str = "") -> float: """Uniform float in [lo, hi], seeded from body_id.""" return float(_rng(body_id, salt).uniform(lo, hi)) def _rand_choice(body_id: str, choices: list, salt: str = "") -> object: """Random choice from list, seeded from body_id.""" idx = int(_rng(body_id, salt).integers(0, len(choices))) return choices[idx] def _rand_bool(body_id: str, probability: float, salt: str = "") -> bool: """True with given probability, seeded from body_id.""" return float(_rng(body_id, salt).uniform(0, 1)) < probability # --------------------------------------------------------------------------- # Orbital mechanics # --------------------------------------------------------------------------- def _derive_distance_au(period_days: float, star_type: str) -> float: """ Kepler's third law: a³ = P² × M_star Returns orbital distance in AU. M_star approximated from spectral type luminosity (L ∝ M^4 for main seq). """ if period_days <= 0: return 1.0 lum = STAR_LUMINOSITY.get(star_type, 1.0) m_star = lum ** 0.25 # rough mass from luminosity p_years = period_days / 365.25 return (p_years ** 2 * m_star) ** (1.0 / 3.0) def _check_habitability(body_def: dict) -> None: """ Warn if a temperate/oceanic/forest world has a physically implausible equilibrium temperature. Helps catch orbital distance errors early. """ pclass = body_def.get("planet_class", "") if pclass not in ("temperate", "oceanic", "forest"): return lum = body_def["star"].get("luminosity_solar", 1.0) dist = body_def["orbit"].get("distance_au", 1.0) atmo = body_def["physical"].get("atmosphere", "standard") gh = {"none": 0, "thin": 8, "standard": 33, "thick": 80}.get(atmo, 33) t_eq = 278.5 * (lum ** 0.25) / math.sqrt(max(dist, 0.01)) + gh if t_eq > 340: log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — " f"too hot for {pclass}. Check distance_au ({dist:.2f} AU). " f"Habitable zone ≈ {(278.5*(lum**0.25)/(290-gh))**2:.2f} AU") elif t_eq < 220: log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — " f"too cold for {pclass}. Check distance_au ({dist:.2f} AU).") def _is_tidally_locked(period_days: float, star_type: str) -> bool: """ Bodies with very short periods around dim stars are likely tidally locked. Rough threshold: period < 20 days for M-stars, < 10 for K-stars. """ thresholds = {"M": 20, "K": 10, "F": 4, "G": 4, "A": 2, "B": 1, "O": 1} return period_days < thresholds.get(star_type, 5) def _tidal_heating(period_days: float, mass_class: str, parent_is_giant: bool) -> str: """ Estimate geothermal flux modifier from tidal heating. Short-period moons around gas giants get significant heating (Io/Europa). """ if not parent_is_giant: return "low" if period_days < 3: return "extreme" # Io-like if period_days < 10: return "moderate" # Europa-like return "low" # --------------------------------------------------------------------------- # Markdown parser — bodies table # --------------------------------------------------------------------------- def _parse_star(system_profile_text: str) -> dict: """ Extract star type and luminosity from system profile section. Looks for lines like: | **Star** | G2V · 0.0 ly | """ match = re.search(r'\*\*Star\*\*.*?([OBAFGKM])\d*[Vab]*', system_profile_text) star_type = match.group(1) if match else "G" return { "type": star_type, "luminosity_solar": STAR_LUMINOSITY.get(star_type, 1.0), "color_temp_K": STAR_COLOUR_TEMP.get(star_type, 5800), } def _parse_bodies_table(md_text: str) -> list[dict]: """ Parse the Celestial Bodies table from the markdown. Returns list of raw row dicts. """ # Find the table section table_match = re.search( r'\| Orbit \| ID.*?\n(\|[-| ]+\|\n)(.*?)(?=\n##|\Z)', md_text, re.DOTALL ) if not table_match: log.warning("No bodies table found in markdown") return [] table_body = table_match.group(2) rows = [] for line in table_body.strip().splitlines(): if not line.strip().startswith('|'): continue cells = [c.strip() for c in line.split('|')[1:-1]] if len(cells) < 10: continue # Extract body ID from backtick notation id_match = re.search(r'`([^`]+)`', cells[1]) if not id_match: continue body_id = id_match.group(1) # Skip non-body rows body_type = cells[3].strip().lower() if body_type in ('asteroid_belt', 'oort_cloud', ''): continue if body_type not in ('planet', 'moon', 'gas_giant'): continue def cell(i, default="—"): v = cells[i].strip() if i < len(cells) else default return v if v not in ('—', '', '-') else default # Gravity: strip 'g' suffix grav_str = cell(7) try: gravity = float(re.sub(r'[^\d.]', '', grav_str)) except (ValueError, TypeError): gravity = None # Orbit period try: period = float(cell(8)) except (ValueError, TypeError): period = 0.0 # Day length try: day_h = float(cell(9)) except (ValueError, TypeError): day_h = None # Parent body — detect from ↳ prefix is_moon_row = '↳' in cells[0] rows.append({ "orbit_label": cells[0].strip(), "body_id": body_id, "name": cell(2) if cell(2) != '—' else None, "body_type": body_type, "inhabited": cell(4).lower() == 'yes', "population": cell(5), "mass_class": cell(6).lower(), # terrestrial / dwarf / gas_giant / ice_giant "gravity_g": gravity, "period_days": period, "day_h": day_h, "atmosphere": cell(10).lower(), "biome": cell(11).lower(), "hydrosphere": cell(12).lower(), "economy": cell(13), "settlement": cell(14), "industrial": cell(15), "is_moon_row": is_moon_row, }) return rows # --------------------------------------------------------------------------- # Body definition builder # --------------------------------------------------------------------------- def _build_body_def( row: dict, star: dict, system_id: str, overrides: dict, parent_is_giant: bool = False, ) -> Optional[dict]: """ Convert one bodies table row into a body_definition dict. overrides: per-body override dict (keyed by body_id). Returns None for bodies that don't need a render (asteroid belts etc). """ bid = row["body_id"] btype = row["body_type"] mass = row["mass_class"] biome = row["biome"] hydro = row["hydrosphere"] atmo = row["atmosphere"] period = row["period_days"] gravity = row["gravity_g"] star_type = star["type"] ov = overrides.get(bid, {}) # per-body override dict # ── Planet class ────────────────────────────────────────────────────── if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"): planet_class = "gas_giant" else: planet_class = PLANET_CLASS_MAP.get(biome, "temperate") if biome and biome not in PLANET_CLASS_MAP and biome != "—": log.warning(f" {bid}: unknown planet class '{biome}' — defaulting to temperate") planet_class = ov.get("planet_class", planet_class) # ── Body scale ──────────────────────────────────────────────────────── body_scale = "moon" if row["is_moon_row"] or mass == "dwarf" else "planet" body_scale = ov.get("body_scale", body_scale) # ── Seed — deterministic from body ID ───────────────────────────────── seed = _seed_from_id(bid) seed = ov.get("seed", seed) # ── Orbital distance ────────────────────────────────────────────────── distance_au = _derive_distance_au(period, star_type) # ── Axial tilt ──────────────────────────────────────────────────────── tilt_ov = (ov.get("orbit", {}) or {}).get("axial_tilt_deg", "rand") if tilt_ov != "rand": axial_tilt = float(tilt_ov) elif _is_tidally_locked(period, star_type) and not parent_is_giant: axial_tilt = _rand_range(bid, 0, 5, "tilt") elif planet_class in CLASS_TILT: lo, hi = CLASS_TILT[planet_class] axial_tilt = _rand_range(bid, lo, hi, "tilt") else: axial_tilt = _rand_range(bid, 5, 35, "tilt") # ── Atmosphere density ──────────────────────────────────────────────── atmo_density = ATMO_MAP.get(atmo, "none") atmo_density = ov.get("atmosphere_density", atmo_density) # ── Atmosphere colour — from star type + planet class ───────────────── atmo_colors = { "temperate": [0.45, 0.65, 1.00], "temperate_terminator": [0.45, 0.65, 1.00], "oceanic": [0.40, 0.60, 1.00], "forest": [0.42, 0.68, 0.80], "arid": [0.90, 0.72, 0.50], "cold_arid": [0.82, 0.58, 0.40], "hot_arid": [0.90, 0.72, 0.50], "tropical": [0.42, 0.68, 0.80], "boreal": [0.45, 0.65, 1.00], "frozen": [0.75, 0.88, 1.00], "volcanic": [0.55, 0.40, 0.30], "barren": None, } atmo_color = atmo_colors.get(planet_class) if atmo_density == "none": atmo_color = None # no atmosphere = no rim glow atmo_color = ov.get("atmosphere_color", atmo_color) # ── Land fraction ───────────────────────────────────────────────────── land_ov = (ov.get("terrain", {}) or {}).get("land_fraction", "rand") if land_ov != "rand": land_fraction = float(land_ov) else: lo, hi = HYDRO_LAND.get(hydro, (0.90, 0.99)) land_fraction = _rand_range(bid, lo, hi, "land") # ── Polar ice latitude ──────────────────────────────────────────────── ice_ov = (ov.get("terrain", {}) or {}).get("polar_ice_lat", "rand") if ice_ov != "rand": polar_ice_lat = float(ice_ov) else: lo, hi = CLASS_POLAR_ICE.get(planet_class, (0.80, 0.95)) # High axial tilt → ice caps extend further toward equator tilt_factor = (axial_tilt / 90.0) * 0.3 lo = max(0.05, lo - tilt_factor) hi = max(0.10, hi - tilt_factor) polar_ice_lat = _rand_range(bid, lo, hi, "ice") # ── Tectonics ───────────────────────────────────────────────────────── tectonic_map = { "volcanic": "extreme", "temperate": "active", "temperate_terminator": "active", "oceanic": "active", "forest": "active", "arid": "low", "cold_arid": "low", "hot_arid": "low", "tropical": "active", "boreal": "active", "frozen": "low", "barren": "none", } tectonics = tectonic_map.get(planet_class, "low") tectonics = ov.get("tectonics", tectonics) # ── Geothermal flux ─────────────────────────────────────────────────── geothermal = CLASS_GEOTHERMAL.get(planet_class, "low") # Tidal heating for moons of gas giants if parent_is_giant: tidal = _tidal_heating(period, mass, parent_is_giant) if tidal != "low": geothermal = tidal geothermal = ov.get("geothermal_flux", geothermal) # ── UV index ────────────────────────────────────────────────────────── uv_index = STAR_UV.get(star_type, "moderate") # Thin/no atmosphere → UV reaches surface directly if atmo_density in ("none", "thin"): uv_map = {"low": "moderate", "moderate": "high", "high": "extreme"} uv_index = uv_map.get(uv_index, uv_index) uv_index = ov.get("uv_index", uv_index) # ── Substrate ───────────────────────────────────────────────────────── substrate_map = { "volcanic": "sulfuric", "arid": "silicate", "cold_arid": "silicate", "hot_arid": "silicate", "tropical": "silicate", "boreal": "silicate", "frozen": "ice", "barren": "silicate", "temperate": "silicate", "temperate_terminator": "silicate", "oceanic": "silicate", "forest": "silicate", } substrate = substrate_map.get(planet_class, "silicate") if hydro == "subsurface" and planet_class == "frozen": substrate = "ice" substrate = ov.get("substrate", substrate) # ── Chemosynthetic modifier ─────────────────────────────────────────── # Europa case: frozen + subsurface + tidal heating → chemosynthetic chemosynthetic = False if hydro == "subsurface" and geothermal in ("moderate", "high", "extreme"): chemosynthetic = True chemosynthetic = ov.get("chemosynthetic", chemosynthetic) # ── Oblateness ──────────────────────────────────────────────────────── oblat_lo, oblat_hi = CLASS_OBLATENESS.get(planet_class, (0.001, 0.005)) oblateness = _rand_range(bid, oblat_lo, oblat_hi, "oblat") if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"): oblateness = _rand_range(bid, 0.050, 0.090, "oblat") oblateness = ov.get("oblateness", oblateness) # ── Clouds ──────────────────────────────────────────────────────────── clouds_enabled = atmo_density in CLOUD_CAPABLE and planet_class != "barren" if planet_class == "barren": clouds_enabled = False cld_ov = ov.get("clouds", {}) or {} clouds_enabled = cld_ov.get("enabled", clouds_enabled) coverage_ov = cld_ov.get("coverage_base", "rand") if coverage_ov != "rand": coverage = float(coverage_ov) else: lo, hi = CLASS_CLOUD.get(planet_class, (0.10, 0.40)) coverage = _rand_range(bid, lo, hi, "cloud") # ── Gas giant specific ──────────────────────────────────────────────── gas_giant_cfg = None rings_cfg = None if planet_class == "gas_giant": palette_ov = (ov.get("gas_giant", {}) or {}).get("band_palette", "rand") if palette_ov == "rand": palette = _rand_choice(bid, GAS_PALETTES, "palette") else: palette = palette_ov storm_count = int(_rand_range(bid, 1, 5, "storms")) storm_count = (ov.get("gas_giant", {}) or {}).get("storm_count", storm_count) storm_size = _rand_range(bid, 0.06, 0.14, "storm_sz") storm_size = (ov.get("gas_giant", {}) or {}).get("storm_max_size", storm_size) gas_giant_cfg = { "band_palette": palette, "storm_count": storm_count, "storm_max_size": round(float(storm_size), 3), } # Rings rings_ov = ov.get("rings", "rand") if rings_ov == "rand": has_rings = _rand_bool(bid, RING_PROBABILITY, "rings") elif isinstance(rings_ov, dict): has_rings = rings_ov.get("enabled", True) else: has_rings = bool(rings_ov) if has_rings: planet_class = "gas_giant_ringed" r_inner = round(_rand_range(bid, 1.08, 1.25, "r_inner"), 2) r_outer = round(_rand_range(bid, 2.20, 2.80, "r_outer"), 2) opacity = round(_rand_range(bid, 0.45, 0.72, "r_opa"), 2) rcolor = RING_COLOURS.get(palette, [0.75, 0.70, 0.60]) # Merge any explicit ring overrides if isinstance(rings_ov, dict): r_inner = rings_ov.get("inner_radius_factor", r_inner) r_outer = rings_ov.get("outer_radius_factor", r_outer) opacity = rings_ov.get("opacity_base", opacity) rcolor = rings_ov.get("ring_color", rcolor) rings_cfg = { "enabled": True, "inner_radius_factor": r_inner, "outer_radius_factor": r_outer, "opacity_base": opacity, "ring_color": rcolor, } # ── Render config ───────────────────────────────────────────────────── render_cfg = dict(RENDER_DEFAULTS) render_cfg["specular_ocean"] = hydro in ("ocean", "liquid_water", "rivers") if planet_class in ("barren", "arid", "volcanic"): render_cfg["specular_ocean"] = False render_cfg.update(ov.get("render", {})) # ── Assemble ────────────────────────────────────────────────────────── body_def = { "id": bid, "name": row["name"], "body_type": btype, "planet_class": planet_class, "body_scale": body_scale, "seed": seed, "star": star, "orbit": { "distance_au": round(distance_au, 3), "period_days": period, "axial_tilt_deg": round(axial_tilt, 1), }, "physical": { "gravity_g": gravity, "oblateness": round(oblateness, 4), "atmosphere": atmo_density, "atmosphere_color": atmo_color, }, "terrain": { "land_fraction": round(land_fraction, 3), "polar_ice_lat": round(polar_ice_lat, 3), "tectonics": tectonics, }, "environment": { "geothermal_flux": geothermal, "uv_index": uv_index, "substrate": substrate, "chemosynthetic": chemosynthetic, "hydrosphere": hydro, }, "clouds": { "enabled": bool(clouds_enabled), "coverage_base": round(coverage, 3), }, "render": render_cfg, } # Gas giants: drop meaningless terrain fields if gas_giant_cfg: body_def.pop("terrain", None) body_def["gas_giant"] = gas_giant_cfg if rings_cfg: body_def["rings"] = rings_cfg # Wiki cultural data — not used by the generator, carried for the # body index.md template and downstream pipelines. pop_raw = row.get("population", "—") body_def["wiki"] = { "inhabited": row.get("inhabited", False), "population": pop_raw if pop_raw not in ("—", "", None) else None, "economy": row.get("economy") if row.get("economy") not in ("—", "", None) else None, "settlement": row.get("settlement") if row.get("settlement") not in ("—", "", None) else None, "industrial": row.get("industrial") if row.get("industrial") not in ("—", "", None) else None, } return body_def # --------------------------------------------------------------------------- # System parser — top-level entry # --------------------------------------------------------------------------- def parse_system( md_path: str, overrides: dict = None, out_dir: str = None, ) -> list[dict]: """ Parse a system index.md and return list of body_definition dicts. Optionally write one JSON file per body into out_dir. overrides: { body_id: { field: value, ... } } """ overrides = overrides or {} md_text = Path(md_path).read_text(encoding="utf-8") # Extract system ID from first header sys_match = re.search(r'\*\*([A-Z0-9 ]+)\*\*', md_text) system_id = sys_match.group(1).replace(" ", "_") if sys_match else "UNKNOWN" # Parse star star = _parse_star(md_text) log.info(f"System: {system_id} Star: {star['type']}-type " f"L={star['luminosity_solar']:.3g} Lsun") # Parse bodies table rows = _parse_bodies_table(md_text) log.info(f"Found {len(rows)} renderable bodies") # Track which bodies are moons of gas giants (for tidal heating) # Simple heuristic: if the previous non-moon row was a gas_giant, this is its moon last_giant = False body_defs = [] for row in rows: bid = row["body_id"] btype = row["body_type"] mass = row["mass_class"] is_giant = btype == "gas_giant" or mass in ("gas_giant", "ice_giant") # Determine if this moon orbits a gas giant parent_is_giant = row["is_moon_row"] and last_giant if not row["is_moon_row"]: last_giant = is_giant # Build definition body_def = _build_body_def( row, star, system_id, overrides, parent_is_giant=parent_is_giant, ) if body_def is None: continue body_defs.append(body_def) log.info(f" {bid:20s} {body_def['planet_class']:20s} " f"scale={body_def['body_scale']:6s} " f"seed={body_def['seed']}") _check_habitability(body_def) # Write output files if out_dir: os.makedirs(out_dir, exist_ok=True) for bd in body_defs: out_path = os.path.join(out_dir, f"{bd['id']}_def.json") with open(out_path, "w") as f: json.dump(bd, f, indent=2) log.info(f"Wrote {len(body_defs)} body definitions → {out_dir}/") return body_defs # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- if __name__ == "__main__": parser = argparse.ArgumentParser( description="Parse system index.md → body_definition.json files" ) parser.add_argument("md_file", help="Path to system index.md") parser.add_argument("--out-dir", default="./body_defs", help="Output directory for JSON files (default: ./body_defs)") parser.add_argument("--overrides", default=None, help="Path to JSON overrides file (optional)") parser.add_argument("--print", action="store_true", help="Print all body definitions to stdout") args = parser.parse_args() overrides = {} if args.overrides: with open(args.overrides) as f: overrides = json.load(f) defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir) if args.print: console.out(json.dumps(defs, indent=2))