Merge remote-tracking branch 'origin/sprint-35/server'
This commit is contained in:
@@ -39,6 +39,13 @@ 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"
|
||||
BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "brands.toml"
|
||||
|
||||
|
||||
class _ImportAborted(Exception):
|
||||
"""Raised internally by main() when a validation step wants a clean
|
||||
rollback + exit 1. Caught only by main(); error messages are printed
|
||||
before raising so the user sees them."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -48,6 +55,61 @@ CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations"
|
||||
MIGRATION_SQL = """
|
||||
-- Economics tables (idempotent — safe to re-run)
|
||||
|
||||
-- Brand layer tables (D-189, #827)
|
||||
CREATE TABLE IF NOT EXISTS brand_products (
|
||||
brand_product_id TEXT PRIMARY KEY,
|
||||
corp_id TEXT NOT NULL REFERENCES corporations(corp_id),
|
||||
product_name TEXT NOT NULL,
|
||||
brand_category TEXT NOT NULL,
|
||||
value_trajectory TEXT NOT NULL,
|
||||
scarcity_class TEXT NOT NULL,
|
||||
product_subcategory TEXT,
|
||||
base_premium_multiplier REAL NOT NULL DEFAULT 1.0,
|
||||
premium_floor REAL NOT NULL DEFAULT 0.0,
|
||||
origin_system TEXT REFERENCES star_systems(system_id),
|
||||
terroir_locked INTEGER NOT NULL DEFAULT 0,
|
||||
currency_denomination TEXT NOT NULL DEFAULT 'tractus',
|
||||
shadow_viable INTEGER NOT NULL DEFAULT 0,
|
||||
brand_tier TEXT NOT NULL,
|
||||
halo_brand_id TEXT REFERENCES brand_products(brand_product_id),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brand_inputs (
|
||||
brand_product_id TEXT NOT NULL REFERENCES brand_products(brand_product_id),
|
||||
commodity_id TEXT NOT NULL REFERENCES commodities(commodity_id),
|
||||
quantity REAL NOT NULL,
|
||||
PRIMARY KEY (brand_product_id, commodity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_fiscal (
|
||||
system_id TEXT PRIMARY KEY REFERENCES star_systems(system_id),
|
||||
corp_tax_rate REAL NOT NULL DEFAULT 0.22,
|
||||
collection_efficiency REAL NOT NULL DEFAULT 1.0,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS corp_financial_state (
|
||||
corp_id TEXT PRIMARY KEY REFERENCES corporations(corp_id),
|
||||
health_metric REAL NOT NULL DEFAULT 1.0,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS corp_lifecycle_events (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
corp_id TEXT NOT NULL REFERENCES corporations(corp_id),
|
||||
event_type TEXT NOT NULL,
|
||||
event_tick INTEGER NOT NULL DEFAULT 0,
|
||||
event_data TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_brand_products_corp_category ON brand_products(corp_id, brand_category);
|
||||
CREATE INDEX IF NOT EXISTS idx_brand_products_origin ON brand_products(origin_system);
|
||||
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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gate_links (
|
||||
from_system_id TEXT NOT NULL REFERENCES star_systems(system_id),
|
||||
to_system_id TEXT NOT NULL REFERENCES star_systems(system_id),
|
||||
@@ -669,6 +731,237 @@ def _validate_system_coverage(
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Brand layer import (D-189, #827)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_BRAND_CATEGORIES = {
|
||||
"terroir", "heritage_craft", "tech_premium", "cultural",
|
||||
"service_premium", "commodity_branded", "design_heritage", "platform_catalogue",
|
||||
}
|
||||
VALID_VALUE_TRAJECTORIES = {"appreciating", "depreciating", "timeless"}
|
||||
VALID_SCARCITY_CLASSES = {"capped", "constrained", "scalable", "unlimited"}
|
||||
VALID_BRAND_TIERS = {"halo", "volume"}
|
||||
VALID_CURRENCY_DENOMINATIONS = {"tractus", "mark", "mixed", "sol_adjacent"}
|
||||
|
||||
|
||||
def import_brands(
|
||||
conn: sqlite3.Connection, dry_run: bool
|
||||
) -> tuple[int, int]:
|
||||
"""Import brand_products and brand_inputs from wiki/economics/corporations/brands.toml.
|
||||
|
||||
Returns (n_products, n_inputs).
|
||||
"""
|
||||
if not BRANDS_TOML.exists():
|
||||
print(" warning: brands.toml not found — brand layer skipped")
|
||||
return 0, 0
|
||||
|
||||
with open(BRANDS_TOML, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
products = data.get("brand_products", [])
|
||||
inputs = data.get("brand_inputs", [])
|
||||
|
||||
product_rows = []
|
||||
for p in products:
|
||||
product_rows.append((
|
||||
p["brand_product_id"],
|
||||
p["corp_id"],
|
||||
p["product_name"],
|
||||
p["brand_category"],
|
||||
p["value_trajectory"],
|
||||
p["scarcity_class"],
|
||||
p.get("product_subcategory"),
|
||||
p.get("base_premium_multiplier", 1.0),
|
||||
p.get("premium_floor", 0.0),
|
||||
p.get("origin_system"),
|
||||
int(p.get("terroir_locked", False)),
|
||||
p.get("currency_denomination", "tractus"),
|
||||
int(p.get("shadow_viable", False)),
|
||||
p["brand_tier"],
|
||||
p.get("halo_brand_id"),
|
||||
))
|
||||
|
||||
input_rows = []
|
||||
for inp in inputs:
|
||||
input_rows.append((
|
||||
inp["brand_product_id"],
|
||||
inp["commodity_id"],
|
||||
inp["quantity"],
|
||||
))
|
||||
|
||||
if not dry_run:
|
||||
conn.executemany(
|
||||
"""INSERT OR REPLACE INTO brand_products (
|
||||
brand_product_id, corp_id, product_name, brand_category,
|
||||
value_trajectory, scarcity_class, product_subcategory,
|
||||
base_premium_multiplier, premium_floor, origin_system,
|
||||
terroir_locked, currency_denomination, shadow_viable,
|
||||
brand_tier, halo_brand_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
product_rows,
|
||||
)
|
||||
conn.executemany(
|
||||
"""INSERT OR REPLACE INTO brand_inputs
|
||||
(brand_product_id, commodity_id, quantity) VALUES (?, ?, ?)""",
|
||||
input_rows,
|
||||
)
|
||||
|
||||
return len(product_rows), len(input_rows)
|
||||
|
||||
|
||||
def import_system_fiscal(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Populate system_fiscal with hardcoded Phase 2 values.
|
||||
|
||||
Phase 2 values (NOT derived from D-189 §6 yet):
|
||||
- corp_tax_rate = 0.22 (flat default)
|
||||
- collection_efficiency = 0.85 (mid-reach average placeholder)
|
||||
|
||||
The D-189 §6 formula `collection_efficiency = 1.0 - shadow_economy_intensity × 0.6`
|
||||
is deliberately NOT implemented here — `shadow_economy_intensity` is not
|
||||
yet per-system in the DB (pending the shadow_economy.toml pipeline). When
|
||||
that pipeline lands, replace the hardcoded 0.85 with the derivation and
|
||||
wire `shadow_economy_intensity` through the SELECT. Tracked as a Phase 3
|
||||
follow-up.
|
||||
"""
|
||||
inhabited = conn.execute("""
|
||||
SELECT ss.system_id, COALESCE(se.population, 0)
|
||||
FROM star_systems ss
|
||||
LEFT JOIN system_economy se ON ss.system_id = se.system_id
|
||||
WHERE ss.inhabited_planet_count > 0 OR se.population > 0
|
||||
ORDER BY ss.system_id
|
||||
""").fetchall()
|
||||
|
||||
PHASE2_CORP_TAX_RATE = 0.22
|
||||
PHASE2_COLLECTION_EFFICIENCY = 0.85
|
||||
|
||||
rows = [
|
||||
(system_id, PHASE2_CORP_TAX_RATE, PHASE2_COLLECTION_EFFICIENCY)
|
||||
for system_id, _pop in inhabited
|
||||
]
|
||||
|
||||
if not dry_run:
|
||||
conn.executemany(
|
||||
"""INSERT OR IGNORE INTO system_fiscal
|
||||
(system_id, corp_tax_rate, collection_efficiency) VALUES (?, ?, ?)""",
|
||||
rows,
|
||||
)
|
||||
|
||||
return len(rows)
|
||||
|
||||
|
||||
def validate_brands(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Brand layer structural validation rules V-B01 through V-B06.
|
||||
|
||||
V-B01: Every brand_products row has a valid corp_id (FK to corporations).
|
||||
V-B02: Every brand_inputs row has valid brand_product_id and commodity_id FKs.
|
||||
V-B03: Every halo brand has at least one brand_inputs entry (demand stub must consume).
|
||||
V-B04: Every volume tier must reference an existing halo brand_product_id.
|
||||
V-B05: No brand_product_id is used as halo_brand_id by a non-volume-tier product.
|
||||
V-B06: Every enum column (brand_category, value_trajectory, scarcity_class,
|
||||
brand_tier, currency_denomination) is a member of its VALID_* set.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# V-B01: brand_products → corporations FK
|
||||
orphan_corps = conn.execute("""
|
||||
SELECT bp.brand_product_id, bp.corp_id
|
||||
FROM brand_products bp
|
||||
LEFT JOIN corporations c ON bp.corp_id = c.corp_id
|
||||
WHERE c.corp_id IS NULL
|
||||
""").fetchall()
|
||||
for pid, corp_id in orphan_corps:
|
||||
errors.append(
|
||||
f"V-B01: brand_product '{pid}' references unknown corp_id '{corp_id}'"
|
||||
)
|
||||
|
||||
# V-B02: brand_inputs → brand_products and brand_inputs → commodities FKs
|
||||
orphan_inputs_bp = conn.execute("""
|
||||
SELECT bi.brand_product_id, bi.commodity_id
|
||||
FROM brand_inputs bi
|
||||
LEFT JOIN brand_products bp ON bi.brand_product_id = bp.brand_product_id
|
||||
WHERE bp.brand_product_id IS NULL
|
||||
""").fetchall()
|
||||
for pid, cid in orphan_inputs_bp:
|
||||
errors.append(
|
||||
f"V-B02: brand_inputs row ({pid}, {cid}) references unknown brand_product_id"
|
||||
)
|
||||
|
||||
orphan_inputs_comm = conn.execute("""
|
||||
SELECT bi.brand_product_id, bi.commodity_id
|
||||
FROM brand_inputs bi
|
||||
LEFT JOIN commodities c ON bi.commodity_id = c.commodity_id
|
||||
WHERE c.commodity_id IS NULL
|
||||
""").fetchall()
|
||||
for pid, cid in orphan_inputs_comm:
|
||||
errors.append(
|
||||
f"V-B02: brand_inputs row ({pid}, {cid}) references unknown commodity_id '{cid}'"
|
||||
)
|
||||
|
||||
# V-B03: every halo brand has at least one brand_inputs entry
|
||||
halo_no_inputs = conn.execute("""
|
||||
SELECT bp.brand_product_id
|
||||
FROM brand_products bp
|
||||
WHERE bp.brand_tier = 'halo'
|
||||
AND bp.brand_product_id NOT IN (SELECT brand_product_id FROM brand_inputs)
|
||||
""").fetchall()
|
||||
for (pid,) in halo_no_inputs:
|
||||
errors.append(
|
||||
f"V-B03: halo brand '{pid}' has no brand_inputs entries "
|
||||
f"(must consume at least one commodity as a demand node)"
|
||||
)
|
||||
|
||||
# V-B04: volume tiers reference valid halo_brand_id
|
||||
volume_bad_halo = conn.execute("""
|
||||
SELECT bp.brand_product_id, bp.halo_brand_id
|
||||
FROM brand_products bp
|
||||
WHERE bp.brand_tier = 'volume'
|
||||
AND (bp.halo_brand_id IS NULL
|
||||
OR bp.halo_brand_id NOT IN (SELECT brand_product_id FROM brand_products))
|
||||
""").fetchall()
|
||||
for pid, halo_id in volume_bad_halo:
|
||||
errors.append(
|
||||
f"V-B04: volume brand '{pid}' has invalid halo_brand_id '{halo_id}'"
|
||||
)
|
||||
|
||||
# V-B05: halo_brand_id must only point to halo-tier products
|
||||
halo_points_to_non_halo = conn.execute("""
|
||||
SELECT child.brand_product_id, child.halo_brand_id, parent.brand_tier
|
||||
FROM brand_products child
|
||||
JOIN brand_products parent ON child.halo_brand_id = parent.brand_product_id
|
||||
WHERE child.brand_tier = 'volume'
|
||||
AND parent.brand_tier != 'halo'
|
||||
""").fetchall()
|
||||
for child_id, halo_id, parent_tier in halo_points_to_non_halo:
|
||||
errors.append(
|
||||
f"V-B05: volume brand '{child_id}' points to '{halo_id}' "
|
||||
f"which has brand_tier='{parent_tier}', not 'halo'"
|
||||
)
|
||||
|
||||
# V-B06: every enum column is in its VALID_* set. The SQL columns are
|
||||
# plain TEXT without CHECK constraints, so a typo like `terrior` would
|
||||
# otherwise silently import.
|
||||
enum_checks = [
|
||||
("brand_category", VALID_BRAND_CATEGORIES),
|
||||
("value_trajectory", VALID_VALUE_TRAJECTORIES),
|
||||
("scarcity_class", VALID_SCARCITY_CLASSES),
|
||||
("brand_tier", VALID_BRAND_TIERS),
|
||||
("currency_denomination", VALID_CURRENCY_DENOMINATIONS),
|
||||
]
|
||||
for column, valid_set in enum_checks:
|
||||
bad = conn.execute(
|
||||
f"SELECT brand_product_id, {column} FROM brand_products"
|
||||
).fetchall()
|
||||
for pid, value in bad:
|
||||
if value not in valid_set:
|
||||
errors.append(
|
||||
f"V-B06: brand_product '{pid}' has {column}='{value}' — "
|
||||
f"must be one of {sorted(valid_set)}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -698,91 +991,128 @@ def main():
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
# 1. Migrate schema
|
||||
print(" [1/8] Schema migration...")
|
||||
for table, col, col_type in COLUMN_MIGRATIONS:
|
||||
_add_column(conn, table, col, col_type)
|
||||
conn.executescript(MIGRATION_SQL)
|
||||
print(" tables and columns ready")
|
||||
# The clear-then-reimport cycle below runs as a single explicit
|
||||
# transaction. Any crash, validation error, or KeyboardInterrupt
|
||||
# between the first DELETE and the final commit rolls everything
|
||||
# back — the DB never ends up half-cleared with stale rows in some
|
||||
# tables and empty rows in others. On success we commit exactly
|
||||
# once, immediately after structural validation passes.
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
# 1. Migrate schema (idempotent, inside the tx so a crash here
|
||||
# leaves no half-applied ALTER TABLE.)
|
||||
print(" [1/10] Schema migration...")
|
||||
for table, col, col_type in COLUMN_MIGRATIONS:
|
||||
_add_column(conn, table, col, col_type)
|
||||
conn.executescript(MIGRATION_SQL)
|
||||
print(" tables and columns ready")
|
||||
|
||||
# Clear economics tables in FK-safe order (children before parents)
|
||||
# corp_presence cleared here; corporations table is append-only (never cleared)
|
||||
if not args.dry_run:
|
||||
conn.execute("DELETE FROM corp_presence")
|
||||
conn.execute("DELETE FROM chain_inputs")
|
||||
conn.execute("DELETE FROM production_chains")
|
||||
conn.execute("DELETE FROM commodities")
|
||||
conn.execute("DELETE FROM gate_links")
|
||||
# Clear economics tables in FK-safe order (children before parents)
|
||||
# corp_presence cleared here; corporations table is append-only
|
||||
# (never cleared).
|
||||
if not args.dry_run:
|
||||
conn.execute("DELETE FROM corp_presence")
|
||||
conn.execute("DELETE FROM brand_inputs")
|
||||
conn.execute("DELETE FROM brand_products")
|
||||
conn.execute("DELETE FROM system_fiscal")
|
||||
conn.execute("DELETE FROM corp_financial_state")
|
||||
conn.execute("DELETE FROM corp_lifecycle_events")
|
||||
conn.execute("DELETE FROM chain_inputs")
|
||||
conn.execute("DELETE FROM production_chains")
|
||||
conn.execute("DELETE FROM commodities")
|
||||
conn.execute("DELETE FROM gate_links")
|
||||
|
||||
# 2. Gate links
|
||||
print(" [2/8] Importing gate links...")
|
||||
n_links = import_gate_links(conn, args.dry_run)
|
||||
print(f" {n_links} rows (bidirectional)")
|
||||
# 2. Gate links
|
||||
print(" [2/10] Importing gate links...")
|
||||
n_links = import_gate_links(conn, args.dry_run)
|
||||
print(f" {n_links} rows (bidirectional)")
|
||||
|
||||
# 3. Commodities
|
||||
print(" [3/8] Importing commodities...")
|
||||
n_commodities = import_commodities(conn, args.dry_run)
|
||||
print(f" {n_commodities} commodities")
|
||||
# 3. Commodities
|
||||
print(" [3/10] Importing commodities...")
|
||||
n_commodities = import_commodities(conn, args.dry_run)
|
||||
print(f" {n_commodities} commodities")
|
||||
|
||||
# 4. Production chains
|
||||
print(" [4/8] Importing production chains...")
|
||||
n_chains, n_inputs = import_chains(conn, args.dry_run)
|
||||
print(f" {n_chains} chains, {n_inputs} inputs")
|
||||
# 4. Production chains
|
||||
print(" [4/10] Importing production chains...")
|
||||
n_chains, n_inputs = import_chains(conn, args.dry_run)
|
||||
print(f" {n_chains} chains, {n_inputs} inputs")
|
||||
|
||||
# 5. Currency zones
|
||||
print(" [5/8] Setting currency zones...")
|
||||
zones = set_currency_zones(conn, args.dry_run)
|
||||
for zone, count in sorted(zones.items()):
|
||||
print(f" {zone}: {count}")
|
||||
# 5. Currency zones
|
||||
print(" [5/10] Setting currency zones...")
|
||||
zones = set_currency_zones(conn, args.dry_run)
|
||||
for zone, count in sorted(zones.items()):
|
||||
print(f" {zone}: {count}")
|
||||
|
||||
# 6. Gate energy connectivity (D-186) — must run after currency zones
|
||||
print(" [6/8] Setting gate energy connectivity...")
|
||||
energy = set_gate_energy(conn, args.dry_run)
|
||||
for label, count in sorted(energy.items()):
|
||||
print(f" {label}: {count}")
|
||||
# 6. Gate energy connectivity (D-186) — must run after currency zones
|
||||
print(" [6/10] Setting gate energy connectivity...")
|
||||
energy = set_gate_energy(conn, args.dry_run)
|
||||
for label, count in sorted(energy.items()):
|
||||
print(f" {label}: {count}")
|
||||
|
||||
# 7. Sync corporations from wiki (D-182: hard error on name divergence)
|
||||
print(" [7/8] Syncing corporations...")
|
||||
corp_errors = sync_corporations(conn, wiki_corps, args.dry_run)
|
||||
if corp_errors:
|
||||
print(f" ERRORS ({len(corp_errors)}) — name divergence detected (D-182):")
|
||||
for e in corp_errors:
|
||||
print(f" - {e}")
|
||||
print(" Fix: update wiki title or DB proper_name to match, then re-run.")
|
||||
# 7. Sync corporations from wiki (D-182: hard error on name divergence)
|
||||
print(" [7/10] Syncing corporations...")
|
||||
corp_errors = sync_corporations(conn, wiki_corps, args.dry_run)
|
||||
if corp_errors:
|
||||
print(f" ERRORS ({len(corp_errors)}) — name divergence detected (D-182):")
|
||||
for e in corp_errors:
|
||||
print(f" - {e}")
|
||||
print(" Fix: update wiki title or DB proper_name to match, then re-run.")
|
||||
raise _ImportAborted()
|
||||
n_db_corps = conn.execute("SELECT COUNT(*) FROM corporations").fetchone()[0]
|
||||
print(f" {n_db_corps} corporations in DB ({len(wiki_corps)} from wiki)")
|
||||
|
||||
# 8. Corp presence from wiki headquarters data
|
||||
print(" [8/10] Importing corp presence...")
|
||||
commodity_ids = {
|
||||
r[0] for r in conn.execute("SELECT commodity_id FROM commodities").fetchall()
|
||||
}
|
||||
n_presence = import_corp_presence(conn, wiki_corps, commodity_ids, args.dry_run)
|
||||
print(f" {n_presence} corp_presence rows")
|
||||
|
||||
# 9. Brand products and inputs (D-189, #827)
|
||||
print(" [9/10] Importing brand products and inputs...")
|
||||
n_brands, n_brand_inputs = import_brands(conn, args.dry_run)
|
||||
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...")
|
||||
n_fiscal = import_system_fiscal(conn, args.dry_run)
|
||||
print(f" {n_fiscal} system_fiscal rows")
|
||||
|
||||
# Validate structural integrity (FK, chain refs, chain completeness).
|
||||
# These errors indicate broken imported data — do NOT commit.
|
||||
print("\n Validating structural integrity...")
|
||||
struct_errors = validate(conn)
|
||||
struct_errors.extend(validate_brands(conn))
|
||||
if struct_errors:
|
||||
print(f" STRUCTURAL ERRORS ({len(struct_errors)}) — rolling back:")
|
||||
for e in struct_errors:
|
||||
print(f" - {e}")
|
||||
raise _ImportAborted()
|
||||
print(" FK integrity, chain completeness, and brand layer (V-B01–V-B06) OK")
|
||||
|
||||
# Commit all imported data (corps, presence, etc.) before coverage check.
|
||||
# Coverage validation is a Phase 2 gate (D-175) — data should be persisted
|
||||
# so tools can query it and report gaps clearly.
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
print(" Data committed.")
|
||||
else:
|
||||
# Dry-run: leave the transaction open so the coverage check below
|
||||
# can still SELECT against the in-memory imported data. The
|
||||
# transaction is discarded when conn.close() runs on exit.
|
||||
print(" Dry run — no changes written.")
|
||||
except _ImportAborted:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
n_db_corps = conn.execute("SELECT COUNT(*) FROM corporations").fetchone()[0]
|
||||
print(f" {n_db_corps} corporations in DB ({len(wiki_corps)} from wiki)")
|
||||
|
||||
# 8. Corp presence from wiki headquarters data
|
||||
print(" [8/8] Importing corp presence...")
|
||||
commodity_ids = {
|
||||
r[0] for r in conn.execute("SELECT commodity_id FROM commodities").fetchall()
|
||||
}
|
||||
n_presence = import_corp_presence(conn, wiki_corps, commodity_ids, args.dry_run)
|
||||
print(f" {n_presence} corp_presence rows")
|
||||
|
||||
# Validate structural integrity (FK, chain refs, chain completeness).
|
||||
# These errors indicate broken imported data — do NOT commit.
|
||||
print("\n Validating structural integrity...")
|
||||
struct_errors = validate(conn)
|
||||
if struct_errors:
|
||||
print(f" STRUCTURAL ERRORS ({len(struct_errors)}) — rolling back:")
|
||||
for e in struct_errors:
|
||||
print(f" - {e}")
|
||||
except BaseException:
|
||||
# Any other exception (KeyboardInterrupt, MemoryError, DB error,
|
||||
# programmer error) triggers a rollback so the DB is never left in
|
||||
# a half-imported state. Re-raise so the user sees the traceback.
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(" FK integrity and chain completeness OK")
|
||||
|
||||
# Commit all imported data (corps, presence, etc.) before coverage check.
|
||||
# Coverage validation is a Phase 2 gate (D-175) — data should be persisted
|
||||
# so tools can query it and report gaps clearly.
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
print(" Data committed.")
|
||||
else:
|
||||
print(" Dry run — no changes written.")
|
||||
raise
|
||||
|
||||
# Validate coverage (hard errors per D-175, but after commit so data is usable).
|
||||
print("\n Validating coverage (D-175 Phase 2 gate)...")
|
||||
@@ -809,7 +1139,9 @@ def main():
|
||||
conn.close()
|
||||
|
||||
print(f"\n Done: {n_links} gate_links, {n_commodities} commodities, "
|
||||
f"{n_chains} chains, {n_inputs} inputs, {n_presence} corp_presence\n")
|
||||
f"{n_chains} chains, {n_inputs} inputs, {n_presence} corp_presence, "
|
||||
f"{n_brands} brand_products, {n_brand_inputs} brand_inputs, "
|
||||
f"{n_fiscal} system_fiscal\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch populate terrain_reference column in systems.db bodies table.
|
||||
|
||||
For each inhabited body with NULL terrain_reference:
|
||||
1. Construct expected wiki heightmap path:
|
||||
wiki/star-systems/{system_slug}/bodies/{body_id}/heightmap.png
|
||||
where system_slug = system_id with spaces replaced by hyphens.
|
||||
2. Verify the file exists.
|
||||
3. Update terrain_reference to the relative path.
|
||||
|
||||
Bodies with missing heightmaps are logged to stdout for remediation.
|
||||
This is the prerequisite for generate_atlas.py (#832).
|
||||
|
||||
Usage:
|
||||
python3 tooling/planet-gen/populate_terrain_reference.py
|
||||
python3 tooling/planet-gen/populate_terrain_reference.py --dry-run
|
||||
python3 tooling/planet-gen/populate_terrain_reference.py --db path/to/systems.db
|
||||
|
||||
Decisions: D-191 (atlas pipeline prerequisites)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (SCRIPT_DIR / ".." / "..").resolve()
|
||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
WIKI_DIR = REPO_ROOT / "wiki" / "star-systems"
|
||||
|
||||
|
||||
def system_slug(system_id: str) -> str:
|
||||
"""Convert a system_id to its wiki directory slug.
|
||||
|
||||
'GJ 71' → 'GJ-71'
|
||||
'GJ 244A' → 'GJ-244A'
|
||||
'GJ 559B' → 'GJ-559B'
|
||||
"""
|
||||
return system_id.replace(" ", "-")
|
||||
|
||||
|
||||
def expected_heightmap_path(system_id: str, body_id: str) -> Path:
|
||||
"""Return the expected absolute heightmap path for a body."""
|
||||
return WIKI_DIR / system_slug(system_id) / "bodies" / body_id / "heightmap.png"
|
||||
|
||||
|
||||
def relative_terrain_reference(system_id: str, body_id: str) -> str:
|
||||
"""Return the terrain_reference value to store in the DB.
|
||||
|
||||
Stored as a repo-root-relative path so it is portable across checkouts.
|
||||
"""
|
||||
return f"wiki/star-systems/{system_slug(system_id)}/bodies/{body_id}/heightmap.png"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Populate terrain_reference column in systems.db bodies table"
|
||||
)
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Report without writing changes",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found")
|
||||
raise SystemExit(1)
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
# Fetch all inhabited bodies with NULL terrain_reference.
|
||||
rows = conn.execute("""
|
||||
SELECT b.body_id, b.system_id
|
||||
FROM bodies b
|
||||
WHERE b.terrain_reference IS NULL
|
||||
ORDER BY b.system_id, b.body_id
|
||||
""").fetchall()
|
||||
|
||||
print(f"\n terrain_reference population pass")
|
||||
print(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN")
|
||||
print(f"\n {len(rows)} bodies with NULL terrain_reference\n")
|
||||
|
||||
found = []
|
||||
missing = []
|
||||
|
||||
for body_id, system_id in rows:
|
||||
path = expected_heightmap_path(system_id, body_id)
|
||||
if path.exists():
|
||||
found.append((body_id, system_id))
|
||||
else:
|
||||
missing.append((body_id, system_id, str(path)))
|
||||
|
||||
# Report missing heightmaps before writing — helps flag gaps early.
|
||||
if missing:
|
||||
print(f" MISSING heightmaps ({len(missing)} bodies — no update for these):")
|
||||
for body_id, system_id, path in missing:
|
||||
print(f" {body_id} ({system_id}) → {path}")
|
||||
print()
|
||||
|
||||
if found:
|
||||
print(f" Updating {len(found)} bodies with terrain_reference:")
|
||||
for body_id, system_id in found:
|
||||
ref = relative_terrain_reference(system_id, body_id)
|
||||
print(f" {body_id} ({system_id}) → {ref}")
|
||||
if not args.dry_run:
|
||||
conn.execute(
|
||||
"UPDATE bodies SET terrain_reference = ? WHERE body_id = ?",
|
||||
(ref, body_id),
|
||||
)
|
||||
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
print(f"\n Committed {len(found)} terrain_reference updates.")
|
||||
else:
|
||||
print(f"\n Dry run — no changes written.")
|
||||
|
||||
conn.close()
|
||||
|
||||
# Summary
|
||||
print(f"\n Summary:")
|
||||
print(f" Updated: {len(found)}")
|
||||
print(f" Missing: {len(missing)}")
|
||||
print(f" Total: {len(rows)}\n")
|
||||
|
||||
if missing:
|
||||
print(f" Action required: generate heightmaps for {len(missing)} bodies "
|
||||
f"before running generate_atlas.py (#832).")
|
||||
print(f" Use: make generate-terrain (or run generate.py per body)\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user