aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/mapgen/config.py
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:52:03 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:52:03 +0200
commit346b1c5195bffc71ceaa9262453e3c189656400b (patch)
tree01ac0d31e2724cd6abcc689a5a228e2cbea2f6cf /mapgen/config.py
downloadworldgen-346b1c5195bffc71ceaa9262453e3c189656400b.tar.gz
worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.zip
worldgen: initial public history
Diffstat (limited to 'mapgen/config.py')
-rw-r--r--mapgen/config.py269
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}