diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index 8fb0c4466..c4654ea65 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -238,8 +238,11 @@ CREATE TABLE IF NOT EXISTS corporations ( corp_type TEXT NOT NULL, -- corporation, combine, syndic, institution, independent scope TEXT, -- reach-wide, sector, system, local headquarters_system TEXT REFERENCES star_systems(system_id), - headquarters_body TEXT, -- body_id or station_id - specialization TEXT, -- primary product/service + headquarters_body TEXT, -- body_id or station_id (D-242: backfilled at import for + -- every corp via the most-populated-body-in-system heuristic + -- when not authored; see populate_corp_specialization) + specialization TEXT, -- free-text primary product/service (legacy prose; read by + -- generate_corporations for fuzzy brand relevance — left as-is) parent_corp TEXT REFERENCES corporations(corp_id), notes TEXT, @@ -248,6 +251,19 @@ CREATE TABLE IF NOT EXISTS corporations ( supply_chain_role TEXT, shadow_economy_access INTEGER DEFAULT 0, + -- D-242: controlled-vocabulary corp specialization (HQ-placement key) + its + -- baked placement outcome. corp_specialization is the corp_specialization + -- controlled vocabulary (reuses the D-237 27-value specialization_vocabulary + -- id-space — see wiki/economics/corp_hq_placement.toml header). hq_placement + -- is 'CityTenant' | 'Standalone', derived once at import from corp_specialization + -- via the authored placement map; NULL until populate_corp_specialization runs. + -- headquarters_city_id is the D-242 "corp -> city link" for CityTenant HQs + -- (many corps may tenant one city); NULL for Standalone HQs (which instead + -- get their own atlas_city_names row, cross-referenced by headquarters_body). + corp_specialization TEXT, + hq_placement TEXT, + headquarters_city_id INTEGER REFERENCES atlas_city_names(id), + updated_at TEXT DEFAULT (datetime('now')) ); @@ -493,6 +509,10 @@ CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_rang -- City name reservations — replaces authored city positions in markers.json (D-207, #902) -- Position is generated by the city placement algorithm; name is authored or LLM-generated. +-- D-242: a Standalone corp HQ IS a settlement, so it gets an ordinary row here +-- (populate_standalone_hq_settlements) — same shape as a wiki-pooled city, no +-- special kind/flag. A CityTenant HQ is NOT a row here; its corp->city link +-- lives on corporations.headquarters_city_id (the inverse FK) instead. 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, @@ -500,8 +520,15 @@ CREATE TABLE IF NOT EXISTS atlas_city_names ( kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city' economic_role TEXT NOT NULL, population INTEGER NOT NULL, - settlement_class TEXT, -- D-196 SettlementClass variant; NULL until placement - corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable + settlement_class TEXT, -- D-196 SettlementClass variant; baked at import (D-242/T-1075) + corp_id TEXT REFERENCES corporations(corp_id), -- SUPERSEDED (D-242): the D-207/D-223 corp-HQ + -- cross-reference insert that populated this is + -- removed (it produced duplicate co-named "cities" — + -- one row per corp HQ, no UNIQUE(body_id, name)). + -- Always NULL going forward; the corp<->settlement + -- relationship now lives on corporations + -- (headquarters_body / headquarters_city_id). Column + -- kept (not dropped) — additive-safe, no live reader. reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/server/data/systems.db b/server/data/systems.db index 93ffd0844..1c900a5ab 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/tooling/economy-db/economy_import/atlas.py b/tooling/economy-db/economy_import/atlas.py index 2a4be95f5..308418c66 100644 --- a/tooling/economy-db/economy_import/atlas.py +++ b/tooling/economy-db/economy_import/atlas.py @@ -1,12 +1,24 @@ -"""Atlas index ownership (D-223, #951): canonical atlas_* schema, the -names-only city pool, and the corp-HQ city cross-reference (D-207).""" +"""Atlas index ownership (D-223, #951): canonical atlas_* schema and the +names-only city pool. + +The corp-HQ city cross-reference (D-207, `populate_atlas_city_names_corps`) +was REMOVED here (D-242, T-1074) — it inserted one atlas_city_names row per +corp HQ with no UNIQUE(body_id, name), producing duplicate co-named "cities" +(e.g. 10 Groombridge rows on GJ380c). The corp<->settlement relationship now +lives with the corp (economy_import/corporations.py:populate_corp_specialization), +not in the city pool. `most_populated_body_in_system` below is the one piece +of that removed function's logic still needed (by the D-242 replacement). + +`populate_settlement_population_class` (D-242, T-1075) bakes the per-settlement +population spread + settlement_class onto atlas_city_names — see its own +docstring for the rank-size derivation and D-196 class-assignment rule.""" import glob import json -import re import sqlite3 +import tomllib -from .paths import CORPORATIONS_DIR, SCHEMA_SQL, WIKI_STAR_SYSTEMS +from .paths import SCHEMA_SQL, SETTLEMENT_NAME_LOCKED_TOML, WIKI_STAR_SYSTEMS # Atlas geometry index tables (D-191). These hold computed positions — city # centres, road/river/rail polylines, ocean/mountain extents. Under D-223 the @@ -67,8 +79,9 @@ def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int: - name : pooled city name - kind : 'city' — capital is chosen at placement (#955) - economic_role : inherited from bodies.economic_role; fallback 'mixed' - - population : 0 — assigned by the server cascade at placement (#955) - - corp_id : NULL — populated by populate_atlas_city_names_corps (#909) + - population : 0 — baked at import by populate_settlement_population_class (D-242, T-1075) + - corp_id : NULL — SUPERSEDED (D-242): the corp-HQ cross-reference + that used to populate this was removed; always NULL now - reserved : 0 markers.json is a names-only flavoured pool (D-223): it carries no geometry @@ -129,6 +142,16 @@ def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int: print(f" warning: {len(unique)} body dirs not in DB — skipped: {unique[:5]}") if not dry_run: + # D-242: corporations.headquarters_city_id FK-references + # atlas_city_names(id). corporations is append-only (never cleared — + # db.clear_economics_tables doesn't touch it), so a PRIOR run's + # resolved CityTenant links survive as live FK references into the + # very rows this DELETE is about to remove — clear them first or the + # DELETE hits a FOREIGN KEY constraint failure on every run after the + # first (caught live in the T-1074/T-1075 regen-db integration test). + # Re-populated by corporations.populate_standalone_hq_settlements + # later in this same import run. + conn.execute("UPDATE corporations SET headquarters_city_id = NULL") conn.execute("DELETE FROM atlas_city_names") if rows: conn.executemany( @@ -141,110 +164,212 @@ def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int: return len(rows) -def populate_atlas_city_names_corps(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]: - """Cross-reference corp HQ city names into atlas_city_names (D-207, #909). +# body_type preference for the most_populated_body_in_system tiebreak — lower +# sorts first. Only breaks a population TIE (real population always wins); +# this exists because a plain population-then-body_id sort can otherwise land +# on an asteroid_belt/oort_cloud sibling purely because '-' (the body_id +# naming convention for these types, e.g. "GJ702B-belt") sorts before letters +# — a body type that structurally cannot host a settlement (found live in +# T-1074/T-1075 regen: GJ 702B's 12 bodies are ALL population=0, and the old +# alphabetical-only tiebreak picked "GJ702B-belt" over 6 sibling planets, +# stranding prometheus-labs/kovalev-freight's CityTenant resolution with a +# city-less body). planet/moon/gas_giant can plausibly carry cities or +# stations; asteroid_belt/oort_cloud are geometry, never named settlements. +_BODY_TYPE_SETTLEMENT_PREFERENCE: dict[str, int] = { + "planet": 0, + "moon": 1, + "gas_giant": 2, + "asteroid_belt": 3, + "oort_cloud": 3, +} - For each corporation with a parseable headquarters field ("City (SYSTEM_ID)"): - - If atlas_city_names already has a row with matching name on a body in that - system: UPDATE the row to set corp_id. - - Otherwise: INSERT a reserved row (reserved=1) so the name is protected. - Attaches to the most-populated body in the system (fallback: any body). - Returns (n_updated, n_inserted). +def most_populated_body_in_system(conn: sqlite3.Connection, system_id: str) -> str | None: + """Return the most-populated body_id in `system_id`, or None if the system + has no bodies. + + D-242, T-1074: this is the heuristic the retired + `populate_atlas_city_names_corps` (D-207, #909) used to attach a corp-HQ + row to a body — lifted out here (rather than duplicated) so + `populate_corp_specialization` (economy_import/corporations.py) can reuse + it to backfill `corporations.headquarters_body` for Standalone HQs. + + Deterministic tiebreak, in order (D-010 #4): (1) population descending — + real population always wins regardless of type; (2) body_type preference + (planet < moon < gas_giant < asteroid_belt/oort_cloud — see + `_BODY_TYPE_SETTLEMENT_PREFERENCE`) — only engages among population TIES, + so it never overrides an authored population signal, it only picks a + saner body among zero-population siblings; (3) body_id ascending, the + final tiebreak when population AND type both tie. """ - # Build system_id -> sorted bodies (by population desc, then body_id) - sys_bodies: dict[str, list[tuple[int, str, str]]] = {} - for body_id, sys_id, pop, role in conn.execute( - "SELECT body_id, system_id, COALESCE(population, 0), COALESCE(economic_role, 'mixed') FROM bodies" - ).fetchall(): - sys_bodies.setdefault(sys_id, []).append((pop, body_id, role)) - for v in sys_bodies.values(): - v.sort(key=lambda x: (-x[0], x[1])) + rows = conn.execute( + "SELECT body_id, body_type, COALESCE(population, 0) FROM bodies WHERE system_id = ?", + (system_id,), + ).fetchall() + if not rows: + return None + rows.sort( + key=lambda r: ( + -r[2], # population descending + _BODY_TYPE_SETTLEMENT_PREFERENCE.get(r[1], 3), # body_type preference + r[0], # body_id ascending + ) + ) + return rows[0][0] - # Build (body_id, name_lower) -> id index for existing atlas_city_names rows - existing: dict[tuple[str, str], int] = {} - body_to_sys: dict[str, str] = { - r[0]: r[1] - for r in conn.execute("SELECT body_id, system_id FROM bodies").fetchall() + +# --------------------------------------------------------------------------- +# D-242 population/settlement_class bake (T-1075) +# --------------------------------------------------------------------------- + +# Zipf/rank-size curve constants (T-1075: "name + document the curve +# constants — tuning is a code change by design"). weight(rank) = +# ZIPF_WEIGHT_SCALE // rank, i.e. the classic Zipf's-law exponent s=1.0 +# (rank-2 city gets half of rank-1's weight, rank-3 a third, ...) expressed +# as an integer basis-weight — no floats anywhere in the derivation (D-010). +# ZIPF_WEIGHT_SCALE only needs to be large enough that //rank doesn't +# truncate the tail ranks to 0 before normalization; 100 pooled cities on the +# densest body (Groombridge, ~16 rows pre-T-1074-cleanup) is far under this. +ZIPF_WEIGHT_SCALE = 1_000_000 + + +def _zipf_population_spread(body_population: int, n_cities: int) -> list[int]: + """Split `body_population` across `n_cities` settlements via a Zipf/rank-size + curve (T-1075). Returns a list of `n_cities` integer populations, index 0 = + rank 1 (the largest), summing EXACTLY to `body_population` (any integer- + division remainder is added to rank 1, so the total never drifts — the + standard largest-remainder-to-the-leader technique, not silently dropped). + + `n_cities == 0` returns `[]`. `body_population <= 0` returns all zeros + (an authored-population gap; not this function's job to invent one, per + D-242's "only position stays seed-derived" — POPULATION is baked from + `bodies.population`, and an unauthored body simply bakes zero). + + Which of a body's `atlas_city_names` rows *is* "rank 1" (gets the largest + share) is the caller's business (see `populate_settlement_population_class` + — rank is assigned by ascending `id`, i.e. insertion order, D-010 + determinism; there is no population signal to rank by BEFORE this + function runs, which is the whole reason it exists). + """ + if n_cities <= 0: + return [] + if body_population <= 0: + return [0] * n_cities + + weights = [ZIPF_WEIGHT_SCALE // rank for rank in range(1, n_cities + 1)] + total_weight = sum(weights) + shares = [body_population * w // total_weight for w in weights] + remainder = body_population - sum(shares) + shares[0] += remainder # give the leftover to rank 1 (D-010: deterministic) + return shares + + +def populate_settlement_population_class(conn: sqlite3.Connection, dry_run: bool) -> dict: + """Bake per-settlement population + settlement_class onto atlas_city_names + (D-242, T-1075). + + MUST run after BOTH D-242/T-1074 steps (`populate_atlas_city_names` and + `corporations.populate_standalone_hq_settlements`) — "the spread runs over + the CORRECTED pool," i.e. Standalone-HQ settlement rows are included in + the per-body city count/rank BEFORE the spread runs, not bolted on after. + + Two passes: + + (1) Population — for every body with at least one atlas_city_names row, + split `bodies.population` (COALESCE to 0 for unauthored bodies) across + its cities via `_zipf_population_spread`. Rank 1 (the largest share) + is the row with the LOWEST atlas_city_names.id on that body (D-010: + deterministic — insertion order is the only signal available; there + is no pre-existing population to rank by, which is exactly the gap + this function closes). A body with population=0/NULL bakes every one + of its cities to population=0 (not an error — 6/273 populated bodies + currently have zero pooled cities and 9/276 city-bearing bodies + currently have zero authored population; both directions are + legitimate content gaps, not import failures). + + (2) settlement_class — every pooled city defaults to 'PopulationBudget' + (D-196; the >=50k-active/<5k-ghost thresholds are read from this baked + population at GENERATION time by a later, not-yet-built consumer — + out of this bake's scope, confirmed: no such consumer exists in + server/src/atlas yet). The authored NameLocked override list + (settlement_name_locked.toml, `[[hero]]` stanzas of body_id + name) + then overrides specific rows to 'NameLocked' — matched by + (body_id, name), NOT the volatile numeric id (see that TOML's header). + A stanza with no matching row is a non-fatal warning (a future + markers.json rename must not hard-fail regen-db). + + Returns a coverage dict: {"bodies_spread", "cities_populated", + "name_locked_applied", "name_locked_unmatched"}. + """ + # --- Pass 1: population spread ----------------------------------------- + body_population: dict[str, int] = { + r[0]: (r[1] or 0) for r in conn.execute("SELECT body_id, population FROM bodies").fetchall() } - for row_id, body_id, name in conn.execute( - "SELECT id, body_id, name FROM atlas_city_names" + # body_id -> [(city_id, ...)] ordered by id ascending (rank 1 = lowest id). + city_rows_by_body: dict[str, list[int]] = {} + for city_id, body_id in conn.execute( + "SELECT id, body_id FROM atlas_city_names ORDER BY body_id, id" ).fetchall(): - existing[(body_id, name.lower())] = row_id + city_rows_by_body.setdefault(body_id, []).append(city_id) - # Build system_id -> set of body_ids for quick lookup - sys_body_ids: dict[str, set[str]] = {} - for body_id, sys_id in body_to_sys.items(): - sys_body_ids.setdefault(sys_id, set()).add(body_id) + population_updates: list[tuple[int, int]] = [] # (population, city_id) + for body_id, city_ids in city_rows_by_body.items(): + pop = body_population.get(body_id, 0) + spread = _zipf_population_spread(pop, len(city_ids)) + for city_id, city_pop in zip(city_ids, spread): + population_updates.append((city_pop, city_id)) - updated: list[tuple[str, int]] = [] # (corp_id, atlas_row_id) - inserted: list[tuple] = [] # insert rows - - for corp_id, headquarters_system in conn.execute( - "SELECT corp_id, headquarters_system FROM corporations WHERE headquarters_system IS NOT NULL" - ).fetchall(): - # Retrieve original headquarters string from wiki to get city name - md_file = CORPORATIONS_DIR / f"{corp_id}.md" - if not md_file.exists(): - continue - hq_raw = "" - with open(md_file) as f: - in_fm = False - for line in f: - if line.strip() == "---": - if not in_fm: - in_fm = True - continue - else: - break - if in_fm and line.startswith("headquarters:"): - hq_raw = line.split(":", 1)[1].strip().strip('"') - break - if not hq_raw: - continue - m = re.search(r"\(([^)]+)\)", hq_raw) - city_name = hq_raw[: m.start()].strip() if m else hq_raw.strip() - if not city_name: - continue - - # Try to find a matching atlas_city_names row in the same system - body_ids_in_sys = sys_body_ids.get(headquarters_system, set()) - match_id: int | None = None - # sorted() for determinism: on a name collision across bodies in the - # same system, set iteration order is not stable (D-010 #4). - for body_id in sorted(body_ids_in_sys): - key = (body_id, city_name.lower()) - if key in existing: - match_id = existing[key] - break - - if match_id is not None: - updated.append((corp_id, match_id)) - else: - # Sol (system 'GJ 0') is exempt from the normal generators (D-223, - # #951) — do not synthesize a reserved corp-HQ row on a Sol body; - # Sol's atlas data comes from its own scripted integration. - if headquarters_system == "GJ 0": - continue - # Insert a reserved row on the most-populated body in the system - candidates = sys_bodies.get(headquarters_system, []) - if not candidates: - continue - _, target_body_id, body_role = candidates[0] - inserted.append((target_body_id, city_name, "city", body_role, 0, corp_id, 1)) + # --- Pass 2: settlement_class default + NameLocked override ------------ + with open(SETTLEMENT_NAME_LOCKED_TOML, "rb") as f: + hero_data = tomllib.load(f) + hero_entries: list[dict] = hero_data.get("hero", []) if not dry_run: - for corp_id, row_id in updated: - conn.execute( - "UPDATE atlas_city_names SET corp_id = ? WHERE id = ?", - (corp_id, row_id), - ) - if inserted: + if population_updates: conn.executemany( - """INSERT OR IGNORE INTO atlas_city_names - (body_id, name, kind, economic_role, population, corp_id, reserved) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - inserted, + "UPDATE atlas_city_names SET population = ? WHERE id = ?", + population_updates, ) + # Default every row to PopulationBudget (D-196). Idempotent: a re-run + # always resets to the default before re-applying overrides below, so + # a stanza REMOVED from settlement_name_locked.toml correctly reverts + # its row to PopulationBudget rather than leaving a stale NameLocked. + conn.execute("UPDATE atlas_city_names SET settlement_class = 'PopulationBudget'") - return len(updated), len(inserted) + # NameLocked matching is a read-only SELECT count (safe under dry_run too + # — a --dry-run run must still be able to REPORT an unmatched hero stanza, + # not just skip detecting it; the WRITE is what's gated on dry_run). + name_locked_applied = 0 + name_locked_unmatched: list[str] = [] + for entry in hero_entries: + body_id = entry.get("body_id") + name = entry.get("name") + if not body_id or not name: + continue + match_count = conn.execute( + "SELECT COUNT(*) FROM atlas_city_names WHERE body_id = ? AND name = ?", + (body_id, name), + ).fetchone()[0] + if match_count == 0: + name_locked_unmatched.append(f"{body_id}/{name}") + continue + if not dry_run: + conn.execute( + "UPDATE atlas_city_names SET settlement_class = 'NameLocked' " + "WHERE body_id = ? AND name = ?", + (body_id, name), + ) + name_locked_applied += match_count + + if name_locked_unmatched: + print( + f" warning: {len(name_locked_unmatched)} NameLocked hero stanza(s) " + f"matched no atlas_city_names row: {name_locked_unmatched}" + ) + + return { + "bodies_spread": len(city_rows_by_body), + "cities_populated": len(population_updates), + "name_locked_applied": name_locked_applied, + "name_locked_unmatched": len(name_locked_unmatched), + } diff --git a/tooling/economy-db/economy_import/corporations.py b/tooling/economy-db/economy_import/corporations.py index 898d33c26..576de28af 100644 --- a/tooling/economy-db/economy_import/corporations.py +++ b/tooling/economy-db/economy_import/corporations.py @@ -1,10 +1,14 @@ -"""Corporation wiki parsing, D-182 sync, and corp_presence population.""" +"""Corporation wiki parsing, D-182 sync, corp_presence population, and the +D-242 corp-HQ specialization/placement bake (T-1074).""" import re import sqlite3 +import tomllib from pathlib import Path -from .paths import CORPORATIONS_DIR +from .atlas import most_populated_body_in_system +from .errors import ImportAborted +from .paths import CORP_HQ_PLACEMENT_TOML, CORPORATIONS_DIR # --------------------------------------------------------------------------- # Corporation wiki parsing @@ -51,12 +55,18 @@ def load_wiki_corps() -> list[dict]: hq = fm.get("headquarters", "") m = re.search(r"\(([^)]+)\)", hq) system_id = m.group(1) if m else None + # D-242, T-1074: authored corp_specialization frontmatter key — the + # HQ-placement key (reuses the D-237 specialization_vocabulary + # id-space, extended from 27 to 30 values for corp coverage; see + # wiki/economics/corp_hq_placement.toml). Optional — None until a + # corp's page is authored with the key. corps.append({ "corp_id": fm["slug"], "proper_name": fm["title"], "system_id": system_id, "tags": fm.get("tags", []), "scope": fm.get("scope", ""), + "corp_specialization": fm.get("corp_specialization") or None, }) return corps @@ -173,6 +183,143 @@ def _resolve_hq_location( return None +# --------------------------------------------------------------------------- +# D-242 corp-HQ specialization + placement bake (T-1074) +# --------------------------------------------------------------------------- + + +def populate_corp_specialization( + conn: sqlite3.Connection, wiki_corps: list[dict], dry_run: bool +) -> dict: + """Phase A of the D-242 corp-HQ model (T-1074). + + For every wiki corp: validate its authored `corp_specialization` (frontmatter + key, reuses the D-237 `specialization_vocabulary` id-space — see + wiki/economics/corp_hq_placement.toml header) against the vocabulary table + (must run AFTER `import_system_specialization`, which populates + `specialization_vocabulary`); resolve `hq_placement` ('CityTenant' | + 'Standalone') from `corp_hq_placement.toml`; and backfill + `corporations.headquarters_body` via the most-populated-body-in-system + heuristic (`atlas.most_populated_body_in_system` — lifted from the retired + `populate_atlas_city_names_corps`, D-207/#909) for any corp that doesn't + already have one authored. + + Must run AFTER `sync_corporations` (corp rows must exist) and BEFORE + `import_corp_presence` (which reads `headquarters_body` back off + `corporations` to resolve corp_presence locations, #909's existing + resolution order) — so `import_corp_presence` picks up the backfilled + value for free, with no change to its own logic. + + Does NOT touch `atlas_city_names` — this is Phase A. Phase B + (`populate_standalone_hq_settlements`) runs after `populate_atlas_city_names` + and emits Standalone-HQ settlement rows + resolves CityTenant + `headquarters_city_id` links, since both need the city pool to exist. + + HARD error (raises ImportAborted) if any corp's authored + `corp_specialization` is non-empty but not a valid vocabulary id, OR if + any corp's specialization does not resolve to a placement value in + `corp_hq_placement.toml` (a vocabulary/placement-map drift — every + vocabulary id must have a placement stanza). A corp with NO authored + `corp_specialization` is a soft warning (defers headquarters_body + backfill and Phase B placement for that corp) — not fatal, so an + un-authored future corp doesn't block regen-db; but ALL 165 corps in the + live wiki are authored as of T-1074, so this path is not expected to fire + on the shipped data. + + Returns a coverage dict: {"total", "specialized", "placed", "backfilled_hq"}. + """ + with open(CORP_HQ_PLACEMENT_TOML, "rb") as f: + placement_map: dict[str, dict] = tomllib.load(f) + + valid_spec_ids = { + r[0] for r in conn.execute("SELECT specialization_id FROM specialization_vocabulary").fetchall() + } + + errors: list[str] = [] + unauthored: list[str] = [] + + # (corp_id, corp_specialization, hq_placement) rows to write. + spec_rows: list[tuple[str, str, str]] = [] + + for corp in wiki_corps: + corp_id = corp["corp_id"] + spec = corp.get("corp_specialization") + if not spec: + unauthored.append(corp_id) + continue + if spec not in valid_spec_ids: + errors.append( + f"{corp_id}: corp_specialization '{spec}' not in specialization_vocabulary " + f"(add it to wiki/economics/specialization_vocabulary.toml or fix the typo)" + ) + continue + placement_entry = placement_map.get(spec) + if not placement_entry or not placement_entry.get("hq_placement"): + errors.append( + f"{corp_id}: specialization '{spec}' has no hq_placement stanza in " + f"corp_hq_placement.toml (every specialization_vocabulary id needs one)" + ) + continue + placement = placement_entry["hq_placement"] + if placement not in ("CityTenant", "Standalone"): + errors.append( + f"{corp_id}: specialization '{spec}' hq_placement '{placement}' invalid " + f"(expected 'CityTenant' or 'Standalone')" + ) + continue + spec_rows.append((corp_id, spec, placement)) + + if errors: + print(f" CORP SPECIALIZATION ERRORS ({len(errors)}):") + for e in errors: + print(f" - {e}") + raise ImportAborted() + + if unauthored: + print( + f" warning: {len(unauthored)} corp(s) have no authored " + f"corp_specialization (headquarters_body/placement skipped): {unauthored[:5]}" + ) + + n_backfilled = 0 + if not dry_run: + for corp_id, spec, placement in spec_rows: + conn.execute( + "UPDATE corporations SET corp_specialization = ?, hq_placement = ? " + "WHERE corp_id = ?", + (spec, placement, corp_id), + ) + # Backfill headquarters_body — only for rows that don't already have one + # (never clobber an authored value; today none are authored, but the + # column exists precisely so a future ticket CAN author one directly). + for corp_id, _spec, _placement in spec_rows: + row = conn.execute( + "SELECT headquarters_system, headquarters_body FROM corporations WHERE corp_id = ?", + (corp_id,), + ).fetchone() + if row is None: + continue + hq_system, hq_body = row + if hq_body: + continue + if not hq_system: + continue + resolved = most_populated_body_in_system(conn, hq_system) + if resolved: + conn.execute( + "UPDATE corporations SET headquarters_body = ? WHERE corp_id = ?", + (resolved, corp_id), + ) + n_backfilled += 1 + + return { + "total": len(wiki_corps), + "specialized": len(spec_rows), + "placed": len(spec_rows), + "backfilled_hq": n_backfilled, + } + + def import_corp_presence( conn: sqlite3.Connection, wiki_corps: list[dict], @@ -236,3 +383,109 @@ def import_corp_presence( ) return len(rows) + + +def populate_standalone_hq_settlements(conn: sqlite3.Connection, dry_run: bool) -> dict: + """Phase B of the D-242 corp-HQ model (T-1074). + + MUST run after `populate_atlas_city_names` (D-223, atlas.py) — that step's + `DELETE FROM atlas_city_names` at the top of its own run means Phase B is + always writing into a freshly-cleared, freshly-repopulated pool, so this + function is the SOLE remaining writer for corp-HQ rows on every run: no + idempotency guard is needed (nothing from a prior run can survive to + collide with this one). Also must run after `populate_corp_specialization` + (Phase A — needs corp_specialization/hq_placement/headquarters_body set). + + Two independent passes over `corporations` rows with + `hq_placement IS NOT NULL AND headquarters_body IS NOT NULL`: + + - Standalone: INSERT one new atlas_city_names row per corp — a company-town + settlement named after the corp (`proper_name`), on `headquarters_body`, + with `economic_role` from `corp_hq_placement.toml`'s + `standalone_economic_role` (the existing 10-value D-195/D-197 vocabulary + — NOT the 30-value specialization_vocabulary, which `match_cities`/ + `CompatibilityMatrix` do not index). `population = 0` — T-1075 bakes the + per-settlement population spread over the corrected pool (this row + included) in a later step; this function only creates the row. + `settlement_class` is left NULL here too, for the same reason (T-1075 + assigns it from the baked population). + - CityTenant: resolve `corporations.headquarters_city_id` to an existing + atlas_city_names row on `headquarters_body` — the lowest `id` (D-010: + deterministic, no population signal exists yet to rank by; population is + baked in a later step by T-1075). A body with a Standalone-only pool (no + ordinary city — e.g. a purely industrial body with zero wiki-pooled + names) has nothing to tenant; the corp is logged and left untenanted + rather than erroring — a corp with no city to join on its own HQ body is + a content gap (an empty markers.json city pool for that body), not an + import-time bug. + + Returns a coverage dict: {"standalone_inserted", "tenant_linked", "tenant_unmatched"}. + """ + with open(CORP_HQ_PLACEMENT_TOML, "rb") as f: + placement_map: dict[str, dict] = tomllib.load(f) + + rows = conn.execute( + """SELECT corp_id, proper_name, hq_placement, headquarters_body, corp_specialization + FROM corporations + WHERE hq_placement IS NOT NULL AND headquarters_body IS NOT NULL""" + ).fetchall() + + valid_body_ids = {r[0] for r in conn.execute("SELECT body_id FROM bodies").fetchall()} + + standalone_inserts: list[tuple] = [] # (body_id, name, kind, economic_role, population) + tenant_candidates: list[tuple[str, str]] = [] # (corp_id, headquarters_body) + unmatched_bodies: list[str] = [] + + for corp_id, proper_name, placement, hq_body, spec in rows: + if hq_body not in valid_body_ids: + unmatched_bodies.append(f"{corp_id} (headquarters_body '{hq_body}' not in bodies)") + continue + if placement == "Standalone": + role = (placement_map.get(spec) or {}).get("standalone_economic_role") or "manufacturing" + standalone_inserts.append((hq_body, proper_name, "city", role, 0)) + elif placement == "CityTenant": + tenant_candidates.append((corp_id, hq_body)) + + if not dry_run and standalone_inserts: + conn.executemany( + """INSERT INTO atlas_city_names (body_id, name, kind, economic_role, population) + VALUES (?, ?, ?, ?, ?)""", + standalone_inserts, + ) + n_inserted = len(standalone_inserts) + + # Tenant-matching is a read-only SELECT (safe under dry_run too — a + # --dry-run run must still be able to REPORT the "no city to tenant on + # this body" case, not just skip detecting it; the WRITE is what's gated + # on dry_run, not the check). + n_linked = 0 + n_unmatched = 0 + unmatched_corps: list[str] = [] + for corp_id, hq_body in tenant_candidates: + city_row = conn.execute( + "SELECT id FROM atlas_city_names WHERE body_id = ? ORDER BY id LIMIT 1", + (hq_body,), + ).fetchone() + if city_row is None: + unmatched_corps.append(f"{corp_id} (no city on body '{hq_body}' to tenant)") + n_unmatched += 1 + continue + if not dry_run: + conn.execute( + "UPDATE corporations SET headquarters_city_id = ? WHERE corp_id = ?", + (city_row[0], corp_id), + ) + n_linked += 1 + + if unmatched_bodies: + print(f" warning: {len(unmatched_bodies)} corp(s) skipped (bad headquarters_body): " + f"{unmatched_bodies[:5]}") + if unmatched_corps: + print(f" warning: {len(unmatched_corps)} CityTenant corp(s) unmatched: " + f"{unmatched_corps[:5]}") + + return { + "standalone_inserted": n_inserted, + "tenant_linked": n_linked, + "tenant_unmatched": n_unmatched, + } diff --git a/tooling/economy-db/economy_import/migration.py b/tooling/economy-db/economy_import/migration.py index 308023cf7..4881baa6c 100644 --- a/tooling/economy-db/economy_import/migration.py +++ b/tooling/economy-db/economy_import/migration.py @@ -345,6 +345,9 @@ COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [ ("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 ] diff --git a/tooling/economy-db/economy_import/paths.py b/tooling/economy-db/economy_import/paths.py index 00c46d3ff..8e905c88b 100644 --- a/tooling/economy-db/economy_import/paths.py +++ b/tooling/economy-db/economy_import/paths.py @@ -13,9 +13,11 @@ from generator_sources import ( ARCHITECTURE_TRAIT_CATALOG_TOML, ARCHITECTURE_ZONE_BIAS_TOML, COLOR_REGISTER_BANDS_TOML, + CORP_HQ_PLACEMENT_TOML, GENERATE_BRANDS_WRAPPER, OBJECT_TAG_VOCABULARY_TOML, REPO_ROOT, + SETTLEMENT_NAME_LOCKED_TOML, SPECIALIZATION_VOCAB_TOML, SYSTEM_SPECIALIZATION_TOML, ) @@ -29,6 +31,7 @@ __all__ = [ "COLOR_REGISTER_BANDS_TOML", "COMMODITIES_TOML", "CORPORATIONS_DIR", + "CORP_HQ_PLACEMENT_TOML", "CURRENCY_ZONES_TOML", "DB_PATH", "GENERATED_BRANDS_TOML", @@ -36,6 +39,7 @@ __all__ = [ "OBJECT_TAG_VOCABULARY_TOML", "REPO_ROOT", "SCHEMA_SQL", + "SETTLEMENT_NAME_LOCKED_TOML", "SPECIALIZATION_VOCAB_TOML", "STAR_MAP", "SYSTEM_SPECIALIZATION_TOML", diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 51e6eb031..9736dcdb6 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -9,7 +9,11 @@ Reads TOML/JSON source files and populates the economics tables: - currency_zone on star_systems (default TRACTUS_PRIMARY) - gate_energy_connected on star_systems (D-186: false for MARK_PRIMARY zones) - corporations from wiki/corporations/*.md (sync + insert new records) + - corp_specialization/hq_placement/headquarters_body/headquarters_city_id + on corporations (D-242: HQ-placement key + baked + CityTenant link, from corp_hq_placement.toml) - corp_presence from wiki/corporations/*.md (headquarters location data) + - atlas_city_names Standalone-HQ settlement rows (D-242, T-1074) Validation (hard errors, non-zero exit on any failure): - Wiki corporation names must match DB proper_name records (D-182 sync constraint) @@ -178,6 +182,16 @@ def main() -> None: n_db_corps = conn.execute("SELECT COUNT(*) FROM corporations").fetchone()[0] print(f" {n_db_corps} corporations in DB ({len(wiki_corps)} from wiki)") + # 7b. Corp specialization + HQ placement (D-242, T-1074) — Phase A. + # Must run after corp sync (rows must exist) and before corp_presence + # (which reads headquarters_body back off corporations). + print(" [7b/10] Importing corp specialization + HQ placement (D-242)...") + corp_spec = corporations.populate_corp_specialization(conn, wiki_corps, args.dry_run) + print( + f" {corp_spec['specialized']}/{corp_spec['total']} corps specialized, " + f"{corp_spec['backfilled_hq']} headquarters_body backfilled" + ) + # 8. Corp presence from wiki headquarters data print(" [8/10] Importing corp presence...") commodity_ids = { @@ -208,10 +222,32 @@ def main() -> None: n_cities = atlas.populate_atlas_city_names(conn, args.dry_run) print(f" {n_cities} city name rows") - # 13. atlas_city_names corp HQ cross-reference (D-207, #909) - print(" [13/13] Cross-referencing corp HQ cities into atlas_city_names...") - n_updated, n_inserted = atlas.populate_atlas_city_names_corps(conn, args.dry_run) - print(f" {n_updated} rows updated, {n_inserted} reserved rows inserted") + # 13. Standalone-HQ settlements + CityTenant city links (D-242, T-1074) — Phase B. + # SUPERSEDES the retired corp-HQ cross-reference (D-207, #909) — that + # step inserted one atlas_city_names row per corp HQ with no + # UNIQUE(body_id, name), producing duplicate co-named "cities" (10 + # Groombridge rows on GJ380c). Must run after populate_atlas_city_names + # (the city pool Standalone HQs join and CityTenant HQs tenant) and + # after step 7b (corp_specialization/hq_placement/headquarters_body). + print(" [13/13] Emitting Standalone-HQ settlements + CityTenant links (D-242)...") + hq_settlements = corporations.populate_standalone_hq_settlements(conn, args.dry_run) + print( + f" {hq_settlements['standalone_inserted']} Standalone-HQ settlements, " + f"{hq_settlements['tenant_linked']} CityTenant links " + f"({hq_settlements['tenant_unmatched']} unmatched)" + ) + + # 13b. Per-settlement population + settlement_class bake (D-242, T-1075). + # Runs over the CORRECTED pool — after both T-1074 steps, so + # Standalone-HQ settlements are included in the rank-size spread, not + # bolted on after. + print(" [13b/13] Baking settlement population + settlement_class (D-242)...") + pop_bake = atlas.populate_settlement_population_class(conn, args.dry_run) + print( + f" {pop_bake['cities_populated']} cities populated across " + f"{pop_bake['bodies_spread']} bodies, {pop_bake['name_locked_applied']} " + f"NameLocked pins applied" + ) # 14. Architecture-flavor trait templates (D-232, #993). Catalog first, # then sparse per-body hero bias (FK -> trait_templates + bodies). diff --git a/tooling/generator_sources.py b/tooling/generator_sources.py index 453352743..0ff97e4c3 100644 --- a/tooling/generator_sources.py +++ b/tooling/generator_sources.py @@ -74,6 +74,16 @@ SPECIALIZATION_VOCAB_TOML: Path = ( SYSTEM_SPECIALIZATION_TOML: Path = ( REPO_ROOT / "wiki" / "economics" / "system_specialization.toml" ) +# D-242 corp-HQ settlement model (T-1074): specialization -> {CityTenant, +# Standalone} placement map, keyed on the reused D-237 vocabulary above. +CORP_HQ_PLACEMENT_TOML: Path = ( + REPO_ROOT / "wiki" / "economics" / "corp_hq_placement.toml" +) +# D-242 population/settlement_class bake (T-1075): the small authored +# NameLocked hero-city override list. +SETTLEMENT_NAME_LOCKED_TOML: Path = ( + REPO_ROOT / "wiki" / "economics" / "settlement_name_locked.toml" +) # D-232 architecture-flavor trait-template catalog + sparse hero bias (#993). ARCHITECTURE_TRAIT_CATALOG_TOML: Path = ( REPO_ROOT / "wiki" / "economics" / "architecture_trait_catalog.toml" @@ -132,6 +142,8 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = ( REPO_ROOT / "tooling" / "schema_version.py", SPECIALIZATION_VOCAB_TOML, SYSTEM_SPECIALIZATION_TOML, + CORP_HQ_PLACEMENT_TOML, + SETTLEMENT_NAME_LOCKED_TOML, ARCHITECTURE_TRAIT_CATALOG_TOML, ARCHITECTURE_TRAIT_BIAS_TOML, OBJECT_TAG_VOCABULARY_TOML, diff --git a/tooling/populate-corporations.sh b/tooling/populate-corporations.sh deleted file mode 100755 index 17abd0ff3..000000000 --- a/tooling/populate-corporations.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Populate corporations table from wiki data + Cygni B combines + DSMC -set -euo pipefail -DB="$(cd "$(dirname "$0")/.." && pwd)/server/data/systems.db" - -sqlite3 "$DB" <<'SQL' --- From wiki/corporations/ -INSERT OR IGNORE INTO corporations VALUES ('gate-corporation', 'Gate Corporation', 'corporation', 'reach-wide', 'GJ 251', NULL, 'Span gate infrastructure', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('mvg', 'Mastroianni Vehicle Group', 'corporation', 'reach-wide', 'GJ 764', NULL, 'Transit vehicles, trains, haulers', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('prometheus-labs', 'Prometheus Labs', 'corporation', 'reach-wide', 'GJ 702B', NULL, 'Longevity hardware, neural augmentation', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('adams-ford', 'Adams & Ford Publishing', 'corporation', 'reach-wide', 'GJ 280A', NULL, 'The Drifters Guide to the Reach', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('mercado-travessia', 'Mercado Travessia', 'corporation', 'sector', 'GJ 1156', NULL, 'Grocery and household goods', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('stalownia-kowalski', 'Stalownia Kowalski', 'corporation', 'reach-wide', 'GJ 896A', NULL, 'Heavy mining and construction equipment', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('ferreira-monteiro', 'Ferreira Monteiro', 'corporation', 'reach-wide', 'GJ 884', NULL, 'Trade arbitration and commercial law', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('calloway-distillery', 'Calloway Distillery', 'corporation', 'sector', 'GJ 3325', NULL, 'Single malt whisky', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('thrds', 'thrds', 'corporation', 'sector', 'GJ 475', NULL, 'Cold-weather technical clothing', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('nordmark-skog', 'Nordmark Skog', 'corporation', 'sector', 'GJ 534', NULL, 'Timber and structural panels', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('talbrau', 'Talbräu', 'corporation', 'local', 'GJ 798', NULL, 'Franconian farmhouse lager', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('vins-de-grand-vide', 'Vins de Grand Vide', 'corporation', 'sector', 'GJ 395', NULL, 'Negociant wine cooperative', NULL, NULL, NULL); - --- DSMC from cultural-migration-pressure.md -INSERT OR IGNORE INTO corporations VALUES ('dsmc', 'Dorfhausen Sydney Mining Corporation', 'corporation', 'reach-wide', 'GJ 3522', NULL, 'Heavy extraction, mining infrastructure', NULL, NULL, NULL); - --- Cygni B combines (7 majors) -INSERT OR IGNORE INTO corporations VALUES ('vethara', 'Vethara Combine', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Primary hull fabrication, capital-class freight haulers', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('kossler-drun', 'Kossler-Drun', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Station module construction, habitat segments, docking architecture', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('amberan', 'Amberan Works', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Gate-adjacent platform fabrication', NULL, 'Deepest involvement in Institute design-rights dispute', NULL); -INSERT OR IGNORE INTO corporations VALUES ('talavec', 'Talavec & Sons', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Drive systems and propulsion assemblies', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('orren-skel', 'Orren-Skel', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Precision components and sensor integration', NULL, 'The combine everyone subcontracts to', NULL); -INSERT OR IGNORE INTO corporations VALUES ('marhoud', 'Marhoud Fabrication', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Freight hauler production lines, volume manufacturing', NULL, NULL, NULL); -INSERT OR IGNORE INTO corporations VALUES ('drevast', 'Drevast Extraction & Processing', 'combine', 'system', 'GJ 820B', NULL, 'In-system ore extraction and feedstock processing', NULL, 'Controls feedstock supply the other six need', NULL); -SQL - -echo "Populated $(sqlite3 "$DB" 'SELECT COUNT(*) FROM corporations') corporations" diff --git a/wiki/economics/corp_hq_placement.toml b/wiki/economics/corp_hq_placement.toml new file mode 100644 index 000000000..2b1ec9e32 --- /dev/null +++ b/wiki/economics/corp_hq_placement.toml @@ -0,0 +1,194 @@ +# ========================================================================== +# Settled Reach — Corp HQ Placement Map (D-242) +# Source of truth. Compiled into corporations.hq_placement by +# import_economics.py (populate_corp_specialization), and into the +# Standalone-HQ settlement rows atlas_city_names gains by +# populate_standalone_hq_settlements (both economy_import/corporations.py). +# +# D-242: a corporate headquarters is not a city. Each corp's HQ is either: +# CityTenant — a tenant of an EXISTING city; a corp -> city link +# (corporations.headquarters_city_id), NO new settlement. +# Many corps may tenant one city (a bank and a newspaper +# share a downtown). +# Standalone — its OWN settlement, emitted into the cascade settlement +# list with its own body + position (a mine or shipyard +# sits alone). Standalone stanzas carry a second field, +# standalone_economic_role, so the emitted atlas_city_names +# row has a real economic_role (the existing 10-value +# D-195/D-197 vocabulary — manufacturing | financial | +# agricultural | extraction | service_mixed | institutional | +# transit_hub | research | military | residential) instead +# of a generic fallback. CityTenant stanzas do not carry this +# field — no settlement is emitted for them, so it would be +# dead data. +# +# This file has exactly one stanza per corp_specialization value — the +# REUSED D-237 specialization_vocabulary id-space (see +# wiki/economics/specialization_vocabulary.toml): the original 27 +# system-authored values plus 3 corp-only Services-extension values added +# alongside this file (trade_distribution, hospitality_hub, +# professional_services — a real gap the T-1074 corp categorization pass +# found: ~10% of corps do logistics/hospitality/consulting work no original +# value covered). D-242 asked for an authored "specialization -> HQ-placement +# map"; this vocabulary already exists and already carries the signal that +# decides placement (bulk_class_projected: NonPhysical vs everything else), +# so rather than invent a second, parallel corp-only taxonomy, the map below +# is this vocabulary keyed onto the two-value placement enum (D-242 +# amendment, recorded 2026-07-16 alongside T-1074). +# +# The default follows bulk_class_projected exactly: +# NonPhysical (financial/institutional/media/legal/research services) +# -> CityTenant — an office; no physical plant needed. +# Everything else (BulkSolid/BulkLiquid/PrecisionDense/Perishable — +# farms, mines, refineries, shipyards, factories) +# -> Standalone — needs land/plant of its own. +# +# hq_placement is authored EXPLICITLY per value (not computed from +# bulk_class_projected at import time) so a future ticket can flip one +# value without touching import code — three values in the Services group +# (governance_center, transit_hub, research_station) are individually +# lore-ambiguous (a "transit hub" corp could plausibly BE its own +# spaceport-town, e.g. Gateway/Wolf 359 read as whole transit-identity +# systems in the vocabulary's own descriptions) but are pinned to the +# bulk_class_projected default here for consistency; revisit case-by-case +# if a specific corp's placement reads wrong once bodies are walkable. +# +# Decisions: D-242 (this map), D-237 (the reused vocabulary + the +# bulk_class_projected signal this defaults from). Ticket: T-1074. +# ========================================================================== + +# ------------------------------------------------------------------------- +# Agricultural — Standalone (farms/estates need land) +# ------------------------------------------------------------------------- +[estate_farming] +hq_placement = "Standalone" +standalone_economic_role = "agricultural" + +[breadbasket] +hq_placement = "Standalone" +standalone_economic_role = "agricultural" + +[terroir_agriculture] +hq_placement = "Standalone" +standalone_economic_role = "agricultural" + +[terroir_spirits] +hq_placement = "Standalone" +standalone_economic_role = "agricultural" + +[terroir_organics] +hq_placement = "Standalone" +standalone_economic_role = "agricultural" + +# ------------------------------------------------------------------------- +# Energy — Standalone (extraction/refinery infrastructure) +# ------------------------------------------------------------------------- +[fuel_production] +hq_placement = "Standalone" +standalone_economic_role = "extraction" + +[geothermal_hub] +hq_placement = "Standalone" +standalone_economic_role = "extraction" + +# ------------------------------------------------------------------------- +# Extraction — Standalone (mine/quarry sits on the deposit) +# ------------------------------------------------------------------------- +[ore_extraction] +hq_placement = "Standalone" +standalone_economic_role = "extraction" + +[company_mining] +hq_placement = "Standalone" +standalone_economic_role = "extraction" + +[marble_monopoly] +hq_placement = "Standalone" +standalone_economic_role = "extraction" + +[rare_mineral_extraction] +hq_placement = "Standalone" +standalone_economic_role = "extraction" + +[lattice_material_source] +hq_placement = "Standalone" +standalone_economic_role = "extraction" + +# ------------------------------------------------------------------------- +# Manufacturing — Standalone (factory/yard footprint) +# ------------------------------------------------------------------------- +[general_industrial] +hq_placement = "Standalone" +standalone_economic_role = "manufacturing" + +[shipbuilding] +hq_placement = "Standalone" +standalone_economic_role = "manufacturing" + +[vehicle_production] +hq_placement = "Standalone" +standalone_economic_role = "manufacturing" + +[consumer_goods_bazaar] +hq_placement = "Standalone" +standalone_economic_role = "manufacturing" + +[gate_fabrication] +hq_placement = "Standalone" +standalone_economic_role = "manufacturing" + +[military_industrial] +hq_placement = "Standalone" +standalone_economic_role = "military" + +# ------------------------------------------------------------------------- +# High-tech — Standalone (fabrication/aquaculture plant) +# ------------------------------------------------------------------------- +[precision_tech] +hq_placement = "Standalone" +standalone_economic_role = "manufacturing" + +[marine_farming] +hq_placement = "Standalone" +standalone_economic_role = "agricultural" + +# ------------------------------------------------------------------------- +# Services (all NonPhysical) — CityTenant (an office, no plant); no +# standalone_economic_role — no settlement is ever emitted for these. +# ------------------------------------------------------------------------- +[financial_hub] +hq_placement = "CityTenant" + +[governance_center] +hq_placement = "CityTenant" + +[transit_hub] +hq_placement = "CityTenant" + +[research_station] +hq_placement = "CityTenant" + +[legal_archive] +hq_placement = "CityTenant" + +[media_center] +hq_placement = "CityTenant" + +[longevity_monopoly] +hq_placement = "CityTenant" + +# ------------------------------------------------------------------------- +# Services extension (3, all NonPhysical) — added alongside the vocabulary +# extension in specialization_vocabulary.toml (D-242/T-1074). Same rule as +# the rest of Services: NonPhysical -> CityTenant (an office/depot, not a +# factory — this is exactly what made these corps hard to place in the +# original 27). +# ------------------------------------------------------------------------- +[trade_distribution] +hq_placement = "CityTenant" + +[hospitality_hub] +hq_placement = "CityTenant" + +[professional_services] +hq_placement = "CityTenant" diff --git a/wiki/economics/settlement_name_locked.toml b/wiki/economics/settlement_name_locked.toml new file mode 100644 index 000000000..42ab64393 --- /dev/null +++ b/wiki/economics/settlement_name_locked.toml @@ -0,0 +1,88 @@ +# ========================================================================== +# Settled Reach — Authored NameLocked Settlement Overrides (D-242, T-1075) +# Source of truth. Compiled by import_economics.py +# (atlas.populate_settlement_population_class) onto atlas_city_names.settlement_class. +# +# D-196 defines four SettlementClass variants. Bake scope (T-1075) only +# assigns two of them: every pooled city defaults to PopulationBudget (the +# D-196 population thresholds — >=50k active / <5k ghost — are read from +# this baked population by a later, not-yet-built GENERATION-time consumer; +# out of this bake's scope); this file is the small authored override list +# that PINS a settlement to NameLocked instead — "named in wiki; always +# active regardless of population" (D-196). EconomicTriggered and +# OrganicGrowth are simulation-time classes (D-196) — out of bake scope, +# never written here. +# +# Use sparingly. NameLocked exists for settlements whose narrative +# significance does not depend on (and must survive) whatever population +# number the rank-size bake happens to produce — capitals and the handful of +# systems with a singular landmark identity (D-237's must-pin hero systems: +# docs/workshops/system-economic-specialization/workshop-outcomes.md §7). +# +# REVISED 2026-07-16 after the first live make regen-db (T-1074+T-1075 +# integration test): the first draft of this file picked names straight out +# of the PRE-T-1074 atlas_city_names table (Sirius, Groombridge, Cygni B, +# Renaissance, Parallax, Prometheus, Nova Roma) — those turned out to be +# corp-HQ cross-reference ARTIFACTS from the retired D-207 +# populate_atlas_city_names_corps (the corp's authored HQ-city text +# frequently equalled its system's proper_name, e.g. Gate Corp's +# "headquarters: Renaissance (GJ 251)"). Once that insert path was removed +# (D-242's whole point), those names no longer exist in the pure +# markers.json pool. Every entry below was re-verified against a real +# `make regen-db` output — see the note on each stanza for its actual +# source row. Prometheus (GJ 702B) is DROPPED from this list — verified +# live: every one of its 12 bodies has zero authored markers.json city +# names AND zero population, so there is no settlement to pin at all (a +# genuine wiki content gap: the Reach's longevity-monopoly hero system has +# no named city anywhere in its system — flagged for the lead, not fixed +# here; population/settlement_class baking cannot invent a name to pin). +# +# Match key: (body_id, name) against atlas_city_names — NOT the numeric +# atlas_city_names.id, which is regen-volatile (AUTOINCREMENT, no +# UNIQUE(body_id, name) — see the atlas_city_names schema comment, D-242). +# A stanza with no matching row is a silent no-op (logged) rather than an +# import error, so a future markers.json rename doesn't hard-fail regen-db. +# +# Decisions: D-196 (SettlementClass), D-242 (population/class baking scope), +# D-237 (the hero-system set these are drawn from). Ticket: T-1075. +# ========================================================================== + +[[hero]] +body_id = "GJ144d" +name = "Kallast" +note = "Kallast / GJ 144 (Ran) — THE breadbasket; lore-anchored plains body (D-239 §1). Pure markers.json pool name, unaffected by the corp-HQ-artifact issue." + +[[hero]] +body_id = "GJ820Bc" +name = "Cygni Combines" +note = "Cygni B / GJ 820B — shipbuilding cluster, 7 competing combines (D-237). D-242 Standalone-HQ settlement (general_industrial corp_specialization) — the corp's own settlement IS the hero identity here; the pure pool has only 'Metropolis' (generic)." + +[[hero]] +body_id = "GJ251c" +name = "The Gate Corporation" +note = "Renaissance / GJ 251 — Gate Corp fabrication monopoly (D-237). D-242 Standalone-HQ settlement (gate_fabrication, MonopolySource) — the pure pool has only 'Tributarium'/'Ruhr' (generic); the monopolist's own company town is the hero place." + +[[hero]] +body_id = "GJ764d" +name = "Mastroianni Vehicle Group" +note = "Nova Roma / GJ 764 — MVG vehicle production (D-237). D-242 Standalone-HQ settlement (vehicle_production) — pure pool has only 'Mirafiori'/'Fiorino' (generic)." + +[[hero]] +body_id = "GJ380c" +name = "Aldren" +note = "Groombridge / GJ 380 — financial cluster + GSH clearing-house landmark (D-237). D-237's hero identity is the SERVICES/finance character, not any one CityTenant corp's manufacturing HQ — pinned to the lowest-id pure-pool city (a CityTenant host) rather than a Standalone-HQ settlement, since Groombridge's Standalone HQs (Alcyone Tech, Arclamp, ...) are all general_industrial/precision_tech, not financial_hub." + +[[hero]] +body_id = "GJ244Ad" +name = "Mandate" +note = "Sirius / GJ 244A — 'the capital' (system_specialization.toml). Lowest-id pure-pool city; the pure pool no longer contains 'Sirius' itself (that was a corp-HQ artifact)." + +[[hero]] +body_id = "GJ280Ad" +name = "Strata" +note = "Parallax / GJ 280A — news syndicates / cultural production center (D-237). Lowest-id pure-pool city (CityTenant host for the media_center specialization)." + +[[hero]] +body_id = "GJ338Bd" +name = "Viridarium" +note = "Arbour / GJ 338B — estate amenity agriculture (D-237). Lowest-id pure-pool city; reads as the manor/estate theme D-237 describes." diff --git a/wiki/economics/specialization_vocabulary.toml b/wiki/economics/specialization_vocabulary.toml index a22d006a8..77cf9af83 100644 --- a/wiki/economics/specialization_vocabulary.toml +++ b/wiki/economics/specialization_vocabulary.toml @@ -1,5 +1,5 @@ # ========================================================================== -# Settled Reach — Economic Specialization Vocabulary (D-237) +# Settled Reach — Economic Specialization Vocabulary (D-237, extended D-242) # Source of truth. Compiled to systems.db specialization_vocabulary by # import_economics.py. # @@ -8,6 +8,18 @@ # SCALE is encoded in the value itself (breadbasket vs estate_farming vs # terroir_agriculture) — there is no separate scale column. # +# SHARED VOCABULARY (D-242, T-1074): this id-space is used by TWO axes — +# system_economy.economic_specialization (D-237, the original per-SYSTEM +# authored identity) and corporations.corp_specialization (D-242, the +# per-CORP HQ-placement key; see corp_hq_placement.toml). A corp's +# specialization is authored directly in its wiki-page frontmatter +# (wiki/corporations/*.md, key: corp_specialization), not in a separate +# per-corp TOML — one value in this table, two authored sources reading it. +# The 3-value "Services extension" block near the end of the Services +# section was added for corp_specialization coverage (a corp-only gap the +# original 27 system-scale values didn't need to cover); nothing stops a +# future system from adopting them too if the fit is right. +# # Fields per entry: # commodity_id FK -> commodities.commodity_id (the anchor # commodity; also drives body-level draws) @@ -252,3 +264,41 @@ production_ubiquity_override = "monopolistic" bulk_class_projected = "NonPhysical" production_ubiquity_projected = "MonopolySource" description = "The single licensed longevity/re-embodiment facility; access-controlled (Prometheus)." + +# ------------------------------------------------------------------------- +# Services extension (3) — added D-242/T-1074 for corp_specialization reuse. +# +# The categorization pass that backfilled corp_specialization for all 165 +# corporations (T-1074) found 16/155 wiki corps (~10%) whose actual business +# — moving/selling already-made goods, running hospitality/tourism +# properties, or general professional advisory — had no honest fit among +# the original 24 system-authored values above. Per D-242's amendment +# ("prefer reusing/extending the existing vocabulary over inventing a +# parallel taxonomy"), these three values extend the shared vocabulary +# rather than spawn a corp-only one. All three are NonPhysical (services; +# CityTenant HQ placement per corp_hq_placement.toml) even though +# trade_distribution's anchor commodity is itself physical — the +# SPECIALIZATION is the logistics/service activity, not manufacture, which +# is exactly the distinction that made these corps ungroupable in the first +# place (a distributor's HQ is an office/depot, not a factory). +# ------------------------------------------------------------------------- +[trade_distribution] +commodity_id = "consumer_goods" +production_ubiquity_override = "" # catalog default (ubiquitous) — distribution touches everything +bulk_class_projected = "NonPhysical" +production_ubiquity_projected = "Common" +description = "Wholesale/retail trade and freight distribution — moves goods at scale, does not manufacture them (Mercado Travessia, Eastbay Trading)." + +[hospitality_hub] +commodity_id = "hospitality" +production_ubiquity_override = "" # catalog default (regional) +bulk_class_projected = "NonPhysical" +production_ubiquity_projected = "Specialist" +description = "Lodging, resort, spa, and recovery-stay operators; tourism/hospitality is the settlement's economic identity (Alcazar Hospitality, Thalassa Resort Group)." + +[professional_services] +commodity_id = "insurance" +production_ubiquity_override = "" # catalog default (regional) +bulk_class_projected = "NonPhysical" +production_ubiquity_projected = "Common" +description = "General business/strategy/operational advisory — risk, consulting, arbitration outside the dedicated legal_archive/financial_hub identities (Saigon Consulting, Lusaka Advisory)."