fix(tooling): address PR #129 review — atlas generator, schema, Makefile

- ON DELETE CASCADE added to every atlas_* foreign key (atlas_body_grids,
  atlas_cities, atlas_roads, atlas_railroads, atlas_pois, atlas_rivers,
  atlas_oceans, atlas_mountain_ranges). Previously, deleting a body from
  the bodies table or NULL-ing its terrain_reference would leave orphan
  atlas rows forever — sync_markers_to_db only cleans up for bodies it
  re-processes. The existing atlas tables in systems.db were dropped and
  recreated with the new constraint; FK list now reports CASCADE.

- Atlas DDL deduplicated. systems-schema.sql is now the single source of
  truth, bracketed by `-- BEGIN ATLAS INDEX` / `-- END ATLAS INDEX`
  markers. generate_atlas.py reads that block via `_load_atlas_schema()`
  and applies it at runtime, so there is no second copy of the DDL to
  keep in sync. Adding a column requires one edit, not two.

- Uniqueness guard on city coordinates. `_enforce_unique_city_coords`
  runs at the end of `place_cities` and deterministically perturbs any
  duplicate (row, col) via a fixed spiral walk to the first free
  walkable land cell. Rare in practice but the MST collapses to a
  zero-distance edge otherwise, producing an empty A* path and silently
  dropping the road.

- Grid header validation. `load_markers` now raises `AtlasGridMismatch`
  if the loaded `grid: {w, h}` header does not match `GRID_W`/`GRID_H`.
  Both the incremental-skip path and the regenerate path route through
  this loader, so a hand-authored template shipping a different grid
  size fails loud with a per-body error rather than silently producing
  half-scale coordinates.

- Unused `seed_rng` parameter removed from `_analyse_terrain`. The
  function is RNG-free (continent flood-fill, habitability scoring,
  river-mouth dedup, cost grid — all pure functions of terrain). The
  false API contract made it look like terrain analysis consumed RNG
  state and had to be sequenced with downstream RNG use.

- `_score_capital_sites` river-mouth bonus now builds one sparse
  accumulator with all mouth points set at once and runs a single
  `gaussian_filter` call, instead of O(n_mouths) filter calls over
  single-point images.

- `binary_dilation(analysis["land_mask"] == False)` replaced with the
  idiomatic `~analysis["land_mask"]`, matching the convention used
  elsewhere in the file.

- `atlas-generate` Makefile target now guards on
  `SELECT COUNT(*) FROM bodies WHERE terrain_reference IS NOT NULL`.
  On a fresh DB that count is 0 and the generator previously exited
  "success" after processing zero bodies. The target now fails loud
  with a pointer to `populate_terrain_reference.py`.

- `main.rs` SimRng defensive re-insertion gains a long comment
  explaining the exact plugin-ordering hazard it guards against, so
  future readers don't treat the line as dead code. Tied to #826.
