raw · 12456 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 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} |