worldgen

git clone https://git.godosa.eu/worldgen

master

raw · 12456 bytes

"""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}