- Remove tracked kallast_terrain.npy (HIGH — binary in git) - Amend D-086: stance + interaction icons delivered, not deferred - Create ticket #818 for icon_tint.gdshader (client team) - Fix gas giant profile table: suppress gravity/land%/hydrosphere - Fix atmosphere_color: null when atmosphere is none - Fix gas_giant_ringed display as gas_giant in profile table - Re-scaffold + regenerate Ran system with fixes .import sidecars: not applicable — gitignored by design (client/**/*.import). Godot auto-generates on first run. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
779 lines
29 KiB
Python
779 lines
29 KiB
Python
"""
|
||
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))
|