Files
settled-reach/tooling/planet-gen/generate_atlas.py
T
jpmschweitzerandClaude Opus 4.6 3d9dd7d909 chore(ci): merge brand pipeline into import_economics + harden review findings
Addresses all blocking + minor items from PR #136 review.

Architectural change (T2/H3 — the review's main complaint):

  generate_brands was previously a separate Rust binary that produced a TOML
  artifact, with its stamp written "on behalf" by import_economics.py at the
  end of its own run.  Reviewers flagged the invisible coupling: two sources
  of truth in a system designed to have one, and no way to tell from the
  stamp that one "generator" was really a subroutine of the other.

  import_economics now invokes tooling/generate-brands as the first step of
  its main() flow, before opening its own DB connection.  The TOML artefact
  is still produced and still committed (useful for diff-review of brand
  changes), but there's now one pipeline owner.  The meta table carries two
  rows (import_economics, generate_atlas) not three; the Rust binary's
  source SHA folds into import_economics' stamp via IMPORT_ECONOMICS_SOURCES.
  A MIGRATION_SQL DELETE cleans up pre-merge DBs that still have the
  orphan generate_brands row.

Other review items addressed in-line:

  H1  generate_atlas._write_stamp no longer commits — transaction ownership
      stays with the caller (matches import_economics pattern).  Stamp +
      atlas data now commit atomically; a failed stamp rolls back the
      atlas data rather than leaving a stamp-missing-data intermediate.

  H2  _file_sha1 (in both import_economics, generate_atlas,
      check-systems-db-stamp) raises FileNotFoundError on missing sources
      instead of silently contributing an empty-bytes hash.  A ghost-SHA
      convergence could otherwise produce vacuous "fresh" passes.

  H4  pre-push no-meta-table warning rephrased — was "run after next
      regeneration", now "run now if this DB was generated by you".

  T1  asset-pipeline.md determinism claim softened: the stamp is
      deterministic (same source → same recorded SHA), the DB binary is
      not (generated_at + SQLite rowids/freelist churn).

  T3  asset-pipeline.md gains a "migration escape hatch" section naming
      MIGRATION_SQL in import_economics.py as the only sanctioned path
      for direct writes, and forbidding hand-run sqlite-exec / one-off
      patch scripts / SQLite-GUI edits.

  T4  Makefile regen-db now runs as a single shell with `set -e`.  A
      failure in one generator halts the pipeline immediately, preventing
      the "stale data, fresh stamp" state where a later step stamped a
      DB whose earlier step had failed.  import_economics' exit code 2
      (coverage gate warning) remains explicitly tolerated.

  T5  pre-push stamp check now runs on a branch's first push too —
      compares against origin/main instead of origin/$BRANCH, closing
      the gap where a new branch could ship a stale DB via the first push.

  T6  check-systems-db-stamp fails closed on unknown generator_names in
      meta — a future branch adding a new generator without registering
      it in GENERATOR_SOURCES will now be rejected, not silently skipped.

  T7  /pr-push watch list gains a mutual cross-reference comment with
      GENERATOR_SOURCES in check-systems-db-stamp, plus the missing
      names.rs source file, so the two lists cannot silently drift.

Follow-up tickets created:

  #887 T8  decisions-orphan-tickets CLI — surfaces tickets whose
           decision_ref points at a non-existent D-record.
  #888 T9  meta.schema_version monotonic semver — for savegame migration
           lineage in Phase 5+ (SHA comparison can't be ordered).

Verified:

  make regen-db end-to-end — OK
  make check-systems-db    — OK, 2 generator(s) up to date
  STALE detection          — OK, verified by touching generate_atlas.py
  /pr-push watch list      — OK, flags this branch's changed sources
  decision show D-159      — OK, structured output with tickets + refs

Refs: #855 #856 #857 #858 #859 PR #136

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:38:41 +02:00

1431 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
generate_atlas.py — Terrain-aware sequential city placement and infrastructure
generation for The Settled Reach Atlas (Phase 3, D-191 §3, §8, §9, #832).
Pipeline per body:
1. Load body definition from body index.md frontmatter
2. Simulate terrain via planet_simulation.simulate()
3. Analyse terrain: continents, habitability, river mouths, cost grid
4. Place cities sequentially (capital first, corridor growth, quadrant spread)
5. Generate infrastructure: A* roads + rail MST connecting all cities
6. Place gate terminal POI at largest population centre (occasionally scatter)
7. Write updated markers.json (preserves existing rivers/oceans/mountains)
City names are left as empty strings; gemma_naming.py (#833) fills them.
Usage:
python3 tooling/planet-gen/generate_atlas.py
python3 tooling/planet-gen/generate_atlas.py --body GJ380c
python3 tooling/planet-gen/generate_atlas.py --force
python3 tooling/planet-gen/generate_atlas.py --dry-run
python3 tooling/planet-gen/generate_atlas.py --seed 12345
Decisions: D-191 (atlas pipeline), D-188 (planet_class field name)
"""
import argparse
import hashlib
import heapq
import json
import math
import os
import sys
import time
from pathlib import Path
# ---------------------------------------------------------------------------
# Venv bootstrap
# ---------------------------------------------------------------------------
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
import numpy as np
import sqlite3
import yaml
from planet_simulation import simulate
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
GRID_W = 512
GRID_H = 256
# Terrain cost grid (D-191 §3: A* on terrain cost grid)
COST_WATER = 1e9 # impassable
COST_MOUNTAIN = 10.0 # expensive
COST_RIVER = 0.5 # cheap corridor
COST_FLAT = 1.0 # baseline land
# City placement
MOUNTAIN_ELEV_THRESHOLD = 0.55 # normalised elevation above sea_level → mountain
SETTLEMENT_PATTERN_MODIFIERS = {
"urban_concentrated": -1,
"dispersed_rural": +1,
"orbital_only": None, # no surface cities
"domed": None, # special: forced to 1
"cave": None, # special: forced to 1
}
# Quadrant distribution (D-191 §3: after 2 cities in same quadrant, prefer others)
QUADRANT_SATURATION = 2
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
SYSTEMS_SCHEMA_PATH = REPO_ROOT / "server" / "data" / "systems-schema.sql"
# Delimiters for the single canonical atlas_* DDL block in
# server/data/systems-schema.sql. `_load_atlas_schema()` extracts everything
# between these markers at runtime so this file does not have to duplicate
# the schema (and drift from it).
_ATLAS_SCHEMA_BEGIN_MARKER = "-- BEGIN ATLAS INDEX"
_ATLAS_SCHEMA_END_MARKER = "-- END ATLAS INDEX"
# ---------------------------------------------------------------------------
# Generator metadata stamp (#855, #856)
# ---------------------------------------------------------------------------
def _file_sha1(*paths: Path) -> str:
"""Return SHA-1 hex of the concatenated content of one or more files.
Files are sorted by path for determinism. Missing files raise
FileNotFoundError rather than silently skip — a ghost hash (empty-bytes
digest) can mask real breakage when stored and current SHAs converge
(#136 review H2).
"""
h = hashlib.sha1()
for p in sorted(paths):
if not p.exists():
raise FileNotFoundError(f"generator source not found: {p}")
h.update(p.read_bytes())
return h.hexdigest()
def _write_stamp(conn: sqlite3.Connection) -> None:
"""Upsert a meta row for generate_atlas after a successful run.
Idempotent: running twice on the same source files writes the same SHA
with an updated timestamp. The meta table is created by the atlas schema
migration executed in ensure_atlas_schema(); this function assumes it
exists.
Transaction ownership stays with the caller (matches the import_economics
pattern) — no inner commit here. Review H1 flagged the prior behaviour as
a double-commit with the atlas data write that precedes it.
"""
schema_sha = _file_sha1(SYSTEMS_SCHEMA_PATH)
generator_sha = _file_sha1(Path(__file__))
conn.execute(
"""INSERT OR REPLACE INTO meta
(generator_name, schema_version, generator_sha, generated_at)
VALUES ('generate_atlas', ?, ?, datetime('now'))""",
(schema_sha, generator_sha),
)
def _load_atlas_schema() -> str:
"""Return the atlas_* DDL block from systems-schema.sql.
systems-schema.sql is the single source of truth for the atlas index
tables (see the BEGIN ATLAS INDEX / END ATLAS INDEX markers). We extract
just that block and run it through `executescript` so the generator
works against any DB state (fresh or partially-migrated) without
requiring a prior `wiki_sync.ensure_schema()` call and without
maintaining a second copy of the DDL here.
"""
if not SYSTEMS_SCHEMA_PATH.exists():
raise RuntimeError(
f"systems-schema.sql not found at {SYSTEMS_SCHEMA_PATH} — "
"atlas generator cannot proceed without the canonical schema."
)
text = SYSTEMS_SCHEMA_PATH.read_text()
try:
start = text.index(_ATLAS_SCHEMA_BEGIN_MARKER)
end = text.index(_ATLAS_SCHEMA_END_MARKER, start)
except ValueError as e:
raise RuntimeError(
f"systems-schema.sql is missing the {_ATLAS_SCHEMA_BEGIN_MARKER}/"
f"{_ATLAS_SCHEMA_END_MARKER} block — has the schema been "
"restructured?"
) from e
return text[start:end]
def ensure_atlas_schema(conn: sqlite3.Connection) -> None:
"""Apply the canonical atlas_* DDL from systems-schema.sql.
Idempotent: all statements inside the block use CREATE TABLE / INDEX
IF NOT EXISTS, so running this on an already-migrated DB is a no-op.
Also ensures the meta stamp table exists (#855, #856).
"""
conn.executescript(_load_atlas_schema())
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS meta (
generator_name TEXT PRIMARY KEY,
schema_version TEXT NOT NULL,
generator_sha TEXT NOT NULL,
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
)
def _first_int(values, default: int = 0) -> int:
"""Coerce the first numeric element of an iterable (e.g. [r, c]) to int."""
try:
return int(values[0])
except (TypeError, ValueError, IndexError):
return default
def sync_markers_to_db(
conn: sqlite3.Connection,
body_id: str,
markers: dict,
) -> dict:
"""Refresh atlas_* rows for a single body from its markers.json contents.
Deletes any existing rows for the body (cascading across all seven atlas
tables) and re-inserts from the markers dict. Returns a count dict for
reporting.
"""
# Wipe existing rows for this body — fully deterministic rebuild.
for table in (
"atlas_cities",
"atlas_roads",
"atlas_railroads",
"atlas_pois",
"atlas_rivers",
"atlas_oceans",
"atlas_mountain_ranges",
):
conn.execute(f"DELETE FROM {table} WHERE body_id = ?", (body_id,))
grid = markers.get("grid") or {}
grid_w = int(grid.get("w", GRID_W))
grid_h = int(grid.get("h", GRID_H))
conn.execute(
"""INSERT INTO atlas_body_grids (body_id, grid_w, grid_h, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(body_id) DO UPDATE SET
grid_w = excluded.grid_w,
grid_h = excluded.grid_h,
updated_at = excluded.updated_at""",
(body_id, grid_w, grid_h),
)
counts = {
"cities": 0, "roads": 0, "railroads": 0, "pois": 0,
"rivers": 0, "oceans": 0, "mountain_ranges": 0,
}
for city in markers.get("cities") or []:
local = city.get("id")
if not local:
continue
center = city.get("center") or [0, 0]
conn.execute(
"""INSERT INTO atlas_cities
(city_id, body_id, local_id, name, kind, center_row, center_col, population)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
f"{body_id}/{local}",
body_id,
local,
city.get("name") or "",
city.get("kind") or "city",
_first_int(center, 0),
_first_int(center[1:] if len(center) > 1 else [0], 0),
int(city.get("population") or 0),
),
)
counts["cities"] += 1
for road in markers.get("roads") or []:
local = road.get("id")
if not local:
continue
path = road.get("path") or []
conn.execute(
"""INSERT INTO atlas_roads
(road_id, body_id, local_id, name, kind, point_count)
VALUES (?, ?, ?, ?, ?, ?)""",
(
f"{body_id}/{local}",
body_id,
local,
road.get("name") or "",
road.get("kind") or "commercial",
len(path),
),
)
counts["roads"] += 1
for rail in markers.get("railroads") or []:
local = rail.get("id")
if not local:
continue
path = rail.get("path") or []
conn.execute(
"""INSERT INTO atlas_railroads
(railroad_id, body_id, local_id, name, kind, point_count)
VALUES (?, ?, ?, ?, ?, ?)""",
(
f"{body_id}/{local}",
body_id,
local,
rail.get("name") or "",
rail.get("kind") or "passenger_freight",
len(path),
),
)
counts["railroads"] += 1
for poi in markers.get("pois") or []:
local = poi.get("id")
if not local:
continue
center = poi.get("center") or [0, 0]
conn.execute(
"""INSERT INTO atlas_pois
(poi_id, body_id, local_id, name, kind, center_row, center_col)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
f"{body_id}/{local}",
body_id,
local,
poi.get("name") or "",
poi.get("kind") or "transit",
_first_int(center, 0),
_first_int(center[1:] if len(center) > 1 else [0], 0),
),
)
counts["pois"] += 1
for river in markers.get("rivers") or []:
local = river.get("id")
if not local:
continue
path = river.get("path") or []
conn.execute(
"""INSERT INTO atlas_rivers
(river_id, body_id, local_id, name, point_count)
VALUES (?, ?, ?, ?, ?)""",
(
f"{body_id}/{local}",
body_id,
local,
river.get("name") or "",
len(path),
),
)
counts["rivers"] += 1
for water in markers.get("oceans") or []:
local = water.get("id")
if not local:
continue
center = water.get("center") or [0, 0]
conn.execute(
"""INSERT INTO atlas_oceans
(water_id, body_id, local_id, name, kind, center_row, center_col, area_fraction)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
f"{body_id}/{local}",
body_id,
local,
water.get("name") or "",
water.get("kind") or "ocean",
_first_int(center, 0),
_first_int(center[1:] if len(center) > 1 else [0], 0),
float(water.get("area_fraction") or 0.0),
),
)
counts["oceans"] += 1
for rng_feat in markers.get("mountain_ranges") or []:
local = rng_feat.get("id")
if not local:
continue
center = rng_feat.get("center") or [0, 0]
peak = rng_feat.get("peak") or center
conn.execute(
"""INSERT INTO atlas_mountain_ranges
(range_id, body_id, local_id, name, center_row, center_col,
peak_row, peak_col, area_cells)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
f"{body_id}/{local}",
body_id,
local,
rng_feat.get("name") or "",
_first_int(center, 0),
_first_int(center[1:] if len(center) > 1 else [0], 0),
_first_int(peak, 0),
_first_int(peak[1:] if len(peak) > 1 else [0], 0),
int(rng_feat.get("area_cells") or 0),
),
)
counts["mountain_ranges"] += 1
return counts
# ---------------------------------------------------------------------------
# Grid helpers
# ---------------------------------------------------------------------------
# markers.json is stored in pixel space per D-191 §8 — the canonical format is
# `center: [row, col]` and `path: [[row, col], ...]`. Lat/lon is a display-time
# derivation in the atlas UI, not a storage format.
def quadrant(row: int, col: int) -> int:
"""Return quadrant index 0-3: NW=0, NE=1, SW=2, SE=3."""
h = 0 if row < GRID_H // 2 else 1
w = 0 if col < GRID_W // 2 else 1
return h * 2 + w
def body_id_salt(body_id: str) -> int:
"""Deterministic per-body salt from body_id."""
return int(hashlib.sha256(body_id.encode()).hexdigest()[:8], 16)
# ---------------------------------------------------------------------------
# Body definition loading
# ---------------------------------------------------------------------------
def load_body_def(body_dir: Path) -> dict | None:
"""Load body definition from body index.md frontmatter."""
index_md = body_dir / "index.md"
if not index_md.exists():
return None
content = index_md.read_text()
if not content.startswith("---"):
return None
try:
end = content.index("---", 3)
bd = yaml.safe_load(content[3:end])
except (ValueError, yaml.YAMLError):
return None
if not bd or "id" not in bd or "planet_class" not in bd:
return None
return bd
# ---------------------------------------------------------------------------
# Terrain analysis
# ---------------------------------------------------------------------------
def _analyse_terrain(terrain: dict) -> dict:
"""Analyse terrain for atlas city placement.
Deterministic and RNG-free: continent flood-fill, habitability scoring,
river-mouth detection, and the terrain cost grid are all pure functions
of the terrain dict. Per-body variation comes from the RNG used later
in `_score_capital_sites` and `place_cities`, not from this function.
Returns:
continents: list of continent dicts {cells, area, id}
habitability: float32 (H, W) — 0=uninhabitable, 1=ideal
river_mouths: list of (row, col) — land cells where rivers meet water
cost_grid: float32 (H, W) — A* traversal cost per cell
continent_map: int32 (H, W) — continent label per land cell (-1=water)
land_mask: bool (H, W)
"""
from scipy.ndimage import label
elevation = terrain["elevation"]
temperature = terrain["temperature"]
moisture = terrain["moisture"]
surface_water = terrain["surface_water"]
river_grid = terrain.get("river_grid", np.zeros((GRID_H, GRID_W), bool))
sea_level = terrain["sea_level"]
land = ~surface_water
# Normalised elevation above sea level
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
mountains = land & (elev_norm > MOUNTAIN_ELEV_THRESHOLD)
# --- Continent detection (flood-fill connected land) ---
continent_labels, n_continents = label(land)
continents = []
for lbl in range(1, n_continents + 1):
mask = continent_labels == lbl
area = int(mask.sum())
if area < 50:
continue # ignore tiny rock outcroppings
ys, xs = np.where(mask)
continents.append({
"id": lbl,
"area": area,
"cells": list(zip(ys.tolist(), xs.tolist())),
"_center": (int(ys.mean()), int(xs.mean())),
})
continents.sort(key=lambda c: c["area"], reverse=True)
continent_map = np.where(land, continent_labels, -1).astype(np.int32)
# --- Habitability scoring ---
# temperature: 0.30.7 is ideal (mid-range)
temp_score = 1.0 - np.abs(temperature - 0.5) * 2.0
temp_score = np.clip(temp_score, 0.0, 1.0)
# moisture: 0.30.6 is ideal
moist_score = np.where(
moisture < 0.3, moisture / 0.3,
np.where(moisture < 0.6, 1.0, 1.0 - (moisture - 0.6) / 0.4)
)
moist_score = np.clip(moist_score, 0.0, 1.0)
# slope: low slope = ideal (flat land)
dy = np.gradient(elevation, axis=0)
dx = np.gradient(elevation, axis=1)
slope = np.sqrt(dy**2 + dx**2)
slope_max = float(slope[land].max()) if land.any() else 1.0
slope_score = 1.0 - np.clip(slope / (slope_max + 1e-9), 0.0, 1.0)
habitability = (temp_score * 0.4 + moist_score * 0.3 + slope_score * 0.3)
habitability = np.where(land, habitability, 0.0).astype(np.float32)
# Coastal access bonus: land cells adjacent to water
water_f = surface_water.astype(np.float32)
from scipy.ndimage import uniform_filter
water_proximity = uniform_filter(water_f, size=7)
coastal_bonus = np.clip(water_proximity * 2.0, 0.0, 0.3)
habitability = np.where(land, habitability + coastal_bonus.astype(np.float32), 0.0)
habitability = np.clip(habitability, 0.0, 1.0).astype(np.float32)
# --- River mouth detection ---
# A river mouth is a river-grid cell that is land but adjacent to water
river_mouths = []
if river_grid.any() and surface_water.any():
# Dilate surface_water by 1 cell
from scipy.ndimage import binary_dilation
water_dilated = binary_dilation(surface_water)
# River cells on land that touch water
mouth_mask = river_grid & land & water_dilated
rys, rxs = np.where(mouth_mask)
# Deduplicate by proximity (cluster to strongest mouth)
if len(rys) > 0:
used = np.zeros(len(rys), bool)
for i in range(len(rys)):
if used[i]:
continue
used[i] = True
r, c = int(rys[i]), int(rxs[i])
# Suppress nearby duplicates within radius 10
dist = np.sqrt((rys - r)**2 + (rxs - c)**2)
used[dist < 10] = True
river_mouths.append((r, c))
# --- Terrain cost grid (D-191 §3) ---
cost_grid = np.full((GRID_H, GRID_W), COST_FLAT, dtype=np.float32)
cost_grid[surface_water] = COST_WATER
cost_grid[mountains] = COST_MOUNTAIN
cost_grid[river_grid & land] = COST_RIVER
return {
"continents": continents,
"habitability": habitability,
"river_mouths": river_mouths,
"cost_grid": cost_grid,
"continent_map": continent_map,
"land_mask": land,
}
# ---------------------------------------------------------------------------
# City count computation (D-191 §8)
# ---------------------------------------------------------------------------
def distribute_population(total_population: int, n_cities: int) -> list[int]:
"""Split a body's population across n_cities using a simple geometric decay.
Capital gets ~50%, each subsequent city gets half of what the previous one
did, and the remaining tail is absorbed by the capital so totals still match.
Returns a list of int populations (rounded to the nearest 10,000 for realism).
"""
if n_cities <= 0 or total_population <= 0:
return []
if n_cities == 1:
return [int(total_population)]
weights = [0.5 ** i for i in range(n_cities)]
weight_sum = sum(weights)
shares = [w / weight_sum for w in weights]
populations = []
remaining = total_population
for i, share in enumerate(shares):
if i == len(shares) - 1:
pop = remaining
else:
pop = int(round(total_population * share / 10_000) * 10_000)
pop = min(pop, remaining - (len(shares) - i - 1) * 10_000)
pop = max(pop, 10_000)
populations.append(pop)
remaining -= pop
return populations
def compute_city_count(population: int, settlement_pattern: str | None) -> int:
"""Compute number of cities for a body.
Base count: floor(log10(pop / 1_000_000)), minimum 1 if inhabited.
Settlement pattern modifiers from D-191 §8.
"""
if population <= 0:
return 0
if settlement_pattern in ("orbital_only",):
return 0
if settlement_pattern in ("domed", "cave"):
return 1
base = int(math.floor(math.log10(max(population, 1_000_001) / 1_000_000)))
base = max(1, base) # at least 1 city if inhabited
mod = SETTLEMENT_PATTERN_MODIFIERS.get(settlement_pattern, 0) or 0
result = max(1, base + mod)
return min(result, 12) # cap at 12 to avoid overcrowding small maps
# ---------------------------------------------------------------------------
# City placement (D-191 §3)
# ---------------------------------------------------------------------------
def _score_capital_sites(
analysis: dict,
rng: np.random.Generator,
noise_factor: float = 0.25,
) -> np.ndarray:
"""Score all land cells for capital placement.
Capital scoring factors:
- Habitability score (temperature, moisture, slope)
- River mouth bonus (~50% of capitals at river mouths per D-191 §3)
- ±25% noise variation per seed
"""
habitability = analysis["habitability"]
land = analysis["land_mask"]
river_mouths = analysis["river_mouths"]
# Base score
score = habitability.copy()
# River mouth bonus (makes ~50% of capital choices go there).
# Build a single sparse accumulator with all mouth points set, then
# run gaussian_filter once — O(1) filter calls rather than O(n_mouths).
if river_mouths:
from scipy.ndimage import gaussian_filter
mouth_field = np.zeros((GRID_H, GRID_W), dtype=np.float32)
for r, c in river_mouths:
mouth_field[r, c] = 1.0
river_bonus = gaussian_filter(mouth_field, sigma=8.0)
river_bonus = river_bonus / (river_bonus.max() + 1e-9)
score = score * 0.5 + river_bonus * 0.5
# ±25% noise for seed variation (D-191 §3)
noise = rng.uniform(1.0 - noise_factor, 1.0 + noise_factor,
size=(GRID_H, GRID_W)).astype(np.float32)
score = score * noise
score = np.where(land, score, 0.0)
return score
def _quadrant_counts(placed: list[tuple[int, int]]) -> np.ndarray:
"""Return count of placed cities per quadrant (4 quadrants)."""
counts = np.zeros(4, dtype=int)
for r, c in placed:
counts[quadrant(r, c)] += 1
return counts
def _dijkstra_from_sources(
cost_grid: np.ndarray,
sources: list[tuple[int, int]],
) -> np.ndarray:
"""Multi-source Dijkstra on cost_grid from all source cells.
Returns distance array (H, W). Uses 8-connectivity.
"""
dist = np.full((GRID_H, GRID_W), np.inf, dtype=np.float64)
heap = []
for r, c in sources:
if cost_grid[r, c] < 1e8:
dist[r, c] = 0.0
heapq.heappush(heap, (0.0, r, c))
while heap:
d, r, c = heapq.heappop(heap)
if d > dist[r, c]:
continue
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0:
continue
nr, nc = r + dr, c + dc
if nr < 0 or nr >= GRID_H or nc < 0 or nc >= GRID_W:
continue
edge = cost_grid[nr, nc]
if edge >= 1e8:
continue
step = math.sqrt(2) if dr != 0 and dc != 0 else 1.0
nd = d + edge * step
if nd < dist[nr, nc]:
dist[nr, nc] = nd
heapq.heappush(heap, (nd, nr, nc))
return dist
def place_cities(
analysis: dict,
n_cities: int,
rng: np.random.Generator,
noise_factor: float = 0.25,
body_population: int = 0,
) -> list[dict]:
"""Place cities sequentially per D-191 §3.
Returns a list of city dicts in the canonical markers.json schema
(D-191 §8 — pixel space):
{id, name, kind, center: [row, col], population}
with internal `_row`, `_col`, `_continent_id` fields used by downstream
infrastructure generation. The underscore-prefixed fields are stripped
before writing to disk.
"""
if n_cities <= 0:
return []
continents = analysis["continents"]
habitability = analysis["habitability"]
cost_grid = analysis["cost_grid"]
continent_map = analysis["continent_map"]
land = analysis["land_mask"]
if not continents:
return []
placed_coords: list[tuple[int, int]] = []
placed_continents: set[int] = set()
cities = []
# ── 1. Capital ──────────────────────────────────────────────────────────
capital_score = _score_capital_sites(analysis, rng, noise_factor)
# Suppress edge pixels (poles tend to be degenerate)
capital_score[:5, :] = 0
capital_score[-5:, :] = 0
best = int(np.argmax(capital_score))
cap_r, cap_c = divmod(best, GRID_W)
continent_id = int(continent_map[cap_r, cap_c]) if continent_map[cap_r, cap_c] > 0 else 1
placed_coords.append((cap_r, cap_c))
placed_continents.add(continent_id)
cities.append({
"id": "city_0",
"name": "",
"kind": "capital",
"center": [cap_r, cap_c],
"population": 0, # filled after all cities are placed
"_row": cap_r,
"_col": cap_c,
"_continent_id": continent_id,
})
if n_cities == 1:
_assign_populations(cities, body_population)
return cities
# Minimum exclusion radius: varies by city count (more cities → tighter spacing)
excl_radius = max(20, GRID_H // (n_cities + 1))
# ── 2. Subsequent cities (corridor growth + quadrant spread) ────────────
for i in range(1, n_cities):
q_counts = _quadrant_counts(placed_coords)
# Multi-source Dijkstra from all placed cities along cost grid
corridor_dist = _dijkstra_from_sources(cost_grid, placed_coords)
# Score for new city: habitability weighted by corridor distance
# Prefer connected but not too-close (distance 50300 from existing cities)
ideal_min = excl_radius
ideal_max = min(300, excl_radius * 6)
dist_score = np.where(
(corridor_dist >= ideal_min) & (corridor_dist < ideal_max),
1.0 - (corridor_dist - ideal_min) / (ideal_max - ideal_min + 1e-9),
np.where(corridor_dist < ideal_min, 0.0, 0.1),
).astype(np.float32)
site_score = habitability * 0.6 + dist_score * 0.4
# Quadrant penalty: if a quadrant is already at QUADRANT_SATURATION,
# reduce its score so subsequent cities prefer emptier quadrants.
q_penalty = np.ones((GRID_H, GRID_W), np.float32)
for qr in (0, 1):
for qc in (0, 1):
q_idx = qr * 2 + qc
if q_counts[q_idx] >= QUADRANT_SATURATION:
rlo, rhi = (0, GRID_H // 2) if qr == 0 else (GRID_H // 2, GRID_H)
clo, chi = (0, GRID_W // 2) if qc == 0 else (GRID_W // 2, GRID_W)
q_penalty[rlo:rhi, clo:chi] = 0.3
site_score = site_score * q_penalty
# New-continent bonus at cities 3-4 (D-191 §3: port on new continent)
if i in (2, 3) and len(continents) > 1:
unexplored_conts = [
c for c in continents
if c["id"] not in placed_continents and c["area"] > 200
]
if unexplored_conts:
cont = unexplored_conts[0]
# Find coastline cells on that continent
coast_bonus = np.zeros((GRID_H, GRID_W), np.float32)
cont_mask = continent_map == cont["id"]
# Coastal cells: continent land cells adjacent to water
from scipy.ndimage import binary_dilation
water_dilated = binary_dilation(~analysis["land_mask"])
coast_mask = cont_mask & water_dilated
coast_bonus[coast_mask] = 0.4
site_score = site_score + coast_bonus
# ±25% noise
noise = rng.uniform(1.0 - noise_factor, 1.0 + noise_factor,
size=(GRID_H, GRID_W)).astype(np.float32)
site_score = site_score * noise
site_score = np.where(land, site_score, 0.0)
# Suppress already-placed city zones
for pr, pc in placed_coords:
rlo = max(0, pr - excl_radius)
rhi = min(GRID_H, pr + excl_radius)
clo = max(0, pc - excl_radius)
chi = min(GRID_W, pc + excl_radius)
site_score[rlo:rhi, clo:chi] = 0.0
if site_score.max() < 1e-6:
break # no more habitable land
best = int(np.argmax(site_score))
r, c = divmod(best, GRID_W)
cont_id = int(continent_map[r, c]) if continent_map[r, c] > 0 else continent_id
placed_coords.append((r, c))
placed_continents.add(cont_id)
cities.append({
"id": f"city_{i}",
"name": "",
"kind": "city",
"center": [r, c],
"population": 0,
"_row": r,
"_col": c,
"_continent_id": cont_id,
})
_enforce_unique_city_coords(cities, analysis["land_mask"])
_assign_populations(cities, body_population)
return cities
def _assign_populations(cities: list[dict], body_population: int) -> None:
"""Fill the `population` field on each city from the body's total."""
pops = distribute_population(body_population, len(cities))
for city, pop in zip(cities, pops):
city["population"] = int(pop)
def _enforce_unique_city_coords(cities: list[dict], land: np.ndarray) -> None:
"""Guarantee no two cities share the same (row, col).
Rare on real heightmaps but possible when grids are small and the
quadrant-saturation penalty pushes candidates into tight corners. If
two cities land on identical pixels the MST treats them as zero-
distance nodes and A* produces an empty path, silently skipping the
edge. We deterministically perturb duplicates by walking outward in
a fixed spiral until a free, walkable land cell is found; the search
order is fully determined by `city_index` so the operation stays
byte-equivalent across runs.
"""
if len(cities) <= 1:
return
# Fixed spiral offsets — small radius first, then widen.
spiral: list[tuple[int, int]] = []
for radius in range(1, 12):
for dr in range(-radius, radius + 1):
for dc in range(-radius, radius + 1):
if abs(dr) == radius or abs(dc) == radius:
spiral.append((dr, dc))
occupied: set[tuple[int, int]] = set()
for idx, city in enumerate(cities):
center = (city["_row"], city["_col"])
if center not in occupied:
occupied.add(center)
continue
# Duplicate — walk the spiral for the first free land cell.
for dr, dc in spiral:
nr, nc = center[0] + dr, center[1] + dc
if 0 <= nr < GRID_H and 0 <= nc < GRID_W and land[nr, nc] and (nr, nc) not in occupied:
print(
f" warning: city {idx} at {center} collided with an "
f"earlier placement — deterministically perturbed to ({nr}, {nc})"
)
city["_row"] = nr
city["_col"] = nc
city["center"] = [nr, nc]
occupied.add((nr, nc))
break
else:
# Fallback: land is saturated — just keep the duplicate; the
# MST edge will collapse but the body is a degenerate case.
print(
f" warning: city {idx} at {center} has no free neighbor "
f"— duplicate allowed (degenerate body)"
)
occupied.add(center)
# ---------------------------------------------------------------------------
# Infrastructure generation (D-191 §3)
# ---------------------------------------------------------------------------
def _astar_path(
cost_grid: np.ndarray,
start: tuple[int, int],
goal: tuple[int, int],
) -> list[tuple[int, int]]:
"""A* pathfinding on cost_grid (8-connectivity). Returns path or []."""
sr, sc = start
gr, gc = goal
def h(r, c):
return math.sqrt((r - gr)**2 + (c - gc)**2)
dist = {(sr, sc): 0.0}
prev = {}
heap = [(h(sr, sc), 0.0, sr, sc)]
while heap:
_, d, r, c = heapq.heappop(heap)
if r == gr and c == gc:
# Reconstruct path
path = []
cur = (gr, gc)
while cur in prev:
path.append(cur)
cur = prev[cur]
path.append((sr, sc))
path.reverse()
return path
if d > dist.get((r, c), float("inf")) + 1e-9:
continue
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0:
continue
nr, nc = r + dr, c + dc
if nr < 0 or nr >= GRID_H or nc < 0 or nc >= GRID_W:
continue
edge = cost_grid[nr, nc]
if edge >= 1e8:
continue
step = math.sqrt(2) if dr != 0 and dc != 0 else 1.0
nd = d + edge * step
if nd < dist.get((nr, nc), float("inf")):
dist[(nr, nc)] = nd
prev[(nr, nc)] = (r, c)
heapq.heappush(heap, (nd + h(nr, nc), nd, nr, nc))
return []
def _mst_edges(n_cities: int, city_coords: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Prim's MST on Euclidean distances between cities. Returns list of (i, j) edges."""
if n_cities <= 1:
return []
in_tree = {0}
edges = []
while len(in_tree) < n_cities:
best_cost = float("inf")
best_edge = None
for i in in_tree:
for j in range(n_cities):
if j in in_tree:
continue
r1, c1 = city_coords[i]
r2, c2 = city_coords[j]
cost = math.sqrt((r1 - r2)**2 + (c1 - c2)**2)
if cost < best_cost:
best_cost = cost
best_edge = (i, j)
if best_edge is None:
break
edges.append(best_edge)
in_tree.add(best_edge[1])
return edges
def _subsample_path(path: list[tuple[int, int]], max_points: int = 64) -> list[list[int]]:
"""Subsample a path to at most max_points for compact JSON storage."""
if len(path) <= max_points:
return [[r, c] for r, c in path]
step = len(path) / max_points
result = []
for i in range(max_points):
idx = int(i * step)
r, c = path[idx]
result.append([r, c])
# Always include endpoint
r, c = path[-1]
if result[-1] != [r, c]:
result.append([r, c])
return result
def generate_infrastructure(
analysis: dict,
cities: list[dict],
) -> tuple[list[dict], list[dict]]:
"""Generate roads and railroads connecting cities.
Roads: A* paths on terrain cost grid connecting each MST edge.
Railroads: same MST edges but via a cost grid favouring corridors.
Returns (roads, railroads) — each is a list of path dicts.
"""
if len(cities) < 2:
return [], []
cost_grid = analysis["cost_grid"]
city_coords = [((c["_row"], c["_col"])) for c in cities]
n = len(city_coords)
mst = _mst_edges(n, city_coords)
roads = []
railroads = []
road_cost = cost_grid.copy()
# Rail cost: slightly prefer following roads (lower cost after first pass)
rail_cost = cost_grid.copy()
for edge_idx, (i, j) in enumerate(mst):
start = city_coords[i]
goal = city_coords[j]
road_path = _astar_path(road_cost, start, goal)
if road_path:
roads.append({
"id": f"road_{edge_idx}",
"name": "",
"kind": "commercial",
"path": _subsample_path(road_path),
})
# After placing a road, reduce cost along it for rail (rail follows roads)
for r, c in road_path:
if rail_cost[r, c] < 1e8:
rail_cost[r, c] = max(0.3, rail_cost[r, c] * 0.5)
rail_path = _astar_path(rail_cost, start, goal)
if rail_path:
railroads.append({
"id": f"railroad_{edge_idx}",
"name": "",
"kind": "passenger_freight",
"path": _subsample_path(rail_path),
})
return roads, railroads
# ---------------------------------------------------------------------------
# Gate terminal POI placement (D-191 §8)
# ---------------------------------------------------------------------------
def place_gate_terminal(
cities: list[dict],
rng: np.random.Generator,
) -> list[dict]:
"""Place a gate terminal POI at the largest population centre (sometimes scatter).
Primary placement: city 0 (capital, largest population center).
Occasional scatter: 15% chance to a secondary city (per D-191 §8).
Returns a list with a single POI dict in the canonical template schema:
`{id, name, kind: "transit", center: [row, col]}`. The name is left empty
for gemma_naming.py (#833) to fill.
"""
if not cities:
return []
scatter_prob = 0.15
gate_city = cities[0]
if len(cities) > 1 and rng.random() < scatter_prob:
gate_city = cities[int(rng.integers(1, len(cities)))]
return [{
"id": "poi_0",
"name": "",
"kind": "transit",
"center": [gate_city["_row"], gate_city["_col"]],
}]
# ---------------------------------------------------------------------------
# Markers.json update
# ---------------------------------------------------------------------------
class AtlasGridMismatch(Exception):
"""Raised when a markers.json grid header does not match generator constants.
If a hand-authored template ships with, say, `{"w": 1024, "h": 512}`
and the generator overlays new cities computed against the 512 × 256
cost grid, every coordinate is half-scale and every marker is broken.
Fail loud here rather than silently produce corrupt output.
"""
def load_markers(body_dir: Path) -> dict:
"""Load existing markers.json, return empty structure if missing.
Validates the grid header against the generator constants so the
caller can trust that overlaid city/road/poi coordinates are in
the same pixel space as the loaded geographic features. A mismatch
raises `AtlasGridMismatch` — regenerating a markers.json against
a different grid would corrupt every coordinate in it.
"""
markers_path = body_dir / "markers.json"
if markers_path.exists():
with open(markers_path) as f:
markers = json.load(f)
grid = markers.get("grid") or {}
g_w = grid.get("w")
g_h = grid.get("h")
if g_w != GRID_W or g_h != GRID_H:
raise AtlasGridMismatch(
f"{markers_path} has grid {{w: {g_w}, h: {g_h}}} but the "
f"atlas generator runs against {{w: {GRID_W}, h: {GRID_H}}}. "
"Either regenerate the heightmap pipeline at the generator "
"resolution, or update GRID_W / GRID_H to match the source."
)
return markers
return {
"grid": {"w": GRID_W, "h": GRID_H},
"rivers": [],
"oceans": [],
"mountain_ranges": [],
"roads": [],
"cities": [],
"railroads": [],
"pois": [],
}
# ---------------------------------------------------------------------------
# DB queries
# ---------------------------------------------------------------------------
def query_inhabited_bodies(conn: sqlite3.Connection) -> list[dict]:
"""Query systems.db for all inhabited bodies with terrain_reference set."""
rows = conn.execute("""
SELECT
b.body_id,
b.system_id,
b.terrain_reference,
b.population,
b.settlement_pattern,
b.planet_class,
b.economic_role,
COALESCE(b.cultural_corridor, s.cultural_corridor) AS cultural_corridor
FROM bodies b
JOIN star_systems s ON b.system_id = s.system_id
WHERE b.inhabited = 1
AND b.terrain_reference IS NOT NULL
AND b.population > 0
ORDER BY b.system_id, b.body_id
""").fetchall()
return [
{
"body_id": row[0],
"system_id": row[1],
"terrain_reference": row[2],
"population": row[3] or 0,
"settlement_pattern": row[4],
"planet_class": row[5],
"economic_role": row[6],
"cultural_corridor": row[7],
}
for row in rows
]
# ---------------------------------------------------------------------------
# Main pipeline
# ---------------------------------------------------------------------------
def process_body(
body_info: dict,
seed: int,
noise_factor: float,
dry_run: bool,
force: bool,
verbose: bool,
) -> dict:
"""Process one body.
Returns a dict with:
status: 'generated' | 'already_populated' | 'gas_giant' | 'error'
message: human-readable detail (on error)
markers: the markers.json dict if one was produced or loaded (for DB sync)
"""
body_id = body_info["body_id"]
terrain_ref = body_info["terrain_reference"]
population = body_info["population"]
settlement_pattern = body_info["settlement_pattern"]
# Locate body directory from terrain_reference (repo-root relative).
body_dir = REPO_ROOT / Path(terrain_ref).parent
if not body_dir.exists():
return {"status": "error", "message": f"body_dir not found: {body_dir}"}
# Incremental check: if the markers file already has cities, we keep it as
# the source of truth and return it for DB sync instead of regenerating.
# `load_markers` validates the grid header; a mismatch on a hand-authored
# template is reported as an error so downstream DB sync doesn't silently
# index a file that disagrees with the generator's pixel space.
if not force:
markers_path = body_dir / "markers.json"
if markers_path.exists():
try:
existing = load_markers(body_dir)
except AtlasGridMismatch as e:
return {"status": "error", "message": str(e)}
except json.JSONDecodeError:
existing = None # corrupted markers.json → regenerate below
if existing and existing.get("cities"):
return {
"status": "already_populated",
"markers": existing,
}
bd = load_body_def(body_dir)
if not bd:
return {"status": "error", "message": f"no body definition for {body_id}"}
# Seeded RNG: deterministic per (world_seed, body_id)
body_salt = body_id_salt(body_id)
rng = np.random.default_rng(seed ^ body_salt)
try:
terrain = simulate(bd)
except Exception as e:
return {"status": "error", "message": f"simulate failed: {e}"}
if not terrain:
return {"status": "gas_giant"}
analysis = _analyse_terrain(terrain)
n_cities = compute_city_count(population, settlement_pattern)
if verbose:
print(f" {body_id}: pop={population:,} pattern={settlement_pattern} "
f"→ {n_cities} cities, {len(analysis['river_mouths'])} river mouths, "
f"{len(analysis['continents'])} continents")
cities = place_cities(
analysis, n_cities, rng, noise_factor, body_population=population
)
roads, railroads = generate_infrastructure(analysis, cities)
pois = place_gate_terminal(cities, rng)
# Load existing markers (preserves rivers/oceans/mountain_ranges) and
# overlay the new generator output. `load_markers` asserts the loaded
# grid header matches the generator constants — a mismatch would
# silently corrupt every coordinate in the file.
try:
markers = load_markers(body_dir)
except AtlasGridMismatch as e:
return {"status": "error", "message": str(e)}
output_cities = [
{k: v for k, v in city.items() if not k.startswith("_")}
for city in cities
]
markers["cities"] = output_cities
markers["roads"] = roads
markers["railroads"] = railroads
markers["pois"] = pois
if not dry_run:
with open(body_dir / "markers.json", "w") as f:
json.dump(markers, f, indent=2)
return {"status": "generated", "markers": markers}
def main():
parser = argparse.ArgumentParser(
description="Atlas generation — terrain-aware city placement and infrastructure (#832)"
)
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
parser.add_argument("--body", help="Process only this body_id")
parser.add_argument("--force", action="store_true",
help="Regenerate even if cities already populated")
parser.add_argument("--dry-run", action="store_true",
help="Run analysis but do not write markers.json")
parser.add_argument("--seed", type=int, default=42,
help="World seed for deterministic placement (default: 42)")
parser.add_argument("--noise", type=float, default=0.25,
help="City placement noise factor ±N (default: 0.25 = ±25%%)")
parser.add_argument("--verbose", action="store_true",
help="Print per-body detail")
args = parser.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
print(f"\n Atlas Generation Pipeline (#832)")
print(f" DB: {db_path}")
print(f" Seed: {args.seed}")
if args.dry_run:
print(f" Mode: DRY RUN (no markers.json written, no DB sync)")
if args.force:
print(f" Force: enabled (will overwrite existing city placements)")
print()
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys=ON")
ensure_atlas_schema(conn)
bodies = query_inhabited_bodies(conn)
if args.body:
bodies = [b for b in bodies if b["body_id"] == args.body]
if not bodies:
print(f"error: body '{args.body}' not found or has no terrain_reference",
file=sys.stderr)
conn.close()
sys.exit(1)
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
t_total = time.time()
n_generated = 0
n_already = 0
n_gas = 0
n_errors = 0
total_counts = {
"cities": 0, "roads": 0, "railroads": 0, "pois": 0,
"rivers": 0, "oceans": 0, "mountain_ranges": 0,
}
for i, body_info in enumerate(bodies):
body_id = body_info["body_id"]
t0 = time.time()
result = process_body(
body_info,
seed=args.seed,
noise_factor=args.noise,
dry_run=args.dry_run,
force=args.force,
verbose=args.verbose,
)
elapsed = time.time() - t0
status = result["status"]
if status == "generated":
n_generated += 1
if not args.dry_run:
counts = sync_markers_to_db(conn, body_id, result["markers"])
for k, v in counts.items():
total_counts[k] += v
print(f" [{i+1}/{len(bodies)}] {body_id:20s} generated ({elapsed:.1f}s)")
elif status == "already_populated":
n_already += 1
if not args.dry_run:
counts = sync_markers_to_db(conn, body_id, result["markers"])
for k, v in counts.items():
total_counts[k] += v
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} already populated — DB sync only")
elif status == "gas_giant":
n_gas += 1
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} gas giant — no surface")
elif status == "error":
n_errors += 1
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
if not args.dry_run:
# Stamp generator metadata (#855, #856) together with the atlas data
# in a single commit — atlas data + stamp land atomically, and the
# stamp function itself no longer commits (H1). Failure of the stamp
# write rolls back the atlas data too rather than leaving a stamped-
# but-missing-data intermediate state.
try:
_write_stamp(conn)
conn.commit()
print(" Stamped: generate_atlas")
except Exception as exc: # noqa: BLE001
conn.rollback()
print(f" WARNING: failed to write generator stamp: {exc}", file=sys.stderr)
print(" Atlas data NOT committed — regen required.", file=sys.stderr)
conn.close()
elapsed_total = time.time() - t_total
print(f"\n Done: {elapsed_total:.0f}s")
print(f" generated: {n_generated}")
print(f" already populated: {n_already}")
print(f" gas giants: {n_gas}")
print(f" errors: {n_errors}")
if args.dry_run:
print(f"\n Dry run — no markers.json files were written and no DB rows were touched.")
else:
print(f"\n Atlas index refreshed:")
for k in ("cities", "roads", "railroads", "pois", "rivers", "oceans", "mountain_ranges"):
print(f" atlas_{k:16s} {total_counts[k]:6d} rows")
if n_generated > 0:
print(f"\n {n_generated} markers.json files updated with cities, roads, railroads, pois.")
print(f" Run gemma_naming.py (#833) to fill city and feature names.")
print()
if n_errors > 0:
sys.exit(1)
if __name__ == "__main__":
main()