diff options
| author | godosa <godosa@godosa.eu> | 2026-10-06 23:52:03 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-06 23:52:03 +0200 |
| commit | 346b1c5195bffc71ceaa9262453e3c189656400b (patch) | |
| tree | 01ac0d31e2724cd6abcc689a5a228e2cbea2f6cf /mapgen/config.py | |
| download | worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.tar.gz worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.zip | |
worldgen: initial public history
Diffstat (limited to 'mapgen/config.py')
| -rw-r--r-- | mapgen/config.py | 269 |
1 files changed, 269 insertions, 0 deletions
diff --git a/mapgen/config.py b/mapgen/config.py new file mode 100644 index 0000000..a52986a --- /dev/null +++ b/mapgen/config.py @@ -0,0 +1,269 @@ +"""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} |
