"""Load and validate map/config/*.toml.""" from __future__ import annotations import tomllib from pathlib import Path class ConfigError(ValueError): pass MASK_DEFAULTS = {"o2_range": 0.5, "gravity_range": 0.7} REQUIRED = { "planet": { "radius_km": (1000.0, 100000.0), "gravity_g": (0.1, 5.0), "day_hours": (1.0, 10000.0), "year_days": (1.0, 100000.0), "tilt_deg": (0.0, 90.0), "sea_level_pressure_bar": (0.01, 100.0), "scale_height_km": (1.0, 100.0), "o2_fraction": (0.0, 1.0), }, "build": { "seed": (0, 2**31 - 1), "res_dev": (0, 8), "res_final": (0, 8), "raster_width": (64, 32768), "preview_width": (64, 8192), "land_fraction": (0.01, 0.99), }, } def _check_ranges(cfg: dict, schema: dict, src: str) -> None: for sec, keys in schema.items(): if sec not in cfg: raise ConfigError(f"{src}: missing section [{sec}]") for k, (lo, hi) in keys.items(): if k not in cfg[sec]: raise ConfigError(f"{src}: missing [{sec}].{k}") v = cfg[sec][k] if isinstance(v, bool) or not isinstance(v, (int, float)): raise ConfigError(f"{src}: [{sec}].{k} must be a number, got {v!r}") if not lo <= v <= hi: raise ConfigError(f"{src}: [{sec}].{k}={v} outside [{lo}, {hi}]") def _latlon(v, what: str, src: str = "tectonics.toml") -> None: ok = isinstance(v, list) and len(v) == 2 and all(isinstance(x, (int, float)) for x in v) if not ok or not (-90 <= v[0] <= 90 and -180 <= v[1] <= 360): raise ConfigError(f"{src}: {what} must be [lat, lon], got {v!r}") def validate_tectonics(t: dict) -> None: plates = t.get("plate", []) if len(plates) < 2: raise ConfigError("tectonics.toml: need at least 2 [[plate]] tables") seen = set() for p in plates: pid = p.get("id", "?") for k in ("id", "seed", "kind", "motion"): if k not in p: raise ConfigError(f"tectonics.toml: plate {pid} missing {k}") if pid in seen: raise ConfigError(f"tectonics.toml: duplicate plate id {pid}") seen.add(pid) if p["kind"] not in ("continental", "oceanic"): raise ConfigError(f"tectonics.toml: plate {pid} kind must be continental|oceanic") _latlon(p["seed"], f"plate {pid}.seed") m = p["motion"] if not (isinstance(m, list) and len(m) == 2 and 0 <= m[1] <= 20): raise ConfigError(f"tectonics.toml: plate {pid}.motion must be [azimuth_deg, speed 0..20 cm/yr]") for kind in ("lip", "volcano", "hotspot", "microcontinent"): for x in t.get(kind, []): for k in ("name", "center"): if k not in x: raise ConfigError(f"tectonics.toml: [[{kind}]] missing {k}") _latlon(x["center"], f"{kind} {x['name']}.center") if kind in ("lip", "volcano", "microcontinent") and not x.get("radius_km", 0) > 0: raise ConfigError(f"tectonics.toml: {kind} {x['name']} needs radius_km > 0") if kind == "hotspot" and not x.get("length_km", 0) > 0: raise ConfigError(f"tectonics.toml: hotspot {x['name']} needs length_km > 0") ERAS_FILE = "eras.toml" # events and eras: not part of the base world's inputs key (pipeline.inputs_key) ZONE_FIELDS = {"gravity_g": (0.05, 5.0), "o2_fraction": (0.0, 1.0), "pressure_bar": (0.01, 10.0), "fire_reactivity": (0.0, 5.0)} # render.CONTINUOUS holds every value in range PLATEAU_SPACING_KM = 2500.0 RESERVED_ERA_KEYS = {"order", "default"} def _num(v, what: str, lo: float, hi: float, src: str = "tectonics.toml") -> None: if isinstance(v, bool) or not isinstance(v, (int, float)) or not lo <= v <= hi: raise ConfigError(f"{src}: {what} must be a number in [{lo}, {hi}], got {v!r}") def _pair(v, what: str, lo: float, hi: float, src: str = "tectonics.toml") -> None: ok = isinstance(v, list) and len(v) == 2 and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in v) if not ok or not lo <= v[0] <= v[1] <= hi: raise ConfigError(f"{src}: {what} must be [low, high] within [{lo}, {hi}], got {v!r}") def _named(items: list, kind: str, src: str = "tectonics.toml") -> set: seen = set() for x in items: if not isinstance(x.get("name"), str): raise ConfigError(f"{src}: [[{kind}]] needs a name") if x["name"] in seen: raise ConfigError(f"{src}: duplicate {kind} name {x['name']!r}") seen.add(x["name"]) return seen def _event(e: dict) -> None: w, src, kind = f"event {e['name']}", ERAS_FILE, e.get("kind") if kind == "disintegrate": _latlon(e.get("center"), f"{w}.center", src) _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0, src) _num(e.get("depth_m"), f"{w}.depth_m", 1.0, 20000.0, src) elif kind == "volcano": _latlon(e.get("center"), f"{w}.center", src) _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 5000.0, src) _num(e.get("peak_m"), f"{w}.peak_m", -11000.0, 12000.0, src) if e.get("shape", "cone") not in ("cone", "shield", "caldera"): raise ConfigError(f"{src}: {w}.shape must be cone|shield|caldera, got {e.get('shape')!r}") if e.get("peak_mode", "above") not in ("above", "absolute"): raise ConfigError(f"{src}: {w}.peak_mode must be above|absolute, got {e.get('peak_mode')!r}") elif kind == "zone": f = e.get("fields") if not isinstance(f, dict) or not f: raise ConfigError(f"{src}: {w}.fields must set at least one of {sorted(ZONE_FIELDS)}") for k, v in f.items(): if k not in ZONE_FIELDS: raise ConfigError(f"{src}: {w}.fields: unknown field {k!r} (known: {sorted(ZONE_FIELDS)})") _num(v, f"{w}.fields.{k}", *ZONE_FIELDS[k], src) shape = e.get("shape") if shape == "circle": _latlon(e.get("center"), f"{w}.center", src) _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0, src) elif shape == "landmass": _latlon(e.get("seed"), f"{w}.seed", src) _pair(e.get("reach_km"), f"{w}.reach_km", 0.0, 20000.0, src) else: raise ConfigError(f"{src}: {w}.shape must be circle|landmass, got {shape!r}") if "edge_km" in e: _num(e["edge_km"], f"{w}.edge_km", 0.001, 5000.0, src) elif e.get("profile") != "smooth": raise ConfigError(f'{src}: {w} needs edge_km (a sharp edge) or profile = "smooth"') else: raise ConfigError(f"{src}: {w}.kind must be disintegrate|zone|volcano, got {kind!r}") def validate_revision(t: dict, radius_km: float) -> None: """Plateaus, land patches, zones, events and eras.""" from .sphere import gc_dist_km, latlon_to_xyz plateaus = t.get("plateau", []) _named(plateaus, "plateau") for p in plateaus: w = f"plateau {p['name']}" _latlon(p.get("center"), f"{w}.center") _num(p.get("area_km2"), f"{w}.area_km2", 1.0e4, 5.0e7) _num(p.get("elongation", 1.0), f"{w}.elongation", 1.0, 10.0) _num(p.get("azimuth_deg", 0.0), f"{w}.azimuth_deg", -360.0, 360.0) _pair(p.get("top_m"), f"{w}.top_m (metres below sea level, shallowest first)", 1.0, 11000.0) if not isinstance(p.get("islands", False), bool): raise ConfigError(f"tectonics.toml: {w}.islands must be true or false") _num(p.get("vent", 1.0), f"{w}.vent", 0.0, 1.0) for i, a in enumerate(plateaus): for b in plateaus[i + 1:]: d = float(gc_dist_km(latlon_to_xyz(*a["center"]), latlon_to_xyz(*b["center"]), radius_km)) if d < PLATEAU_SPACING_KM: raise ConfigError(f"tectonics.toml: plateaus {a['name']} and {b['name']} are {d:.0f} km apart " f"(need ≥ {PLATEAU_SPACING_KM:.0f} km)") _named(t.get("land_patch", []), "land_patch") for p in t.get("land_patch", []): w = f"land_patch {p['name']}" _latlon(p.get("center"), f"{w}.center") _num(p.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0) _num(p.get("strength", 1.0), f"{w}.strength", 0.0, 2.0) _num(p.get("edge_noise", 0.0), f"{w}.edge_noise", 0.0, 1.0) _named(t.get("zone", []), "zone") for z in t.get("zone", []): w = f"zone {z['name']}" if z.get("field") not in ("o2", "gravity"): raise ConfigError(f"tectonics.toml: {w}.field must be o2|gravity, got {z.get('field')!r}") _latlon(z.get("center"), f"{w}.center") _num(z.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0) _num(z.get("v"), f"{w}.v", -1.0, 1.0) if z.get("profile", "smooth") != "smooth": raise ConfigError(f"tectonics.toml: {w}.profile must be smooth") names = _named(t.get("event", []), "event", ERAS_FILE) for e in t.get("event", []): _event(e) eras = t.get("eras") if eras is None: return order = eras.get("order") if not (isinstance(order, list) and order and all(isinstance(n, str) for n in order) and len(set(order)) == len(order)) or RESERVED_ERA_KEYS & set(order): raise ConfigError(f"{ERAS_FILE}: [eras].order must list distinct era names (not order/default), got {order!r}") if eras.get("default") not in order: raise ConfigError(f"{ERAS_FILE}: [eras].default {eras.get('default')!r} is not in order {order}") extra = set(eras) - set(order) - RESERVED_ERA_KEYS if extra: raise ConfigError(f"{ERAS_FILE}: [eras] tables {sorted(extra)} are not in order {order}") for n in order: e = eras.get(n) if not isinstance(e, dict) or not isinstance(e.get("label", n), str): raise ConfigError(f"{ERAS_FILE}: missing [eras.{n}] (label, events)") evs = e.get("events", []) if not isinstance(evs, list) or not all(isinstance(x, str) for x in evs): raise ConfigError(f"{ERAS_FILE}: [eras.{n}].events must be a list of event names") if "years" in e: _num(e["years"], f"[eras.{n}].years (since the era before it)", 0.0, 1.0e9, ERAS_FILE) unknown = [x for x in evs if x not in names] if unknown: raise ConfigError(f"{ERAS_FILE}: era {n} names unknown events {unknown}") def era_events(tect: dict, name: str) -> list: """The events of era `name`: those of every era before it in [eras].order, then its own (cumulative).""" eras = tect.get("eras") or {} order = eras.get("order", []) if name not in order: raise ConfigError(f"{ERAS_FILE}: unknown era {name!r} (eras: {order})") by_name = {e["name"]: e for e in tect.get("event", [])} out = [] for n in order[: order.index(name) + 1]: out += [by_name[e] for e in eras.get(n, {}).get("events", [])] return out def load(root: Path) -> tuple[dict, dict]: wpath = root / "config" / "world.toml" tpath = root / "config" / "tectonics.toml" for p in (wpath, tpath): if not p.exists(): raise ConfigError(f"missing {p}") with open(wpath, "rb") as f: cfg = tomllib.load(f) with open(tpath, "rb") as f: tect = tomllib.load(f) for k in ("event", "eras"): if k in tect: raise ConfigError(f"tectonics.toml: [{k}] belongs in config/{ERAS_FILE} (editing an era must not rebuild " f"the base world)") epath = root / "config" / ERAS_FILE if epath.exists(): with open(epath, "rb") as f: e = tomllib.load(f) unknown = set(e) - {"event", "eras"} if unknown: raise ConfigError(f"{ERAS_FILE}: unknown tables {sorted(unknown)} (only [[event]] and [eras])") tect = {**tect, **e} _check_ranges(cfg, REQUIRED, "world.toml") validate_tectonics(tect) validate_revision(tect, float(cfg["planet"]["radius_km"])) return cfg, tect def params(cfg: dict, section: str, defaults: dict) -> dict: """Module defaults overridden by world.toml [section]; unknown keys are errors (typo guard).""" over = cfg.get(section, {}) unknown = set(over) - set(defaults) if unknown: raise ConfigError(f"world.toml [{section}]: unknown keys {sorted(unknown)}") return {**defaults, **over}