aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/mapgen/eras.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/eras.py
downloadworldgen-346b1c5195bffc71ceaa9262453e3c189656400b.tar.gz
worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.zip
worldgen: initial public history
Diffstat (limited to 'mapgen/eras.py')
-rw-r--r--mapgen/eras.py219
1 files changed, 219 insertions, 0 deletions
diff --git a/mapgen/eras.py b/mapgen/eras.py
new file mode 100644
index 0000000..9b0dbc4
--- /dev/null
+++ b/mapgen/eras.py
@@ -0,0 +1,219 @@
+"""Eras: the base world plus local events. build_era applies an era's events
+to the built base, re-runs the stages they affect inside an influence mask (changed cells + mask_km) and keeps every
+cell outside it exactly as the base. Results: out/r<res>/eras/<era>/ (as out/r<res>/), previews in
+previews/r<res>/eras/<era>/. An era's cache key is the base world's inputs key plus its events (config/eras.toml). Each era's events happen at the end of the era before it; its `years` erode what the events so far
+changed; zone events follow the land at the start of their own era."""
+from __future__ import annotations
+
+import hashlib
+import importlib
+import json
+import re
+import shutil
+from pathlib import Path
+
+import numpy as np
+
+from . import config as C, environment as EN, events as EV, fields as FL, pipeline as P
+from .config import params
+from .erosion import DEFAULTS as EROSION_DEFAULTS, K_MULT, erode
+from .graph import distance_to, ocean_mask
+
+DEFAULTS = {"mask_km": 1500.0, "climate_blend_km": 300.0, "erosion_steps": 1, "years": 10000.0}
+RERUN = ("climate", "hydrology", "seabed", "environment", "ice", "fields")
+
+
+def era_dir(root: Path, res: int, name: str) -> Path:
+ return Path(root) / "out" / f"r{res}" / "eras" / name
+
+
+def steps(tect: dict, name: str) -> list:
+ """[(era, its own events, years)] for every era up to and including `name` ([eras] order): an era's events happen
+ at the end of the era before it; `years` is the time from them to the era's map (None: the default)."""
+ C.era_events(tect, name) # an unknown era: ConfigError
+ eras = tect.get("eras") or {}
+ order = eras["order"]
+ by_name = {e["name"]: e for e in tect.get("event", [])}
+ return [(n, [by_name[e] for e in eras.get(n, {}).get("events", [])], eras.get(n, {}).get("years"))
+ for n in order[: order.index(name) + 1]]
+
+
+def fingerprint(base_key: str, steps_: list) -> str:
+ content = [[own, yrs] for _, own, yrs in steps_]
+ return hashlib.sha256((base_key + json.dumps(content, sort_keys=True)).encode()).hexdigest()[:16]
+
+
+def _same(a, b) -> bool:
+ """Equal arrays; NaN equals NaN in float arrays (a field may hold NaN where it has no value)."""
+ a, b = np.asarray(a), np.asarray(b)
+ return np.array_equal(a, b, equal_nan=a.dtype.kind in "fc" and b.dtype.kind in "fc")
+
+
+def merge_lake_ids(base, new, mask):
+ """Lake ids of an era: the base's outside the mask; inside it a lake the era left as it was keeps its base id and
+ every other lake gets a fresh id after the base's, so one id never names two lakes."""
+ base, new, mask = np.asarray(base), np.asarray(new), np.asarray(mask, bool)
+ out = base.copy()
+ nxt = max(int(base.max()) + 1, 0) if len(base) else 0
+ counts = np.bincount(base[base >= 0]) if (base >= 0).any() else np.zeros(0, np.int64)
+ ks = np.unique(new[mask & (new >= 0)])
+ idx = np.flatnonzero(np.isin(new, ks))
+ idx = idx[np.argsort(new[idx], kind="stable")]
+ starts = np.searchsorted(new[idx], ks)
+ ends = np.append(starts[1:], len(idx))
+ for s, e in zip(starts.tolist(), ends.tolist()):
+ cells = idx[s:e]
+ b = base[cells]
+ kept = b[0] >= 0 and bool(np.all(b == b[0])) and counts[b[0]] == len(cells)
+ into = cells[mask[cells]]
+ if kept:
+ out[into] = b[0]
+ else:
+ out[into] = nxt
+ nxt += 1
+ out[mask & (new < 0)] = -1
+ return out.astype(base.dtype)
+
+
+def zone_key(name: str) -> str:
+ return "zone_" + re.sub(r"[^A-Za-z0-9]", "_", name)
+
+
+def _heights(g, z, events, min_sea, log, name, uncut=lambda z: z):
+ for ev in events: # heights first, in era order
+ if ev["kind"] == "disintegrate":
+ z, z_ref = EV.disintegrate(g.xyz, z, ~ocean_mask(g, uncut(z), min_sea), ev, g.radius_km)
+ if z_ref is None:
+ log(f"era {name}: warning: {ev['name']} has no land in its rim ring (0.9–1.0 × radius): "
+ f"its bowl hangs from 0 m")
+ else:
+ log(f"era {name}: {ev['name']} (ground at its rim {z_ref:.0f} m)")
+ elif ev["kind"] == "volcano":
+ z = EV.volcano(g.xyz, z, ev, g.radius_km)
+ return z
+
+
+def build_era(root: Path, res: int, name: str, log=print, base=None, low_memory: bool = False) -> Path:
+ root = Path(root)
+ cfg, tect = C.load(root)
+ events = C.era_events(tect, name)
+ if not events:
+ log(f"era {name}: the base world (out/r{res})")
+ return root / "out" / f"r{res}"
+ out = era_dir(root, res, name)
+ base_key = P.inputs_key(root, res)
+ plan = steps(tect, name)
+ key = fingerprint(base_key, plan)
+ label = tect["eras"][name].get("label", name)
+ meta_path = out / "cells_meta.json"
+ if meta_path.exists() and (out / "cells.npz").exists():
+ meta = json.loads(meta_path.read_text())
+ era = meta.get("era", {})
+ if era.get("fingerprint") == key:
+ if (era.get("label"), era.get("name")) != (label, name): # a new label or name needs no rebuild
+ era["label"], era["name"] = label, name
+ meta_path.write_text(json.dumps(meta, indent=1))
+ log(f"era {name}: cached")
+ return out
+ ctx = base if base is not None else P.build(root, res, stop="fields", log=lambda m: None, low_memory=low_memory)
+ low_memory = low_memory or ctx.low_memory
+ g, d = ctx.grid, ctx.data
+ E = params(ctx.cfg, "eras", DEFAULTS)
+ EP = {**params(ctx.cfg, "erosion", EROSION_DEFAULTS), "steps": E["erosion_steps"]}
+ kmult = np.vectorize(K_MULT.get)(np.asarray(d["age_class"])).astype(np.float64)
+ z0 = np.asarray(d["elevation_eroded_m"], dtype=np.float64)
+ z, ocean = z0.copy(), np.asarray(d["ocean"])
+ water = np.asarray(d.get("open_water", ocean))
+ cut = np.asarray(d.get("lake_cut_m", np.zeros(g.n)), dtype=np.float64)
+ uncut = lambda zz: np.where(zz == z0, zz + cut, zz) # sea masks: the ground before lake beds were carved
+ zones, lands = [], []
+ for _, own, yrs in plan: # era by era: its events at the end of the era before
+ for ev in own: # zones follow the land before their own era's events
+ if ev["kind"] == "zone":
+ zones.append(ev)
+ lands.append(EV.landmass(g, ~water, ev["seed"], ev["name"]) if ev["shape"] == "landmass" else None)
+ z = _heights(g, z, own, EP["min_sea_km2"], log, name, uncut)
+ hit = z != z0
+ if hit.any(): # the changed ground weathers for the era's years
+ years = float(E["years"] if yrs is None else yrs)
+ ze = erode(g, z, kmult, {**EP, "dt_myr": years / 1.0e6 / E["erosion_steps"]})
+ z = np.where(hit, np.maximum(np.minimum(z, ze), -11000.0), z)
+ ocean, water = ocean_mask(g, uncut(z), np.inf), ocean_mask(g, uncut(z), EP["min_sea_km2"])
+ hit = z != z0
+ weights = [EV.zone_weight(g.xyz, ev, g.radius_km, ctx.seed, None if land is None else g.xyz[land])
+ for ev, land in zip(zones, lands)]
+ changed = hit | (ocean != np.asarray(d["ocean"])) | (water != np.asarray(d.get("open_water", d["ocean"])))
+ for w in weights:
+ changed |= w > 0
+ mask = distance_to(g, changed) <= E["mask_km"] if changed.any() else np.zeros(g.n, bool)
+
+ ec = P.Ctx(ctx.root, ctx.cfg, ctx.tect, ctx.res, dict(d), g, low_memory=low_memory)
+ ec.data.update({"elevation_eroded_m": z.astype(np.asarray(d["elevation_eroded_m"]).dtype), "ocean": ocean,
+ "open_water": water, "lake_carve": hit}) # lake beds: geology, carved where the ground moved
+ new_keys = {}
+ for s in RERUN:
+ o = importlib.import_module(f"mapgen.{s}").run(ec)
+ ec.data.update(o)
+ new_keys[s] = list(o)
+ blend = np.clip(distance_to(g, ~mask) / E["climate_blend_km"], 0.0, 1.0) if (~mask).any() else np.ones(g.n)
+ shape = lambda a, v: v.reshape(-1, *([1] * (a.ndim - 1)))
+ merged = dict(d)
+ for s, keys in new_keys.items():
+ for k in keys:
+ nv, bv = np.asarray(ec.data[k]), np.asarray(d[k])
+ if s == "climate" and nv.dtype.kind == "f": # solved on the whole world, blended in over 300 km
+ nv = (bv + shape(nv, blend) * (nv - bv)).astype(nv.dtype)
+ merged[k] = np.where(shape(nv, mask), nv, bv)
+ for k in ("deposits", "deposit_main", "iron_potential"): # ore is the base's geology: events move ground, the
+ if k in d: # rank-by-share placement would shift it world-wide
+ merged[k] = d[k]
+ if "lake_id" in merged: # one id never names two lakes
+ merged["lake_id"] = merge_lake_ids(d["lake_id"], ec.data["lake_id"], mask)
+ merged["elevation_eroded_m"] = np.where(mask, ec.data["elevation_eroded_m"], d["elevation_eroded_m"]) # events and
+ merged["ocean"] = ocean # lake beds: inside only
+ if "open_water" in d:
+ merged["open_water"] = water
+ H = float(ctx.cfg["planet"]["scale_height_km"]) * 1000.0
+ for ev, w, land in zip(zones, weights, lands): # zone events write their fields last
+ merged.update(EV.apply_zone(merged, w, ev, merged["z_surface_m"], H))
+ merged[zone_key(ev["name"])] = w.astype(np.float32)
+ if land is not None:
+ merged[zone_key(ev["name"]) + "_land"] = land
+ if zones: # plants settle into the zone's air and gravity
+ pl = ctx.cfg["planet"]
+ merged["plant_height_x"] = FL.plant_height(pl["gravity_g"], merged["gravity_g"]).astype(
+ np.asarray(d["plant_height_x"]).dtype)
+ sea_p = np.asarray(merged["pressure_bar"], dtype=np.float64) / FL.pressure(1.0, merged["z_surface_m"], H)
+ wet = np.sqrt(sea_p / pl["sea_level_pressure_bar"]) # more CO₂ per breath: less water lost per growth
+ hz, hr = EN.holdridge(merged["biotemp"], np.asarray(merged["P_ann"], dtype=np.float64) * wet, merged["T_min"])
+ inside = np.logical_or.reduce([w > 0 for w in weights])
+ merged["holdridge"] = np.where(inside, hz, merged["holdridge"]).astype(np.asarray(d["holdridge"]).dtype)
+ merged["hold_region"] = np.where(inside, hr, merged["hold_region"]).astype(np.asarray(d["hold_region"]).dtype)
+ merged["era_mask"] = mask
+ for k, a in d.items(): # guard: nothing outside the mask may differ
+ a, b = np.asarray(a), np.asarray(merged[k])
+ if a.shape[:1] == (g.n,) and not _same(a[~mask], b[~mask]):
+ raise RuntimeError(f"era {name}: {k} changed outside the influence mask")
+
+ rc = P.Ctx(ctx.root, ctx.cfg, ctx.tect, ctx.res, merged, g, out_dir=out,
+ preview_dir=root / "previews" / f"r{res}" / "eras" / name, low_memory=low_memory)
+ importlib.import_module("mapgen.render").run(rc)
+ meta = json.loads(meta_path.read_text())
+ meta["era"] = {"name": name, "label": label, "events": events, "fingerprint": key, "base_key": base_key,
+ "steps": [[n, [e["name"] for e in own], yrs] for n, own, yrs in plan],
+ "mask_km": E["mask_km"], "mask_cells": int(mask.sum())}
+ meta_path.write_text(json.dumps(meta, indent=1))
+ log(f"era {name}: {int(mask.sum())} of {g.n} cells inside the influence mask")
+ return out
+
+
+def build_all(root: Path, res: int, log=print, base=None, low_memory: bool = False) -> list:
+ """Every configured era with events (the base era is the base build); era folders no longer configured go."""
+ _, tect = C.load(Path(root))
+ names = [n for n in (tect.get("eras") or {}).get("order", []) if C.era_events(tect, n)]
+ built = [build_era(root, res, n, log, base, low_memory) for n in names]
+ top = Path(root) / "out" / f"r{res}" / "eras"
+ for p in (top.iterdir() if top.is_dir() else []):
+ if p.is_dir() and p.name not in names:
+ shutil.rmtree(p, ignore_errors=True)
+ return built