aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/mapgen/config.py
blob: a52986a33c17bec6c0c57863a2cc64fc4b984af8 (plain)
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}