""" 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: python3 body_definition_parser.py path/to/index.md [--out-dir ./defs] # With overrides (e.g. Sol) python3 body_definition_parser.py 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 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 ATMO_MAP = { "none": "none", "thin": "thin", "breathable": "standard", "dense": "thick", "toxic": "thick", # Venus-style reducing atmosphere } # 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 BIOME_CLASS = { "temperate": "temperate", "arid": "arid", "frozen": "frozen", "volcanic": "volcanic", "barren": "barren", "forest": "forest", "oceanic": "oceanic", } # planet_class → axial tilt range [min, max] degrees # Tidal locking check overrides this for short-period bodies CLASS_TILT = { "temperate": (10, 35), "oceanic": (5, 25), "forest": (10, 40), "arid": (5, 30), "frozen": (15, 60), # high tilt → seasonal extremes → frozen "volcanic": (2, 20), "barren": (0, 45), } # planet_class → geothermal flux CLASS_GEOTHERMAL = { "volcanic": "extreme", "temperate": "low", "oceanic": "low", "forest": "low", "arid": "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), "oceanic": (0.80, 0.92), "forest": (0.75, 0.88), "arid": (0.90, 0.99), "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), "oceanic": (0.001, 0.004), "forest": (0.001, 0.005), "arid": (0.001, 0.004), "frozen": (0.001, 0.003), "volcanic": (0.002, 0.008), "barren": (0.000, 0.003), } # Gas giant band palettes available from biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES # planet_class → cloud coverage base range CLASS_CLOUD = { "temperate": (0.35, 0.55), "oceanic": (0.55, 0.75), "forest": (0.40, 0.60), "arid": (0.05, 0.20), "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 } # --------------------------------------------------------------------------- # 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 = BIOME_CLASS.get(biome, "barren") 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], "oceanic": [0.40, 0.60, 1.00], "forest": [0.42, 0.68, 0.80], "arid": [0.90, 0.72, 0.50], "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", "oceanic": "active", "forest": "active", "arid": "low", "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", "frozen": "ice", "barren": "silicate", "temperate":"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 giant extras if gas_giant_cfg: 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']}") # 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}/") _check_habitability(body_def) 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: print(json.dumps(defs, indent=2))