Remove the reserved=1 corp-HQ city-pool cross-reference (duplicate co-named cities); corp_specialization keyed on the D-237 vocabulary extended 27->30; authored corp_hq_placement.toml {CityTenant,Standalone} map; headquarters_body backfill via lifted most-populated-body heuristic + body-type tiebreak; standalone HQs emitted as ordinary settlement rows; Zipf rank-size population spread from bodies.population at import; settlement_class defaults PopulationBudget + settlement_name_locked.toml hero pins; retire orphaned populate-corporations.sh; regen systems.db.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
374 lines
17 KiB
Python
374 lines
17 KiB
Python
"""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)
|