diff --git a/Makefile b/Makefile index d4a2a11c0..42189b692 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \ decisions-sync decisions-coverage decisions-active decisions-orphan \ db-backup db-install validate-content check-fact-ids setup-hooks \ - audit atlas-verify \ + audit atlas-verify economy-db \ pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \ pre-pr-server pre-pr-client pre-pr-content \ fixtures-client fixtures-gauntlet golden-diff golden-update \ @@ -53,6 +53,7 @@ help: @echo " make validate-content Validate content YAML against schemas" @echo " make check-fact-ids Check fact_id references against knowledge catalogs" @echo " make atlas-verify Verify atlas proposal JSONs (all in docs/atlas/proposals/)" + @echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)" @echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)" @echo " make golden-diff Show diff if golden file output has changed" @echo " make golden-update Regenerate golden file and stage for commit" @@ -323,6 +324,9 @@ db-backup: db-install: @tooling/db-install +economy-db: ## Import economics data (commodities, chains, gate links) into systems.db + @python3 tooling/economy-db/import_economics.py + # --- Decisions --- decisions-sync: diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index d02ad559e..ad94690b7 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -38,6 +38,9 @@ CREATE TABLE IF NOT EXISTS star_systems ( cultural_corridor TEXT, generation_priority TEXT, + -- Economics (D-172) + currency_zone TEXT DEFAULT 'TRACTUS_PRIMARY', -- TRACTUS_PRIMARY | MARK_PRIMARY | MIXED + updated_at TEXT DEFAULT (datetime('now')) ); @@ -201,9 +204,73 @@ CREATE TABLE IF NOT EXISTS corporations ( specialization TEXT, -- primary product/service parent_corp TEXT REFERENCES corporations(corp_id), notes TEXT, + + -- Economics (D-175) + behavioral_archetype TEXT, -- Monopolist|Distributor|Producer|Specialist|Cooperative|Intermediary + supply_chain_role TEXT, + shadow_economy_access INTEGER DEFAULT 0, + updated_at TEXT DEFAULT (datetime('now')) ); +-- Gate adjacency graph — bidirectional edges from star-map.json (D-178) +-- Transport cost computation: 5–12% per hop (gate edge), 1–3% (orbital edge) +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) +); + +-- Commodity catalog — 36 types (D-184) +-- Source: wiki/economics/commodities.toml +CREATE TABLE IF NOT EXISTS commodities ( + commodity_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + tier TEXT NOT NULL, -- raw|intermediate|final|service_professional|service_luxury + elasticity TEXT NOT NULL, -- perfectly_inelastic|inelastic|unit_elastic|elastic + base_price REAL NOT NULL, + bulk_class TEXT, -- bulk|liquid|perishable|standard|compact|precision|oversized|non_physical + unit TEXT, -- tonnes|units|contracts + production_ubiquity TEXT, -- ubiquitous|common|regional|concentrated|monopolistic + demand_model TEXT, -- market|utility|compliance + commission_certifiable INTEGER DEFAULT 0, -- subject to certification in TRACTUS_PRIMARY zones + compact_contested INTEGER DEFAULT 0, + shadow_viable INTEGER DEFAULT 0, + panic_threshold_weeks INTEGER DEFAULT 0, + description TEXT, + updated_at TEXT DEFAULT (datetime('now')) +); + +-- Leontief production chain recipes (D-178) +-- Source: wiki/economics/production_chains.toml +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')) +); + +-- Input requirements per production chain +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) +); + +-- Corporation operating locations (D-175) +-- Populated by corporation assignment pipeline, not by initial import +CREATE TABLE IF NOT EXISTS corp_presence ( + corp_id TEXT NOT NULL REFERENCES corporations(corp_id), + location_id TEXT NOT NULL, -- body_id or station_id + location_type TEXT NOT NULL, -- body|station + primary_operation TEXT, + updated_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (corp_id, location_id) +); + -- Indexes -- astronomical_id removed: system_id IS the GJ catalog number CREATE INDEX IF NOT EXISTS idx_star_systems_sector ON star_systems(geographic_sector); @@ -224,3 +291,11 @@ CREATE INDEX IF NOT EXISTS idx_stations_orbits ON stations(orbits_body_id); CREATE INDEX IF NOT EXISTS idx_stations_type ON stations(station_type); CREATE INDEX IF NOT EXISTS idx_corps_system ON corporations(headquarters_system); CREATE INDEX IF NOT EXISTS idx_corps_type ON corporations(corp_type); +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); diff --git a/server/data/systems.db b/server/data/systems.db index 632e87bda..bb667d19d 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 new file mode 100755 index 000000000..d4d3e3c82 --- /dev/null +++ b/tooling/economy-db/import_economics.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +Import economics data into systems.db. + +Reads TOML/JSON source files and populates the economics tables: + - gate_links from docs/design/star-map.json (335 edges, bidirectional) + - commodities from wiki/economics/commodities.toml (36 types) + - production_chains + chain_inputs from wiki/economics/production_chains.toml + - currency_zone on star_systems (default TRACTUS_PRIMARY) + +Does NOT populate corp_presence — that's a future pipeline step. + +Usage: + python3 tooling/economy-db/import_economics.py + python3 tooling/economy-db/import_economics.py --dry-run + python3 tooling/economy-db/import_economics.py --db path/to/systems.db +""" + +import argparse +import json +import os +import sqlite3 +import sys +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + +DB_PATH = REPO_ROOT / "server" / "data" / "systems.db" +STAR_MAP = REPO_ROOT / "docs" / "design" / "star-map.json" +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" + + +# --------------------------------------------------------------------------- +# Schema migration — add new tables and columns to existing DB +# --------------------------------------------------------------------------- + +MIGRATION_SQL = """ +-- Economics tables (idempotent — safe to re-run) + +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')) +); + +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); +""" + +# Columns to add to existing tables (ALTER TABLE is idempotent via try/except) +COLUMN_MIGRATIONS = [ + ("star_systems", "currency_zone", "TEXT DEFAULT 'TRACTUS_PRIMARY'"), + ("corporations", "behavioral_archetype", "TEXT"), + ("corporations", "supply_chain_role", "TEXT"), + ("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"), +] + + +def _add_column(conn: sqlite3.Connection, table: str, col: str, col_type: str): + """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 + + +# --------------------------------------------------------------------------- +# Gate links +# --------------------------------------------------------------------------- + +def import_gate_links(conn: sqlite3.Connection, dry_run: bool) -> int: + with open(STAR_MAP) as f: + data = json.load(f) + + edges = data["edges"] + system_ids = {r[0] for r in conn.execute("SELECT system_id FROM star_systems").fetchall()} + + rows = [] + skipped = [] + for a, b in edges: + if a not in system_ids: + skipped.append(a) + continue + if b not in system_ids: + skipped.append(b) + continue + rows.append((a, b)) + rows.append((b, a)) + + if skipped: + unique_skipped = sorted(set(skipped)) + print(f" warning: {len(unique_skipped)} system(s) in star-map.json not in DB: {unique_skipped[:5]}...") + + if not dry_run: + conn.executemany( + "INSERT OR IGNORE INTO gate_links (from_system_id, to_system_id) VALUES (?, ?)", + rows, + ) + + return len(rows) + + +# --------------------------------------------------------------------------- +# Commodities +# --------------------------------------------------------------------------- + +def import_commodities(conn: sqlite3.Connection, dry_run: bool) -> int: + with open(COMMODITIES_TOML, "rb") as f: + data = tomllib.load(f) + + rows = [] + for cid, c in data.items(): + rows.append(( + cid, + c["name"], + c["tier"], + c["elasticity"], + c["base_price"], + c.get("bulk_class"), + c.get("unit"), + c.get("production_ubiquity"), + c.get("demand_model"), + int(c.get("commission_certifiable", False)), + int(c.get("compact_contested", False)), + int(c.get("shadow_viable", False)), + c.get("panic_threshold_weeks", 0), + c.get("description"), + )) + + if not dry_run: + conn.executemany( + """INSERT INTO commodities ( + commodity_id, name, tier, elasticity, base_price, + bulk_class, unit, production_ubiquity, demand_model, + commission_certifiable, compact_contested, shadow_viable, + panic_threshold_weeks, description + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + rows, + ) + + return len(rows) + + +# --------------------------------------------------------------------------- +# Production chains + inputs +# --------------------------------------------------------------------------- + +def import_chains(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]: + with open(CHAINS_TOML, "rb") as f: + data = tomllib.load(f) + + chain_rows = [] + input_rows = [] + for chain_id, c in data.items(): + chain_rows.append(( + chain_id, + c["output"], + c.get("output_quantity", 1.0), + int(c.get("location_bound", False)), + c.get("description"), + )) + for inp in c.get("inputs", []): + input_rows.append(( + chain_id, + inp["commodity"], + inp["quantity"], + )) + + if not dry_run: + conn.executemany( + """INSERT INTO production_chains ( + chain_id, output_commodity_id, output_quantity, + location_bound, description + ) VALUES (?, ?, ?, ?, ?)""", + chain_rows, + ) + conn.executemany( + """INSERT INTO chain_inputs ( + chain_id, input_commodity_id, quantity + ) VALUES (?, ?, ?)""", + input_rows, + ) + + return len(chain_rows), len(input_rows) + + +# --------------------------------------------------------------------------- +# Currency zones +# --------------------------------------------------------------------------- + +def set_currency_zones(conn: sqlite3.Connection, dry_run: bool) -> dict: + """Set currency_zone on star_systems. Default TRACTUS_PRIMARY, Sol = MIXED.""" + if dry_run: + return {"TRACTUS_PRIMARY": "all", "MIXED": "GJ 0"} + + # Default everything to TRACTUS_PRIMARY + conn.execute("UPDATE star_systems SET currency_zone = 'TRACTUS_PRIMARY' WHERE currency_zone IS NULL") + + # Sol system is MIXED (Earth legacy currency presence) + conn.execute("UPDATE star_systems SET currency_zone = 'MIXED' WHERE system_id = 'GJ 0'") + + # Future: Compact systems → MARK_PRIMARY (requires authored Compact membership data) + + counts = {} + for row in conn.execute("SELECT currency_zone, COUNT(*) FROM star_systems GROUP BY currency_zone"): + counts[row[0]] = row[1] + + return counts + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +def validate(conn: sqlite3.Connection) -> list[str]: + errors = [] + + # FK integrity + fk_issues = conn.execute("PRAGMA foreign_key_check").fetchall() + if fk_issues: + for issue in fk_issues[:10]: + errors.append(f"FK violation: table={issue[0]} rowid={issue[1]} " + f"parent={issue[2]} fkid={issue[3]}") + + # Chain inputs reference valid commodities + orphan_inputs = conn.execute(""" + SELECT ci.chain_id, ci.input_commodity_id + FROM chain_inputs ci + LEFT JOIN commodities c ON ci.input_commodity_id = c.commodity_id + WHERE c.commodity_id IS NULL + """).fetchall() + for chain_id, cid in orphan_inputs: + errors.append(f"chain_inputs: chain '{chain_id}' references unknown commodity '{cid}'") + + # Chain outputs reference valid commodities + orphan_outputs = conn.execute(""" + SELECT pc.chain_id, pc.output_commodity_id + FROM production_chains pc + LEFT JOIN commodities c ON pc.output_commodity_id = c.commodity_id + WHERE c.commodity_id IS NULL + """).fetchall() + for chain_id, cid in orphan_outputs: + errors.append(f"production_chains: chain '{chain_id}' outputs unknown commodity '{cid}'") + + return errors + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Import economics data into systems.db") + parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db") + parser.add_argument("--dry-run", action="store_true", help="Validate without writing") + 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 Economics Import Pipeline") + print(f" DB: {db_path}") + if args.dry_run: + print(f" Mode: DRY RUN") + print() + + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON") + + # 1. Migrate schema + print(" [1/5] 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) + if not args.dry_run: + 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/5] Importing gate links...") + n_links = import_gate_links(conn, args.dry_run) + print(f" {n_links} rows (bidirectional)") + + # 3. Commodities + print(" [3/5] Importing commodities...") + n_commodities = import_commodities(conn, args.dry_run) + print(f" {n_commodities} commodities") + + # 4. Production chains + print(" [4/5] 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/5] Setting currency zones...") + zones = set_currency_zones(conn, args.dry_run) + for zone, count in sorted(zones.items()): + print(f" {zone}: {count}") + + # Validate + print("\n Validating...") + errors = validate(conn) + if errors: + print(f" ERRORS ({len(errors)}):") + for e in errors: + print(f" - {e}") + conn.close() + sys.exit(1) + else: + print(" FK integrity OK") + + if not args.dry_run: + conn.commit() + print("\n Committed.") + else: + print("\n Dry run — no changes written.") + + conn.close() + + print(f"\n Done: {n_links} gate_links, {n_commodities} commodities, " + f"{n_chains} chains, {n_inputs} inputs\n") + + +if __name__ == "__main__": + main()