This commit is contained in:
2026-04-15 09:25:46 +02:00
parent 64bb83f749
commit d21c690293
5 changed files with 209 additions and 152 deletions
+12 -1
View File
@@ -329,7 +329,18 @@ economy-db: ## Import economics data (commodities, chains, gate links) into sys
@python3 tooling/economy-db/import_economics.py
atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabited bodies (#832)
@cd tooling/planet-gen && python3 generate_atlas.py --seed 42
@# Loud guard: generate_atlas.py reads bodies with a non-NULL terrain_reference.
@# If populate_terrain_reference.py has not run on a fresh DB, the generator
@# silently processes zero bodies and exits 0 — fail fast instead.
@count=$$(python3 -c "import sqlite3; c = sqlite3.connect('server/data/systems.db'); print(c.execute('SELECT COUNT(*) FROM bodies WHERE terrain_reference IS NOT NULL').fetchone()[0])"); \
if [ "$$count" = "0" ]; then \
echo "ERROR: no bodies have terrain_reference populated yet."; \
echo "Run: python3 tooling/planet-gen/populate_terrain_reference.py"; \
echo "(This is a prerequisite for atlas-generate — see D-191 §9 pipeline order.)"; \
exit 1; \
fi; \
echo " [guard] $$count bodies with terrain_reference — proceeding."
@python3 tooling/planet-gen/generate_atlas.py --seed 42
econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade)
@cargo build --manifest-path tooling/econ-sim/Cargo.toml --release
+27 -22
View File
@@ -339,17 +339,20 @@ CREATE TABLE IF NOT EXISTS corp_lifecycle_events (
created_at TEXT DEFAULT (datetime('now'))
);
-- Atlas index (D-191 §8, #832) — scalar metadata mirror of markers.json files.
-- BEGIN ATLAS INDEX (D-191 §8, #832) -- DO NOT EDIT THE MARKER COMMENTS
-- Atlas index — scalar metadata mirror of markers.json files.
-- Source of truth is wiki/star-systems/.../markers.json (next to the heightmap);
-- these tables exist so the atlas implant app and development queries don't
-- have to scan hundreds of JSON files. Polyline geometry stays in the files —
-- the DB only stores scalar/filterable fields + `point_count` as a rough length
-- proxy. Populated and refreshed by tooling/planet-gen/generate_atlas.py.
-- proxy. Populated and refreshed by tooling/planet-gen/generate_atlas.py, which
-- extracts this entire block (between BEGIN/END ATLAS INDEX markers) from this
-- file at runtime so the DDL lives in exactly one place.
-- Per-body grid dimensions (one row per body that has a markers.json).
-- Lets any query interpret the pixel coordinates below without touching disk.
CREATE TABLE IF NOT EXISTS atlas_body_grids (
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id),
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE,
grid_w INTEGER NOT NULL,
grid_h INTEGER NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
@@ -357,7 +360,7 @@ CREATE TABLE IF NOT EXISTS atlas_body_grids (
CREATE TABLE IF NOT EXISTS atlas_cities (
city_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>" e.g. "GJ380c/city_0"
body_id TEXT NOT NULL REFERENCES bodies(body_id),
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
local_id TEXT NOT NULL, -- "city_0", "city_1" — matches markers.json id
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- capital|city
@@ -369,7 +372,7 @@ CREATE TABLE IF NOT EXISTS atlas_cities (
CREATE TABLE IF NOT EXISTS atlas_roads (
road_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
body_id TEXT NOT NULL REFERENCES bodies(body_id),
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- commercial|highway|rural|...
@@ -379,7 +382,7 @@ CREATE TABLE IF NOT EXISTS atlas_roads (
CREATE TABLE IF NOT EXISTS atlas_railroads (
railroad_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
body_id TEXT NOT NULL REFERENCES bodies(body_id),
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- passenger_freight|freight|maglev|...
@@ -389,7 +392,7 @@ CREATE TABLE IF NOT EXISTS atlas_railroads (
CREATE TABLE IF NOT EXISTS atlas_pois (
poi_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
body_id TEXT NOT NULL REFERENCES bodies(body_id),
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- transit|institutional|cultural|...
@@ -400,7 +403,7 @@ CREATE TABLE IF NOT EXISTS atlas_pois (
CREATE TABLE IF NOT EXISTS atlas_rivers (
river_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
body_id TEXT NOT NULL REFERENCES bodies(body_id),
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '', -- empty for untitled procedural rivers
point_count INTEGER NOT NULL,
@@ -409,7 +412,7 @@ CREATE TABLE IF NOT EXISTS atlas_rivers (
CREATE TABLE IF NOT EXISTS atlas_oceans (
water_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
body_id TEXT NOT NULL REFERENCES bodies(body_id),
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- lake|sea|ocean
@@ -421,7 +424,7 @@ CREATE TABLE IF NOT EXISTS atlas_oceans (
CREATE TABLE IF NOT EXISTS atlas_mountain_ranges (
range_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
body_id TEXT NOT NULL REFERENCES bodies(body_id),
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
center_row INTEGER NOT NULL,
@@ -432,6 +435,19 @@ CREATE TABLE IF NOT EXISTS atlas_mountain_ranges (
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_body ON atlas_cities(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_kind ON atlas_cities(kind);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_population ON atlas_cities(population);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_name ON atlas_cities(name);
CREATE INDEX IF NOT EXISTS idx_atlas_roads_body ON atlas_roads(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_railroads_body ON atlas_railroads(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_pois_body ON atlas_pois(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_pois_kind ON atlas_pois(kind);
CREATE INDEX IF NOT EXISTS idx_atlas_rivers_body ON atlas_rivers(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_oceans_body ON atlas_oceans(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_ranges(body_id);
-- END ATLAS INDEX (D-191 §8, #832)
-- Indexes
-- astronomical_id removed: system_id IS the GJ catalog number
CREATE INDEX IF NOT EXISTS idx_star_systems_sector ON star_systems(geographic_sector);
@@ -466,15 +482,4 @@ CREATE INDEX IF NOT EXISTS idx_brand_products_origin ON brand_products(origin_sy
CREATE INDEX IF NOT EXISTS idx_brand_products_tier ON brand_products(brand_tier);
CREATE INDEX IF NOT EXISTS idx_brand_inputs_commodity ON brand_inputs(commodity_id);
CREATE INDEX IF NOT EXISTS idx_corp_lifecycle_events_corp ON corp_lifecycle_events(corp_id);
-- Atlas index (D-191 §8, #832)
CREATE INDEX IF NOT EXISTS idx_atlas_cities_body ON atlas_cities(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_kind ON atlas_cities(kind);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_population ON atlas_cities(population);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_name ON atlas_cities(name);
CREATE INDEX IF NOT EXISTS idx_atlas_roads_body ON atlas_roads(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_railroads_body ON atlas_railroads(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_pois_body ON atlas_pois(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_pois_kind ON atlas_pois(kind);
CREATE INDEX IF NOT EXISTS idx_atlas_rivers_body ON atlas_rivers(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_oceans_body ON atlas_oceans(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_ranges(body_id);
-- Atlas index (D-191 §8, #832) — see BEGIN/END ATLAS INDEX block above.
Binary file not shown.
+12 -2
View File
@@ -176,8 +176,18 @@ fn main() {
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(HandshakeState::Complete);
// SimulationPlugin { seed } already inserts SimRng with the correct seed.
// Re-insert here as a defensive override in case plugin ordering shifts.
// SimulationPlugin { seed } already inserts SimRng with the correct seed
// during plugin build. We re-insert here as a defensive override for one
// specific ordering risk: any future plugin that registers *before*
// SimulationPlugin in `App::add_plugins` order (e.g. a pre-simulation
// observability plugin) and consumes SimRng at plugin build time would
// see a stale resource that was never seeded from StartupMessage. This
// `insert_resource` call happens AFTER all plugins have built, so it
// always overwrites whatever SimRng is currently in the world with the
// authoritative value from the StartupMessage. If you remove this line,
// also audit every `app.add_plugins(...)` call in this file and in
// `SimulationPlugin::build` for plugins that touch SimRng, and verify
// none of them run before SimulationPlugin's seeding logic. #826 thread.
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
// Initialize empty line pool index (populated by generator pipeline in v0.2).
+158 -127
View File
@@ -78,116 +78,51 @@ 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"
# ---------------------------------------------------------------------------
# Atlas index tables (D-191 §8, #832) — scalar mirror of markers.json.
# Duplicated from server/data/systems-schema.sql so generate_atlas.py works
# against any DB state without needing a wiki_sync first. Keep in sync with
# the canonical schema when adding columns.
# ---------------------------------------------------------------------------
ATLAS_MIGRATION_SQL = """
CREATE TABLE IF NOT EXISTS atlas_body_grids (
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id),
grid_w INTEGER NOT NULL,
grid_h INTEGER NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
def _load_atlas_schema() -> str:
"""Return the atlas_* DDL block from systems-schema.sql.
CREATE TABLE IF NOT EXISTS atlas_cities (
city_id TEXT PRIMARY KEY,
body_id TEXT NOT NULL REFERENCES bodies(body_id),
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
center_row INTEGER NOT NULL,
center_col INTEGER NOT NULL,
population INTEGER NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS atlas_roads (
road_id TEXT PRIMARY KEY,
body_id TEXT NOT NULL REFERENCES bodies(body_id),
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
point_count INTEGER NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS atlas_railroads (
railroad_id TEXT PRIMARY KEY,
body_id TEXT NOT NULL REFERENCES bodies(body_id),
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
point_count INTEGER NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS atlas_pois (
poi_id TEXT PRIMARY KEY,
body_id TEXT NOT NULL REFERENCES bodies(body_id),
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
center_row INTEGER NOT NULL,
center_col INTEGER NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS atlas_rivers (
river_id TEXT PRIMARY KEY,
body_id TEXT NOT NULL REFERENCES bodies(body_id),
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
point_count INTEGER NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS atlas_oceans (
water_id TEXT PRIMARY KEY,
body_id TEXT NOT NULL REFERENCES bodies(body_id),
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
center_row INTEGER NOT NULL,
center_col INTEGER NOT NULL,
area_fraction REAL NOT NULL DEFAULT 0.0,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS atlas_mountain_ranges (
range_id TEXT PRIMARY KEY,
body_id TEXT NOT NULL REFERENCES bodies(body_id),
local_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
center_row INTEGER NOT NULL,
center_col INTEGER NOT NULL,
peak_row INTEGER NOT NULL,
peak_col INTEGER NOT NULL,
area_cells INTEGER NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_body ON atlas_cities(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_kind ON atlas_cities(kind);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_population ON atlas_cities(population);
CREATE INDEX IF NOT EXISTS idx_atlas_cities_name ON atlas_cities(name);
CREATE INDEX IF NOT EXISTS idx_atlas_roads_body ON atlas_roads(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_railroads_body ON atlas_railroads(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_pois_body ON atlas_pois(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_pois_kind ON atlas_pois(kind);
CREATE INDEX IF NOT EXISTS idx_atlas_rivers_body ON atlas_rivers(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_oceans_body ON atlas_oceans(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_ranges(body_id);
"""
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 CREATE TABLE / INDEX IF NOT EXISTS for all atlas_* tables."""
conn.executescript(ATLAS_MIGRATION_SQL)
"""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.
"""
conn.executescript(_load_atlas_schema())
def _first_int(values, default: int = 0) -> int:
@@ -438,9 +373,14 @@ def load_body_def(body_dir: Path) -> dict | None:
# Terrain analysis
# ---------------------------------------------------------------------------
def _analyse_terrain(terrain: dict, seed_rng: np.random.Generator) -> dict:
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
@@ -634,15 +574,15 @@ def _score_capital_sites(
# Base score
score = habitability.copy()
# River mouth bonus (makes ~50% of capital choices go there)
river_bonus = np.zeros((GRID_H, GRID_W), dtype=np.float32)
for r, c in river_mouths:
# Smooth bonus around the river mouth
from scipy.ndimage import gaussian_filter
tmp = np.zeros((GRID_H, GRID_W), np.float32)
tmp[r, c] = 1.0
river_bonus += gaussian_filter(tmp, sigma=8.0)
# 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
@@ -810,7 +750,7 @@ def place_cities(
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"] == False) # noqa: E712
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
@@ -849,6 +789,7 @@ def place_cities(
"_continent_id": cont_id,
})
_enforce_unique_city_coords(cities, analysis["land_mask"])
_assign_populations(cities, body_population)
return cities
@@ -860,6 +801,58 @@ def _assign_populations(cities: list[dict], body_population: int) -> None:
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)
# ---------------------------------------------------------------------------
@@ -1052,12 +1045,40 @@ def place_gate_terminal(
# 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."""
"""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:
return json.load(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": [],
@@ -1141,19 +1162,23 @@ def process_body(
# 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:
with open(markers_path) as f:
existing = json.load(f)
if existing.get("cities"):
return {
"status": "already_populated",
"markers": existing,
}
existing = load_markers(body_dir)
except AtlasGridMismatch as e:
return {"status": "error", "message": str(e)}
except json.JSONDecodeError:
pass # corrupted markers.json → regenerate below
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:
@@ -1171,7 +1196,7 @@ def process_body(
if not terrain:
return {"status": "gas_giant"}
analysis = _analyse_terrain(terrain, rng)
analysis = _analyse_terrain(terrain)
n_cities = compute_city_count(population, settlement_pattern)
@@ -1187,8 +1212,14 @@ def process_body(
pois = place_gate_terminal(cities, rng)
# Load existing markers (preserves rivers/oceans/mountain_ranges) and
# overlay the new generator output.
markers = load_markers(body_dir)
# 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