diff --git a/Makefile b/Makefile index 65852a4f6..8f3f7cd3b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index 0e5f344de..796b7933c 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -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, -- "/" 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 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 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 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 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 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 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. diff --git a/server/data/systems.db b/server/data/systems.db index 8cdfe7e43..34ce951c9 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/main.rs b/server/src/main.rs index 7182c49e9..c386848b6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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). diff --git a/tooling/planet-gen/generate_atlas.py b/tooling/planet-gen/generate_atlas.py index 9a6d8dd43..a4a643660 100644 --- a/tooling/planet-gen/generate_atlas.py +++ b/tooling/planet-gen/generate_atlas.py @@ -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