feat(assets): biome and terrain as two maps, and the bake that writes them (T-1295)

D-258 rulings 5 and 6: the pre-seed biome input is class ids only, on two
maps, because compute_biome was already computing them apart and throwing
one away. It gave every land pixel its Whittaker (climate) class, then painted
terrain over it (ocean bands, ice, altitude snow, mountain rock, lava and ash,
the dry, lunar and ferric ground).

planet_simulation:
- compute_biome_layers returns (biome, terrain). Each rule declares the layer
  it writes, by RULE not by class: ice from the cold climate box is biome,
  altitude snow is terrain with the lowland biome kept beneath. Rules that
  place life (thermophiles, mats, crust, the oasis rings) write the biome and
  clear the terrain under them.
- 255 means "nothing on this layer", not 0, because class 0 is ocean_deep,
  itself terrain. Ids stay literal.
- compute_biome is now compose_layers() of the pair, so the renderers are
  untouched. Proven byte-identical, dtype included, on 65 real bodies: every
  8th standard-atmosphere body plus every thin, thick, reducing and trace one,
  captured before the edit and compared after it.
- simulate() carries both maps as biome_layer / terrain_layer.

reach atlas planet bake-biome [--body --limit --dry-run --check]:
- Writes biomemap.png + terrainmap.png (8-bit, 1024x512, tEXt: layer, none,
  classes, decision) beside each heightmap. Scope is the heightmap bake set
  with an atmosphere (257 bodies; airless are deferred, per D-258).
- Pre-seed by construction: simulate() is keyed on the body frontmatter's
  seed, and no world seed is accepted anywhere.
- --check re-simulates and compares DECODED PIXELS, never bytes, since PNG
  encoding drifts across Pillow/zlib versions (T-1291) and a check that trips
  on that gets muted. Mutation-proven: one flipped pixel exits 1 and names
  the file; the restored file exits 0.
- import_heightmaps' body lookup is extracted as body_def_for and shared,
  not copied.

