"""Schema migration for systems.db — new tables and columns on existing DBs. MIGRATION_SQL is the sanctioned migration path for systems.db (see .claude/rules/asset-pipeline.md): idempotent CREATE TABLE/INDEX IF NOT EXISTS statements plus data normalizations, executed at the top of every import run inside the same transaction that clears + reimports data. ALTER TABLE column additions go through COLUMN_MIGRATIONS (SQLite has no IF NOT EXISTS for ALTER). """ import sqlite3 MIGRATION_SQL: str = """ -- 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), price_tier TEXT, 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), PRIMARY KEY (from_system_id, to_system_id) ); CREATE TABLE IF NOT EXISTS commodities ( commodity_id TEXT PRIMARY KEY, name TEXT NOT NULL, tier TEXT NOT NULL, elasticity TEXT NOT NULL, base_price REAL NOT NULL, bulk_class TEXT, unit TEXT, production_ubiquity TEXT, demand_model TEXT, commission_certifiable INTEGER DEFAULT 0, compact_contested INTEGER DEFAULT 0, shadow_viable INTEGER DEFAULT 0, panic_threshold_weeks INTEGER DEFAULT 0, description TEXT, updated_at TEXT DEFAULT (datetime('now')) ); -- D-237 authored specialization layer vocabulary (must follow commodities for FK). -- Mirrors the canonical DDL in systems-schema.sql; here so the migration path -- (existing DBs) gets the table, not just fresh systems-schema.sql builds. CREATE TABLE IF NOT EXISTS specialization_vocabulary ( specialization_id TEXT PRIMARY KEY, commodity_id TEXT NOT NULL REFERENCES commodities(commodity_id), production_ubiquity_override TEXT, bulk_class_projected TEXT NOT NULL, production_ubiquity_projected TEXT NOT NULL, description TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_spec_vocab_commodity ON specialization_vocabulary(commodity_id); CREATE TABLE IF NOT EXISTS production_chains ( chain_id TEXT PRIMARY KEY, output_commodity_id TEXT NOT NULL REFERENCES commodities(commodity_id), output_quantity REAL NOT NULL DEFAULT 1.0, location_bound INTEGER DEFAULT 0, description TEXT, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS chain_inputs ( chain_id TEXT NOT NULL REFERENCES production_chains(chain_id), input_commodity_id TEXT NOT NULL REFERENCES commodities(commodity_id), quantity REAL NOT NULL, PRIMARY KEY (chain_id, input_commodity_id) ); CREATE TABLE IF NOT EXISTS corp_presence ( corp_id TEXT NOT NULL REFERENCES corporations(corp_id), location_id TEXT NOT NULL, location_type TEXT NOT NULL, primary_operation TEXT, updated_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (corp_id, location_id) ); -- New indexes CREATE INDEX IF NOT EXISTS idx_star_systems_currency ON star_systems(currency_zone); CREATE INDEX IF NOT EXISTS idx_gate_links_from ON gate_links(from_system_id); CREATE INDEX IF NOT EXISTS idx_gate_links_to ON gate_links(to_system_id); CREATE INDEX IF NOT EXISTS idx_commodities_tier ON commodities(tier); CREATE INDEX IF NOT EXISTS idx_production_chains_output ON production_chains(output_commodity_id); CREATE INDEX IF NOT EXISTS idx_chain_inputs_commodity ON chain_inputs(input_commodity_id); CREATE INDEX IF NOT EXISTS idx_corp_presence_corp ON corp_presence(corp_id); CREATE INDEX IF NOT EXISTS idx_corp_presence_location ON corp_presence(location_id); -- Generator metadata stamp (#855, #856) CREATE TABLE IF NOT EXISTS meta ( generator_name TEXT PRIMARY KEY, schema_version TEXT NOT NULL, generator_sha TEXT NOT NULL, generated_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Drop the pre-merge 'generate_brands' stamp row if it exists (PR #136 review T2/H3). -- The Rust brand binary is now a subroutine of import_economics — its source -- SHA contributes to the 'import_economics' stamp — so it no longer merits its -- 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'; -- Drop the retired 'generate_atlas' stamp row if it exists (#951, D-223). -- The atlas geometry generator was retired; import_economics now owns the -- atlas index, so generate_atlas no longer merits its own meta row. Without -- this DELETE, check-systems-db-stamp's fail-closed "unknown generator" path -- (T6) would reject any committed DB that still carries the old row. DELETE FROM meta WHERE generator_name = 'generate_atlas'; -- Drop the retired heightmap BLOB table (D-202 amended, #963): canonical -- elevation is now a per-body 16-bit grayscale heightmap.png file, not a DB -- BLOB. The Rust loader reads the PNG; nothing reads this table anymore. DROP TABLE IF EXISTS atlas_body_heightmaps; -- 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, settlement_class TEXT, 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); -- City positions — attractor-matched placement output (D-211, #34) CREATE TABLE IF NOT EXISTS atlas_city_positions ( city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE, body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, row INTEGER NOT NULL, col INTEGER NOT NULL, attractor_type TEXT NOT NULL, score REAL NOT NULL ); CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id); -- Architecture-flavor trait templates (D-232, #993). trait_templates = the -- shared catalog (baked from architecture_trait_catalog.toml); atlas_body_trait_bias -- = sparse per-body hero pins (#1017). List/map fields are JSON; numerics are -- integer basis-points (D-010). Retires the round-2 atlas_body_culture tables. DROP TABLE IF EXISTS atlas_body_culture_era; DROP TABLE IF EXISTS atlas_body_culture; CREATE TABLE IF NOT EXISTS trait_templates ( tag TEXT PRIMARY KEY, label TEXT NOT NULL, cultural_description TEXT, corridor_pool TEXT NOT NULL DEFAULT 'baseline', geographic_sector TEXT, bulk_class_gate TEXT, production_ubiquity_gate TEXT, min_prosperity_bps INTEGER NOT NULL DEFAULT 0, base_weight INTEGER NOT NULL DEFAULT 10000, weight_mods TEXT, zone_affinity TEXT, allow_tags TEXT, block_tags TEXT, era_scope TEXT, visual_bundle TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_trait_templates_pool ON trait_templates(corridor_pool); CREATE INDEX IF NOT EXISTS idx_trait_templates_sector ON trait_templates(geographic_sector); CREATE TABLE IF NOT EXISTS atlas_body_trait_bias ( id INTEGER PRIMARY KEY AUTOINCREMENT, body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, template_tag TEXT NOT NULL REFERENCES trait_templates(tag) ON DELETE CASCADE, bias_kind TEXT NOT NULL, weight_multiplier_bps INTEGER, note TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (body_id, template_tag) ); CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_body ON atlas_body_trait_bias(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_tag ON atlas_body_trait_bias(template_tag); -- D-235 exterior-grammar content (T-988). Mirrors the canonical DDL in -- systems-schema.sql; here so the migration path (existing DBs) gets the -- tables, not just fresh systems-schema.sql builds. CREATE TABLE IF NOT EXISTS architecture_zone_bias ( template_tag TEXT NOT NULL REFERENCES trait_templates(tag) ON DELETE CASCADE, zone_type_id TEXT NOT NULL, bias TEXT NOT NULL, PRIMARY KEY (template_tag, zone_type_id) ); CREATE INDEX IF NOT EXISTS idx_architecture_zone_bias_template ON architecture_zone_bias(template_tag); CREATE TABLE IF NOT EXISTS color_register_bands ( color_register TEXT PRIMARY KEY, hue_min INTEGER NOT NULL, hue_max INTEGER NOT NULL, sat_min INTEGER NOT NULL, sat_max INTEGER NOT NULL, val_min INTEGER NOT NULL, val_max INTEGER NOT NULL ); -- 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'; -- Backfill bodies.founding_age_years for all inhabited bodies in player scope (D-216 amendment, -- ticket #1000). Idempotent: WHERE clause limits to NULL rows, so a re-run is a no-op. -- -- Strategy: COALESCE(events-first, wave-fallback) -- events-first: the system's colonial_charter event age_years (authored; 9 systems in live DB). -- system_history is keyed PRIMARY KEY on system_id, so the wave subquery returns -- at most one row; LIMIT 1 is used on historical_events for safety (max one -- colonial_charter per system in the data). -- wave-fallback: canonical founding-edge of each settlement_wave range per D-216: -- wave_1=600, wave_2=500, wave_3=300, wave_4=100, wave_5=40. -- -- Exclusions (founding_age_years stays NULL): -- origin — Sol; out of player scope per D-236. -- unsettled — 24 systems with inhabited=0; the EXISTS guard also excludes them. UPDATE bodies SET founding_age_years = COALESCE( ( SELECT he.age_years FROM historical_events he WHERE he.system_id = bodies.system_id AND he.event_type = 'colonial_charter' ORDER BY he.sort_order ASC LIMIT 1 ), ( SELECT CASE sh.settlement_wave WHEN 'wave_1' THEN 600 WHEN 'wave_2' THEN 500 WHEN 'wave_3' THEN 300 WHEN 'wave_4' THEN 100 WHEN 'wave_5' THEN 40 ELSE NULL -- unexpected settlement_wave: add a WHEN above END FROM system_history sh WHERE sh.system_id = bodies.system_id ) ) WHERE founding_age_years IS NULL AND inhabited = 1 AND EXISTS ( SELECT 1 FROM system_history sh2 WHERE sh2.system_id = bodies.system_id AND sh2.settlement_wave NOT IN ('origin', 'unsettled') ); """ # Columns to add to existing tables (ALTER TABLE is idempotent via try/except) COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [ ("star_systems", "currency_zone", "TEXT DEFAULT 'TRACTUS_PRIMARY'"), ("star_systems", "gate_energy_connected", "INTEGER DEFAULT 1"), ("corporations", "behavioral_archetype", "TEXT"), ("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 ("bodies", "axial_tilt_deg", "REAL"), # T-1024, D-239 §2 — axial tilt from body-def frontmatter ("bodies", "biosphere_class", "TEXT"), # D-247, T-1085 — chirality/edibility register from body frontmatter + two-gate default ("meta", "schema_sha", "TEXT"), ("atlas_city_names", "settlement_class", "TEXT"), # D-196 — NULL until placement (#37) ("system_economy", "economic_specialization", "TEXT"), # D-237 — authored specialization layer ("system_economy", "cultural_specialization", "TEXT"), # D-237 — authored specialization layer ("corporations", "corp_specialization", "TEXT"), # D-242 — HQ-placement key (reused D-237 vocab) ("corporations", "hq_placement", "TEXT"), # D-242 — 'CityTenant' | 'Standalone', baked at import ("corporations", "headquarters_city_id", "INTEGER REFERENCES atlas_city_names(id)"), # D-242 CityTenant link ] def _add_column(conn: sqlite3.Connection, table: str, col: str, col_type: str) -> None: """Add a column if it doesn't exist. SQLite has no IF NOT EXISTS for ALTER.""" try: conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {col_type}") except sqlite3.OperationalError as e: if "duplicate column" in str(e).lower(): pass # already exists else: raise def apply_schema_migrations(conn: sqlite3.Connection) -> None: """Apply COLUMN_MIGRATIONS then MIGRATION_SQL (idempotent, in-transaction). Note: ``executescript`` implicitly commits any pending transaction before running the script — same semantics as the pre-split importer. """ for table, col, col_type in COLUMN_MIGRATIONS: _add_column(conn, table, col, col_type) conn.executescript(MIGRATION_SQL)