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:
@@ -329,7 +329,18 @@ economy-db: ## Import economics data (commodities, chains, gate links) into sys
|
|||||||
@python3 tooling/economy-db/import_economics.py
|
@python3 tooling/economy-db/import_economics.py
|
||||||
|
|
||||||
atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabited bodies (#832)
|
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)
|
econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade)
|
||||||
@cargo build --manifest-path tooling/econ-sim/Cargo.toml --release
|
@cargo build --manifest-path tooling/econ-sim/Cargo.toml --release
|
||||||
|
|||||||
@@ -339,17 +339,20 @@ CREATE TABLE IF NOT EXISTS corp_lifecycle_events (
|
|||||||
created_at TEXT DEFAULT (datetime('now'))
|
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);
|
-- 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
|
-- 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 —
|
-- 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
|
-- 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).
|
-- Per-body grid dimensions (one row per body that has a markers.json).
|
||||||
-- Lets any query interpret the pixel coordinates below without touching disk.
|
-- Lets any query interpret the pixel coordinates below without touching disk.
|
||||||
CREATE TABLE IF NOT EXISTS atlas_body_grids (
|
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_w INTEGER NOT NULL,
|
||||||
grid_h INTEGER NOT NULL,
|
grid_h INTEGER NOT NULL,
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
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 (
|
CREATE TABLE IF NOT EXISTS atlas_cities (
|
||||||
city_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>" e.g. "GJ380c/city_0"
|
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
|
local_id TEXT NOT NULL, -- "city_0", "city_1" — matches markers.json id
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
kind TEXT NOT NULL, -- capital|city
|
kind TEXT NOT NULL, -- capital|city
|
||||||
@@ -369,7 +372,7 @@ CREATE TABLE IF NOT EXISTS atlas_cities (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS atlas_roads (
|
CREATE TABLE IF NOT EXISTS atlas_roads (
|
||||||
road_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
|
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,
|
local_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
kind TEXT NOT NULL, -- commercial|highway|rural|...
|
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 (
|
CREATE TABLE IF NOT EXISTS atlas_railroads (
|
||||||
railroad_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
|
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,
|
local_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
kind TEXT NOT NULL, -- passenger_freight|freight|maglev|...
|
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 (
|
CREATE TABLE IF NOT EXISTS atlas_pois (
|
||||||
poi_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
|
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,
|
local_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
kind TEXT NOT NULL, -- transit|institutional|cultural|...
|
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 (
|
CREATE TABLE IF NOT EXISTS atlas_rivers (
|
||||||
river_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
|
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,
|
local_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL DEFAULT '', -- empty for untitled procedural rivers
|
name TEXT NOT NULL DEFAULT '', -- empty for untitled procedural rivers
|
||||||
point_count INTEGER NOT NULL,
|
point_count INTEGER NOT NULL,
|
||||||
@@ -409,7 +412,7 @@ CREATE TABLE IF NOT EXISTS atlas_rivers (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS atlas_oceans (
|
CREATE TABLE IF NOT EXISTS atlas_oceans (
|
||||||
water_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
|
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,
|
local_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
kind TEXT NOT NULL, -- lake|sea|ocean
|
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 (
|
CREATE TABLE IF NOT EXISTS atlas_mountain_ranges (
|
||||||
range_id TEXT PRIMARY KEY, -- "<body_id>/<local_id>"
|
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,
|
local_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
center_row INTEGER NOT NULL,
|
center_row INTEGER NOT NULL,
|
||||||
@@ -432,6 +435,19 @@ CREATE TABLE IF NOT EXISTS atlas_mountain_ranges (
|
|||||||
updated_at TEXT DEFAULT (datetime('now'))
|
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
|
-- Indexes
|
||||||
-- astronomical_id removed: system_id IS the GJ catalog number
|
-- astronomical_id removed: system_id IS the GJ catalog number
|
||||||
CREATE INDEX IF NOT EXISTS idx_star_systems_sector ON star_systems(geographic_sector);
|
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_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_brand_inputs_commodity ON brand_inputs(commodity_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_corp_lifecycle_events_corp ON corp_lifecycle_events(corp_id);
|
CREATE INDEX IF NOT EXISTS idx_corp_lifecycle_events_corp ON corp_lifecycle_events(corp_id);
|
||||||
-- Atlas index (D-191 §8, #832)
|
-- Atlas index (D-191 §8, #832) — see BEGIN/END ATLAS INDEX block above.
|
||||||
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);
|
|
||||||
|
|||||||
Binary file not shown.
+12
-2
@@ -176,8 +176,18 @@ fn main() {
|
|||||||
app.insert_resource(BridgeResource::new(bridge));
|
app.insert_resource(BridgeResource::new(bridge));
|
||||||
app.insert_resource(HandshakeState::Complete);
|
app.insert_resource(HandshakeState::Complete);
|
||||||
|
|
||||||
// SimulationPlugin { seed } already inserts SimRng with the correct seed.
|
// SimulationPlugin { seed } already inserts SimRng with the correct seed
|
||||||
// Re-insert here as a defensive override in case plugin ordering shifts.
|
// 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));
|
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
|
||||||
|
|
||||||
// Initialize empty line pool index (populated by generator pipeline in v0.2).
|
// Initialize empty line pool index (populated by generator pipeline in v0.2).
|
||||||
|
|||||||
@@ -78,116 +78,51 @@ QUADRANT_SATURATION = 2
|
|||||||
|
|
||||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||||
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
|
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"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def _load_atlas_schema() -> str:
|
||||||
# Atlas index tables (D-191 §8, #832) — scalar mirror of markers.json.
|
"""Return the atlas_* DDL block from systems-schema.sql.
|
||||||
# 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'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS atlas_cities (
|
systems-schema.sql is the single source of truth for the atlas index
|
||||||
city_id TEXT PRIMARY KEY,
|
tables (see the BEGIN ATLAS INDEX / END ATLAS INDEX markers). We extract
|
||||||
body_id TEXT NOT NULL REFERENCES bodies(body_id),
|
just that block and run it through `executescript` so the generator
|
||||||
local_id TEXT NOT NULL,
|
works against any DB state (fresh or partially-migrated) without
|
||||||
name TEXT NOT NULL DEFAULT '',
|
requiring a prior `wiki_sync.ensure_schema()` call and without
|
||||||
kind TEXT NOT NULL,
|
maintaining a second copy of the DDL here.
|
||||||
center_row INTEGER NOT NULL,
|
"""
|
||||||
center_col INTEGER NOT NULL,
|
if not SYSTEMS_SCHEMA_PATH.exists():
|
||||||
population INTEGER NOT NULL DEFAULT 0,
|
raise RuntimeError(
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
f"systems-schema.sql not found at {SYSTEMS_SCHEMA_PATH} — "
|
||||||
);
|
"atlas generator cannot proceed without the canonical schema."
|
||||||
|
)
|
||||||
CREATE TABLE IF NOT EXISTS atlas_roads (
|
text = SYSTEMS_SCHEMA_PATH.read_text()
|
||||||
road_id TEXT PRIMARY KEY,
|
try:
|
||||||
body_id TEXT NOT NULL REFERENCES bodies(body_id),
|
start = text.index(_ATLAS_SCHEMA_BEGIN_MARKER)
|
||||||
local_id TEXT NOT NULL,
|
end = text.index(_ATLAS_SCHEMA_END_MARKER, start)
|
||||||
name TEXT NOT NULL DEFAULT '',
|
except ValueError as e:
|
||||||
kind TEXT NOT NULL,
|
raise RuntimeError(
|
||||||
point_count INTEGER NOT NULL,
|
f"systems-schema.sql is missing the {_ATLAS_SCHEMA_BEGIN_MARKER}/"
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
f"{_ATLAS_SCHEMA_END_MARKER} block — has the schema been "
|
||||||
);
|
"restructured?"
|
||||||
|
) from e
|
||||||
CREATE TABLE IF NOT EXISTS atlas_railroads (
|
return text[start:end]
|
||||||
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);
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_atlas_schema(conn: sqlite3.Connection) -> None:
|
def ensure_atlas_schema(conn: sqlite3.Connection) -> None:
|
||||||
"""Apply CREATE TABLE / INDEX IF NOT EXISTS for all atlas_* tables."""
|
"""Apply the canonical atlas_* DDL from systems-schema.sql.
|
||||||
conn.executescript(ATLAS_MIGRATION_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:
|
def _first_int(values, default: int = 0) -> int:
|
||||||
@@ -438,9 +373,14 @@ def load_body_def(body_dir: Path) -> dict | None:
|
|||||||
# Terrain analysis
|
# 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.
|
"""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:
|
Returns:
|
||||||
continents: list of continent dicts {cells, area, id}
|
continents: list of continent dicts {cells, area, id}
|
||||||
habitability: float32 (H, W) — 0=uninhabitable, 1=ideal
|
habitability: float32 (H, W) — 0=uninhabitable, 1=ideal
|
||||||
@@ -634,15 +574,15 @@ def _score_capital_sites(
|
|||||||
# Base score
|
# Base score
|
||||||
score = habitability.copy()
|
score = habitability.copy()
|
||||||
|
|
||||||
# River mouth bonus (makes ~50% of capital choices go there)
|
# River mouth bonus (makes ~50% of capital choices go there).
|
||||||
river_bonus = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
# Build a single sparse accumulator with all mouth points set, then
|
||||||
for r, c in river_mouths:
|
# run gaussian_filter once — O(1) filter calls rather than O(n_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)
|
|
||||||
if river_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)
|
river_bonus = river_bonus / (river_bonus.max() + 1e-9)
|
||||||
score = score * 0.5 + river_bonus * 0.5
|
score = score * 0.5 + river_bonus * 0.5
|
||||||
|
|
||||||
@@ -810,7 +750,7 @@ def place_cities(
|
|||||||
cont_mask = continent_map == cont["id"]
|
cont_mask = continent_map == cont["id"]
|
||||||
# Coastal cells: continent land cells adjacent to water
|
# Coastal cells: continent land cells adjacent to water
|
||||||
from scipy.ndimage import binary_dilation
|
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_mask = cont_mask & water_dilated
|
||||||
coast_bonus[coast_mask] = 0.4
|
coast_bonus[coast_mask] = 0.4
|
||||||
site_score = site_score + coast_bonus
|
site_score = site_score + coast_bonus
|
||||||
@@ -849,6 +789,7 @@ def place_cities(
|
|||||||
"_continent_id": cont_id,
|
"_continent_id": cont_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
_enforce_unique_city_coords(cities, analysis["land_mask"])
|
||||||
_assign_populations(cities, body_population)
|
_assign_populations(cities, body_population)
|
||||||
return cities
|
return cities
|
||||||
|
|
||||||
@@ -860,6 +801,58 @@ def _assign_populations(cities: list[dict], body_population: int) -> None:
|
|||||||
city["population"] = int(pop)
|
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)
|
# Infrastructure generation (D-191 §3)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1052,12 +1045,40 @@ def place_gate_terminal(
|
|||||||
# Markers.json update
|
# 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:
|
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"
|
markers_path = body_dir / "markers.json"
|
||||||
if markers_path.exists():
|
if markers_path.exists():
|
||||||
with open(markers_path) as f:
|
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 {
|
return {
|
||||||
"grid": {"w": GRID_W, "h": GRID_H},
|
"grid": {"w": GRID_W, "h": GRID_H},
|
||||||
"rivers": [],
|
"rivers": [],
|
||||||
@@ -1141,19 +1162,23 @@ def process_body(
|
|||||||
|
|
||||||
# Incremental check: if the markers file already has cities, we keep it as
|
# 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.
|
# 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:
|
if not force:
|
||||||
markers_path = body_dir / "markers.json"
|
markers_path = body_dir / "markers.json"
|
||||||
if markers_path.exists():
|
if markers_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(markers_path) as f:
|
existing = load_markers(body_dir)
|
||||||
existing = json.load(f)
|
except AtlasGridMismatch as e:
|
||||||
if existing.get("cities"):
|
return {"status": "error", "message": str(e)}
|
||||||
return {
|
|
||||||
"status": "already_populated",
|
|
||||||
"markers": existing,
|
|
||||||
}
|
|
||||||
except json.JSONDecodeError:
|
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)
|
bd = load_body_def(body_dir)
|
||||||
if not bd:
|
if not bd:
|
||||||
@@ -1171,7 +1196,7 @@ def process_body(
|
|||||||
if not terrain:
|
if not terrain:
|
||||||
return {"status": "gas_giant"}
|
return {"status": "gas_giant"}
|
||||||
|
|
||||||
analysis = _analyse_terrain(terrain, rng)
|
analysis = _analyse_terrain(terrain)
|
||||||
|
|
||||||
n_cities = compute_city_count(population, settlement_pattern)
|
n_cities = compute_city_count(population, settlement_pattern)
|
||||||
|
|
||||||
@@ -1187,8 +1212,14 @@ def process_body(
|
|||||||
pois = place_gate_terminal(cities, rng)
|
pois = place_gate_terminal(cities, rng)
|
||||||
|
|
||||||
# Load existing markers (preserves rivers/oceans/mountain_ranges) and
|
# Load existing markers (preserves rivers/oceans/mountain_ranges) and
|
||||||
# overlay the new generator output.
|
# overlay the new generator output. `load_markers` asserts the loaded
|
||||||
markers = load_markers(body_dir)
|
# 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 = [
|
output_cities = [
|
||||||
{k: v for k, v in city.items() if not k.startswith("_")}
|
{k: v for k, v in city.items() if not k.startswith("_")}
|
||||||
for city in cities
|
for city in cities
|
||||||
|
|||||||
Reference in New Issue
Block a user