Tests (make test-tooling): rock keeps its biome beneath, thin-atmosphere
ground is terrain with only scattered life, water is terrain only, no pixel is
empty on both maps, and stacking equals the rendered grid. Routing rock
through the biome map fails two of them by name. The router drift test covers
the new verb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:55:47 +02:00
co-authored by Claude Opus 5.5
parent 10811029e5
commit 76e3f3ec96
8 changed files with 562 additions and 39 deletions
+184
View File
@@ -0,0 +1,184 @@
"""bake_biome — the pre-seed biome input, baked per body (D-258, T-1295).
For every in-scope body this writes two 8-bit class-id maps beside its
`heightmap.png`, at the same 1024x512:
biomemap.png the climate and life class, taken before terrain is laid
over it, so it covers every land pixel, rock and ice included
terrainmap.png the landform or substrate class a terrain rule laid down
Both use 255 for "nothing on this layer" and literal class ids otherwise
(`biomes.toml`); what a class MEANS lives in the class tables, not here.
PRE-SEED by construction. `simulate()` is keyed on the body frontmatter's own
`seed`, and nothing here takes a world seed, so a body bakes to the same maps
in every playthrough; the server varies the ground, not the map.
SCOPE: bodies with an atmosphere, out of the heightmap bake set. Airless bodies
are deferred (D-258, 2026-09-25). Gas giants have no surface and are skipped.
`--check` re-simulates and compares decoded pixels, never file bytes: PNG
encoding can drift across Pillow/zlib versions (T-1291) without the map
changing, and a check that trips on that would be muted.
Run through reach:
reach atlas planet bake-biome --limit 3 # smoke test
reach atlas planet bake-biome # all in-scope bodies (~27 min; --detach)
reach atlas planet bake-biome --check # fail if committed maps are stale
"""
from __future__ import annotations
import argparse
import sqlite3
import time
from pathlib import Path
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tooling.core import config, console
from tooling.core.errors import ReachError
from tooling.domains.atlas.planet.import_heightmaps import body_def_for
from tooling.domains.atlas.planet.planet_simulation import LAYER_NONE, simulate
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
BIOME_FILE = "biomemap.png"
TERRAIN_FILE = "terrainmap.png"
# The heightmap bake set, narrowed to bodies with an atmosphere.
SCOPE_SQL = (
"SELECT body_id, terrain_reference FROM bodies "
"WHERE inhabited=1 AND terrain_reference IS NOT NULL AND population>0 "
"AND system_id != 'GJ 0' "
"AND atmosphere IS NOT NULL AND atmosphere != 'none' "
"ORDER BY body_id"
)
def in_scope(db_path: Path) -> list[tuple[str, str]]:
"""`(body_id, terrain_reference)` for every body this bake covers."""
conn = sqlite3.connect(str(db_path))
try:
return conn.execute(SCOPE_SQL).fetchall()
finally:
conn.close()
def layers_for(bd: dict) -> tuple[np.ndarray, np.ndarray] | None:
"""The two maps for one body, or None for a gas giant (no surface)."""
terrain = simulate(bd)
if not terrain:
return None
return terrain["biome_layer"], terrain["terrain_layer"]
def _png(layer: np.ndarray, name: str) -> tuple[Image.Image, PngInfo]:
meta = PngInfo()
meta.add_text("layer", name)
meta.add_text("none", str(LAYER_NONE))
meta.add_text("classes", "tooling/domains/atlas/planet/biomes.toml")
meta.add_text("decision", "D-258")
return Image.fromarray(layer.astype(np.uint8), mode="L"), meta
def write_layers(body_dir: Path, biome: np.ndarray, terrain: np.ndarray) -> None:
for layer, name, fname in ((biome, "biome", BIOME_FILE), (terrain, "terrain", TERRAIN_FILE)):
img, meta = _png(layer, name)
img.save(body_dir / fname, pnginfo=meta, optimize=True)
def stale_layers(body_dir: Path, biome: np.ndarray, terrain: np.ndarray) -> list[str]:
"""Which committed maps differ, pixel for pixel, from a fresh simulation."""
stale = []
for layer, fname in ((biome, BIOME_FILE), (terrain, TERRAIN_FILE)):
path = body_dir / fname
if not path.exists():
stale.append(f"{fname} missing")
continue
on_disk = np.asarray(Image.open(path))
if on_disk.shape != layer.shape or not np.array_equal(on_disk, layer):
stale.append(fname)
return stale
def main(argv: list[str] | None = None) -> None:
ap = argparse.ArgumentParser(description="Bake biomemap.png + terrainmap.png per body (D-258)")
ap.add_argument("--db", default=str(DB_PATH))
ap.add_argument("--body", help="Bake only this body_id")
ap.add_argument("--limit", type=int, help="Bake at most N bodies (smoke test)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--check", action="store_true",
help="Write nothing; fail if any committed map differs from a fresh bake")
args = ap.parse_args(argv)
db_path = Path(args.db)
if not db_path.exists():
raise ReachError(f"{db_path} not found",
fix="make regen-db, or point --db at an existing systems.db")
rows = in_scope(db_path)
if args.body:
rows = [r for r in rows if r[0] == args.body]
if not rows:
raise ReachError(
f"{args.body} is not in the bake scope",
fix="the scope is inhabited, populated, non-Sol bodies with an atmosphere; "
"reach atlas planet bake-biome --dry-run lists it",
)
if args.limit:
rows = rows[: args.limit]
mode = "check" if args.check else "dry run" if args.dry_run else "bake"
console.event(f"biome bake ({mode}): {len(rows)} bodies")
parsed: dict[str, list] = {}
n_done = n_gas = 0
errors: list[str] = []
stale: list[str] = []
t0 = time.time()
for i, (body_id, tref) in enumerate(rows):
try:
bd = body_def_for(body_id, tref, parsed)
except Exception as exc: # noqa: BLE001 — per-body, reported and counted
errors.append(f"{body_id}: {exc}")
continue
if args.dry_run:
n_done += 1
continue
layers = layers_for(bd)
if layers is None:
n_gas += 1
continue
body_dir = REPO_ROOT / Path(tref).parent
if args.check:
diff = stale_layers(body_dir, *layers)
if diff:
stale.append(f"{body_id}: {', '.join(diff)}")
else:
write_layers(body_dir, *layers)
n_done += 1
if (i + 1) % 25 == 0:
console.event(f" [{i + 1}/{len(rows)}] {body_id}")
summary = (f"{n_done} bodies, {n_gas} gas giants skipped, {len(errors)} errors "
f"in {time.time() - t0:.0f}s")
for line in errors:
console.event(f" ERROR {line}", level="error")
if errors:
raise ReachError(
f"biome bake: {summary}",
fix="each ERROR line names the body; its system page's Celestial Bodies table "
"is what body_definition_parser reads",
)
if stale:
for line in stale:
console.event(f" STALE {line}", level="error")
raise ReachError(
f"{len(stale)} bodies' biome maps are stale",
fix="reach atlas planet bake-biome, then stage the maps under wiki/star-systems",
)
console.verdict(f"biome bake ({mode}): {summary}")
@@ -67,6 +67,23 @@ def rename_legacy_heightmaps(dry_run: bool) -> int:
return n
def body_def_for(body_id: str, tref: str, parsed: dict[str, list]) -> dict:
"""The parsed body definition for one baked body.
`tref` is the body's terrain_reference (a path under wiki/star-systems);
`parsed` caches each system's parse, since sibling bodies share it. Raises
on a parse failure or a body missing from its system, so callers report
it per body and carry on.
"""
sys_index = str(REPO_ROOT / Path(tref.split("/bodies/")[0]) / "index.md")
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
bd = next((d for d in defs if d.get("id") == body_id), None)
if bd is None:
raise LookupError("not in system defs")
return bd
def bake_body(bd: dict, body_dir: Path, dry_run: bool) -> dict:
"""Simulate one body at 1024×512 and write reliefmap.png + 16-bit heightmap.png."""
try:
@@ -134,17 +151,10 @@ def main(argv: list[str] | None = None) -> None:
n_baked = n_gas = n_err = 0
for i, (body_id, tref) in enumerate(rows):
body_dir = REPO_ROOT / Path(tref).parent
sys_index = str(REPO_ROOT / Path(tref.split("/bodies/")[0]) / "index.md")
try:
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
bd = body_def_for(body_id, tref, parsed)
except Exception as exc: # noqa: BLE001
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
n_err += 1
continue
bd = next((d for d in defs if d.get("id") == body_id), None)
if bd is None:
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {exc}")
n_err += 1
continue
ts = time.time()
@@ -11,7 +11,9 @@ Output terrain dict:
"elevation": float32 (H, W) [0, 1] normalised elevation
"temperature": float32 (H, W) [0, 1] 0=coldest, 1=hottest
"moisture": float32 (H, W) [0, 1] 0=driest, 1=wettest
"biome": int8 (H, W) biome class index
"biome": int8 (H, W) biome class index (composed)
"biome_layer": uint8 (H, W) climate/life class, 255 = none
"terrain_layer": uint8 (H, W) landform/substrate class, 255 = none
"surface_water": bool (H, W) ocean/lake mask
"hillshade": float32 (H, W) [0, 1] lighting from slope+aspect
"sea_level": float elevation threshold
@@ -617,11 +619,54 @@ def compute_hillshade(elevation,
# WHITTAKER_TABLE, EXOTIC_CLASSES loaded from biomes.toml via biome_config
# "Nothing on this layer" in the biome and terrain maps. Not 0: class 0 is
# ocean_deep, itself a terrain class, and every id stays literal (D-258).
LAYER_NONE = 255
def compose_layers(biome, terrain):
"""The single class grid the renderers draw: terrain where there is any,
the biome beneath it everywhere else."""
return np.where(terrain != LAYER_NONE, terrain, biome).astype(np.int8)
def compute_biome(body_def, elevation, sea_level, surface_water,
temperature, moisture):
H, W = elevation.shape
biome = np.zeros((H, W), dtype=np.int8)
land = ~surface_water
"""The composed class grid — what the reliefmap and globe renders colour."""
return compose_layers(*compute_biome_layers(
body_def, elevation, sea_level, surface_water, temperature, moisture))
def compute_biome_layers(body_def, elevation, sea_level, surface_water,
temperature, moisture):
"""Biome and terrain as two maps (D-258, 2026-09-25 rulings 5 and 6).
The biome map is the climate and life class; the terrain map is the
landform or substrate a terrain rule laid over it. Each rule below writes
the layer it belongs to, by RULE rather than by class: ice from the cold
climate box is biome, snow on a tropical summit is terrain with the tropical
biome kept beneath it. A biome rule that lands on terrain (a crust of life
on bare rock, a shrub patch on a geothermal spot) replaces the terrain there,
exactly as it replaced it in the single grid, so compose_layers() of the
result is byte-identical to what compute_biome returned before the split.
Both maps use LAYER_NONE for "nothing on this layer": water and
thin-atmosphere ground carry no biome, most land carries no terrain.
"""
H, W = elevation.shape
biome = np.full((H, W), LAYER_NONE, dtype=np.uint8)
terrain = np.full((H, W), LAYER_NONE, dtype=np.uint8)
land = ~surface_water
def paint_terrain(mask, cls):
terrain[mask] = cls
def paint_biome(mask, cls):
biome[mask] = cls
terrain[mask] = LAYER_NONE
def composed():
return np.where(terrain != LAYER_NONE, terrain, biome)
atmo = body_def["physical"]["atmosphere"]
@@ -669,7 +714,8 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
cf[(en > 0.70) & (tf < 273)] = 17 # high + cold = ice cap
cf[(en < 0.20) & (tf >= 260)] = 27 # low elevation = dust plain
biome[land] = cf
# Bare ground, no life: terrain, with no biome beneath it.
paint_terrain(land, cf)
else:
# --- Standard Whittaker lookup for breathable/toxic atmospheres ---
tf = temperature[land].ravel()
@@ -681,14 +727,14 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
mask = (tf >= tlo) & (tf <= thi) & (mf >= mlo) & (mf <= mhi)
cf[mask] = cls
biome[land] = cf
biome[land] = cf # the climate class — the biome map's whole reason to exist
# Ocean depth bands
if surface_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
biome[surface_water & (depth < 0.15)] = 2
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1
biome[surface_water & (depth >= 0.50)] = 0
paint_terrain(surface_water & (depth < 0.15), 2)
paint_terrain(surface_water & (depth >= 0.15) & (depth < 0.50), 1)
paint_terrain(surface_water & (depth >= 0.50), 0)
# Frozen ocean — override ocean biome with ice shelf (class 26).
# Distinct from land ice (17) — slightly different appearance,
@@ -701,11 +747,13 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
gain=0.5, base_freq=3.0) * 2.0 - 1.0
freeze_threshold = 271.0 + ice_noise * 8.0 # ±8K variation
frozen_ocean = surface_water & (temperature < freeze_threshold)
biome[frozen_ocean] = 26
paint_terrain(frozen_ocean, 26)
# Very cold override — only on worlds with atmosphere (ice needs deposition)
# Very cold override — only on worlds with atmosphere (ice needs deposition).
# Biome, not terrain: it is the climate's own verdict, the same class the
# Whittaker ice box gives, so it carries that climate.
if atmo not in ("none",):
biome[(temperature < 243.0) & land] = 17 # below -30C → ice
paint_biome((temperature < 243.0) & land, 17) # below -30C → ice
# Elevation overrides — mountain rock and permanent snow.
# Only apply snow on worlds with atmosphere (ice needs deposition).
@@ -715,7 +763,8 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
hydro_here = body_def.get("environment", {}).get("hydrosphere", "none")
has_ice_deposition = atmo not in ("none",) and hydro_here not in ("none", "subsurface")
if has_ice_deposition:
biome[land & (elev_norm > 0.85)] = 17
# Altitude snow is terrain: a glacier with the lowland biome beneath.
paint_terrain(land & (elev_norm > 0.85), 17)
# `temperature` is absolute kelvin here, but the two rules below were
# written against a 0-1 scale and compared kelvin to 0.35 from the first
# commit: mountain rock could never fire, and the sulfuric test was always
@@ -724,7 +773,7 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
# that is what they now compare against. A world with no range reads 0.
t_span = float(temperature.max() - temperature.min())
t_norm = (temperature - temperature.min()) / t_span if t_span > 0 else np.zeros_like(temperature)
biome[land & (elev_norm > 0.65) & (t_norm < 0.35)] = 18
paint_terrain(land & (elev_norm > 0.65) & (t_norm < 0.35), 18)
# ── Modifier stack ─────────────────────────────────────────────────────
env = body_def.get("environment", {})
@@ -737,30 +786,36 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
# Geothermal: volcanic worlds get lava/ash at high elevations
if geothermal in ("extreme", "high") and planet_class == "volcanic":
biome[land & (elev_norm > 0.75)] = EXOTIC_CLASSES["lava_field"]
biome[land & (elev_norm > 0.45) & (elev_norm <= 0.75)] = EXOTIC_CLASSES["ash_field"]
paint_terrain(land & (elev_norm > 0.75), EXOTIC_CLASSES["lava_field"])
paint_terrain(land & (elev_norm > 0.45) & (elev_norm <= 0.75), EXOTIC_CLASSES["ash_field"])
# The four rules below place LIFE (thermophiles, mats, crusts, scrub), so
# they write the biome map and replace any terrain under them.
# Thermophilic fields near heat vents on any high-geothermal world
if geothermal in ("extreme", "high") and not chemosyn:
hot = (temperature > 303.0) & land & (elev_norm < 0.45)
biome[hot] = EXOTIC_CLASSES["thermophilic_field"]
paint_biome(hot, EXOTIC_CLASSES["thermophilic_field"])
# Chemosynthetic worlds (Europa-type): cold surface, geothermal warmth
if chemosyn:
geo_warm = (temperature > 263.0) & (temperature < 293.0) & land
biome[geo_warm] = EXOTIC_CLASSES["chemosynthetic_mat"]
paint_biome(geo_warm, EXOTIC_CLASSES["chemosynthetic_mat"])
# UV radiation: cryptobiotic crust on exposed terrain with thin/no atmo
if uv_index in ("extreme", "high") and atmo in ("none", "thin"):
shown = composed()
exposed = (land & (elev_norm > 0.15) & (elev_norm < 0.65)
& (moisture < 0.30)
& (biome != 17) & (biome != 18) & (biome != 19))
biome[exposed] = EXOTIC_CLASSES["cryptobiotic_crust"]
& (shown != 17) & (shown != 18) & (shown != 19))
paint_biome(exposed, EXOTIC_CLASSES["cryptobiotic_crust"])
# Sulfuric substrate: scrub on volcanic mid-elevations
# Sulfuric substrate: scrub on volcanic mid-elevations. Unreachable as
# written — it converts rock (18) only below elev_norm 0.65, and rock exists
# only above it (T-1295; left for the balancing pass).
if substrate == "sulfuric":
scrub = land & (elev_norm > 0.25) & (elev_norm < 0.65) & (t_norm > 0.35)
biome[scrub & (biome == 18)] = EXOTIC_CLASSES["sulfuric_scrub"]
paint_biome(scrub & (composed() == 18), EXOTIC_CLASSES["sulfuric_scrub"])
# ── Anomaly scatter ─────────────────────────────────────────────────
# Sparse micro-features that break biome uniformity and tell stories.
@@ -775,7 +830,7 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
scatter_mask = (scatter_noise > 0.72) & land
if scatter_mask.any():
b_local = biome[scatter_mask]
b_local = composed()[scatter_mask]
t_local = temperature[scatter_mask]
m_local = moisture[scatter_mask]
e_local = elev_norm[scatter_mask]
@@ -805,12 +860,20 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
lava_mask = np.isin(b_local, [19, 25])
new_b[lava_mask & (t_local < 310) & (m_local > 0.30)] = 24 # lithic pioneer
biome[scatter_mask] = new_b
# Route each repainted cell to its layer: vents, ash and oasis
# water are ground; savanna, shrubland and pioneer growth are life.
changed = new_b != b_local
rows, cols = (a[changed] for a in np.nonzero(scatter_mask))
vals = new_b[changed]
ground = np.isin(vals, (19, 25, 2))
terrain[rows[ground], cols[ground]] = vals[ground]
biome[rows[~ground], cols[~ground]] = vals[~ground]
terrain[rows[~ground], cols[~ground]] = LAYER_NONE
# Vegetation ring around oasis lakes: dilate water cells from the
# scatter pass and assign graduated vegetation to the ring.
# water → coast vegetation → savanna/shrub → original biome
oasis_water = (biome == 2) & land # scattered lake cells on land
oasis_water = (composed() == 2) & land # scattered lake cells on land
if oasis_water.any():
from scipy.ndimage import binary_dilation
# Pixel-unit iteration counts scale by GRID_W/512 (module
@@ -833,11 +896,11 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
& land
)
# Inner ring: lush vegetation (coast/lowland green)
biome[ring1] = 4 # lowland
paint_biome(ring1, 4) # lowland
# Outer ring: transitional (savanna/shrub)
biome[ring2] = 12 # shrubland
paint_biome(ring2, 12) # shrubland
return biome
return biome, terrain
# ---------------------------------------------------------------------------
@@ -874,8 +937,9 @@ def simulate(body_def: dict) -> dict:
hillshade = compute_hillshade(elevation)
biome = compute_biome(
biome_layer, terrain_layer = compute_biome_layers(
body_def, elevation, sea_level, surface_water, temperature, moisture)
biome = compose_layers(biome_layer, terrain_layer)
# Normalise temperature to [0,1] for renderer display — biome already computed
t_min, t_max = temperature.min(), temperature.max()
@@ -885,7 +949,9 @@ def simulate(body_def: dict) -> dict:
"elevation": elevation,
"temperature": temperature_norm, # normalised [0,1] for renderer
"moisture": moisture,
"biome": biome,
"biome": biome, # composed — what the renders colour
"biome_layer": biome_layer, # D-258: the baked biomemap (LAYER_NONE = none)
"terrain_layer": terrain_layer, # D-258: the baked terrainmap
"surface_water": surface_water,
"hillshade": hillshade,
"sea_level": sea_level,
+22
View File
@@ -162,6 +162,28 @@ def import_heightmaps(
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, skip_rename=skip_rename))
@app.command("bake-biome")
@command
def bake_biome(
db: Path = typer.Option(None, "--db", help="systems.db path (reads the body scope)."),
body: str = typer.Option(None, "--body", help="Bake only this body."),
limit: int = typer.Option(None, "--limit", help="Stop after N bodies."),
dry_run: bool = typer.Option(False, "--dry-run", help="Count the scope, write nothing."),
check: bool = typer.Option(
False, "--check", help="Write nothing; fail if a committed map differs from a fresh bake."
),
) -> None:
"""Bake biomemap.png + terrainmap.png beside each body's heightmap (D-258).
Pre-seed: keyed on the body's own seed, never the world's. Scope is the
heightmap bake set with an atmosphere. A full bake is ~27 minutes;
`--detach` and tail it. Stage the maps under wiki/star-systems after.
"""
from tooling.domains.atlas.planet import bake_biome as impl
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, check=check))
@app.command("import-provinces")
@command
def import_provinces(
+64
View File
@@ -28,7 +28,10 @@ import numpy as np # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import ( # noqa: E402
GRID_H,
GRID_W,
LAYER_NONE,
compose_layers,
compute_biome,
compute_biome_layers,
)
MOUNTAIN_ROCK = 18
@@ -78,5 +81,66 @@ class MountainRockTests(unittest.TestCase):
self.assertEqual(int((low == MOUNTAIN_ROCK).sum()), 0)
class LayerSplitTests(unittest.TestCase):
"""D-258 ruling 6: biome and terrain are two maps (T-1295)."""
def _layers(self, atmosphere: str, water_rows: int = 0):
elev = np.full((GRID_H, GRID_W), 0.3, dtype=np.float32)
elev[:, : GRID_W // 2] = 0.95 # high west, as in MountainRockTests
water = np.zeros((GRID_H, GRID_W), dtype=bool)
if water_rows:
water[-water_rows:, :] = True # a southern sea
elev[-water_rows:, :] = 0.05
temp = np.tile(np.linspace(275.0, 305.0, GRID_W, dtype=np.float32), (GRID_H, 1))
moisture = np.full((GRID_H, GRID_W), 0.5, dtype=np.float32)
body = _body()
body["physical"]["atmosphere"] = atmosphere
body["environment"]["hydrosphere"] = "liquid_water" if water_rows else "none"
biome, terrain = compute_biome_layers(body, elev, SEA_LEVEL, water, temp, moisture)
return biome, terrain, water
def test_rock_keeps_its_biome_beneath(self):
# The point of the split: the climate class under a mountain survives.
biome, terrain, _ = self._layers("standard")
rock = terrain == MOUNTAIN_ROCK
self.assertTrue(rock.any())
self.assertFalse((biome[rock] == LAYER_NONE).any(), "rock with no biome beneath")
def test_thin_atmosphere_is_bare_ground_with_scattered_life(self):
# Thin worlds skip the Whittaker table, so their ground is terrain. The
# only life is what the anomaly scatter places (it skips airless worlds
# only): oasis savanna, shrubs, pioneers. So the biome map is sparse and
# holds nothing but those classes.
biome, terrain, _ = self._layers("thin")
placed = biome != LAYER_NONE
self.assertLess(float(placed.mean()), 0.10, "thin world carries a climate biome")
self.assertTrue(np.isin(biome[placed], (4, 7, 12, 24)).all(), set(np.unique(biome[placed])))
self.assertFalse((terrain[~placed] == LAYER_NONE).any())
def test_water_is_terrain_only(self):
biome, terrain, water = self._layers("standard", water_rows=64)
self.assertTrue((biome[water] == LAYER_NONE).all())
self.assertTrue(np.isin(terrain[water], (0, 1, 2, 26)).all())
def test_no_pixel_is_empty_on_both_maps(self):
for atmo, rows in (("standard", 0), ("standard", 64), ("thin", 0)):
biome, terrain, _ = self._layers(atmo, water_rows=rows)
both = (biome == LAYER_NONE) & (terrain == LAYER_NONE)
self.assertFalse(both.any(), f"{atmo}/{rows}: {int(both.sum())} px on neither map")
def test_stacking_the_maps_is_the_rendered_grid(self):
biome, terrain, water = self._layers("standard", water_rows=64)
composed = compose_layers(biome, terrain)
elev = np.full((GRID_H, GRID_W), 0.3, dtype=np.float32)
elev[:, : GRID_W // 2] = 0.95
elev[-64:, :] = 0.05
temp = np.tile(np.linspace(275.0, 305.0, GRID_W, dtype=np.float32), (GRID_H, 1))
body = _body()
body["environment"]["hydrosphere"] = "liquid_water"
grid = compute_biome(body, elev, SEA_LEVEL, water, temp,
np.full((GRID_H, GRID_W), 0.5, dtype=np.float32))
self.assertTrue(np.array_equal(composed, grid))
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -38,6 +38,7 @@ IMPLEMENTATIONS = {
"batch": ("batch", []),
"scaffold": ("scaffold_bodies", ["index.json"]),
"import-heightmaps": ("import_heightmaps", []),
"bake-biome": ("bake_biome", []),
"import-provinces": ("import_province_boundaries", []),
"terrain-reference": ("populate_terrain_reference", []),
"sol-import": ("sol_import", []),