Hoshe + Tyre review of the micro-habitat-mosaic branch requested changes on two data-confirmed correctness bugs plus follow-through. All addressed. Blocking: - C1 (voxel.rs): the mosaic re-granted vegetation the climate withheld — a frozen ice world (Edict) read 61% vegetated tundra. The Grassland apply-gate now fires only where the climate already grants cover (!Barren); a climatically-barren district (frozen < -50C = surface ice/geology D-239 §2, or hyper-arid moisture<5, both resolved to VegetationClass::Barren upstream) is skipped. Genuine tundra (cold + moisture>=15) is Scrub upstream and still reaches the tundra palette via the same !Barren path. believability.json regenerated: Edict vegetated_districts 39->0, Arbour 20->8 (its real cold-pole/arid districts revert to barren); intra-class variety preserved (Arbour vegetation_classes 3, micro_habitat_distinct 2). - H1 (bodies.py): biosphere Gate 0 keyed on `inhabited`, conflating settlement with biosphere and force-Airless'ing uninhabited-but-alive worlds (D-247: worlds were alive before humans). Gate 0 is now settlement-independent — keyed on atmosphere/hydrosphere/planet_class. 24 uninhabited-alive worlds now classified (was 0); GJ0g-1 (dense atm + rivers) -> NativeMirror. Split 122 Compatible : 121 Mirror; all 9 canon exemplars hold. Follow-through: - C2 (bodies.py + D-247): Gate 0 now reads planet_class (barren -> Airless) as D-247's "+ planet_class" phrasing names. D-247 amended to record the actual two-gate signal set (atm/hydro/planet_class habitability; economic_base_primary chirality; settlement-independence). - C3 (believability.rs): micro_habitat_distinct criterion relaxed to `== 0 || >= 2` — the min-over-patches estimator makes K=3 unreachable against a dominant-entry palette; K=2 provisional, Q-123 calibrates. - C4 + H2 (D-246): amended to record the v1 scope cut — only the Soil-derived vegetated/wet classes take the mosaic; material-driven families are `_ => false` so Hardpan/Scree are authored but unreachable in v1; no elevation change in v1; the mosaic OWNS the §8 climate->vegetation law for the classes it touches. - H3 (voxel.rs): three dedicated mosaic-pass regression tests — C1 barren-respect, §8 material-untouched + forest-textured, T-1040 no-water-without-channel. - H4 (voxel.rs): relief_signal comment corrected to ~[-1,+1]. - H5 (bodies.py): frontmatter override now strips quotes + validates against the 4-value enum (warn-and-skip on typo). systems.db regenerated + stamped (H1). believability golden regenerated (C1). Full cargo test green; clippy -D warnings + ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
353 lines
14 KiB
Python
353 lines
14 KiB
Python
"""Body-attribute backfills: body_radius_km (D-204) and axial_tilt_deg (T-1024)."""
|
||
|
||
import hashlib
|
||
import sqlite3
|
||
from pathlib import Path
|
||
|
||
from .paths import WIKI_STAR_SYSTEMS
|
||
|
||
|
||
def populate_body_radius_km(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||
"""Populate body_radius_km column from planet_class fallback (D-204, #910).
|
||
|
||
Applies the fallback lookup table to rows where body_radius_km IS NULL.
|
||
Does not overwrite rows where body_radius_km is already set (authoritative data).
|
||
|
||
Fallback values (km):
|
||
super_earth -> 8000
|
||
earth_like -> 6371
|
||
earth -> 6371 (alternate spelling)
|
||
sub_earth -> 4500
|
||
ocean_world -> 6500
|
||
arid -> 5800
|
||
frozen -> 4500
|
||
ice_world -> 3000
|
||
barren -> 4500
|
||
volcanic -> 5500
|
||
gas_giant -> 0 (no settlements, skip)
|
||
moon -> 1737
|
||
other/unknown -> 6371 (Earth default)
|
||
"""
|
||
PLANET_CLASS_RADIUS = {
|
||
"super_earth": 8000.0,
|
||
"earth_like": 6371.0,
|
||
"earth": 6371.0,
|
||
"sub_earth": 3500.0,
|
||
"ocean_world": 6500.0,
|
||
"arid": 5800.0,
|
||
"frozen": 3500.0,
|
||
"ice_world": 3000.0,
|
||
"barren": 3500.0,
|
||
"volcanic": 5500.0,
|
||
"temperate": 6371.0,
|
||
"moon": 1737.0,
|
||
}
|
||
DEFAULT_RADIUS = 6371.0
|
||
SKIP_RADIUS_TYPES = {"oort_cloud", "asteroid_belt"}
|
||
|
||
GAS_GIANT_RADIUS = {
|
||
"gas_giant": 50000.0,
|
||
"ice_giant": 25000.0,
|
||
}
|
||
GAS_GIANT_DEFAULT = 45000.0
|
||
GAS_GIANT_SCATTER = 0.20 # ±20%
|
||
|
||
rows = conn.execute(
|
||
"SELECT body_id, planet_class, body_type, mass_class FROM bodies WHERE body_radius_km IS NULL"
|
||
).fetchall()
|
||
|
||
SCATTER_FRACTION = 0.15 # ±15% for rocky bodies
|
||
|
||
updates: list[tuple[float, str]] = []
|
||
for body_id, planet_class, body_type, mass_class in rows:
|
||
if body_type in SKIP_RADIUS_TYPES:
|
||
continue
|
||
|
||
h = int(hashlib.sha256(body_id.encode()).hexdigest()[:8], 16)
|
||
scatter_val = (h / 0xFFFFFFFF) * 2.0 - 1.0 # [-1.0, 1.0]
|
||
|
||
if body_type == "gas_giant":
|
||
mc = (mass_class or "").lower()
|
||
base_radius = GAS_GIANT_RADIUS.get(mc, GAS_GIANT_DEFAULT)
|
||
radius = round(base_radius * (1.0 + scatter_val * GAS_GIANT_SCATTER), 1)
|
||
elif body_type == "moon":
|
||
base_radius = 1400.0
|
||
scatter_frac = 0.86 # ±86% → ~196–2604 km
|
||
radius = round(base_radius * (1.0 + scatter_val * scatter_frac), 1)
|
||
else:
|
||
base_radius = PLANET_CLASS_RADIUS.get(
|
||
(planet_class or "").lower(), DEFAULT_RADIUS
|
||
)
|
||
radius = round(base_radius * (1.0 + scatter_val * SCATTER_FRACTION), 1)
|
||
updates.append((radius, body_id))
|
||
|
||
if not dry_run and updates:
|
||
conn.executemany(
|
||
"UPDATE bodies SET body_radius_km = ? WHERE body_id = ?", updates
|
||
)
|
||
|
||
return len(updates)
|
||
|
||
|
||
def populate_axial_tilt_deg(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||
"""Populate bodies.axial_tilt_deg from planet-gen body-def frontmatter (T-1024).
|
||
|
||
Reads wiki/star-systems/*/bodies/*/index.md YAML frontmatter and extracts
|
||
``orbit.axial_tilt_deg``. Only updates rows where axial_tilt_deg IS NULL
|
||
(preserves any future authoritative column writes).
|
||
|
||
Source: body_definition_parser.py writes ``orbit.axial_tilt_deg`` into each
|
||
body's index.md during the planet-gen batch run. This function mirrors
|
||
``populate_body_radius_km`` in structure.
|
||
"""
|
||
import yaml # stdlib-compatible subset via PyYAML if available, else manual parse
|
||
|
||
def _parse_frontmatter_yaml(text: str) -> dict:
|
||
"""Extract YAML frontmatter block from a markdown file."""
|
||
lines = text.split("\n")
|
||
if not lines or lines[0].strip() != "---":
|
||
return {}
|
||
end_idx = None
|
||
for i, line in enumerate(lines[1:], 1):
|
||
if line.strip() == "---":
|
||
end_idx = i
|
||
break
|
||
if end_idx is None:
|
||
return {}
|
||
fm_text = "\n".join(lines[1:end_idx])
|
||
try:
|
||
result = yaml.safe_load(fm_text)
|
||
return result if isinstance(result, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
# Check if yaml is available; if not, use manual extraction.
|
||
try:
|
||
import yaml as _yaml_check # noqa: F401
|
||
has_yaml = True
|
||
except ImportError:
|
||
has_yaml = False
|
||
|
||
if not has_yaml:
|
||
# Fallback: manual extraction of axial_tilt_deg from YAML frontmatter.
|
||
# Scans for " axial_tilt_deg: <value>" under an "orbit:" block.
|
||
def _parse_frontmatter_manual(text: str) -> dict:
|
||
lines = text.split("\n")
|
||
if not lines or lines[0].strip() != "---":
|
||
return {}
|
||
in_orbit = False
|
||
result: dict = {}
|
||
for line in lines[1:]:
|
||
if line.strip() == "---":
|
||
break
|
||
stripped = line.strip()
|
||
if stripped == "orbit:":
|
||
in_orbit = True
|
||
continue
|
||
if in_orbit:
|
||
# Detect leaving the orbit block (non-indented key).
|
||
if line and not line.startswith(" ") and not line.startswith("\t"):
|
||
in_orbit = False
|
||
elif stripped.startswith("axial_tilt_deg:"):
|
||
_, _, val = stripped.partition(":")
|
||
try:
|
||
result["axial_tilt_deg"] = float(val.strip())
|
||
except ValueError:
|
||
pass
|
||
return result
|
||
|
||
def _parse_frontmatter_yaml(text: str) -> dict: # type: ignore[misc]
|
||
return _parse_frontmatter_manual(text)
|
||
|
||
# Find all body index.md files.
|
||
body_dir_pattern = WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "index.md"
|
||
import glob as _glob
|
||
body_files = sorted(_glob.glob(str(body_dir_pattern)))
|
||
|
||
# Build body_id → axial_tilt_deg mapping from frontmatter.
|
||
tilt_map: dict[str, float] = {}
|
||
for fpath in body_files:
|
||
try:
|
||
text = Path(fpath).read_text(encoding="utf-8")
|
||
except OSError:
|
||
continue
|
||
fm = _parse_frontmatter_yaml(text)
|
||
if not isinstance(fm, dict):
|
||
continue
|
||
body_id = fm.get("id")
|
||
orbit = fm.get("orbit", {})
|
||
if isinstance(orbit, dict):
|
||
tilt = orbit.get("axial_tilt_deg")
|
||
else:
|
||
tilt = None
|
||
if body_id and tilt is not None:
|
||
try:
|
||
tilt_map[str(body_id)] = float(tilt)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
# Get all bodies where axial_tilt_deg IS NULL and body_id is in tilt_map.
|
||
rows = conn.execute(
|
||
"SELECT body_id FROM bodies WHERE axial_tilt_deg IS NULL"
|
||
).fetchall()
|
||
|
||
updates: list[tuple[float, str]] = []
|
||
for (body_id,) in rows:
|
||
if body_id in tilt_map:
|
||
updates.append((tilt_map[body_id], body_id))
|
||
|
||
if not dry_run and updates:
|
||
conn.executemany(
|
||
"UPDATE bodies SET axial_tilt_deg = ? WHERE body_id = ?", updates
|
||
)
|
||
|
||
return len(updates)
|
||
|
||
|
||
def populate_biosphere_class(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||
"""Populate bodies.biosphere_class (D-247, T-1085).
|
||
|
||
Reads ``environment.biosphere_class`` from each body's index.md frontmatter (the
|
||
AUTHORED override) and, where absent, applies the D-247 two-gate default from the
|
||
body's atmosphere / hydrosphere / planet_class + the system's
|
||
``economic_base_primary``. Recomputes for ALL bodies each run (the value is fully
|
||
determined by frontmatter + the default, with no other authoritative writer to
|
||
preserve), so an edited override re-applies on the next regen.
|
||
|
||
Authored input, not seed entropy (D-247): the default is a function of other
|
||
AUTHORED fields, and explicit frontmatter always wins.
|
||
- Gate 0 (habitability — independent of SETTLEMENT, since D-247 holds worlds were
|
||
alive before humans): no atmosphere or an explicitly ``barren`` planet_class ->
|
||
Airless; ``thin`` atmosphere -> TerraformedSterile if inhabited (engineered),
|
||
else Airless (marginal natural); real atmosphere + open liquid water -> Alive ->
|
||
Gate 1; real atmosphere but no liquid water -> Airless (sealed / marginal).
|
||
- Gate 1 (Compatible vs Mirror): agriculture/service exporters -> Compatible;
|
||
penal/extraction/manufacturing -> Mirror; the neutral remainder -> a stable
|
||
body_id split toward the ~50/50 design target (canon worlds carry explicit
|
||
frontmatter overrides, so they never depend on this split).
|
||
"""
|
||
import glob as _glob
|
||
import hashlib
|
||
|
||
try:
|
||
import yaml as _yaml
|
||
|
||
_has_yaml = True
|
||
except ImportError:
|
||
_has_yaml = False
|
||
|
||
def _biosphere_from_frontmatter(text: str) -> tuple[str | None, str | None]:
|
||
"""Return ``(body_id, biosphere_class)`` from a body's frontmatter, or (None, None)."""
|
||
lines = text.split("\n")
|
||
if not lines or lines[0].strip() != "---":
|
||
return (None, None)
|
||
end = None
|
||
for i, line in enumerate(lines[1:], 1):
|
||
if line.strip() == "---":
|
||
end = i
|
||
break
|
||
if end is None:
|
||
return (None, None)
|
||
block_lines = lines[1:end]
|
||
if _has_yaml:
|
||
try:
|
||
fm = _yaml.safe_load("\n".join(block_lines))
|
||
except Exception:
|
||
fm = None
|
||
if isinstance(fm, dict):
|
||
env = fm.get("environment")
|
||
bc = env.get("biosphere_class") if isinstance(env, dict) else None
|
||
return (str(fm.get("id")) if fm.get("id") else None, str(bc) if bc else None)
|
||
return (None, None)
|
||
# Manual fallback: scan `id:` and `environment.biosphere_class`.
|
||
body_id = None
|
||
bc = None
|
||
in_env = False
|
||
for line in block_lines:
|
||
stripped = line.strip()
|
||
if stripped.startswith("id:"):
|
||
body_id = stripped.partition(":")[2].strip() or None
|
||
if stripped == "environment:":
|
||
in_env = True
|
||
continue
|
||
if in_env:
|
||
if line and not line[0].isspace():
|
||
in_env = False
|
||
elif stripped.startswith("biosphere_class:"):
|
||
bc = stripped.partition(":")[2].strip() or None
|
||
return (body_id, bc)
|
||
|
||
# 1. Authored overrides from frontmatter (quote-stripped + enum-validated — H5).
|
||
_VALID = {"NativeCompatible", "NativeMirror", "TerraformedSterile", "Airless"}
|
||
override: dict[str, str] = {}
|
||
body_files = sorted(_glob.glob(str(WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "index.md")))
|
||
for fpath in body_files:
|
||
try:
|
||
text = Path(fpath).read_text(encoding="utf-8")
|
||
except OSError:
|
||
continue
|
||
body_id, bc = _biosphere_from_frontmatter(text)
|
||
if body_id and bc:
|
||
bc = bc.strip().strip("'\"")
|
||
if bc not in _VALID:
|
||
print(f" WARNING: {fpath}: biosphere_class {bc!r} not in {_VALID}; ignored")
|
||
continue
|
||
override[body_id] = bc
|
||
|
||
# 2. Bodies needing a value + the inputs for the two-gate default.
|
||
# Recompute for ALL bodies every run: biosphere_class has no other authoritative
|
||
# writer (unlike axial_tilt/radius, which preserve future column writes) — the
|
||
# frontmatter override + two-gate default are its complete, deterministic source, so
|
||
# an edited override must re-apply on the next regen.
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT b.body_id, b.inhabited, b.atmosphere, b.hydrosphere, b.planet_class,
|
||
e.economic_base_primary
|
||
FROM bodies b
|
||
LEFT JOIN system_economy e ON e.system_id = b.system_id
|
||
"""
|
||
).fetchall()
|
||
|
||
_LIQUID = ("ocean", "liquid", "rivers", "moderate", "extensive", "coastal", "lakes")
|
||
|
||
def _default(inhabited, atmosphere, hydrosphere, planet_class, econ, body_id) -> str:
|
||
atm = (atmosphere or "").strip().lower()
|
||
hyd = (hydrosphere or "").strip().lower()
|
||
pclass = (planet_class or "").strip().lower()
|
||
# Gate 0 — habitability. Independent of SETTLEMENT (H1 fix): D-247's premise is
|
||
# that habitable worlds were alive before humans, so an UNINHABITED world with a
|
||
# real atmosphere + open liquid water is a living world nobody has settled — not
|
||
# Airless. Keyed on atmosphere / hydrosphere / planet_class (C2: these are the
|
||
# real habitability signals; D-247's "+ planet_class" phrasing amended to match).
|
||
if atm in ("", "none", "trace") or pclass == "barren":
|
||
return "Airless" # no atmosphere, or an explicitly lifeless world
|
||
if atm == "thin":
|
||
# A thin *breathable* atmosphere is an engineering achievement only where
|
||
# INHABITED (maintained infrastructure — D-247 Oshima); an uninhabited
|
||
# thin-atmosphere world is a marginal natural one with no open-air biosphere.
|
||
return "TerraformedSterile" if inhabited else "Airless"
|
||
if not any(k in hyd for k in _LIQUID):
|
||
return "Airless" # real atmosphere but no open liquid water -> sealed / marginal
|
||
# Gate 1 — Compatible vs Mirror (Alive).
|
||
base = (econ or "").strip().lower()
|
||
if "agricultur" in base or "service" in base:
|
||
return "NativeCompatible"
|
||
if "penal" in base or "extract" in base or "manufactur" in base:
|
||
return "NativeMirror"
|
||
h = int(hashlib.sha1(str(body_id).encode()).hexdigest(), 16)
|
||
return "NativeCompatible" if h % 2 == 0 else "NativeMirror"
|
||
|
||
updates: list[tuple[str, str]] = []
|
||
for body_id, inhabited, atmosphere, hydrosphere, planet_class, econ in rows:
|
||
val = override.get(body_id) or _default(
|
||
inhabited, atmosphere, hydrosphere, planet_class, econ, body_id
|
||
)
|
||
updates.append((val, body_id))
|
||
|
||
if not dry_run and updates:
|
||
conn.executemany(
|
||
"UPDATE bodies SET biosphere_class = ? WHERE body_id = ?", updates
|
||
)
|
||
|
||
return len(updates)
|