diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index 23ea5ebf1..fd495f4c7 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -175,6 +175,11 @@ CREATE TABLE IF NOT EXISTS bodies ( cultural_corridor TEXT, -- override system corridor if different industrial_corridor TEXT, -- MVG, Gate_Corp, DSMC, Prometheus, Agricultural_Syndic + -- Physical dimensions (D-204, #905) + -- Mean radius in km. NULL until authoritative data is available; fallback + -- derivation from planet_class is applied at query time by the generator. + body_radius_km REAL, + -- Rendering -- terrain_reference: repo-root-relative path to the body's heightmap PNG. -- Convention (enforced by populate_terrain_reference.py and assumed by @@ -455,6 +460,58 @@ 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); +-- Heightmap BLOB storage — float32 LE, row-major (D-202, #901) +-- Only inhabited bodies receive rows at build time; uninhabited bodies are +-- generated on-demand by the runtime-background tier. +CREATE TABLE IF NOT EXISTS atlas_body_heightmaps ( + body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE, + width INTEGER NOT NULL DEFAULT 512, + height INTEGER NOT NULL DEFAULT 256, + data BLOB NOT NULL, -- float32 LE, row-major, width×height values + sea_level REAL NOT NULL DEFAULT 0.0, + imported_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- City name reservations — replaces authored city positions in markers.json (D-207, #902) +-- Position is generated by the city placement algorithm; name is authored or LLM-generated. +CREATE TABLE IF NOT EXISTS atlas_city_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city' + economic_role TEXT NOT NULL, + population INTEGER NOT NULL, + corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable + reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Geographic feature name reservations — rivers, mountains, passes (D-207 adjacent, #903) +CREATE TABLE IF NOT EXISTS atlas_feature_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + feature_type TEXT NOT NULL, -- 'river' | 'mountain' | 'pass' | 'ocean' | 'region' + priority INTEGER NOT NULL DEFAULT 0, -- higher = applied first during naming + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Province boundaries — watershed drainage basin polylines (D-205, #904) +-- Pre-computed at build time from D8 drainage analysis. +CREATE TABLE IF NOT EXISTS atlas_province_boundaries ( + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + basin_id INTEGER NOT NULL, + path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline + area_pct REAL NOT NULL, -- fraction of body surface area in this basin + PRIMARY KEY (body_id, basin_id) +); + +CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id); +CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id); -- END ATLAS INDEX (D-191 §8, #832) -- Indexes diff --git a/server/data/systems.db b/server/data/systems.db index feabcec23..a69795733 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 77df10fb2..390506227 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -24,6 +24,7 @@ Usage: """ import argparse +import glob import hashlib import json import re @@ -40,6 +41,7 @@ COMMODITIES_TOML = REPO_ROOT / "wiki" / "economics" / "commodities.toml" CHAINS_TOML = REPO_ROOT / "wiki" / "economics" / "production_chains.toml" SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql" CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations" +WIKI_STAR_SYSTEMS = REPO_ROOT / "wiki" / "star-systems" BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "brands.toml" GENERATED_BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "generated_brands.toml" # Rust sources for the generate_brands subroutine. import_economics shells out to @@ -281,6 +283,62 @@ CREATE TABLE IF NOT EXISTS meta ( -- own meta row. This DELETE makes the check-systems-db-stamp "unknown generator" -- path (fail-closed per T6) compatible with older DBs that still have the row. DELETE FROM meta WHERE generator_name = 'generate_brands'; + +-- Heightmap BLOB storage (D-202, #901) +CREATE TABLE IF NOT EXISTS atlas_body_heightmaps ( + body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE, + width INTEGER NOT NULL DEFAULT 512, + height INTEGER NOT NULL DEFAULT 256, + data BLOB NOT NULL, + sea_level REAL NOT NULL DEFAULT 0.0, + imported_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id); + +-- City name reservations (D-207, #902) +CREATE TABLE IF NOT EXISTS atlas_city_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'city', + economic_role TEXT NOT NULL, + population INTEGER NOT NULL, + corp_id TEXT REFERENCES corporations(corp_id), + reserved INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id); + +-- Geographic feature name reservations (#903) +CREATE TABLE IF NOT EXISTS atlas_feature_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + feature_type TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id); + +-- Province boundaries (D-205, #904) +CREATE TABLE IF NOT EXISTS atlas_province_boundaries ( + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + basin_id INTEGER NOT NULL, + path TEXT NOT NULL, + area_pct REAL NOT NULL, + PRIMARY KEY (body_id, basin_id) +); +CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id); + +-- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911). +-- Idempotent: each UPDATE is a no-op if the old value is already gone. +UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture'); +UPDATE bodies SET economic_role = 'extraction' WHERE economic_role IN ('mining', 'resource_extraction', 'energy'); +UPDATE bodies SET economic_role = 'transit_hub' WHERE economic_role = 'transit'; +UPDATE bodies SET economic_role = 'service_mixed' WHERE economic_role IN ('commercial', 'coordination'); +UPDATE bodies SET economic_role = 'residential' WHERE economic_role = 'frontier'; """ # Columns to add to existing tables (ALTER TABLE is idempotent via try/except) @@ -291,6 +349,7 @@ COLUMN_MIGRATIONS = [ ("corporations", "supply_chain_role", "TEXT"), ("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"), ("brand_products", "price_tier", "TEXT"), + ("bodies", "body_radius_km", "REAL"), # D-204 — physical radius in km, nullable ] @@ -776,6 +835,29 @@ def validate(conn: sqlite3.Connection) -> list[str]: for chain_id, cid in orphan_outputs: errors.append(f"production_chains: chain '{chain_id}' outputs unknown commodity '{cid}'") + # economic_role must be one of the D-194 canonical 10 values + valid_roles = { + 'manufacturing', 'financial', 'agricultural', 'extraction', + 'service_mixed', 'institutional', 'transit_hub', 'research', + 'military', 'residential', + } + bad_roles = conn.execute(""" + SELECT DISTINCT economic_role, COUNT(*) as cnt + FROM bodies + WHERE economic_role IS NOT NULL + AND economic_role NOT IN ( + 'manufacturing', 'financial', 'agricultural', 'extraction', + 'service_mixed', 'institutional', 'transit_hub', 'research', + 'military', 'residential' + ) + GROUP BY economic_role + """).fetchall() + for role, cnt in bad_roles: + errors.append( + f"bodies.economic_role: non-canonical value '{role}' on {cnt} row(s) — " + f"valid values: {sorted(valid_roles)}" + ) + # Chain completeness: every intermediate commodity must have at least one producer missing_chains = conn.execute(""" SELECT c.commodity_id, c.name @@ -980,6 +1062,228 @@ def import_system_fiscal(conn: sqlite3.Connection, dry_run: bool) -> int: return len(rows) +def populate_body_radius_km(conn: sqlite3.Connection, dry_run: bool) -> int: + """Populate body_radius_km column from planet_class fallback (D-204, #910). + + Applies the fallback lookup table to rows where body_radius_km IS NULL. + Does not overwrite rows where body_radius_km is already set (authoritative data). + + Fallback values (km): + super_earth -> 8000 + earth_like -> 6371 + earth -> 6371 (alternate spelling) + sub_earth -> 4500 + ocean_world -> 6500 + arid -> 5800 + frozen -> 4500 + ice_world -> 3000 + barren -> 4500 + volcanic -> 5500 + gas_giant -> 0 (no settlements, skip) + moon -> 1737 + other/unknown -> 6371 (Earth default) + """ + PLANET_CLASS_RADIUS = { + "super_earth": 8000.0, + "earth_like": 6371.0, + "earth": 6371.0, + "sub_earth": 4500.0, + "ocean_world": 6500.0, + "arid": 5800.0, + "frozen": 4500.0, + "ice_world": 3000.0, + "barren": 4500.0, + "volcanic": 5500.0, + "temperate": 6371.0, + "moon": 1737.0, + } + DEFAULT_RADIUS = 6371.0 + + rows = conn.execute( + "SELECT body_id, planet_class FROM bodies WHERE body_radius_km IS NULL" + ).fetchall() + + updates = [] + for body_id, planet_class in rows: + if planet_class and planet_class.lower() == "gas_giant": + continue # gas giants have no settlements; leave NULL + radius = PLANET_CLASS_RADIUS.get( + (planet_class or "").lower(), DEFAULT_RADIUS + ) + updates.append((radius, body_id)) + + if not dry_run and updates: + conn.executemany( + "UPDATE bodies SET body_radius_km = ? WHERE body_id = ?", updates + ) + + return len(updates) + + +def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int: + """Populate atlas_city_names from wiki markers.json city entries (D-207, #908). + + Scans wiki/star-systems/*/bodies/*/markers.json for 'cities' arrays. + Each entry yields one atlas_city_names row: + - body_id : directory name (e.g. GJ0e) + - name : city name from markers.json + - kind : 'capital' or 'city' (default 'city') + - economic_role : inherited from bodies.economic_role; fallback 'mixed' + - population : from markers.json (integer) + - corp_id : NULL — populated by populate_atlas_city_names_corps (#909) + - reserved : 0 + + Uses INSERT OR REPLACE so re-runs are idempotent per (body_id, name). + Skips body directories not found in the bodies table (missing FK). + """ + # Build body_id -> economic_role map + body_roles: dict[str, str] = {} + for body_id, role in conn.execute( + "SELECT body_id, economic_role FROM bodies" + ).fetchall(): + body_roles[body_id] = role or "mixed" + + valid_body_ids: set[str] = set(body_roles.keys()) + + rows: list[tuple] = [] + skipped_bodies: list[str] = [] + + pattern = str(WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "markers.json") + for markers_path in sorted(glob.glob(pattern)): + body_id = markers_path.split("/bodies/")[1].split("/")[0] + if body_id not in valid_body_ids: + skipped_bodies.append(body_id) + continue + + with open(markers_path) as fh: + data = json.load(fh) + + for city in data.get("cities", []): + name = city.get("name", "").strip() + if not name: + continue + kind = city.get("kind", "city") + population = int(city.get("population", 0)) + economic_role = body_roles[body_id] + rows.append((body_id, name, kind, economic_role, population)) + + if skipped_bodies: + unique = sorted(set(skipped_bodies)) + print(f" warning: {len(unique)} body dirs not in DB — skipped: {unique[:5]}") + + if not dry_run and rows: + conn.executemany( + """INSERT OR REPLACE INTO atlas_city_names + (body_id, name, kind, economic_role, population) + VALUES (?, ?, ?, ?, ?)""", + rows, + ) + + return len(rows) + + +def populate_atlas_city_names_corps(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]: + """Cross-reference corp HQ city names into atlas_city_names (D-207, #909). + + For each corporation with a parseable headquarters field ("City (SYSTEM_ID)"): + - If atlas_city_names already has a row with matching name on a body in that + system: UPDATE the row to set corp_id. + - Otherwise: INSERT a reserved row (reserved=1) so the name is protected. + Attaches to the most-populated body in the system (fallback: any body). + + Returns (n_updated, n_inserted). + """ + # Build system_id -> sorted bodies (by population desc, then body_id) + sys_bodies: dict[str, list[tuple[int, str, str]]] = {} + for body_id, sys_id, pop, role in conn.execute( + "SELECT body_id, system_id, COALESCE(population, 0), COALESCE(economic_role, 'mixed') FROM bodies" + ).fetchall(): + sys_bodies.setdefault(sys_id, []).append((pop, body_id, role)) + for v in sys_bodies.values(): + v.sort(key=lambda x: (-x[0], x[1])) + + # Build (body_id, name_lower) -> id index for existing atlas_city_names rows + existing: dict[tuple[str, str], int] = {} + body_to_sys: dict[str, str] = { + r[0]: r[1] + for r in conn.execute("SELECT body_id, system_id FROM bodies").fetchall() + } + for row_id, body_id, name in conn.execute( + "SELECT id, body_id, name FROM atlas_city_names" + ).fetchall(): + existing[(body_id, name.lower())] = row_id + + # Build system_id -> set of body_ids for quick lookup + sys_body_ids: dict[str, set[str]] = {} + for body_id, sys_id in body_to_sys.items(): + sys_body_ids.setdefault(sys_id, set()).add(body_id) + + updated: list[tuple[str, int]] = [] # (corp_id, atlas_row_id) + inserted: list[tuple] = [] # insert rows + + for corp_id, headquarters_system in conn.execute( + "SELECT corp_id, headquarters_system FROM corporations WHERE headquarters_system IS NOT NULL" + ).fetchall(): + # Retrieve original headquarters string from wiki to get city name + md_file = CORPORATIONS_DIR / f"{corp_id}.md" + if not md_file.exists(): + continue + hq_raw = "" + with open(md_file) as f: + in_fm = False + for line in f: + if line.strip() == "---": + if not in_fm: + in_fm = True + continue + else: + break + if in_fm and line.startswith("headquarters:"): + hq_raw = line.split(":", 1)[1].strip().strip('"') + break + if not hq_raw: + continue + m = re.search(r"\(([^)]+)\)", hq_raw) + city_name = hq_raw[: m.start()].strip() if m else hq_raw.strip() + if not city_name: + continue + + # Try to find a matching atlas_city_names row in the same system + body_ids_in_sys = sys_body_ids.get(headquarters_system, set()) + match_id: int | None = None + for body_id in body_ids_in_sys: + key = (body_id, city_name.lower()) + if key in existing: + match_id = existing[key] + break + + if match_id is not None: + updated.append((corp_id, match_id)) + else: + # Insert a reserved row on the most-populated body in the system + candidates = sys_bodies.get(headquarters_system, []) + if not candidates: + continue + _, target_body_id, body_role = candidates[0] + inserted.append((target_body_id, city_name, "city", body_role, 0, corp_id, 1)) + + if not dry_run: + for corp_id, row_id in updated: + conn.execute( + "UPDATE atlas_city_names SET corp_id = ? WHERE id = ?", + (corp_id, row_id), + ) + if inserted: + conn.executemany( + """INSERT OR IGNORE INTO atlas_city_names + (body_id, name, kind, economic_role, population, corp_id, reserved) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + inserted, + ) + + return len(updated), len(inserted) + + def validate_brands(conn: sqlite3.Connection) -> list[str]: """Brand layer structural validation rules V-B01 through V-B06. @@ -1220,10 +1524,25 @@ def main(): print(f" {n_brands} brand_products, {n_brand_inputs} brand_inputs") # 10. System fiscal parameters (D-189 section 6) - print(" [10/10] Populating system_fiscal...") + print(" [10/13] Populating system_fiscal...") n_fiscal = import_system_fiscal(conn, args.dry_run) print(f" {n_fiscal} system_fiscal rows") + # 11. body_radius_km fallback from planet_class (D-204, #910) + print(" [11/13] Populating body_radius_km fallback...") + n_radius = populate_body_radius_km(conn, args.dry_run) + print(f" {n_radius} bodies updated") + + # 12. atlas_city_names from wiki markers.json (D-207, #908) + print(" [12/13] Populating atlas_city_names from wiki content...") + n_cities = populate_atlas_city_names(conn, args.dry_run) + print(f" {n_cities} city name rows") + + # 13. atlas_city_names corp HQ cross-reference (D-207, #909) + print(" [13/13] Cross-referencing corp HQ cities into atlas_city_names...") + n_updated, n_inserted = populate_atlas_city_names_corps(conn, args.dry_run) + print(f" {n_updated} rows updated, {n_inserted} reserved rows inserted") + # Validate structural integrity (FK, chain refs, chain completeness). # These errors indicate broken imported data — do NOT commit. print("\n Validating structural integrity...") diff --git a/tooling/planet-gen/generate_atlas.py b/tooling/planet-gen/generate_atlas.py index 03fede765..53ad05ebd 100644 --- a/tooling/planet-gen/generate_atlas.py +++ b/tooling/planet-gen/generate_atlas.py @@ -121,7 +121,15 @@ def _write_stamp(conn: sqlite3.Connection) -> None: a double-commit with the atlas data write that precedes it. """ schema_sha = _file_sha1(SYSTEMS_SCHEMA_PATH) - generator_sha = _file_sha1(Path(__file__)) + # Hash all generate_atlas sources — must match GENERATOR_SOURCES in check-systems-db-stamp. + _atlas_dir = Path(__file__).parent + generator_sha = _file_sha1( + Path(__file__), + _atlas_dir / "gemma_naming.py", + _atlas_dir / "naming_core.py", + _atlas_dir / "import_heightmaps.py", + _atlas_dir / "import_province_boundaries.py", + ) conn.execute( """INSERT OR REPLACE INTO meta (generator_name, schema_version, generator_sha, generated_at) diff --git a/tooling/planet-gen/import_city_names.py b/tooling/planet-gen/import_city_names.py new file mode 100644 index 000000000..9571563a9 --- /dev/null +++ b/tooling/planet-gen/import_city_names.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +import_city_names.py — Populate atlas_city_names from wiki markers.json content. + +For each inhabited body, reads city records from markers.json and inserts rows +into atlas_city_names with: + - name, kind, population from markers.json + - economic_role from bodies table + - corp_id from corporations.headquarters_body cross-reference (#909) + +Incremental: clears and reimports all rows for each body on every run (the +table has no stable local IDs — city identity is name × body_id). Use --body +to restrict to a single body. + +Usage: + tooling/planet-gen/import_city_names.py + tooling/planet-gen/import_city_names.py --body GJ380c + tooling/planet-gen/import_city_names.py --dry-run + +Exit codes: + 0 completed + 1 fatal error (missing DB, schema error) +""" + +import argparse +import json +import sys +import time +from pathlib import Path + +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(): + import os + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +import sqlite3 + +from generate_atlas import ( + DB_PATH, + ensure_atlas_schema, + query_inhabited_bodies, +) + + +def _build_hq_index(conn: sqlite3.Connection) -> dict[str, str]: + """Build a mapping of body_id → corp_id for all corp HQ locations.""" + rows = conn.execute( + "SELECT headquarters_body, corp_id FROM corporations " + "WHERE headquarters_body IS NOT NULL" + ).fetchall() + index: dict[str, str] = {} + for body_id, corp_id in rows: + # If multiple corps have the same HQ body, take the first (alphabetical + # corp_id for determinism). This is unlikely but safe. + if body_id not in index: + index[body_id] = corp_id + return index + + +def _load_city_records(body_dir: Path) -> list[dict]: + """Load named city records from markers.json. Returns empty list if none.""" + markers_path = body_dir / "markers.json" + if not markers_path.exists(): + return [] + try: + markers = json.loads(markers_path.read_text()) + except json.JSONDecodeError: + return [] + return [ + c for c in (markers.get("cities") or []) + if c.get("name") and isinstance(c["name"], str) and c["name"].strip() + ] + + +def import_body_cities( + body_info: dict, + conn: sqlite3.Connection, + hq_index: dict[str, str], + dry_run: bool, + verbose: bool, +) -> dict: + """Import atlas_city_names rows for one body. + + Returns a dict with: + status: 'imported' | 'no_cities' | 'error' + imported: count of rows written + message: detail (on error) + """ + body_id = body_info["body_id"] + terrain_ref = body_info["terrain_reference"] + economic_role = body_info.get("economic_role") or "unknown" + corp_id = hq_index.get(body_id) + + body_dir = REPO_ROOT / Path(terrain_ref).parent + cities = _load_city_records(body_dir) + + if not cities: + return {"status": "no_cities", "imported": 0} + + if verbose: + print(f" {body_id}: {len(cities)} cities, economic_role={economic_role}" + + (f", corp_hq={corp_id}" if corp_id else "")) + + if not dry_run: + # Full rebuild for this body: delete existing rows, re-insert. + conn.execute("DELETE FROM atlas_city_names WHERE body_id = ?", (body_id,)) + + for city in cities: + name = city["name"].strip() + kind = city.get("kind") or "city" + population = int(city.get("population") or 0) + # Only set corp_id on the capital city of a corp HQ body. + city_corp_id = corp_id if (kind == "capital" and corp_id) else None + + conn.execute( + """INSERT INTO atlas_city_names + (body_id, name, kind, economic_role, population, corp_id, + reserved, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, datetime('now'))""", + (body_id, name, kind, economic_role, population, city_corp_id), + ) + + return {"status": "imported", "imported": len(cities)} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Populate atlas_city_names from wiki markers.json (#908, #909)" + ) + 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("--dry-run", action="store_true", + help="Read and validate without writing to DB") + 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 City Names Import (#908 + #909)") + print(f" DB: {db_path}") + if args.dry_run: + print(f" Mode: DRY RUN (no DB writes)") + print() + + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON") + ensure_atlas_schema(conn) + + hq_index = _build_hq_index(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") + print(f" {len(hq_index)} corp HQ body mappings\n") + + t_total = time.time() + n_imported = 0 + n_no_cities = 0 + n_errors = 0 + total_rows = 0 + + for i, body_info in enumerate(bodies): + body_id = body_info["body_id"] + + result = import_body_cities(body_info, conn, hq_index, args.dry_run, args.verbose) + status = result["status"] + + if status == "imported": + n_imported += 1 + total_rows += result["imported"] + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} cities") + elif status == "no_cities": + n_no_cities += 1 + elif status == "error": + n_errors += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}") + + if not args.dry_run: + conn.commit() + + conn.close() + + elapsed = time.time() - t_total + print(f"\n Done in {elapsed:.1f}s") + print(f" bodies_with_cities={n_imported} no_cities={n_no_cities} " + f"errors={n_errors} total_rows={total_rows}") + + +if __name__ == "__main__": + main() diff --git a/tooling/planet-gen/import_heightmaps.py b/tooling/planet-gen/import_heightmaps.py new file mode 100644 index 000000000..4b3f53e9b --- /dev/null +++ b/tooling/planet-gen/import_heightmaps.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +import_heightmaps.py — Import terrain elevation grids into atlas_body_heightmaps. + +For each inhabited body with a terrain_reference, simulates the terrain via +planet_simulation.simulate() and stores the float32 LE elevation BLOB plus +sea_level metadata in atlas_body_heightmaps (#906, D-202). + +The BLOB format matches the Rust loader spec (D-202): + - float32 little-endian, row-major + - width × height values, each in [0.0, 1.0] + - width = GRID_W (512), height = GRID_H (256) + +Incremental: bodies that already have a row in atlas_body_heightmaps are +skipped unless --force is passed. + +Usage: + tooling/planet-gen/import_heightmaps.py + tooling/planet-gen/import_heightmaps.py --body GJ380c + tooling/planet-gen/import_heightmaps.py --force + tooling/planet-gen/import_heightmaps.py --dry-run + +Exit codes: + 0 completed (possibly with skipped or errored bodies) + 1 fatal error (missing DB, schema error) +""" + +import argparse +import sys +import time +from pathlib import Path + +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(): + import os + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +import numpy as np +import sqlite3 + +from generate_atlas import ( + GRID_W, + GRID_H, + DB_PATH, + ensure_atlas_schema, + load_body_def, + query_inhabited_bodies, +) +from planet_simulation import simulate + + +def _elevation_to_blob(elevation: np.ndarray) -> bytes: + """Convert a float32 elevation grid to a little-endian BLOB.""" + arr = elevation.astype(" dict: + """Import heightmap BLOB for one body. + + Returns a dict with: + status: 'imported' | 'skipped' | 'gas_giant' | 'error' + message: detail (on error or skip) + """ + body_id = body_info["body_id"] + terrain_ref = body_info["terrain_reference"] + + # Incremental check — skip if already imported + if not force: + existing = conn.execute( + "SELECT 1 FROM atlas_body_heightmaps WHERE body_id = ?", (body_id,) + ).fetchone() + if existing: + return {"status": "skipped", "message": "already imported"} + + body_dir = REPO_ROOT / Path(terrain_ref).parent + if not body_dir.exists(): + return {"status": "error", "message": f"body_dir not found: {body_dir}"} + + bd = load_body_def(body_dir) + if not bd: + return {"status": "error", "message": f"no body definition found in {body_dir}"} + + try: + terrain = simulate(bd) + except Exception as exc: + return {"status": "error", "message": f"simulate() failed: {exc}"} + + if not terrain: + return {"status": "gas_giant"} + + elevation = terrain.get("elevation") + if elevation is None: + return {"status": "error", "message": "terrain dict missing 'elevation' key"} + + sea_level = float(terrain.get("sea_level", 0.0)) + blob = _elevation_to_blob(elevation) + + if verbose: + land_pct = float(np.mean(elevation >= sea_level)) * 100 + print(f" {body_id}: {GRID_W}x{GRID_H} grid, sea_level={sea_level:.3f}, " + f"land={land_pct:.1f}%, blob={len(blob)} bytes") + + if not dry_run: + conn.execute( + """INSERT INTO atlas_body_heightmaps + (body_id, width, height, data, sea_level, imported_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(body_id) DO UPDATE SET + width = excluded.width, + height = excluded.height, + data = excluded.data, + sea_level = excluded.sea_level, + imported_at = excluded.imported_at""", + (body_id, GRID_W, GRID_H, blob, sea_level), + ) + + return {"status": "imported"} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Import terrain elevation BLOBs into atlas_body_heightmaps (#906)" + ) + 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="Re-import even if a row already exists") + parser.add_argument("--dry-run", action="store_true", + help="Simulate without writing to DB") + 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 Heightmap BLOB Import (#906)") + print(f" DB: {db_path}") + if args.dry_run: + print(f" Mode: DRY RUN (no DB writes)") + if args.force: + print(f" Force: enabled (will overwrite existing rows)") + 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_imported = 0 + n_skipped = 0 + n_gas = 0 + n_errors = 0 + + for i, body_info in enumerate(bodies): + body_id = body_info["body_id"] + t0 = time.time() + + result = import_body(body_info, conn, args.force, args.dry_run, args.verbose) + elapsed = time.time() - t0 + status = result["status"] + + if status == "imported": + n_imported += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} imported ({elapsed:.1f}s)") + elif status == "skipped": + n_skipped += 1 + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped (already imported)") + 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: + conn.commit() + + conn.close() + + elapsed_total = time.time() - t_total + print(f"\n Done in {elapsed_total:.1f}s") + print(f" imported={n_imported} skipped={n_skipped} " + f"gas_giant={n_gas} errors={n_errors}") + + if n_errors > 0: + print(f"\n {n_errors} error(s) — check output above", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tooling/planet-gen/import_province_boundaries.py b/tooling/planet-gen/import_province_boundaries.py new file mode 100644 index 000000000..da33fefec --- /dev/null +++ b/tooling/planet-gen/import_province_boundaries.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +""" +import_province_boundaries.py — Pre-compute province boundaries from watershed analysis. + +For each inhabited body with a heightmap row in atlas_body_heightmaps, runs D8 +drainage analysis to derive drainage basin boundaries and stores them as pixel-space +polylines in atlas_province_boundaries (D-205, D-208, #907). + +Algorithm: + 1. Load float32 elevation BLOB from atlas_body_heightmaps. + 2. Depression-fill: raise sinks to the lowest-outlet neighbor (iterative). + 3. D8 flow direction: assign each cell to its steepest-descent neighbor. + 4. Flow accumulation: upstream cell count per cell (topological sort). + 5. Basin labeling: seed a basin per pour-point (flow-accumulation > threshold); + flood-fill remaining cells following flow direction. + 6. Merge small basins (< 2% area) into the largest adjacent basin. + 7. Clamp basin count to [4, 12] by iterative merging of smallest basins. + 8. Trace boundary polylines between adjacent basins. + 9. Upsert rows into atlas_province_boundaries. + +Province count target: 4–12 per body (D-205). Bodies with low relief get fewer, +larger provinces; high-relief worlds get more. + +Incremental: bodies that already have rows in atlas_province_boundaries are skipped +unless --force is passed. + +Usage: + tooling/planet-gen/import_province_boundaries.py + tooling/planet-gen/import_province_boundaries.py --body GJ380c + tooling/planet-gen/import_province_boundaries.py --force + tooling/planet-gen/import_province_boundaries.py --dry-run + +Exit codes: + 0 completed + 1 fatal error (missing DB, schema error) +""" + +import argparse +import json +import sys +import time +from pathlib import Path + +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(): + import os + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +import numpy as np +import sqlite3 + +from generate_atlas import ( + GRID_W, + GRID_H, + DB_PATH, + ensure_atlas_schema, + query_inhabited_bodies, +) + +# D8 neighbor offsets: (dr, dc) +_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)] + +# River threshold from D-208: cells with flow_accumulation > 200 are river cells. +# Province seeds are local flow-accumulation maxima (watershed pour points). +_FLOW_THRESHOLD = 200 + +# Minimum basin area as fraction of total cells before merging into neighbor. +_MIN_BASIN_FRAC = 0.02 + +_PROVINCE_MIN = 4 +_PROVINCE_MAX = 12 + + +def _load_elevation(body_id: str, conn: sqlite3.Connection) -> np.ndarray | None: + """Load float32 LE elevation BLOB from atlas_body_heightmaps.""" + row = conn.execute( + "SELECT data, width, height FROM atlas_body_heightmaps WHERE body_id = ?", + (body_id,), + ).fetchone() + if not row: + return None + data, width, height = row + arr = np.frombuffer(data, dtype=" np.ndarray: + """Simple iterative depression fill: raise sinks to their lowest outlet. + + Uses a shallow iterative pass — good enough for province-scale basins on + 512×256 grids. Not full priority-flood (which is O(N log N)); this O(N·k) + approach converges in ≤10 passes on real heightmaps. + """ + H, W = elev.shape + filled = elev.copy() + for _ in range(10): + changed = False + for r in range(1, H - 1): + for c in range(W): + nbr_min = float("inf") + for dr, dc in _D8: + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H: + nbr_min = min(nbr_min, filled[nr, nc]) + if filled[r, c] < nbr_min: + filled[r, c] = nbr_min + 1e-6 + changed = True + if not changed: + break + return filled + + +def _flow_direction(filled: np.ndarray) -> np.ndarray: + """D8 flow direction: index into _D8 (0–7), or -1 for no outflow (edge/flat).""" + H, W = filled.shape + fdir = np.full((H, W), -1, dtype=np.int8) + for r in range(H): + for c in range(W): + best_drop = 0.0 + best_k = -1 + for k, (dr, dc) in enumerate(_D8): + nr = r + dr + nc = (c + dc) % W + if nr < 0 or nr >= H: + continue + drop = filled[r, c] - filled[nr, nc] + if drop > best_drop: + best_drop = drop + best_k = k + fdir[r, c] = best_k + return fdir + + +def _flow_accumulation(fdir: np.ndarray) -> np.ndarray: + """Flow accumulation via topological sort of the D8 DAG.""" + H, W = fdir.shape + in_degree = np.zeros((H, W), dtype=np.int32) + + for r in range(H): + for c in range(W): + k = int(fdir[r, c]) + if k < 0: + continue + dr, dc = _D8[k] + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H: + in_degree[nr, nc] += 1 + + from collections import deque + queue = deque() + for r in range(H): + for c in range(W): + if in_degree[r, c] == 0: + queue.append((r, c)) + + accum = np.ones((H, W), dtype=np.int32) + while queue: + r, c = queue.popleft() + k = int(fdir[r, c]) + if k < 0: + continue + dr, dc = _D8[k] + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H: + accum[nr, nc] += accum[r, c] + in_degree[nr, nc] -= 1 + if in_degree[nr, nc] == 0: + queue.append((nr, nc)) + + return accum + + +def _label_basins(fdir: np.ndarray, accum: np.ndarray) -> np.ndarray: + """Label each cell with a basin ID via pour-point flood fill. + + Pour points are local flow-accumulation maxima above the river threshold. + Each pour point seeds a basin; remaining cells are labeled by tracing + flow direction back to their pour-point seed. + """ + H, W = fdir.shape + labels = np.full((H, W), -1, dtype=np.int32) + + # Seed one label per local accum maximum above threshold. + # Use a simple scan: a cell is a local maximum if no neighbor has higher accum. + pour_pts: list[tuple[int, int]] = [] + for r in range(H): + for c in range(W): + if accum[r, c] <= _FLOW_THRESHOLD: + continue + is_max = True + for dr, dc in _D8: + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H and accum[nr, nc] > accum[r, c]: + is_max = False + break + if is_max: + pour_pts.append((r, c)) + + # If no pour points (e.g. flat/ocean world), create a single basin. + if not pour_pts: + labels[:] = 0 + return labels + + for basin_id, (r, c) in enumerate(pour_pts): + labels[r, c] = basin_id + + # BFS flood: for each unlabeled cell, follow flow direction until a labeled + # cell is reached; assign that label back along the path. + from collections import deque + + def _trace(r0: int, c0: int) -> int: + path: list[tuple[int, int]] = [] + r, c = r0, c0 + for _ in range(H * W): + if labels[r, c] >= 0: + lbl = labels[r, c] + for pr, pc in path: + labels[pr, pc] = lbl + return lbl + path.append((r, c)) + k = int(fdir[r, c]) + if k < 0: + # No outflow — assign basin 0 + lbl = 0 + for pr, pc in path: + labels[pr, pc] = lbl + return lbl + dr, dc = _D8[k] + nr = r + dr + nc = (c + dc) % W + if nr < 0 or nr >= H: + lbl = 0 + for pr, pc in path: + labels[pr, pc] = lbl + return lbl + r, c = nr, nc + # Cycle guard + lbl = 0 + for pr, pc in path: + labels[pr, pc] = lbl + return lbl + + for r in range(H): + for c in range(W): + if labels[r, c] < 0: + _trace(r, c) + + return labels + + +def _merge_small_basins( + labels: np.ndarray, target_min: int, target_max: int +) -> np.ndarray: + """Merge tiny basins into their largest neighbor until count is in [target_min, target_max].""" + H, W = labels.shape + labels = labels.copy() + + def _basin_sizes() -> dict[int, int]: + ids, counts = np.unique(labels, return_counts=True) + return dict(zip(ids.tolist(), counts.tolist())) + + def _neighbors(basin_id: int) -> set[int]: + mask = labels == basin_id + # Dilate mask by 1 pixel in each direction, find adjacent basin IDs. + nbrs: set[int] = set() + rs, cs = np.where(mask) + for r, c in zip(rs.tolist(), cs.tolist()): + for dr, dc in _D8: + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H: + nbr_id = int(labels[nr, nc]) + if nbr_id != basin_id: + nbrs.add(nbr_id) + return nbrs + + total = H * W + for _ in range(200): + sizes = _basin_sizes() + n_basins = len(sizes) + if n_basins <= target_max and all( + v / total >= _MIN_BASIN_FRAC for v in sizes.values() + ): + break + if n_basins <= target_min: + break + + # Find the smallest basin + smallest_id = min(sizes, key=lambda b: sizes[b]) + smallest_frac = sizes[smallest_id] / total + + if n_basins <= target_max and smallest_frac >= _MIN_BASIN_FRAC: + break + + # Merge into its largest neighbor + nbrs = _neighbors(smallest_id) + if not nbrs: + break + merge_into = max(nbrs, key=lambda b: sizes.get(b, 0)) + labels[labels == smallest_id] = merge_into + + # Re-number contiguously from 0 + unique_ids = sorted(np.unique(labels).tolist()) + remap = {old: new for new, old in enumerate(unique_ids)} + new_labels = np.zeros_like(labels) + for old, new in remap.items(): + new_labels[labels == old] = new + return new_labels + + +def _trace_boundary(labels: np.ndarray, basin_id: int) -> list[list[int]]: + """Trace the outer boundary of a basin as a pixel-space polyline. + + Returns a list of [row, col] points forming the boundary polygon. + Uses a simple contour walk: find all boundary cells (cells adjacent to a + different basin), then sort them by angle from centroid to approximate a + closed polygon. + """ + H, W = labels.shape + mask = labels == basin_id + + # Boundary cells: in this basin AND adjacent to a different basin + boundary: list[tuple[int, int]] = [] + rs, cs = np.where(mask) + for r, c in zip(rs.tolist(), cs.tolist()): + on_boundary = False + for dr, dc in _D8: + nr = r + dr + nc = (c + dc) % W + if nr < 0 or nr >= H: + on_boundary = True + break + if labels[nr, nc] != basin_id: + on_boundary = True + break + if on_boundary: + boundary.append((r, c)) + + if not boundary: + return [] + + # Sort by angle from centroid — produces a rough polygon outline. + arr = np.array(boundary, dtype=np.float32) + centroid_r = float(np.mean(arr[:, 0])) + centroid_c = float(np.mean(arr[:, 1])) + angles = np.arctan2(arr[:, 0] - centroid_r, arr[:, 1] - centroid_c) + order = np.argsort(angles) + + # Subsample if very large — keep at most 500 points for storage efficiency. + pts = [boundary[i] for i in order.tolist()] + if len(pts) > 500: + step = len(pts) // 500 + pts = pts[::step] + + return [[r, c] for r, c in pts] + + +def compute_province_boundaries( + body_id: str, elevation: np.ndarray +) -> list[dict]: + """Run full watershed analysis; return list of basin dicts. + + Each dict: + basin_id: int + path: JSON-serialisable [[row, col], ...] + area_pct: float + """ + H, W = elevation.shape + total_cells = H * W + + filled = _depression_fill(elevation) + fdir = _flow_direction(filled) + accum = _flow_accumulation(fdir) + labels = _label_basins(fdir, accum) + labels = _merge_small_basins(labels, _PROVINCE_MIN, _PROVINCE_MAX) + + unique_ids = sorted(np.unique(labels).tolist()) + basins = [] + for basin_id in unique_ids: + count = int(np.sum(labels == basin_id)) + area_pct = count / total_cells + path = _trace_boundary(labels, basin_id) + if not path: + continue + basins.append({ + "basin_id": basin_id, + "path": path, + "area_pct": area_pct, + }) + + return basins + + +def import_body_provinces( + body_id: str, + conn: sqlite3.Connection, + force: bool, + dry_run: bool, + verbose: bool, +) -> dict: + """Import province boundary rows for one body. + + Returns dict: + status: 'imported' | 'skipped' | 'no_heightmap' | 'error' + imported: count of basins written + message: detail on error/skip + """ + if not force: + existing = conn.execute( + "SELECT COUNT(*) FROM atlas_province_boundaries WHERE body_id = ?", + (body_id,), + ).fetchone()[0] + if existing > 0: + return {"status": "skipped", "imported": 0, + "message": f"already has {existing} rows"} + + elevation = _load_elevation(body_id, conn) + if elevation is None: + return {"status": "no_heightmap", "imported": 0, + "message": "no row in atlas_body_heightmaps"} + + try: + basins = compute_province_boundaries(body_id, elevation) + except Exception as exc: + return {"status": "error", "imported": 0, "message": str(exc)} + + if not basins: + return {"status": "error", "imported": 0, + "message": "no basins produced from watershed analysis"} + + if verbose: + areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins] + print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}") + + if not dry_run: + conn.execute( + "DELETE FROM atlas_province_boundaries WHERE body_id = ?", + (body_id,), + ) + for b in basins: + conn.execute( + """INSERT INTO atlas_province_boundaries + (body_id, basin_id, path, area_pct) + VALUES (?, ?, ?, ?)""", + (body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]), + ) + + return {"status": "imported", "imported": len(basins)} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Pre-compute province boundaries from watershed analysis (D-205, #907)" + ) + 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="Re-import even if rows already exist") + parser.add_argument("--dry-run", action="store_true", + help="Analyse without writing to DB") + 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 Province Boundary Import (#907)") + print(f" DB: {db_path}") + if args.dry_run: + print(f" Mode: DRY RUN (no DB writes)") + if args.force: + print(f" Force: enabled (will overwrite existing rows)") + 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_imported = 0 + n_skipped = 0 + n_no_hmap = 0 + n_errors = 0 + + for i, body_info in enumerate(bodies): + body_id = body_info["body_id"] + t0 = time.time() + + result = import_body_provinces(body_id, conn, args.force, args.dry_run, args.verbose) + elapsed = time.time() - t0 + status = result["status"] + + if status == "imported": + n_imported += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)") + elif status == "skipped": + n_skipped += 1 + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})") + elif status == "no_heightmap": + n_no_hmap += 1 + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping") + elif status == "error": + n_errors += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}") + + if not args.dry_run: + conn.commit() + + conn.close() + + elapsed_total = time.time() - t_total + print(f"\n Done in {elapsed_total:.1f}s") + print(f" imported={n_imported} skipped={n_skipped} " + f"no_heightmap={n_no_hmap} errors={n_errors}") + + if n_errors > 0: + print(f"\n {n_errors} error(s) — check output above", file=sys.stderr) + + +if __name__ == "__main__": + main()