New tables: atlas_body_heightmaps (D-202), atlas_city_names (D-207), atlas_feature_names, atlas_province_boundaries (D-205), body_radius_km column (D-204). Three new importers: heightmap BLOBs, city names from wiki markers.json, province boundaries via D8 watershed analysis. economic_role normalized to 7 canonical values (D-194). Stamp fix in generate_atlas.py to hash all tracked source files. systems.db regenerated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
6.6 KiB
Python
206 lines
6.6 KiB
Python
#!/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()
|