feat(db): corp-HQ settlement model + per-settlement population/class bake (T-1074/T-1075, D-242)

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>
This commit is contained in:
2026-07-16 10:29:00 +02:00
co-authored by Claude Fable 5
parent 43a6d55180
commit 62bbf57009
12 changed files with 903 additions and 145 deletions
+31 -4
View File
@@ -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'))
);
Binary file not shown.
+225 -100
View File
@@ -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),
}
@@ -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,
}
@@ -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
]
@@ -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",
+40 -4
View File
@@ -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).
+12
View File
@@ -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,
-34
View File
@@ -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"
+194
View File
@@ -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"
@@ -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."
+51 -1
View File
@@ -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)."