From 3443c1c65e9f1753e1e656b35d08416c1fa298f2 Mon Sep 17 00:00:00 2001 From: godosa Date: Wed, 7 Oct 2026 00:14:38 +0200 Subject: worldmap-viewer: initial public history --- serve.py | 1389 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1389 insertions(+) create mode 100755 serve.py (limited to 'serve.py') diff --git a/serve.py b/serve.py new file mode 100755 index 0000000..f2eca72 --- /dev/null +++ b/serve.py @@ -0,0 +1,1389 @@ +#!/usr/bin/env python3 +"""World map server: the viewer + a JSON API over a finished map build. Binds 127.0.0.1 only. + + python serve.py --world WORLD_DIR [--world OTHER_DIR …] [--res N] [--port 8765] [--lore NOTES_DIR] + +Several --world: one server, each world at /w// (the viewer's world menu switches). +""" +from __future__ import annotations + +import argparse +import errno +import gzip +import hashlib +import os +import secrets +import shutil +import tempfile +import signal +import threading +import types +from datetime import date +import json +import mimetypes +import re +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse + +os.environ.setdefault("OPENBLAS_THREAD_TIMEOUT", "4") # idle BLAS threads: sleep soon, don't spin (same results) +import numpy as np # noqa: E402 + +HERE = Path(__file__).resolve().parent +VIEWER = HERE / "viewer" +sys.path.insert(0, str(HERE)) + +import worldgen_path # noqa: E402,F401 (mapgen on sys.path) +import servecache +from tilecap import TileCap # noqa: E402 +import tiles # noqa: E402 +from mapgen import render as RN # noqa: E402 + +LOAD_KEYS = ["g_ids", "z_surface_m", "ocean", "landform", "lithology", "T_mean", "T_range", "T_jun", "T_dec", + "P_ann", "P_jun", "P_dec", "holdridge", "seasonality", "ground", "ice", "discharge_km3_yr", "lake", + "endorheic", "dist_ocean_km", "plate", "age_class", "po2_bar", "gravity_g", "g_xyz", + "recv", "river", "z_filled_m", "seabed_type", "seabed_mineral", "bottom_temp_c", "sediment_m", + "vent_potential", "pressure_bar", "o2_fraction", "fire_reactivity", "plant_height_x", "deposits", + "deposit_main", "lake_level_m", "sst", "current_speed", "productivity"] +OVERLAYS = [("coast", "Coastline", "coast.geojson"), ("lakes", "Lakes", "lakes.geojson"), + ("rivers", "Rivers", "rivers.geojson"), ("plates", "Plate boundaries", "plate_boundaries.geojson")] +MAX_BODY = 64 * 1024 +PROFILE_POINTS = (2, 100) +PROFILE_N = (16, 1024) +FIELDS = [ # key, label, unit, group, kind ("num" | "bool" | "cat:" | "bits:" | "derived") + ("z_surface_m", "Elevation", "m", "Terrain", "num"), + ("ocean", "Sea", "", "Terrain", "bool"), + ("landform", "Landform", "", "Terrain", "cat:landform"), + ("lithology", "Rock", "", "Terrain", "cat:lithology"), + ("T_mean", "Mean temperature", "°C", "Climate", "num"), + ("T_summer", "Summer temperature", "°C", "Climate", "derived"), + ("T_winter", "Winter temperature", "°C", "Climate", "derived"), + ("T_range", "Seasonal range", "°C", "Climate", "num"), + ("P_ann", "Rainfall, year", "mm/yr", "Climate", "num"), + ("P_jun", "Rainfall, June season", "mm/yr", "Climate", "num"), + ("P_dec", "Rainfall, December season", "mm/yr", "Climate", "num"), + ("holdridge", "Life zone (Holdridge)", "", "Environment", "cat:holdridge"), + ("seasonality", "Seasonality", "", "Environment", "cat:seasonality"), + ("ground", "Ground", "", "Environment", "cat:ground"), + ("ice", "Ice", "", "Environment", "cat:ice"), + ("deposits", "Mineral deposits", "", "Environment", "bits:deposits"), + ("discharge_km3_yr", "River flow", "km³/yr", "Water", "num"), + ("lake", "Lake", "", "Water", "bool"), + ("endorheic", "Endorheic basin", "", "Water", "bool"), + ("dist_ocean_km", "Distance to coast", "km", "Water", "num"), + ("plate", "Plate", "", "Tectonics", "cat:plates"), + ("age_class", "Crust", "", "Tectonics", "cat:age_class"), + ("seabed_type", "Sea-floor type", "", "Sea floor", "cat:seabed_type"), + ("seabed_mineral", "Sea-floor minerals", "", "Sea floor", "cat:seabed_mineral"), + ("bottom_temp_c", "Bottom water temperature", "°C", "Sea floor", "num"), + ("sediment_m", "Sediment", "m", "Sea floor", "num"), + ("vent_potential", "Vent potential", "0–1", "Sea floor", "num"), + ("vent", "Vent field strength", "0–1", "Sea floor", "num"), + ("canyon_m", "Canyon depth", "m", "Sea floor", "num"), + ("sst", "Sea-surface temperature", "°C", "Sea", "num"), + ("current_speed", "Current speed", "m/s", "Sea", "num"), + ("productivity", "Sea productivity", "0–1", "Sea", "num"), + ("po2_bar", "O₂ partial pressure", "bar", "World", "num"), + ("gravity_g", "Gravity", "g", "World", "num"), + ("o2_fraction", "O₂ fraction", "", "World", "num"), + ("pressure_bar", "Air pressure", "bar", "World", "num"), + ("fire_reactivity", "Fire reactivity", "×", "World", "num"), + ("plant_height_x", "Plant height", "×", "World", "num"), +] + + +class MissingBuild(SystemExit): + pass + + +def world_cache_key(out: Path) -> str: + """Names the world's serve cache: the arrays it holds (LOAD_KEYS, float32) and this build of cells.npz.""" + st = (Path(out) / "cells.npz").stat() + h = hashlib.sha1(f"{','.join(LOAD_KEYS)}:f32:{st.st_size}:{st.st_mtime_ns}".encode()) + return h.hexdigest()[:10] + + +def load_worlds(root: Path, res: int, lore_dir: Path | None = None, log=print, only=None) -> list: + """(era, World) of every world the viewer can show: the base build first, then each built era (only: just that + one; none when it is not built).""" + import refine + from mapgen import config as C + if not (Path(root) / "out" / f"r{res}" / "cells.npz").exists(): + return [("base", World(root, res, lore_dir))] # raises MissingBuild (names what is missing) + eras = (C.load(root)[1].get("eras") or {}) + out = [] + for name, d in refine.world_dirs(root, res, log): + if only is not None and name != only: + continue + label = (eras.get(name) or {}).get("label", name if name != "base" else "Base") + out.append((name, World(root, res, lore_dir, out=d, era={"name": name, "label": label}))) + return out + + +class World: + def __init__(self, data_root: Path, res: int, lore_dir: Path | None = None, use_cache: bool = True, + out: Path | None = None, era: dict | None = None): + out = Path(out) if out is not None else data_root / "out" / f"r{res}" # an era: out/r/eras/ + need = [out / "cells.npz", out / "cells_meta.json", out / "viewer" / "layers.json"] + missing = [str(p) for p in need if not p.exists()] + if missing: + raise MissingBuild(f"missing build outputs {missing}: run `mapgen.py build` " + f"({'--final' if res >= 5 else f'--res {res}'}) first") + self.res, self.out = res, out + self.era = era or {"name": "base", "label": "Base"} + self.cells_meta = json.loads((out / "cells_meta.json").read_text()) + if not {"name", "planet"} <= self.cells_meta.keys() and (data_root / "config" / "world.toml").exists(): + import tomllib # older builds: name and planet from the config + cfg = tomllib.loads((data_root / "config" / "world.toml").read_text()) + if cfg.get("render", {}).get("name"): + self.cells_meta.setdefault("name", cfg["render"]["name"]) + self.cells_meta.setdefault("planet", cfg.get("planet", {})) + style = self.cells_meta.get("style") + if style is not None and style not in RN.STYLES: # a build from before a palette was renamed: every + raise MissingBuild(f"{out} was built with render style {style!r}, which this worldgen no longer has " # relief tile + f"(styles: {sorted(RN.STYLES)}): rebuild it (`mapgen.py build --res {res}`)") # would fail + self.layers = json.loads((out / "viewer" / "layers.json").read_text()) + self.legends = self.cells_meta["legends"] + self.units = dict(self.cells_meta.get("units", {})) + spill = (Path(tempfile.mkdtemp(prefix=".spill-", dir=out)) + if use_cache and servecache.low_memory() else None) # fields on disk while the cache is written + + def build(): + keep = servecache.spiller(spill) + with np.load(out / "cells.npz") as z: + return {k: keep(k, z[k].astype(np.float32) if z[k].dtype == np.float64 else z[k]) + for k in LOAD_KEYS if k in z.files}, {} + self.cache = servecache.cache_dir(out, world_cache_key(out)) if use_cache else None + try: + self.arrays = servecache.load_or_build(self.cache, build)[0] if use_cache else build()[0] + finally: + if spill is not None: + shutil.rmtree(spill, ignore_errors=True) + self.ids = self.arrays["g_ids"] + self.lore_ids = sorted({m.group(1) for p in (Path(lore_dir).glob("**/*.md") if lore_dir else ()) + for m in [re.search(r"^id:\s*(\S+)", p.read_text(), re.M)] if m}) + + def index_of(self, lat: float, lon: float) -> int: + import h3.api.basic_int as h3 + cell = np.uint64(h3.latlng_to_cell(lat, lon, self.res)) + i = int(np.searchsorted(self.ids, cell)) + if i >= len(self.ids) or self.ids[i] != cell: # never hand back a neighbour's data + raise LookupError(f"no cell at {lat:.4f}, {lon:.4f}") + return i + + def meta(self) -> dict: + planet = {"radius_km": self.cells_meta["radius_km"], "day_hours": 24.0, "tilt_deg": 23.44, "year_days": 365, + **self.cells_meta.get("planet", {})} + return {"res": self.res, "radius_km": self.cells_meta["radius_km"], "name": self.cells_meta.get("name", "World"), + "planet": planet, "units": {**self.units, "day_hours": planet["day_hours"]}, + "layers": [{**L, "file": f"/data/viewer/{L['file']}"} for L in self.layers], + "overlays": [{"id": i, "name": n, "file": f"/data/geo/{f}"} for i, n, f in OVERLAYS + if (self.out / "geo" / f).exists()], + "lore_ids": self.lore_ids} + + def _alt(self, key, v): + u = self.units + if not {"span_m", "league_km", "moment_s"} <= u.keys(): # no in-world units in the config: SI only + return None + if key == "z_surface_m": + return f"{v / u['span_m']:,.0f} spans" + if key == "dist_ocean_km": + return f"{v / u['league_km']:,.1f} leagues" + if key == "gravity_g": + return f"{v * 9.80665 / u['span_m'] * u['moment_s'] ** 2:,.0f} spans/moment²" + return None + + def describe(self, lat: float, lon: float) -> dict: + import h3.api.basic_int as h3 + rs = getattr(self, "regions", None) + wt = float(rs.weight(np.array([latlon_xyz(lat, lon)]))[0]) if rs is not None and not rs.empty else 0.0 + j = rs.index_of(lat, lon) if wt > 0.5 else None # where a refined area shows, its fine cell answers + a, i = (rs.arrays, j) if j is not None else (self.arrays, self.index_of(lat, lon)) + north = lat >= 0 + derived = {"T_summer": a["T_jun"][i] if north else a["T_dec"][i], + "T_winter": a["T_dec"][i] if north else a["T_jun"][i]} + fields = [] + sea = bool(a["ocean"][i]) + for key, label, unit, group, kind in FIELDS: + if (kind != "derived" and key not in a) or (group in ("Sea floor", "Sea") and not sea): + continue + raw = derived[key] if kind == "derived" else a[key][i] + f = {"key": key, "label": label, "group": group, "unit": unit} + if kind == "bool": + f["value"] = bool(raw) + f["text"] = "yes" if raw else "no" + elif kind.startswith("bits:"): # legend index i + 1 = bit i + names = self.legends.get(kind[5:], []) + f["value"] = int(raw) + f["text"] = ", ".join(names[b + 1] for b in range(len(names) - 1) if int(raw) >> b & 1) or "none" + elif kind.startswith("cat:"): + names = self.legends.get(kind[4:], []) + f["value"] = int(raw) + f["text"] = names[int(raw)] if 0 <= int(raw) < len(names) else str(int(raw)) + else: + f["value"] = round(float(raw), 4) + alt = self._alt(key, float(raw)) + if alt: + f["alt"] = alt + fields.append(f) + cell = int(a["g_ids"][i]) + return {"cell": h3.int_to_str(cell), "lat": lat, "lon": lon, "center": list(h3.cell_to_latlng(cell)), + "inner_km": self._inner_km(cell, rs), # clients may reuse this answer anywhere this close to the centre + "refined": j is not None, "fields": fields, "era": self.era} + + def _inner_km(self, cell, rs=None) -> float: + """0.8 × the distance from the cell centre to its nearest edge (great circles through the boundary); 0 where + a refined area's edge band may lie within that (the answer changes there).""" + import h3.api.basic_int as h3 + c = np.array(latlon_xyz(*h3.cell_to_latlng(cell))) + b = [np.array(latlon_xyz(*v)) for v in h3.cell_to_boundary(cell)] + near = min(np.arcsin(abs(c @ (n / np.linalg.norm(n)))) for n in (np.cross(b[k], b[(k + 1) % len(b)]) for k in range(len(b)))) + r = float(0.8 * near * self.cells_meta["radius_km"]) + if rs is not None and not rs.empty and rs.edge_km(np.array([c]))[0] < r + 2 * tiles.refine.BLEND_KM + rs.spacing_km: + return 0.0 + return r + + +LORE_ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +class PinStore: + def __init__(self, path: Path, res: int): + """res: H3 resolution of each pin's `cell` — always the final map's, whatever the server runs.""" + self.path, self.res, self.lock = path, res, threading.Lock() + + def _load(self) -> list: + return json.loads(self.path.read_text())["pins"] if self.path.exists() else [] + + def _save(self, pins: list) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".pins-", suffix=".json") + try: + os.fchmod(fd, 0o644) # mkstemp makes 0600; pins.json is a normal repo file + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({"pins": pins}, f, indent=1, ensure_ascii=False) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, self.path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + def validate(self, data) -> dict: + if not isinstance(data, dict): + raise ValueError("body must be a JSON object") + name = data.get("name", "") + if not isinstance(name, str) or not 1 <= len(name.strip()) <= 120: + raise ValueError("name must be 1–120 characters") + name = name.strip() + try: + lat, lon = float(data["lat"]), float(data["lon"]) + except (KeyError, TypeError, ValueError): + raise ValueError("lat and lon must be numbers") + if not (-90.0 <= lat <= 90.0) or not np.isfinite(lon): + raise ValueError("lat must be in [-90, 90]") + lore = data.get("lore") or None + if lore is not None and not (isinstance(lore, str) and LORE_ID.fullmatch(lore)): + raise ValueError("lore must be a kebab-case lore id") + note = data.get("note") or "" + if not isinstance(note, str) or len(note) > 2000: + raise ValueError("note must be ≤ 2000 characters") + epoch = data.get("epoch") or None # the epoch a place belongs to (a note id), for epoch layers + if epoch is not None and not (isinstance(epoch, str) and LORE_ID.fullmatch(epoch)): + raise ValueError("epoch must be a kebab-case lore id") + lon = lon if -180.0 <= lon < 180.0 else (lon + 180.0) % 360.0 - 180.0 # wrap only when needed: keep typed values exact + return {"name": name, "lat": lat, "lon": lon, "lore": lore, "note": note, "epoch": epoch} + + def _cell(self, lat, lon) -> str: + import h3.api.basic_int as h3 + return h3.int_to_str(h3.latlng_to_cell(lat, lon, self.res)) + + def list(self) -> list: + with self.lock: + return self._load() + + def create(self, data) -> dict: + pin = self.validate(data) + pin.update(id=f"pin-{secrets.token_hex(4)}", cell=self._cell(pin["lat"], pin["lon"]), + created=date.today().isoformat()) + with self.lock: + pins = self._load() + pins.append(pin) + self._save(pins) + return pin + + def update(self, pid: str, data) -> dict | None: + if not isinstance(data, dict): + raise ValueError("body must be a JSON object") + with self.lock: + pins = self._load() + for k, p in enumerate(pins): + if p["id"] == pid: + merged = {**p, **self.validate({**p, **data})} + merged["cell"] = self._cell(merged["lat"], merged["lon"]) + pins[k] = merged + self._save(pins) + return merged + return None + + def delete(self, pid: str) -> bool: + with self.lock: + pins = self._load() + keep = [p for p in pins if p["id"] != pid] + if len(keep) == len(pins): + return False + self._save(keep) + return True + + +class RegionsFileError(ValueError): + """places/regions.json exists but cannot be read (e.g. a hand edit went wrong): reported, never overwritten.""" + + +class RegionStore: + """places/regions.json: outlines to refine .""" + MAX_KM2 = 2.0e6 # above this the viewer asks first (slow, memory-hungry builds) + HARD_KM2 = 10 * MAX_KM2 # refused: a build this big could run the server out of memory + + def __init__(self, path: Path): + self.path, self.lock = path, threading.Lock() + + def _load(self) -> list: + if not self.path.exists(): + return [] + try: + regions = json.loads(self.path.read_text())["regions"] + if not isinstance(regions, list) or not all(isinstance(r, dict) and "outline" in r for r in regions): + raise TypeError("regions must be a list of {id, name, outline}") + return regions + except (ValueError, KeyError, TypeError) as e: + raise RegionsFileError(f"{self.path.name} cannot be read ({e}); fix or remove it") from None + + def _save(self, regions: list) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".regions-", suffix=".json") + try: + os.fchmod(fd, 0o644) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({"regions": regions}, f, indent=1, ensure_ascii=False) + f.write("\n") + os.replace(tmp, self.path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + @staticmethod + def _crosses(pts) -> bool: + def ccw(a, b, c): + return (c[1] - a[1]) * (b[0] - a[0]) - (b[1] - a[1]) * (c[0] - a[0]) + n = len(pts) + seg = [(pts[k], pts[(k + 1) % n]) for k in range(n)] + for i in range(n): + for j in range(i + 1, n): + if abs(i - j) in (1, n - 1): + continue # neighbours share a point + (a, b), (c, d) = seg[i], seg[j] + if ccw(a, b, c) * ccw(a, b, d) < 0 and ccw(c, d, a) * ccw(c, d, b) < 0: + return True + return False + + def validate(self, data) -> dict: + if not isinstance(data, dict): + raise ValueError("body must be a JSON object") + name = data.get("name", "") + if not isinstance(name, str) or not 1 <= len(name.strip()) <= 80: + raise ValueError("name must be 1–80 characters") + out = data.get("outline") + if not isinstance(out, list) or len(out) < 3 or len(out) > 500: + raise ValueError("outline needs 3–500 [lat, lon] points") + try: + pts = [(float(p[0]), float(p[1])) for p in out] + except (TypeError, ValueError, IndexError, KeyError): + raise ValueError("outline points must be [lat, lon] numbers") + if not all(np.isfinite(la) and np.isfinite(lo) and -90 <= la <= 90 for la, lo in pts): + raise ValueError("lat must be in [-90, 90] and lon finite") + lon0 = pts[0][1] + pts = [(la, lon0 + ((lo - lon0 + 180) % 360) - 180) for la, lo in pts] # unwrap across ±180° + if self._crosses([(lo, la) for la, lo in pts]): + raise ValueError("the outline crosses itself") + return {"name": name.strip(), "outline": [[round(la, 6), round(((lo + 180) % 360) - 180, 6)] for la, lo in pts]} + + @staticmethod + def area_km2(outline, radius_km: float) -> float: + import h3.api.basic_int as h3 + cells = h3.polygon_to_cells(h3.LatLngPoly([tuple(p) for p in outline]), 5) + return sum(h3.cell_area(c, unit="rads^2") for c in cells) * radius_km ** 2 + + def list(self) -> list: + with self.lock: + return self._load() + + def create(self, data, radius_km: float) -> dict: + r = self.validate(data) + km2 = self.area_km2(r["outline"], radius_km) + if km2 > self.HARD_KM2: + raise ValueError(f"region too large: ≈ {km2:,.0f} km² (limit {self.HARD_KM2:,.0f}); draw it in parts") + r.update(id=f"region-{secrets.token_hex(4)}", created=date.today().isoformat(), area_km2=round(km2)) + with self.lock: + regions = self._load() + regions.append(r) + self._save(regions) + return r + + def delete(self, rid: str) -> bool: + with self.lock: + regions = self._load() + keep = [r for r in regions if r["id"] != rid] + if len(keep) == len(regions): + return False + self._save(keep) + return True + + +PRERENDER_Z = 11 # region tiles are rendered and saved ahead down to z11 (≈ 20 km tiles) … +PRERENDER_MAX = 30000 # … as deep as fits this many tile files (a continent stops a few zooms earlier) + + +class RefineJobs: + """One background worker refining regions (coalescing): saves never wait for a build.""" + def __init__(self, server, root: Path, res: int): + self.server, self.root, self.res = server, root, res + self.lock = threading.Lock() + self.pending = False + self.running = False + self.gen = 0 # pre-render generation: a newer one stops older ones + self.st = {"state": "idle", "stage": "", "fraction": 0.0, "error": None, "finished": None, "tiles": None} + + def status(self) -> dict: + with self.lock: + return dict(self.st) + + def request(self) -> None: + with self.lock: + self.pending = True + if self.st["state"] != "building": + self.st.update(state="queued", stage="", fraction=0.0) + if self.running: + return + self.running = True + threading.Thread(target=self._run, daemon=True).start() + + def _progress(self, stage: str, f: float) -> None: + with self.lock: + self.st.update(state="building", stage=stage, fraction=round(float(f), 3)) + + def _run(self) -> None: + try: + self._loop() + finally: + with self.lock: # whatever happened, later saves start a new worker + self.running = False + + def _loop(self) -> None: + import refine + while True: + with self.lock: + if not self.pending: + return + self.pending = False + self.st.update(state="building", stage="starting", fraction=0.0, error=None) + try: + refine.build_areas(self.root, self.res, self.server.regions.path, log=lambda s: None, + regions_root=self.server.regions_dir, progress=self._progress) + tiles.reload_all(list(self.server.sources.values())) + for name, s in self.server.sources.items(): + self.server.worlds[name].regions = s.regions + with self.lock: + done = not self.pending # a save came in meanwhile: not ready yet + self.st.update(state="ready" if done else "queued", stage="", fraction=1.0 if done else 0.0, + finished=date.today().isoformat(), tiles=None) + if done: + self.prerender() + except (Exception, SystemExit) as e: # report (MissingBuild is a SystemExit); the map + with self.lock: # keeps what it had + self.st.update(state="failed", error=f"{type(e).__name__}: {e}") + + def prerender(self) -> None: + """Render and save the refined areas' tiles in the background (after a build, or at start); a newer build or + request stops it. Progress in status()["tiles"].""" + tiles_ = self.server.tiles + with self.lock: + self.gen += 1 + gen = self.gen + + def progress(done, total): + with self.lock: + if self.gen == gen: + self.st["tiles"] = {"done": done, "total": total} + + def stop(): + if self.server.socket.fileno() == -1: # the server was closed + return True + with self.lock: + return self.gen != gen or self.pending or self.st["state"] in ("queued", "building") + + def run(): + pool = tiles_.pool + try: + tiles_.prerender(self.server.prerender_z, progress=progress, stop=stop, max_tiles=PRERENDER_MAX, + threads=max(1, pool.alive - 2) if pool else 1) + except Exception as e: # noqa: BLE001 — the map still works without + with self.lock: + self.st["tiles_error"] = f"{type(e).__name__}: {e}" + threading.Thread(target=run, daemon=True).start() + + +def refined_flags(server, regions: list) -> list: + """The regions, each with "refined": the map shows the area the last build made of its (unchanged) outline.""" + import refine + rec = (refine.read_record(server.regions_dir, server.tiles.fingerprint) or {}).get("regions", {}) + names = set(server.tiles.regions.area_names) + return [{**r, "refined": bool((e := rec.get(refine.region_key(r))) and e["outline"] == refine.outline_hash(r) + and e["area"] in names)} for r in regions] + + +def regions_missing(server) -> bool: + """Whether the outlines changed since the last successful build, or any world's (era's) areas are missing (e.g. + after a world or era rebuild). Reads the builds' records: no area work (a continent's takes ≈ 20 s).""" + regions = server.regions.list() + return any(_regions_missing(server, src.fingerprint, regions) for src in server.sources.values()) + + +def _regions_missing(server, fingerprint: str, regions: list) -> bool: + import refine + have = {p.name for p in refine.built_areas(server.regions_dir, fingerprint)} + rec = refine.read_record(server.regions_dir, fingerprint) + if rec is None: + return bool(regions) or bool(have) + m = rec.get("regions", {}) + if {refine.region_key(r) for r in regions} != set(m) or any( + m[refine.region_key(r)]["outline"] != refine.outline_hash(r) for r in regions): + return True + return {e["area"] for e in m.values() if e["area"]} != have + + +def h3_res(server) -> int: + import h3.api.basic_int as h3 + return h3.get_resolution(int(server.world.arrays["g_ids"][0])) + + +NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +EXPORT_MAX_SAMPLES = 8193 # 8193² float32 ≈ 270 MB per layer: the largest export the viewer may ask for + + +class ExportJobs: + """Terrain exports asked for in the viewer, one at a time, on the server's own tile source.""" + def __init__(self, server): + self.server = server + self.lock = threading.Lock() + self.st = {"state": "idle", "name": None, "fraction": 0.0, "error": None} + d = Path(server.exports_dir) # work folders of exports cut off by a stop + for junk in d.glob(".*-*") if d.exists() else []: + if junk.is_dir(): + shutil.rmtree(junk, ignore_errors=True) + + def status(self) -> dict: + with self.lock: + return dict(self.st) + + @staticmethod + def validate(data, radius_km: float = 12742.0) -> dict: + import export + if not isinstance(data, dict): + raise ValueError("body must be a JSON object") + try: + lat, lon = float(data["lat"]), float(data["lon"]) + size_km, res_m = float(data["size_km"]), float(data["res_m"]) + except (KeyError, TypeError, ValueError): + raise ValueError("lat, lon, size_km and res_m must be numbers") + if not (-90 <= lat <= 90 and np.isfinite(lon)): + raise ValueError("lat must be in [-90, 90] and lon finite") + if not (0.5 <= size_km <= 200): + raise ValueError("size_km must be 0.5–200") + if not (1 <= res_m <= 5000): + raise ValueError("res_m must be 1–5000") + n = export.samples_for(size_km, res_m) + if n > EXPORT_MAX_SAMPLES: + raise ValueError(f"{n} × {n} samples is too many (≤ {EXPORT_MAX_SAMPLES}): a coarser resolution or a smaller area") + lon = (lon + 180.0) % 360.0 - 180.0 + P = export.plan(lat, lon, size_km, res_m, radius_km) # ValueError near a pole + if P["tiles"] > export.MAX_TILES: # tiles narrow toward the poles: memory + raise ValueError(f"this square needs {P['tiles']:,} map tiles (≤ {export.MAX_TILES:,}; they narrow toward " + f"the poles): a coarser resolution or a smaller square") + name = data.get("name") or export.default_name(lat, lon, size_km, res_m) + if not isinstance(name, str) or not NAME_RE.fullmatch(name) or ".." in name: + raise ValueError("name: 1–64 of a–z, 0–9, . _ - (starting with a letter or digit)") + era = data.get("era") + if era is not None and not isinstance(era, str): + raise ValueError("era must be a string") + return {"lat": lat, "lon": lon, "size_km": size_km, "res_m": res_m, "name": name, "samples": n, "era": era} + + def start(self, p: dict) -> None: + if (Path(self.server.exports_dir) / p["name"]).exists(): + raise FileExistsError(f"an export named {p['name']} exists") + with self.lock: + if self.st["state"] == "running": + raise RuntimeError("an export is running") + self.st = {"state": "running", "name": p["name"], "fraction": 0.0, "error": None} + threading.Thread(target=self._run, args=(p,), daemon=True).start() + + def _run(self, p: dict) -> None: + import export + + def progress(k, n): + with self.lock: + self.st["fraction"] = round(0.95 * k / n, 3) + try: + export.export(self.server.data_root, self.server.res, p["lat"], p["lon"], p["size_km"], p["res_m"], + out_dir=Path(self.server.exports_dir), name=p["name"], pins_path=self.server.pins_path, + src=self.server.sources[p.get("era") or self.server.default_era], progress=progress) + with self.lock: + self.st.update(state="done", fraction=1.0) + except (Exception, SystemExit) as e: # noqa: BLE001 — reported in the viewer + with self.lock: + self.st.update(state="failed", error=f"{type(e).__name__}: {e}") + + def list(self) -> list: + out = [] + d = Path(self.server.exports_dir) + for m in sorted(d.glob("*/meta.json")) if d.exists() else []: + if m.parent.name.startswith("."): # an export still being written + continue + try: + meta = json.loads(m.read_text()) + except (OSError, ValueError): + continue + out.append({k: meta.get(k) for k in ("name", "center", "size_m", "res_m", "samples", "created")} + | {"path": str(m.parent)}) + return out + + +def vents_of(src) -> dict: + """Every vent of the source's refined areas (vents.npz per area; none for older results), one per cell.""" + import refine + import seafloor + seen, out = set(), [] + for p in refine.built_areas(src.regions_dir, src.fingerprint): + f = p / "vents.npz" + if not f.exists(): + continue + with np.load(f) as v: + for c, la, lo, t, tc, fl, mi in zip(v["cell"].tolist(), v["lat"].tolist(), v["lon"].tolist(), + v["type"].tolist(), v["temp_c"].tolist(), v["flow"].tolist(), + v["mineral"].tolist()): + if c in seen: + continue + seen.add(c) + out.append({"lat": round(la, 5), "lon": round(lo, 5), "type": int(t), "temp_c": round(tc, 1), + "flow": int(fl), "mineral": int(mi)}) + return {"vents": out, "types": seafloor.VENT_TYPES, "minerals": seafloor.VENT_MINERALS} + + +def plateau_geojson(root: Path, radius_km: float) -> dict: + """The sunken plateaus' outlines (their noise-warped edge, no margin) as GeoJSON lines.""" + import refine + from mapgen import config as C + cfg, tect = C.load(root) + seed = int(cfg["build"]["seed"]) + feats = [] + for p in tect.get("plateau", []): + ring = refine.plateau_outline(p, seed, radius_km, margin_km=0.0) + feats.append({"type": "Feature", "properties": {"name": p["name"], "islands": bool(p.get("islands", False))}, + "geometry": {"type": "LineString", "coordinates": [[lo, la] for la, lo in ring + ring[:1]]}}) + return {"type": "FeatureCollection", "features": feats} + + +def latlon_xyz(lat, lon): + la, lo = np.radians(lat), np.radians(lon) + return np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la) + + +def _profile(world, path, n: int, surface=None) -> dict: + """Elevation along great-circle legs, n samples evenly spaced by distance.""" + if not PROFILE_POINTS[0] <= len(path) <= PROFILE_POINTS[1] or any(len(p) != 2 for p in path): + raise ValueError("path needs 2–100 lat,lon points") + if not PROFILE_N[0] <= n <= PROFILE_N[1]: + raise ValueError("n must be 16–1024") + lat = np.radians([p[0] for p in path]) + lon = np.radians([p[1] for p in path]) + if not np.all(np.isfinite(lat)) or np.any(np.abs(lat) > np.pi / 2): + raise ValueError("lat must be in [-90, 90]") + if not np.all(np.isfinite(lon)): + raise ValueError("lon must be a finite number") + P = np.stack([np.cos(lat) * np.cos(lon), np.cos(lat) * np.sin(lon), np.sin(lat)], axis=1) + ang = np.arccos(np.clip(np.sum(P[:-1] * P[1:], axis=1), -1.0, 1.0)) + if np.any(ang > np.pi - 1e-6): + raise ValueError("a leg joins antipodal points: its great circle is undefined") + cum = np.concatenate([[0.0], np.cumsum(ang)]) + ds = np.linspace(0.0, cum[-1], n) + k = np.clip(np.searchsorted(cum, ds, side="right") - 1, 0, len(ang) - 1) + a = ang[k] + t = np.where(a > 0, (ds - cum[k]) / np.where(a > 0, a, 1.0), 0.0) + s = np.where(a > 1e-12, np.sin(a), 1.0) + w0 = np.where(a > 1e-12, np.sin((1 - t) * a) / s, 1 - t) + w1 = np.where(a > 1e-12, np.sin(t * a) / s, t) + Q = w0[:, None] * P[k] + w1[:, None] * P[k + 1] + Q /= np.linalg.norm(Q, axis=1, keepdims=True) + qlat = np.degrees(np.arcsin(np.clip(Q[:, 2], -1, 1))) + qlon = np.degrees(np.arctan2(Q[:, 1], Q[:, 0])) + R = float(world.cells_meta["radius_km"]) + d = ds * R + step = float(d[1] - d[0]) if n > 1 else 0.0 + idx = np.array([world.index_of(float(a_), float(b_)) for a_, b_ in zip(qlat, qlon)]) + zc = world.arrays["z_surface_m"][idx].astype(np.float64) + if surface is None: + z, water = zc, world.arrays["ocean"][idx].astype(bool) | world.arrays["lake"][idx].astype(bool) + else: # the procedural surface: same heights and coasts as the tiles + z, water = surface(qlat, qlon, max(step / 2.0, 0.005)) + z, water = np.asarray(z, dtype=np.float64), np.asarray(water, dtype=bool) + dz = np.diff(z) + stats = {"min_m": round(float(z.min()), 1), "max_m": round(float(z.max()), 1), + "ascent_m": round(float(dz[dz > 0].sum()), 1), "descent_m": round(float(-dz[dz < 0].sum()), 1), + "water_km": round(step * float(np.sum(water[:-1] & water[1:]) + 0.5 * np.sum(water[:-1] ^ water[1:])), 3), + "length_km": round(float(d[-1]), 3)} + samples = [{"d_km": round(float(d_), 3), "lat": round(float(a_), 6), "lon": round(float(b_), 6), + "z_m": round(float(z_), 1), "z_cell_m": round(float(c_), 1), "water": bool(w_)} + for d_, a_, b_, z_, c_, w_ in zip(d, qlat, qlon, z, zc, water)] + return {"samples": samples, "stats": stats} + + +def _safe(base: Path, rel: str) -> Path | None: + try: + p = (base / unquote(rel)).resolve() + except (ValueError, OSError): # e.g. an embedded NUL (%00) + return None + return p if p.is_file() and p.is_relative_to(base.resolve()) else None + + +GZIP_TYPES = {"application/octet-stream", "application/json", "application/geo+json", "text/javascript", "text/html", + "text/css", "image/svg+xml", "text/plain"} # mesh tiles shrink to ~60%; JPEG/PNG are compressed already +GZIP_MIN = 1024 + + +def _gzip_ok(ctype: str, body: bytes, accept: str) -> bool: + """Send body gzip-encoded? (browsers decompress it themselves; public links pay for every byte)""" + if ctype.split(";")[0] not in GZIP_TYPES or len(body) < GZIP_MIN: + return False + for part in accept.split(","): + name, _, q = part.strip().partition(";") + if name.strip().lower() in ("gzip", "*"): + return q.replace(" ", "") not in ("q=0", "q=0.0", "q=0.00", "q=0.000") + return False + + +BUSY_RETRY_S = 2 # a public server's answer when every render slot is taken +PRIVATE_API = ("/api/pins", "/api/regions", "/api/exports") # not served by a --public server + + +class Handler(BaseHTTPRequestHandler): + server_version = "worldmap-viewer/1" + _prefix = "" + + def log_message(self, fmt, *args): + pass + + def setup(self): + self._front = self.server + super().setup() + + def _route(self) -> bool: + """Hub (several worlds): /w//… goes to that world, prefix stripped; / redirects to the first world; + other paths go to the first world. False: answered here.""" + self.server, self._prefix = self._front, "" + hub = getattr(self._front, "hub", None) + url = urlparse(self.path) + if url.path == "/api/worlds": + return self._host_ok() and self._route_worlds(hub or {}, getattr(self._front, "default_world", None)) + if hub is None: + return True + parts = url.path.split("/", 3) # ['', 'w', '', 'rest'] + if url.path == "/" or (len(parts) >= 3 and parts[1] == "w" and parts[2] in hub and len(parts) == 3): + wid = parts[2] if url.path != "/" else self._front.default_world + self.send_response(302) + self.send_header("Location", f"/w/{wid}/") + self.send_header("Content-Length", "0") + self.end_headers() + return False + if len(parts) >= 3 and parts[1] == "w": + if parts[2] not in hub: + self._json(404, {"error": f"unknown world {parts[2]!r} (worlds: {list(hub)})"}) + return False + self.server, self._prefix = hub[parts[2]], f"/w/{parts[2]}" + self.path = "/" + (parts[3] if len(parts) == 4 else "") + (f"?{url.query}" if url.query else "") + if urlparse(self.path).path == "/api/worlds": + return self._host_ok() and self._route_worlds(hub, parts[2]) + else: + self.server = hub[self._front.default_world] + return True + + def _route_worlds(self, hub, current) -> bool: + ws = [{"id": k, "name": s.world.cells_meta.get("name", k), "url": f"/w/{k}/"} for k, s in hub.items()] + self._json(200, {"worlds": ws, "current": current}) + return False + + def _px(self, v): + """Absolute URLs in a JSON answer, under this world's prefix (hub).""" + if isinstance(v, dict): + return {k: self._px(x) for k, x in v.items()} + if isinstance(v, list): + return [self._px(x) for x in v] + if isinstance(v, str) and v.startswith(("/api/", "/data/", "/tiles/")): + return self._prefix + v + return v + + def _send(self, status: int, body: bytes, ctype: str, cache: str = "no-cache", extra: dict | None = None) -> None: + gz = _gzip_ok(ctype, body, (getattr(self, "headers", None) or {}).get("Accept-Encoding", "")) + if gz: + body = gzip.compress(body, compresslevel=1, mtime=0) # level 1: ~same size as 6 at a fifth of the time + try: + self.send_response(status) + self.send_header("Content-Type", ctype) + if ctype.split(";")[0] in GZIP_TYPES: + self.send_header("Vary", "Accept-Encoding") + if gz: + self.send_header("Content-Encoding", "gzip") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", cache) + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + self.close_connection = True # the client hung up (closed tab, timed-out request): no one to answer + + def _json(self, status: int, obj) -> None: + self._send(status, json.dumps(obj).encode(), "application/json") + + def _file(self, path: Path | None) -> None: + if path is None: + return self._json(404, {"error": "not found"}) + ctype = {".js": "text/javascript", ".mjs": "text/javascript", ".geojson": "application/geo+json", + ".json": "application/json"}.get(path.suffix) or mimetypes.guess_type(path.name)[0] \ + or "application/octet-stream" + self._send(200, path.read_bytes(), ctype) + + def _host_ok(self) -> bool: + """Refuse foreign Host headers (DNS rebinding): only this machine's own page may talk to us.""" + port = self._front.server_address[1] + host = self.headers.get("Host", "") + allowed = getattr(self._front, "allowed_hosts", ()) # --allow-host: the public name a proxy/tunnel passes on + if host not in (f"127.0.0.1:{port}", f"localhost:{port}") and host.lower() not in allowed: + self._json(403, {"error": "forbidden host"}) + return False + if self.headers.get("Sec-Fetch-Site") == "cross-site" and urlparse(self.path).path.startswith(("/api/", "/tiles/")): + self._json(403, {"error": "cross-site request refused"}) # other sites must not drive the API or fill the tile cache + return False + return True + + def do_GET(self): + if not self._route() or not self._host_ok(): + return None + url = urlparse(self.path) + q = parse_qs(url.query) + if url.path == "/api/meta": + era = self._era(q) + if era is None: + return None + w, src = self.server.worlds[era], self.server.sources[era] + meta = w.meta() + for L in meta["layers"]: + L["file"] += f"?era={era}" + for o in meta["overlays"]: + o["file"] += f"?era={era}" + if self.server.plateaus["features"]: + meta["overlays"].append({"id": "plateaus", "name": "Sunken plateaus", "file": "/api/plateaus.geojson"}) + if self._public(): + meta = {**meta, "lore_ids": []} + return self._json(200, self._px({**meta, "read_only": self._public(), "tiles": src.meta(), "era": era, + "default_era": self.server.default_era, + "eras": [{"name": n, "label": x.era["label"]} for n, x in self.server.worlds.items()]})) + if url.path.startswith(PRIVATE_API) and self._public(): + return self._json(404, {"error": "not on the public map"}) + if url.path.startswith("/api/"): + return self.api_get(url) + if url.path.startswith("/tiles/"): + return self._tile(url.path) + if url.path == "/": + return self._file(_safe(self.server.viewer_dir, "index.html")) + for prefix, sub in (("/data/viewer/", "viewer"), ("/data/geo/", "geo")): + if url.path.startswith(prefix): + era = self._era(q) + if era is None: + return None + return self._file(_safe(self.server.worlds[era].out / sub, url.path[len(prefix):])) + return self._file(_safe(self.server.viewer_dir, url.path.lstrip("/"))) + + def _era(self, q) -> str | None: + """The era a request names (default: the server's), or None after answering 400.""" + name = q.get("era", [self.server.default_era])[0] or self.server.default_era + if name not in self.server.worlds: + self._json(400, {"error": f"unknown era {name!r} (eras: {list(self.server.worlds)})"}) + return None + return name + + def api_get(self, url): + if url.path == "/api/cell": + q = parse_qs(url.query) + try: + lat, lon = float(q["lat"][0]), float(q["lon"][0]) + except (KeyError, ValueError, IndexError): + return self._json(400, {"error": "lat and lon must be numbers"}) + if not -90.0 <= lat <= 90.0: + return self._json(400, {"error": "lat must be in [-90, 90]"}) + if not np.isfinite(lon): + return self._json(400, {"error": "lon must be a finite number"}) + lon = (lon + 180.0) % 360.0 - 180.0 + era = self._era(q) + if era is None: + return None + w = self.server.worlds[era] + try: + d = w.describe(lat, lon) + except LookupError as e: + return self._json(404, {"error": str(e)}) + z = float(self.server.sources[era].z_at(lat, lon)[0]) + d["fields"].insert(1, {"key": "z_detail_m", "label": "Local elevation (procedural)", "group": "Terrain", + "unit": "m", "value": round(z, 1), "alt": w._alt("z_surface_m", z)}) + return self._json(200, d) + if url.path == "/api/vents": + era = self._era(parse_qs(url.query)) + return None if era is None else self._json(200, vents_of(self.server.sources[era])) + if url.path == "/api/plateaus.geojson": + return self._send(200, json.dumps(self.server.plateaus).encode(), "application/geo+json") + if url.path == "/api/locate": + return self._locate(parse_qs(url.query)) + if url.path == "/api/profile": + return self._profile(parse_qs(url.query)) + if url.path.startswith("/api/pins"): + return self._pins("GET") + if url.path.startswith("/api/regions"): + return self._regions("GET") + if url.path == "/api/exports": + return self._exports("GET") + return self._json(404, {"error": "unknown endpoint"}) + + def _tile(self, path: str): + m = re.fullmatch(r"/tiles/(v\d+(?:\.\d+)?-[0-9a-f]+)/([a-z0-9_]+)/(\d+)/(\d+)/(\d+)\.(jpg|png|bin)", path) + src = next((s for s in self.server.sources.values() if s.tag == m.group(1)), None) if m else None + if not m or src is None or m.group(6) != tiles.EXT.get(m.group(2), "jpg"): + return self._json(404, {"error": "no such tile"}) + try: + data = src.tile(m.group(2), int(m.group(3)), int(m.group(4)), int(m.group(5)), + gate=getattr(self._front, "render_gate", None)) + except KeyError: + return self._json(404, {"error": "no such tile"}) + except tiles.Busy: + return self._send(503, b'{"error": "busy rendering, try again shortly"}', "application/json", + extra={"Retry-After": str(BUSY_RETRY_S)}) + ctype = {"jpg": "image/jpeg", "png": "image/png", "bin": "application/octet-stream"}[m.group(6)] + # tile URLs carry their version tag: a public map lets browsers and caches keep them for good + return self._send(200, data, ctype, "public, max-age=31536000, immutable" if self._public() else "max-age=86400") + + def _locate(self, q): + import h3.api.basic_int as h3 + s = q.get("cell", [""])[0].strip().lower() + try: + c = h3.str_to_int(s) + ok = bool(h3.is_valid_cell(c)) + except Exception: # h3 raises its own error types for junk ids + ok = False + if not ok: + return self._json(400, {"error": "not an H3 cell id"}) + lat, lon = h3.cell_to_latlng(c) + return self._json(200, {"cell": s, "lat": lat, "lon": lon, "res": h3.get_resolution(c)}) + + def _profile(self, q): + try: + path = [tuple(float(v) for v in p.split(",")) for p in q.get("path", [""])[0].split(";") if p] + n = int(q.get("n", ["256"])[0]) + except ValueError: + return self._json(400, {"error": "path must be lat,lon;lat,lon;… and n an integer"}) + era = self._era(q) + if era is None: + return None + try: + return self._json(200, _profile(self.server.worlds[era], path, n, self.server.sources[era].surface_at)) + except ValueError as e: + return self._json(400, {"error": str(e)}) + except LookupError as e: + return self._json(404, {"error": str(e)}) + + def _body(self): + n = int(self.headers.get("Content-Length") or 0) + if n < 0: + raise ValueError("bad Content-Length") + if n > MAX_BODY: + raise ValueError("request body too large") + try: + return json.loads(self.rfile.read(n) or b"null") + except json.JSONDecodeError: + raise ValueError("body is not valid JSON") + + def _pins(self, method: str): + parts = urlparse(self.path).path.split("/") # ['', 'api', 'pins', ''?] + if parts[1:3] != ["api", "pins"] or len(parts) > 4: + return self._json(404, {"error": "unknown endpoint"}) + pid = parts[3] if len(parts) == 4 else None + if method in ("POST", "PUT") and self.headers.get_content_type() != "application/json": + return self._json(415, {"error": "Content-Type must be application/json"}) # blocks cross-site form posts + store = self.server.pins + try: + if method == "GET" and pid is None: + return self._json(200, {"pins": store.list()}) + if method == "POST" and pid is None: + return self._json(201, store.create(self._body())) + if method == "PUT" and pid: + pin = store.update(pid, self._body()) + return self._json(200, pin) if pin else self._json(404, {"error": "no such pin"}) + if method == "DELETE" and pid: + if store.delete(pid): + self.send_response(204) + self.end_headers() + return None + return self._json(404, {"error": "no such pin"}) + except ValueError as e: + return self._json(400, {"error": str(e)}) + except OSError as e: + return self._json(500, {"error": f"could not save pins: {e}"}) + return self._json(405, {"error": "method not allowed"}) + + def _regions(self, method: str): + parts = urlparse(self.path).path.split("/") # ['', 'api', 'regions', ''?] + if parts[1:3] != ["api", "regions"] or len(parts) > 4: + return self._json(404, {"error": "unknown endpoint"}) + rid = parts[3] if len(parts) == 4 else None + if method == "POST" and self.headers.get_content_type() != "application/json": + return self._json(415, {"error": "Content-Type must be application/json"}) # blocks cross-site form posts + store, jobs = self.server.regions, getattr(self.server, "jobs", None) + if method != "GET" and getattr(self.server, "regions_read_only", False): + return self._json(403, {"error": "regions are read-only on this server"}) + try: + if method == "GET" and rid is None: + era = self._era(parse_qs(urlparse(self.path).query)) + if era is None: + return None + status = jobs.status() if jobs else {"state": "idle"} + try: + regions = refined_flags(self.server, store.list()) + except RegionsFileError as e: + regions, status = [], {**status, "state": "failed", "error": str(e)} + return self._json(200, {"regions": regions, "status": status, "tiles": self.server.sources[era].meta()}) + if method == "POST" and rid is None: + r = store.create(self._body(), float(self.server.world.cells_meta["radius_km"])) + if jobs: + jobs.request() + return self._json(201, r) + if method == "DELETE" and rid: + if store.delete(rid): + if jobs: + jobs.request() + self.send_response(204) + self.end_headers() + return None + return self._json(404, {"error": "no such region"}) + except ValueError as e: + return self._json(400, {"error": str(e)}) + except OSError as e: + return self._json(500, {"error": f"could not save regions: {e}"}) + return self._json(405, {"error": "method not allowed"}) + + def _exports(self, method: str): + if urlparse(self.path).path != "/api/exports": + return self._json(404, {"error": "unknown endpoint"}) + jobs = self.server.exports + if method == "GET": + return self._json(200, {"exports": jobs.list(), "status": jobs.status()}) + if method != "POST": + return self._json(405, {"error": "method not allowed"}) + if self.headers.get_content_type() != "application/json": + return self._json(415, {"error": "Content-Type must be application/json"}) + try: + p = jobs.validate(self._body(), float(self.server.world.cells_meta["radius_km"])) + if p["era"] is not None and p["era"] not in self.server.worlds: + raise ValueError(f"unknown era {p['era']!r} (eras: {list(self.server.worlds)})") + jobs.start(p) + except ValueError as e: + return self._json(400, {"error": str(e)}) + except (FileExistsError, RuntimeError) as e: + return self._json(409, {"error": str(e)}) + return self._json(202, p) + + def _public(self) -> bool: + """--public: a read-only map for anyone — nothing can be written, and pins, regions, exports and the lore + ids (the author's notes and the names of lore entries) are not served.""" + return bool(getattr(self._front, "public", False)) + + def _write(self, method: str): + if not self._host_ok(): + return None + if self._public(): + return self._json(403, {"error": "this map is read-only"}) + path = urlparse(self.path).path + if path.startswith("/api/exports"): + return self._exports(method) + return self._regions(method) if path.startswith("/api/regions") else self._pins(method) + + def do_POST(self): + return self._write("POST") if self._route() else None + + def do_PUT(self): + return self._write("PUT") if self._route() else None + + def do_DELETE(self): + return self._write("DELETE") if self._route() else None + + +def make_server(data_root: Path, res: int, port: int = 8765, viewer_dir: Path = VIEWER, lore_dir: Path | None = None, + pins_path: Path | None = None, regions_path: Path | None = None, + regions_dir: Path | None = None, workers: int = 0, + exports_dir: Path | None = None, regions_read_only: bool = False, + bind: str = "127.0.0.1") -> ThreadingHTTPServer: + """workers > 0: that many forked render processes (before any thread starts: call from the main thread).""" + server = ThreadingHTTPServer((bind, port), Handler) # bind first: a busy port fails before the slow load + try: + if setup_world(server, data_root, res, viewer_dir, lore_dir, pins_path, regions_path, regions_dir, workers, + exports_dir, regions_read_only): + server.jobs.request() # outlines without results for this world: build them + except BaseException: # any failed setup frees the port + server.server_close() + raise + return server + + +def make_hub(entries: list, port: int = 8765, viewer_dir: Path = VIEWER, lore_dir: Path | None = None, + workers: int = 0, extra_workers: int = 2, bind: str = "127.0.0.1") -> ThreadingHTTPServer: + """One server for several worlds: entries = [(id, data_root, res)], each at /w//; the first is the default. + The first world gets `workers` render processes, the others `extra_workers` (all forked before any thread).""" + server = ThreadingHTTPServer((bind, port), Handler) + try: + server.hub, missing = {}, [] + for i, (wid, root, res) in enumerate(entries): + st = types.SimpleNamespace(socket=server.socket, server_address=server.server_address) + if setup_world(st, root, res, viewer_dir, lore_dir, workers=workers if i == 0 else min(workers, extra_workers)): + missing.append(st) + server.hub[wid] = st + server.default_world = entries[0][0] + for st in missing: + st.jobs.request() + except BaseException: + server.server_close() + raise + return server + + +def world_states(server) -> list: + return list(server.hub.values()) if getattr(server, "hub", None) else [server] + + +def setup_world(server, data_root: Path, res: int, viewer_dir: Path = VIEWER, lore_dir: Path | None = None, + pins_path: Path | None = None, regions_path: Path | None = None, + regions_dir: Path | None = None, workers: int = 0, + exports_dir: Path | None = None, regions_read_only: bool = False) -> bool: + """Load a world onto `server` (a bound server or a hub's per-world namespace). True: refined regions are missing + (the caller starts server.jobs.request() once every render process is forked).""" + from mapgen import config as C + worlds = load_worlds(data_root, res, lore_dir, log=lambda m: None) + server.worlds = dict(worlds) + default = ((C.load(data_root)[1].get("eras") or {}).get("default")) + server.default_era = default if default in server.worlds else worlds[0][0] + world = server.worlds[server.default_era] + server.world, server.viewer_dir = world, viewer_dir + server.pins_path = pins_path or (data_root / "places" / "pins.json") + cfg = C.load(data_root)[0] + server.pins = PinStore(server.pins_path, int(cfg["build"]["res_final"])) + server.regions = RegionStore(regions_path or (data_root / "places" / "regions.json")) + server.regions_dir = regions_dir or (data_root / "out" / f"r{res}" / "regions") + server.regions_read_only = regions_read_only + seed = int(cfg["build"]["seed"]) + base_name, base_world = worlds[0] + base_src = tiles.TileSource(base_world, seed, regions_dir=server.regions_dir, name=base_name) + server.sources = {base_name: base_src} + for name, w in worlds[1:]: + server.sources[name] = tiles.TileSource(w, seed, regions_dir=server.regions_dir, name=name, + share=base_src) + tiles.join(list(server.sources.values())) + server.tiles = server.sources[server.default_era] + server.plateaus = plateau_geojson(data_root, float(world.cells_meta["radius_km"])) + server.tiles.start_workers(workers) + server.prerender_z = PRERENDER_Z if workers > 0 else 0 # without render processes: no rendering ahead + server.jobs = RefineJobs(server, data_root, res) + server.data_root, server.res = data_root, res + server.exports_dir = exports_dir or (data_root / "exports") + server.exports = ExportJobs(server) + server.surface = server.tiles.surface_at + for name, w in server.worlds.items(): + w.regions = server.sources[name].regions + try: + return regions_missing(server) + except RegionsFileError: + return False # reported by GET /api/regions; the map still works + + +def pick_res(root: Path, cfg: dict) -> int: + """The final map if its viewer textures exist, else the dev map.""" + final = int(cfg["build"]["res_final"]) + return final if (root / "out" / f"r{final}" / "viewer" / "layers.json").exists() else int(cfg["build"]["res_dev"]) + + +def world_id(root: Path, cfg: dict, taken) -> str: + base = re.sub(r"[^a-z0-9]+", "-", (cfg.get("render", {}).get("name") or root.name).lower()).strip("-") or "world" + wid, n = base, 2 + while wid in taken: + wid, n = f"{base}-{n}", n + 1 + return wid + + +ENV_PREFIX = "WORLDMAP_" # every option can come from the environment (a container): --tile-cache-gb ← WORLDMAP_TILE_CACHE_GB + + +def env_list(v: str) -> list: + return [x for x in re.split(r"[,\s]+", v) if x] + + +def env_defaults(ap, environ) -> None: + """Options from WORLDMAP_