Merge remote-tracking branch 'origin/road-hub-refinement'
This commit is contained in:
@@ -1155,9 +1155,10 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- The district skeleton generator (Phase 1) applies `FoundingOrientation` as the base rotation for the outermost district ring. Interior districts inherit the orientation unless overridden by a `PoliticalArchetype` modifier.
|
||||
- **Hard constraint:** Maximum ±45° deviation from the parent orientation per district (same limit as D-096 `BlockPlacement.rotation_steps`). Beyond ±45°, tile-based pathfinding produces movement artifacts.
|
||||
- **Rationale:** Street grids reflect the terrain and founding logic of the original settlement. Roman camps faced cardinal directions. River towns align with the river. Coastal cities face the water. Encoding this as a named enum rather than a raw angle makes the orientation legible in the data model and debuggable during generation.
|
||||
- **Ticket:** T-914
|
||||
- **Amendment (T-1076 §4, 2026-07-16 — `RailHeadFacing` variant added; the D-242-gated follow-on):** the enum gains a sixth variant, `RailHeadFacing { bearing_degrees: u16 }` — the street grid faces the settlement's rail head. Unlike the five attractor-derived variants, it is **assigned post-placement by the Layer-2 road-graph pass** (`road_graph::assign_railhead_orientations`, run in the cascade immediately after `build_road_graph`): every settlement that is a **high-connectivity junction** (`RoadGraph::high_connectivity_junctions()`, incident-edge degree ≥ 3 — the criterion from this record's workshop source, paula-round3.md) has its attractor-derived orientation **overridden** to `RailHeadFacing`, with `bearing_degrees` the **octant-snapped** compass bearing (0 = N, clockwise; one of the eight 45° octants — integer-only math, D-010; the diagonal band is the `|minor|·2 > |major|` integer approximation, sound because the consumer snaps octants to quarter-edges anyway) from the settlement toward the **dominant incident edge** (longest `length_cells`, ties to the lowest edge index) — the direction the freight frontage faces. The Layer-4 skeleton consumes it via `skeleton_gen::railhead_edge` — the same octant→cardinal-edge snap as `coastal_edge`, feeding the same D-234b flush-frontage machinery: blocks on the rail-facing quarter edge present flush to the rail head the way waterfront blocks present flush to the quay (coastal wins if a settlement somehow carries both; variants are exclusive by construction). Deterministic: a pure function of the (already deterministic) road graph.
|
||||
- **Ticket:** T-914; T-1076 §4 (RailHeadFacing)
|
||||
- **Raised by:** Generation cascade workshop (T-897)
|
||||
- **Cross-reference:** D-096 (DistrictLayoutMode — inherits orientation), D-211 (attractor-matching — derives orientation), D-214 (PoliticalArchetype — may override orientation), D-215 (spatial arrangement patterns — uses orientation)
|
||||
- **Cross-reference:** D-096 (DistrictLayoutMode — inherits orientation), D-211 (attractor-matching — derives orientation), D-214 (PoliticalArchetype — may override orientation), D-215 (spatial arrangement patterns — uses orientation), [D-242](#d-242) (the road-graph hub refinement this variant shipped under; T-1076)
|
||||
|
||||
### D-214: PoliticalArchetype Enum and Settlement Spatial Character
|
||||
- **Date:** 2026-05-01
|
||||
@@ -1828,6 +1829,8 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **The merge is free.** Standalone-HQ settlements are inserted as ordinary `atlas_city_names` rows (`populate_standalone_hq_settlements`, Phase B) — same schema, no corp-linkage marker — so `read_body_settlements` → `match_cities` (the "natural seam") needed **zero Rust changes**; a Standalone HQ competes for attractors purely on population/`settlement_class`, exactly like any pooled city (verified live: `cascade_for_body("GJ251c")` places "The Gate Corporation" alongside "Tributarium"/"Ruhr" through the unmodified pipeline). Two new Rust unit tests lock this invariant in (`city_context_reader.rs`, `attractor_matching.rs`).
|
||||
- **Verified against real, repeated `make regen-db` runs — each run a full recompute, not a no-op (wording corrected per PR #177 H3; the pre-review text described the one-shot backfill being stably inert):** 0 duplicate `(body_id, name)` groups (was the whole bug); 155/155 wiki corps get `corp_specialization`/`hq_placement`; **154/155 `headquarters_body` re-derived on every run** (the 1 gap is a pre-existing broken `headquarters system` reference on `sova-station-works.md`, unrelated to this record), with two consecutive runs producing **zero differences** across all 165 corps' placement columns and tenancy targets; against the prior committed snapshot the recompute changes **exactly 2 placements** — `prometheus-labs`/`kovalev-freight`, `GJ702B-belt` → `GJ702Bb` (the intended H2 outcome) — and **zero tenancy targets** (`calluna-wellness` → (GJ845-belt, Orkney Ceramics) and `namsan-collective` → (GJ268-belt, Jeju Lattice) preserved via the city-presence tier). 95 `Standalone` corps → 94 settlements emitted (same 1 `sova` gap) + 60 `CityTenant` corps → 58 links (2 unmatched: the GJ 702B pair — now correctly on planet `GJ702Bb`, which still has zero authored city names, like every body in that system: a wiki content gap [T-1115], logged, not an import error). A poisoned-copy test confirmed no DB state survives the reset: hand-written garbage in all three columns is wiped and re-derived (wiki corp) or reverted to NULL (legacy corp with no authored source — the removed-key case). The 10 legacy DB-only corps seeded by the now-deleted `populate-corporations.sh` (`mvg`, `adams-ford`, `dsmc`, and the 7 Cygni-B "combines") have no wiki page to author `corp_specialization` from and are reset to NULL across the board each run — a logged, non-fatal gap (they are load-bearing for 2,468 `brand_products` rows via `generate_brands`, which reads `corporations` directly and doesn't care about wiki-page existence, so their rows are kept, not deleted).
|
||||
- **Amendment (T-1075, 2026-07-16 — population/class derivation clarified):** the phrase above, "the per-settlement spread is **carried down into** the city/settlement source data," is superseded by **derivation-at-import, not hand-authoring**: `import_economics` computes the per-city population spread from the authored `bodies.population` on **every** `regen-db` run, via a documented integer rank-size (Zipf, exponent 1) curve (`atlas.py::_zipf_population_spread`, constants named + commented — tuning the curve is a deliberate code change, not a data edit). There is no per-city population *source file* — only `bodies.population` (already-baked) is authored; the per-settlement split is 100% derived. The spread runs over the **corrected, merged pool** (post-T-1074: Standalone-HQ settlements are ordinary rows by the time this runs, so they get a real share of their body's population like any city, not a bolted-on afterthought). `settlement_class` defaults every pooled row to `PopulationBudget` (D-196); a small authored override TOML (`wiki/economics/settlement_name_locked.toml`, `[[hero]]` stanzas keyed on `(body_id, name)` — not the volatile numeric id) pins 8 hero settlements to `NameLocked`. **EconomicTriggered and OrganicGrowth stay simulation-time** (D-196) — out of bake scope; so is the D-196 *generation-time* consumer that reads the baked population against the >=50k-active/<5k-ghost thresholds to decide skeleton depth (confirmed live: no such consumer exists yet anywhere in `server/src/atlas` — this record's bake only writes the values a future ticket will read).
|
||||
- **Amendment (T-1076 item 0, 2026-07-16 — the Standalone/CityTenant boundary made explicit; Jeroen's ruling):** the original examples ("extraction/mining, heavy industry, shipyards …" → Standalone) were operationalised at T-1074 as *`bulk_class_projected ≠ NonPhysical` → Standalone* — an over-generalisation that made every physical-goods specialization a standalone settlement (95/60 split) and produced a **winery and a leatherworks as standalone asteroid-belt settlements**. The boundary is now explicit, so it cannot be re-coarsened: **Standalone applies ONLY where the HQ is itself an industrial complex / company town** — (1) extraction/mining/quarrying, (2) heavy vehicle + shipyard manufacturing, (3) heavy energy (refinery/extraction complexes), (4) gate/orbital infrastructure fabrication. **Everything else is CityTenant**, explicitly including craft/consumer manufacturing (distilleries, breweries, wineries, ceramics, textiles, furniture), agriculture of every scale (the estate/agribusiness *office* sits in a market town; the fields are not the HQ), and high-tech fabs. Applied per-value in `corp_hq_placement.toml` (one-line justification on every flipped stanza): 9 values flipped Standalone→CityTenant (`estate_farming`, `breadbasket`, `terroir_agriculture`, `terroir_spirits`, `terroir_organics`, `general_industrial`, `consumer_goods_bazaar`, `precision_tech`, `marine_farming`); 11 stay Standalone (`ore_extraction`, `company_mining`, `marble_monopoly`, `rare_mineral_extraction`, `lattice_material_source`, `fuel_production`, `geothermal_hub`, `shipbuilding`, `vehicle_production`, `gate_fabrication`, `military_industrial`). Corp split moves 95/60 → **11 Standalone / 144 CityTenant**; `atlas_city_names` 423 → **340** rows (329 pool + 11 HQ settlements); tenant links 58 → **129** (15 unmatched: 14 on bodies with no city to tenant + `sova-station-works`, whose broken HQ-system ref predates this record). The vanished belt settlements (Orkney Ceramics, Jeju Lattice, Groot Karoo Cellars, Kalahari Leatherworks) took their two belt tenancies with them — expected, correct data change under the boundary; the H2 placement hysteresis converged in one transitional run (fixpoint verified: two subsequent regens byte-identical across all 165 corps, 0 duplicate `(body_id, name)` groups, 0 import errors). Cygni B's `NameLocked` hero pin moved from the retired "Cygni Combines" HQ settlement to its pure-pool city "Metropolis". **Jeroen's rendering note, recorded here so it is not re-litigated as placement:** CityTenant HQs often sit on a settlement's **edge** or in **industrial clusters** — that is CityTenant *rendering/district-placement color* for later phases (building placement inside the settlement), **not a third placement class**.
|
||||
- **Amendment (PR #178 M1 + T1, 2026-07-16 — five steel-complex corps re-tagged; hub-cap scale note):** Miri's content pass verified 19/20 of the item-0 boundary judgments but found five already-authored corps that ARE the steel-complex identity the `general_industrial` flip comment reserved for its own value — re-tagged in wiki frontmatter (one-line justification on each page): `cygni-combines` → `shipbuilding` (the Cygni B yards' hull-structure consortium — the D-237 hero identity, fits directly); `sova-station-works`, `stalownia-kowalski`, `westphalia-heavy-works` → `military_industrial` (habitat/orbital-module and heavy-equipment works — the `heavy_equipment`-anchored yard/complex value; **note:** it now carries civilian heavy works, not only military-administered industry — if that reads wrong later, the clean split is a new `heavy_works` vocabulary value, not re-coarsening `general_industrial`); `sede-chemical-works` → `fuel_production` (feedstock refining/chemical synthesis — the refinery-complex shape; the closest call, per Miri). `shipbuilding` and `military_industrial` previously carried zero corps, so this also closes a content hole. Post-M1 counts: **16 Standalone / 139 CityTenant**, **15** HQ settlements emitted (`sova-station-works` emits nothing — its broken HQ-system ref is T-1054), `atlas_city_names` **344** rows (329 pool + 15), tenant links **125** (14 unmatched); fixpoint immediate — two consecutive regens identical (every re-tagged corp's HQ body already carried pool cities, so the presence tier saw no transition). The Cygni Combines settlement returns on GJ820Bc; the Cygni B hero pin deliberately **stays on "Metropolis"** (Miri: the pool city is the canon-clean hero anchor — the combines' yard is a place beside the city, not the city itself). **Scale note (T1):** the road-graph hub cap (`HUB_SPACING_DIAG_PX`, T-1076 §1) keys off the working-grid diagonal, a fixed 512×256 for every planet today — revisit that premise when [D-243](#d-243)'s elastic planetary seam gives bodies genuinely varying grid sizes.
|
||||
- **Cross-reference:** [D-207](#d-207-fully-generative-placement--markersjson-stripped-to-topographic-features) (corp-HQ cross-ref — the insert path is removed here), [D-223](#d-223) (names-only pool — the corp-HQ-stays + pop/class-deferred notes are superseded here), [D-211](#d-211) (placement — consumes the merged list; only position is seed-derived), [D-213](#d-213) (`FoundingOrientation` — a `RailHeadFacing` variant is the gated follow-on), [D-237](#d-237) (corp `specialization` vocabulary — the HQ-placement key, extended here from 27 to 31 values), [D-195](#d-195)/[D-197](#d-197) (the 10-value `economic_role` vocabulary `standalone_economic_role` maps onto), [D-196](#d-196) (`SettlementClass` — the bake target), [D-010](#d-010) (determinism — only position is seed-derived).
|
||||
- **Dissent:** None
|
||||
|
||||
|
||||
Binary file not shown.
@@ -44,6 +44,16 @@ pub struct CityRecord {
|
||||
/// harness's own settlement read) selects `COALESCE(kind,'city')`, so an
|
||||
/// un-authored `kind` yields `false` — there is no kind-less source left.
|
||||
pub is_capital: bool,
|
||||
/// `true` if this settlement is a D-242 Standalone corp-HQ company town —
|
||||
/// its `atlas_city_names` row matches a `corporations` row with
|
||||
/// `hq_placement = 'Standalone'`, `headquarters_body = body_id`, and
|
||||
/// `proper_name = name` (both readers join it identically). Threaded onto
|
||||
/// [`CityPlacement`] for the T-1076 road-graph hub rule: standalone HQs
|
||||
/// are minor nodes regardless of population — "hubs are the significant
|
||||
/// cities, standalone HQs are typically minor nodes" (D-242). Placement
|
||||
/// itself (`match_cities`) does NOT read this flag — an HQ competes for
|
||||
/// attractors on equal terms (the PR #177 invariant tests below hold).
|
||||
pub is_standalone_hq: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -69,13 +79,18 @@ pub struct CityPlacement {
|
||||
/// Spatial arrangement governing district adjacency (D-215).
|
||||
pub arrangement_pattern: ArrangementPattern,
|
||||
/// Primary street-grid axis (D-213). Derived from the anchoring attractor
|
||||
/// type; pioneer/open-terrain bearings are seed-varied.
|
||||
/// type; pioneer/open-terrain bearings are seed-varied. The road-graph
|
||||
/// pass may override it to `RailHeadFacing` post-hoc (T-1076 §4,
|
||||
/// `road_graph::assign_railhead_orientations`).
|
||||
pub founding_orientation: FoundingOrientation,
|
||||
/// Carried straight from [`CityRecord::population`] (T-960 §2 — the Atlas
|
||||
/// `SettlementLayer` derives its coarse size class from this).
|
||||
pub population: i64,
|
||||
/// Carried straight from [`CityRecord::is_capital`] (T-960 §2).
|
||||
pub is_capital: bool,
|
||||
/// Carried straight from [`CityRecord::is_standalone_hq`] (T-1076 §1 —
|
||||
/// the road-graph hub rule reads it off the placement).
|
||||
pub is_standalone_hq: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -396,6 +411,7 @@ pub fn match_cities(
|
||||
founding_orientation: orientation,
|
||||
population: cities[ci].population,
|
||||
is_capital: cities[ci].is_capital,
|
||||
is_standalone_hq: cities[ci].is_standalone_hq,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -466,6 +482,7 @@ pub fn match_cities(
|
||||
founding_orientation: orientation,
|
||||
population: cities[ci].population,
|
||||
is_capital: cities[ci].is_capital,
|
||||
is_standalone_hq: cities[ci].is_standalone_hq,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -503,6 +520,7 @@ pub fn match_cities(
|
||||
founding_orientation: orientation,
|
||||
population: city.population,
|
||||
is_capital: city.is_capital,
|
||||
is_standalone_hq: city.is_standalone_hq,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -707,6 +725,7 @@ mod tests {
|
||||
population: pop,
|
||||
economic_role: "manufacturing".to_string(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,6 +956,7 @@ mod tests {
|
||||
population: 60_000,
|
||||
economic_role: "agricultural".to_string(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
},
|
||||
CityRecord {
|
||||
city_id: 2,
|
||||
@@ -945,6 +965,7 @@ mod tests {
|
||||
population: 80_000,
|
||||
economic_role: "transit_hub".to_string(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
},
|
||||
];
|
||||
let attractors = vec join
|
||||
/// fragment — single source of truth for the join shape (PR #178 T2).
|
||||
fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, String> {
|
||||
use crate::atlas::city_context_reader::STANDALONE_HQ_JOIN_SQL;
|
||||
|
||||
let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?;
|
||||
let sql = format!(
|
||||
"SELECT acn.id, acn.name, COALESCE(acn.economic_role,'service_mixed'),
|
||||
COALESCE(acn.population,0), COALESCE(acn.kind,'city'),
|
||||
acn.settlement_class, c.corp_id IS NOT NULL
|
||||
FROM atlas_city_names AS acn
|
||||
{STANDALONE_HQ_JOIN_SQL}
|
||||
WHERE acn.body_id = ?1 ORDER BY acn.id"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0),
|
||||
COALESCE(kind,'city')
|
||||
FROM atlas_city_names WHERE body_id = ?1 ORDER BY id",
|
||||
)
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("prepare city query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([body_id], |r| {
|
||||
let kind: String = r.get(4)?;
|
||||
let sclass: Option<String> = r.get(5)?;
|
||||
let settlement_class = match sclass.as_deref() {
|
||||
Some("NameLocked") => SettlementClass::NameLocked,
|
||||
Some("EconomicTriggered") => SettlementClass::EconomicTriggered,
|
||||
Some("OrganicGrowth") => SettlementClass::OrganicGrowth,
|
||||
// "PopulationBudget", NULL, or unknown → the default class.
|
||||
_ => SettlementClass::PopulationBudget,
|
||||
};
|
||||
Ok(CityRecord {
|
||||
city_id: r.get::<_, i64>(0)? as u64,
|
||||
name: r.get(1)?,
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
settlement_class,
|
||||
economic_role: r.get(2)?,
|
||||
population: r.get(3)?,
|
||||
is_capital: kind == "capital",
|
||||
is_standalone_hq: r.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("city query: {e}"))?
|
||||
@@ -742,4 +761,84 @@ mod tests {
|
||||
let b = analyze(7, "GJ1c", &districts);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// PR #178 H4 — the believability settlement reader honours baked
|
||||
/// settlement_class (D-242/T-1075) and derives is_standalone_hq from the
|
||||
/// shared corporations join, in lockstep with
|
||||
/// `CityContextReader::read_body_settlements` (T2). Mirrors that module's
|
||||
/// synthetic-DB fixture pattern.
|
||||
#[test]
|
||||
fn read_cities_honours_baked_class_and_standalone_hq_flag() {
|
||||
use crate::simulation::generator::SettlementClass;
|
||||
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("sr_believability_cities_{}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let conn = rusqlite::Connection::open(&path).expect("create db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE atlas_city_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city',
|
||||
economic_role TEXT,
|
||||
population INTEGER NOT NULL,
|
||||
settlement_class TEXT
|
||||
);
|
||||
CREATE TABLE corporations (
|
||||
corp_id TEXT PRIMARY KEY,
|
||||
proper_name TEXT NOT NULL,
|
||||
hq_placement TEXT,
|
||||
headquarters_body TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create tables");
|
||||
for (name, pop, sclass) in [
|
||||
("Hero City", 2_000_000i64, Some("NameLocked")),
|
||||
("Plain Town", 300_000, Some("PopulationBudget")),
|
||||
("Weird Row", 50_000, Some("TotallyBogus")), // unknown → default
|
||||
("Gate Corporation", 900_000, Some("PopulationBudget")),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO atlas_city_names
|
||||
(body_id, name, economic_role, population, settlement_class)
|
||||
VALUES ('PlanetX', ?1, 'manufacturing', ?2, ?3)",
|
||||
rusqlite::params![name, pop, sclass],
|
||||
)
|
||||
.expect("insert city");
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO corporations (corp_id, proper_name, hq_placement, headquarters_body)
|
||||
VALUES ('gate-corporation', 'Gate Corporation', 'Standalone', 'PlanetX')",
|
||||
[],
|
||||
)
|
||||
.expect("insert corp");
|
||||
drop(conn);
|
||||
|
||||
let cities = read_cities(&path, "PlanetX").expect("read");
|
||||
assert_eq!(cities.len(), 4);
|
||||
let by_name = |n: &str| cities.iter().find(|c| c.name == n).unwrap();
|
||||
assert_eq!(
|
||||
by_name("Hero City").settlement_class,
|
||||
SettlementClass::NameLocked,
|
||||
"baked NameLocked must be honoured, not flattened to the default"
|
||||
);
|
||||
assert_eq!(
|
||||
by_name("Plain Town").settlement_class,
|
||||
SettlementClass::PopulationBudget
|
||||
);
|
||||
assert_eq!(
|
||||
by_name("Weird Row").settlement_class,
|
||||
SettlementClass::PopulationBudget,
|
||||
"unknown class text falls back to the default"
|
||||
);
|
||||
assert!(
|
||||
by_name("Gate Corporation").is_standalone_hq,
|
||||
"registered Standalone corp row is flagged via the shared join"
|
||||
);
|
||||
assert!(
|
||||
!by_name("Plain Town").is_standalone_hq,
|
||||
"unregistered rows stay unflagged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,16 @@ pub fn run_cascade_from_heightmap(
|
||||
&territorial_status,
|
||||
&[],
|
||||
);
|
||||
// RailHeadFacing pass (T-1076 §4, D-213 amended): settlements
|
||||
// that are high-connectivity junctions (degree ≥ 3) get their
|
||||
// founding_orientation overridden toward the dominant incident
|
||||
// edge. Mutates the Layer-3 placements post-hoc — orientation
|
||||
// is a Layer-3 output, but rail-head facing is only knowable
|
||||
// once Layer 2 exists. Deterministic: a pure function of the
|
||||
// (already deterministic) graph.
|
||||
if let Some(l3) = snapshot.layer3.as_mut() {
|
||||
road_graph::assign_railhead_orientations(&mut l3.placements, &graph);
|
||||
}
|
||||
snapshot.road_graph = Some(graph);
|
||||
}
|
||||
}
|
||||
@@ -527,6 +537,7 @@ mod tests {
|
||||
population: 2_000_000,
|
||||
economic_role: "financial".into(),
|
||||
is_capital: true,
|
||||
is_standalone_hq: false,
|
||||
},
|
||||
CityRecord {
|
||||
city_id: 2,
|
||||
@@ -535,6 +546,7 @@ mod tests {
|
||||
population: 120_000,
|
||||
economic_role: "agricultural".into(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
},
|
||||
];
|
||||
let run = || {
|
||||
@@ -621,6 +633,7 @@ mod tests {
|
||||
population: *pop,
|
||||
economic_role: "manufacturing".into(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -676,4 +689,109 @@ mod tests {
|
||||
edge_count
|
||||
);
|
||||
}
|
||||
|
||||
/// PR #178 H3 — the RailHeadFacing wiring end-to-end (T-1076 §4): after the
|
||||
/// RoadGraph layer runs, the cascade must have mutated
|
||||
/// `layer3.placements[..].founding_orientation` to `RailHeadFacing` for
|
||||
/// exactly the settlements that are high-connectivity junctions
|
||||
/// (degree ≥ 3), and for no others. A regression that drops the
|
||||
/// `assign_railhead_orientations` call (or runs it before the graph
|
||||
/// exists) fails here mechanically.
|
||||
#[test]
|
||||
fn cascade_assigns_railhead_orientation_at_junctions() {
|
||||
use crate::atlas::attractor_matching::CityRecord;
|
||||
use crate::atlas::road_graph::{RoadNodeKind, JUNCTION_DEGREE};
|
||||
use crate::simulation::generator::{FoundingOrientation, SettlementClass};
|
||||
|
||||
// A plus-shaped landmass (arms meeting at the centre, ocean elsewhere):
|
||||
// settlements string along the arms, so the trunk MST must branch where
|
||||
// the arms meet — diagnosed to yield exactly 2 degree-3 settlement
|
||||
// junctions with 8 cities. Deterministic: the junction requirement
|
||||
// below is a stable fixture property, not flakiness. (The default
|
||||
// slope fixture never branches — its coastal attractors form a chain,
|
||||
// and snapped minors raise Junction-node degrees, not settlement
|
||||
// degrees.)
|
||||
let (width, height) = (64u32, 32u32);
|
||||
let mut data = vec![0.05f32; (width * height) as usize]; // ocean
|
||||
for r in 0..height {
|
||||
for c in 0..width {
|
||||
let in_v_arm = (24..40).contains(&c); // vertical arm
|
||||
let in_h_arm = (12..20).contains(&r); // horizontal arm
|
||||
if in_v_arm || in_h_arm {
|
||||
data[(r * width + c) as usize] = 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
let cross_hm = crate::atlas::heightmap::BodyHeightmap {
|
||||
body_id: "test_body".into(),
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
sea_level: 0.3,
|
||||
};
|
||||
|
||||
let cities: Vec<CityRecord> = (1..=8u64)
|
||||
.map(|id| CityRecord {
|
||||
city_id: id,
|
||||
name: format!("City{id}"),
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
population: 1_000_000 - (id as i64) * 1_000, // hubs = lowest 6 ids
|
||||
economic_role: "manufacturing".into(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let snap = run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
cross_hm,
|
||||
&cities,
|
||||
Some("independent"),
|
||||
None,
|
||||
CascadeLayer::RoadGraph,
|
||||
);
|
||||
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
|
||||
let junction_city_ids: Vec<u64> = graph
|
||||
.high_connectivity_junctions()
|
||||
.iter()
|
||||
.filter_map(|&i| graph.nodes[i].city_id)
|
||||
.collect();
|
||||
assert!(
|
||||
!junction_city_ids.is_empty(),
|
||||
"fixture must produce at least one degree ≥ {JUNCTION_DEGREE} settlement \
|
||||
junction — if this fires the fixture changed, not the wiring"
|
||||
);
|
||||
|
||||
// The wiring assertion, both directions: junction settlements carry
|
||||
// RailHeadFacing; every other settlement does not.
|
||||
let l3 = snap.layer3.as_ref().expect("Layer 3 ran");
|
||||
for p in &l3.placements {
|
||||
let is_junction = junction_city_ids.contains(&p.city_id);
|
||||
let is_rail = matches!(
|
||||
p.founding_orientation,
|
||||
FoundingOrientation::RailHeadFacing { .. }
|
||||
);
|
||||
assert_eq!(
|
||||
is_junction, is_rail,
|
||||
"city {} junction={} but rail_facing={} — cascade wiring broken",
|
||||
p.city_id, is_junction, is_rail
|
||||
);
|
||||
}
|
||||
// And the mutated placements are what BodyWorldState carries forward.
|
||||
let junction_count = junction_city_ids.len();
|
||||
let state = snap.into_body_world_state();
|
||||
let rail_count = state
|
||||
.placements
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
matches!(
|
||||
p.founding_orientation,
|
||||
FoundingOrientation::RailHeadFacing { .. }
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(rail_count, junction_count);
|
||||
// Silence unused-import warning when the filter above changes.
|
||||
let _ = RoadNodeKind::Settlement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,25 @@ use crate::simulation::generator::{
|
||||
ProductionUbiquity, SettingType, SettlementClass, WorldTier,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared SQL fragments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The D-242 standalone-corp-HQ LEFT JOIN (T-1076 §1; single source of truth,
|
||||
/// PR #178 T2). Marks an `atlas_city_names` row (aliased `acn`) as a Standalone
|
||||
/// corp-HQ company town when a `corporations` row (aliased `c`) matches on
|
||||
/// `hq_placement = 'Standalone'`, `headquarters_body = body_id`, and
|
||||
/// `proper_name = name` — the exact shape
|
||||
/// `populate_standalone_hq_settlements` (economy_import/corporations.py) emits
|
||||
/// settlement rows with. Composed into a query whose FROM clause aliases
|
||||
/// `atlas_city_names AS acn`; the flag is read as `c.corp_id IS NOT NULL`.
|
||||
/// Used by [`CityContextReader::read_body_settlements`] and the believability
|
||||
/// harness's `read_cities` — extend both if the join shape ever changes.
|
||||
pub(crate) const STANDALONE_HQ_JOIN_SQL: &str = "LEFT JOIN corporations AS c
|
||||
ON c.hq_placement = 'Standalone'
|
||||
AND c.headquarters_body = acn.body_id
|
||||
AND c.proper_name = acn.name";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -265,6 +284,13 @@ impl CityContextReader {
|
||||
/// Unlike [`read_set`](Self::read_set) (the strict D-199 path that aborts on
|
||||
/// any malformed field), this method is best-effort: it never fails on row
|
||||
/// content, only on a DB/connection error.
|
||||
///
|
||||
/// `is_standalone_hq` (T-1076 §1): the [`STANDALONE_HQ_JOIN_SQL`] LEFT JOIN
|
||||
/// marks the rows that are D-242 Standalone corp-HQ company towns. The
|
||||
/// road-graph hub rule demotes these to minor nodes regardless of
|
||||
/// population. The believability harness's own settlement read
|
||||
/// (`believability::read_cities`) composes the same shared constant —
|
||||
/// single source of truth for the join (PR #178 T2).
|
||||
pub fn read_body_settlements(
|
||||
&self,
|
||||
body_id: &str,
|
||||
@@ -273,14 +299,17 @@ impl CityContextReader {
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let sql = format!(
|
||||
"SELECT acn.id, acn.name, acn.economic_role, acn.population,
|
||||
acn.settlement_class, COALESCE(acn.kind, 'city'),
|
||||
c.corp_id IS NOT NULL
|
||||
FROM atlas_city_names AS acn
|
||||
{STANDALONE_HQ_JOIN_SQL}
|
||||
WHERE acn.body_id = ?1
|
||||
ORDER BY acn.id"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, economic_role, population, settlement_class,
|
||||
COALESCE(kind, 'city')
|
||||
FROM atlas_city_names
|
||||
WHERE body_id = ?1
|
||||
ORDER BY id",
|
||||
)
|
||||
.prepare(&sql)
|
||||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([body_id], |row| {
|
||||
@@ -291,13 +320,14 @@ impl CityContextReader {
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
row.get::<_, bool>(6)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let (id, name, role, population, sclass, kind) =
|
||||
let (id, name, role, population, sclass, kind, is_standalone_hq) =
|
||||
r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
let city_id = id as u64;
|
||||
let settlement_class = match sclass.as_deref() {
|
||||
@@ -318,6 +348,7 @@ impl CityContextReader {
|
||||
population,
|
||||
economic_role: role.unwrap_or_else(|| "residential".to_string()),
|
||||
is_capital: kind == "capital",
|
||||
is_standalone_hq,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1039,7 +1070,11 @@ mod tests {
|
||||
// ─── read_body_settlements (#955) ────────────────────────────────────────
|
||||
|
||||
/// Build a db with several settlements on one body, returning its path. Some
|
||||
/// rows have a NULL `settlement_class` (the pre-placement state).
|
||||
/// rows have a NULL `settlement_class` (the pre-placement state). Includes
|
||||
/// an EMPTY `corporations` table — `read_body_settlements`' T-1076
|
||||
/// standalone-HQ LEFT JOIN references it, so the fixture schema must carry
|
||||
/// it (empty ⇒ every row reads `is_standalone_hq = false`). Use
|
||||
/// `add_standalone_corp` to mark one.
|
||||
fn make_settlements_db(rows: &[(&str, &str, i64, Option<&str>)]) -> PathBuf {
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("sr_ctxst_{}_{n}.db", std::process::id()));
|
||||
@@ -1054,6 +1089,12 @@ mod tests {
|
||||
economic_role TEXT,
|
||||
population INTEGER NOT NULL,
|
||||
settlement_class TEXT
|
||||
);
|
||||
CREATE TABLE corporations (
|
||||
corp_id TEXT PRIMARY KEY,
|
||||
proper_name TEXT NOT NULL,
|
||||
hq_placement TEXT,
|
||||
headquarters_body TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create table");
|
||||
@@ -1069,6 +1110,18 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
/// Register `name` on `body_id` as a D-242 Standalone corp HQ in the
|
||||
/// fixture's `corporations` table (the shape the T-1076 join matches).
|
||||
fn add_standalone_corp(db: &PathBuf, corp_id: &str, name: &str, body_id: &str) {
|
||||
let conn = Connection::open(db).expect("reopen");
|
||||
conn.execute(
|
||||
"INSERT INTO corporations (corp_id, proper_name, hq_placement, headquarters_body)
|
||||
VALUES (?1, ?2, 'Standalone', ?3)",
|
||||
rusqlite::params![corp_id, name, body_id],
|
||||
)
|
||||
.expect("insert corp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_body_settlements_defaults_null_class_to_population_budget() {
|
||||
// NULL settlement_class is the pre-placement state. It must default to
|
||||
@@ -1154,20 +1207,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── D-242 corp-HQ settlement model (T-1074/T-1075) ──────────────────────
|
||||
// ─── D-242 corp-HQ settlement model (T-1074/T-1075/T-1076) ───────────────
|
||||
//
|
||||
// A Standalone-HQ settlement (economy_import/corporations.py:
|
||||
// populate_standalone_hq_settlements) is inserted into atlas_city_names as
|
||||
// an ORDINARY row — same schema, same fields, no corp linkage marker (the
|
||||
// corp<->settlement relationship lives on corporations.headquarters_body,
|
||||
// not on atlas_city_names). This module's test fixture schema
|
||||
// (make_settlements_db, above) never had a corp_id column to begin with —
|
||||
// this test makes that design invariant explicit: read_body_settlements
|
||||
// (the D-211 placement pipeline's input) cannot distinguish a
|
||||
// corp-Standalone-HQ row from a wiki-pooled city, by construction. If a
|
||||
// future change ever needs to special-case corp-originated settlements,
|
||||
// it will have to be threaded through explicitly — this test fails the
|
||||
// moment that stops being true silently.
|
||||
// an ORDINARY row — same schema, same fields, no corp linkage marker on
|
||||
// the row itself (the corp<->settlement relationship lives on
|
||||
// corporations.headquarters_body). The T-1074 invariant test below
|
||||
// predicted that any future special-casing "will have to be threaded
|
||||
// through explicitly" — T-1076 §1 is exactly that threading: an explicit
|
||||
// LEFT JOIN against corporations now derives `is_standalone_hq`, consumed
|
||||
// ONLY by the road-graph hub rule. The D-211 placement fields stay
|
||||
// identical to an ordinary city's (the equal-terms attractor invariant in
|
||||
// attractor_matching.rs still holds).
|
||||
|
||||
#[test]
|
||||
fn read_body_settlements_treats_standalone_hq_row_identically_to_pooled_city() {
|
||||
@@ -1175,8 +1227,11 @@ mod tests {
|
||||
// Standalone-HQ row (name = corp proper_name, economic_role from
|
||||
// corp_hq_placement.toml's standalone_economic_role, population from
|
||||
// the T-1075 Zipf bake, settlement_class from the same bake/override
|
||||
// path as any other city) — indistinguishable in shape from the
|
||||
// path as any other city) — identical in D-211 placement shape to the
|
||||
// ordinary pooled cities "Tributarium"/"Ruhr" it sits alongside.
|
||||
// (Without a corporations row registering it, the T-1076 join also
|
||||
// leaves is_standalone_hq = false — the flag comes only from the
|
||||
// corporations side, never from the city row.)
|
||||
let db = make_settlements_db(&[
|
||||
(
|
||||
"Tributarium",
|
||||
@@ -1206,11 +1261,56 @@ mod tests {
|
||||
.find(|c| c.name == "Gate Corporation")
|
||||
.expect("Standalone-HQ row must be present");
|
||||
// Every CityRecord field the D-211 pipeline reads is populated exactly
|
||||
// like an ordinary city's — nothing marks this row as corp-originated.
|
||||
// like an ordinary city's.
|
||||
assert_eq!(hq.economic_role, "manufacturing");
|
||||
assert_eq!(hq.population, 909_090_165);
|
||||
assert_eq!(hq.settlement_class, SettlementClass::NameLocked);
|
||||
assert!(!hq.is_capital, "Standalone HQ is not a capital by default");
|
||||
assert!(
|
||||
!hq.is_standalone_hq,
|
||||
"no corporations row registers this name — the flag must stay false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_body_settlements_flags_standalone_hq_via_corporations_join() {
|
||||
// T-1076 §1: the corporations LEFT JOIN marks exactly the rows whose
|
||||
// (headquarters_body, proper_name) matches a Standalone corp — the
|
||||
// shape populate_standalone_hq_settlements emits.
|
||||
let db = make_settlements_db(&[
|
||||
(
|
||||
"Tributarium",
|
||||
"manufacturing",
|
||||
2_000_000,
|
||||
Some("PopulationBudget"),
|
||||
),
|
||||
(
|
||||
"Gate Corporation",
|
||||
"manufacturing",
|
||||
909_090_165,
|
||||
Some("PopulationBudget"),
|
||||
),
|
||||
]);
|
||||
add_standalone_corp(&db, "gate-corporation", "Gate Corporation", "PlanetX");
|
||||
// A Standalone corp on a DIFFERENT body with the same proper_name must
|
||||
// NOT mark PlanetX's row (the join keys on headquarters_body too).
|
||||
add_standalone_corp(&db, "other-corp", "Tributarium", "PlanetY");
|
||||
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
let cities = reader.read_body_settlements("PlanetX").expect("read");
|
||||
let hq = cities
|
||||
.iter()
|
||||
.find(|c| c.name == "Gate Corporation")
|
||||
.unwrap();
|
||||
let pool = cities.iter().find(|c| c.name == "Tributarium").unwrap();
|
||||
assert!(
|
||||
hq.is_standalone_hq,
|
||||
"registered Standalone corp row is flagged"
|
||||
);
|
||||
assert!(
|
||||
!pool.is_standalone_hq,
|
||||
"same-name corp on another body must not leak the flag across bodies"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── read_body_city_names (T-949) ────────────────────────────────────────
|
||||
|
||||
@@ -549,6 +549,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: None,
|
||||
@@ -556,6 +557,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Waypoint,
|
||||
degree: 0,
|
||||
parent_edge: Some(0),
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![RoadEdge {
|
||||
@@ -617,6 +619,7 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population,
|
||||
is_capital,
|
||||
is_standalone_hq: false,
|
||||
};
|
||||
|
||||
let mut state = blank_state("GJ1c");
|
||||
@@ -696,6 +699,7 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population: 2_000_000,
|
||||
is_capital: true,
|
||||
is_standalone_hq: false,
|
||||
}];
|
||||
state.road_graph = RoadGraph {
|
||||
nodes: vec![RoadNode {
|
||||
@@ -704,6 +708,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 0,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
}],
|
||||
edges: vec![RoadEdge {
|
||||
from: 0,
|
||||
|
||||
@@ -984,6 +984,7 @@ mod tests {
|
||||
founding_orientation: orientation,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,6 +1006,7 @@ mod tests {
|
||||
founding_orientation: orientation,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,6 +1112,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(90),
|
||||
@@ -1117,6 +1120,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(91),
|
||||
@@ -1124,6 +1128,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
@@ -1460,6 +1465,7 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -1592,6 +1598,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(2),
|
||||
@@ -1599,6 +1606,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![RoadEdge {
|
||||
@@ -1634,6 +1642,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(11),
|
||||
@@ -1641,6 +1650,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(12),
|
||||
@@ -1648,6 +1658,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
@@ -1694,6 +1705,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(2),
|
||||
@@ -1701,6 +1713,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(3),
|
||||
@@ -1708,6 +1721,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
@@ -1756,6 +1770,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(6),
|
||||
@@ -1763,6 +1778,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![RoadEdge {
|
||||
@@ -1788,6 +1804,7 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
};
|
||||
|
||||
let GenWorkItem::GenerateSkeleton {
|
||||
|
||||
+914
-31
File diff suppressed because it is too large
Load Diff
@@ -813,6 +813,25 @@ fn coastal_edge(orientation: &FoundingOrientation) -> Option<Edge> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The quarter edge facing the settlement's rail head, from a
|
||||
/// `RailHeadFacing` founding orientation (T-1076 §4, D-213 amended). Same
|
||||
/// octant→cardinal snap as [`coastal_edge`]: the bearing points toward the
|
||||
/// dominant incident road/rail edge, and the blocks on that quarter edge
|
||||
/// present flush freight frontage to it — the rail-head analogue of the
|
||||
/// D-234b waterfront quay rule, reusing the same flush-margin machinery.
|
||||
fn railhead_edge(orientation: &FoundingOrientation) -> Option<Edge> {
|
||||
if let FoundingOrientation::RailHeadFacing { bearing_degrees } = orientation {
|
||||
Some(match bearing_degrees % 360 {
|
||||
d if !(45..315).contains(&d) => Edge::North,
|
||||
d if d < 135 => Edge::East,
|
||||
d if d < 225 => Edge::South,
|
||||
_ => Edge::West,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether block `(row, col)` sits on the quarter's `edge` (4×4 grid).
|
||||
fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool {
|
||||
match edge {
|
||||
@@ -1029,15 +1048,19 @@ pub fn assign_all_block_tags(
|
||||
chain: SeedChain,
|
||||
exterior_catalog: &ExteriorCatalog,
|
||||
) -> BTreeMap<(u8, u8), Vec<BuildingPropertyTag>> {
|
||||
// Water-facing quarter edge from the settlement's coastal founding
|
||||
// orientation (D-234b); blocks on it present flush to the quay.
|
||||
let water_edge = coastal_edge(&context.founding_orientation);
|
||||
// Flush-frontage quarter edge: water-facing from a coastal founding
|
||||
// orientation (D-234b — blocks present flush to the quay), or rail-facing
|
||||
// from a RailHeadFacing orientation (T-1076 §4 — blocks present flush
|
||||
// freight frontage to the rail head; same machinery). The two are
|
||||
// mutually exclusive by construction (one orientation per settlement).
|
||||
let flush_edge = coastal_edge(&context.founding_orientation)
|
||||
.or_else(|| railhead_edge(&context.founding_orientation));
|
||||
let mut map = BTreeMap::new();
|
||||
for row in 0..4u8 {
|
||||
for col in 0..4u8 {
|
||||
let block = &skeleton.blocks[row as usize][col as usize];
|
||||
let block_chain = chain.derive(SeedDomain::Block, (row * 4 + col) as u64);
|
||||
let waterfront = water_edge.filter(|&e| block_on_quarter_edge(row, col, e));
|
||||
let waterfront = flush_edge.filter(|&e| block_on_quarter_edge(row, col, e));
|
||||
let tags = assign_block_tags(
|
||||
block,
|
||||
skeleton,
|
||||
@@ -2303,6 +2326,26 @@ mod tests {
|
||||
assert_eq!(coastal_edge(&FoundingOrientation::Cardinal), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn railhead_edge_maps_bearing_to_cardinal() {
|
||||
// T-1076 §4: same octant→cardinal snap as the coastal quay rule, keyed
|
||||
// on RailHeadFacing. Non-rail orientations yield no rail edge, and a
|
||||
// coastal orientation is not a rail edge (the two flush-frontage
|
||||
// sources are distinct variants).
|
||||
let r = |d| railhead_edge(&FoundingOrientation::RailHeadFacing { bearing_degrees: d });
|
||||
assert_eq!(r(0), Some(Edge::North));
|
||||
assert_eq!(r(45), Some(Edge::East));
|
||||
assert_eq!(r(90), Some(Edge::East));
|
||||
assert_eq!(r(180), Some(Edge::South));
|
||||
assert_eq!(r(270), Some(Edge::West));
|
||||
assert_eq!(r(315), Some(Edge::North));
|
||||
assert_eq!(railhead_edge(&FoundingOrientation::Cardinal), None);
|
||||
assert_eq!(
|
||||
railhead_edge(&FoundingOrientation::Coastal { facing_degrees: 0 }),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waterfront_footprints_present_to_the_quay() {
|
||||
// The water-facing (north) edge drops its setback → buildings sit flush
|
||||
|
||||
@@ -410,6 +410,16 @@ pub enum FoundingOrientation {
|
||||
Cardinal,
|
||||
/// Arbitrary bearing (pioneer settlements on open terrain). `bearing_degrees`: 0–359.
|
||||
Free { bearing_degrees: u16 },
|
||||
/// Street grid faces the settlement's rail head (D-213 amended, T-1076 §4).
|
||||
/// Assigned POST-placement by the Layer-2 road-graph pass
|
||||
/// (`road_graph::assign_railhead_orientations`) — never by the attractor
|
||||
/// match — to settlements that are high-connectivity junctions
|
||||
/// (`RoadGraph::high_connectivity_junctions`, degree ≥ 3, per the D-213
|
||||
/// workshop source paula-round3.md). `bearing_degrees`: octant-snapped
|
||||
/// compass bearing (0–359, 0 = N, clockwise; integer octants only, D-010)
|
||||
/// from the settlement toward the dominant (longest) incident road/rail
|
||||
/// edge — the direction the freight frontage faces.
|
||||
RailHeadFacing { bearing_degrees: u16 },
|
||||
}
|
||||
|
||||
/// Who maintains an inter-settlement road/rail edge — readable in the road's
|
||||
|
||||
@@ -9,7 +9,8 @@ updated: 2026-04-05
|
||||
scope: reach-wide
|
||||
faction_type: economic
|
||||
headquarters: Cygni B (GJ 820B)
|
||||
corp_specialization: general_industrial
|
||||
# T-1076 M1 re-tag from general_industrial — the combines ARE the Cygni B yards' hull-structure consortium (D-237 pins GJ 820B shipbuilding); Miri-verified direct fit
|
||||
corp_specialization: shipbuilding
|
||||
tags: [electronics, advanced_alloys, manufacturing, assembly, tractus, core]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: [luanda-fabrication]
|
||||
|
||||
@@ -9,7 +9,8 @@ updated: 2026-05-02
|
||||
scope: GJ 559B local; ACB (Alpha Centauri B) corridor secondary
|
||||
faction_type: economic
|
||||
headquarters: Sede (GJ 559B)
|
||||
corp_specialization: general_industrial
|
||||
# T-1076 M1 re-tag from general_industrial — feedstock refining + chemical synthesis is the refinery-complex shape; fuel_production is the vocabulary's refinery value (closest call, per Miri)
|
||||
corp_specialization: fuel_production
|
||||
tags: [chemical_feedstock, chemicals, manufacturing, tractus]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: [societe-chimique]
|
||||
|
||||
@@ -9,7 +9,8 @@ updated: 2026-04-05
|
||||
scope: regional
|
||||
faction_type: economic
|
||||
headquarters: Sova (GJ 280B)
|
||||
corp_specialization: general_industrial
|
||||
# T-1076 M1 re-tag from general_industrial — habitat/orbital module fabricator; the heavy-yard installation identity (Miri guidance), not craft manufacturing
|
||||
corp_specialization: military_industrial
|
||||
tags: [habitat_modules, structural_panels, manufacturing, assembly, slavic_heritage]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: [station-sova, trans-reach-rail]
|
||||
|
||||
@@ -9,7 +9,8 @@ updated: 2026-03-15
|
||||
scope: south_reach primary; west_reach secondary; reach-wide in extraction sectors
|
||||
faction_type: economic
|
||||
headquarters: Nowa Huta (GJ 896A)
|
||||
corp_specialization: general_industrial
|
||||
# T-1076 M1 re-tag from general_industrial — drilling rigs / excavation platforms / ore-processing machinery are heavy_equipment, the commodity military_industrial anchors; a steel-complex works, not a craft shop
|
||||
corp_specialization: military_industrial
|
||||
tags: [refined_metals, advanced_alloys, structural_panels, heavy_equipment, south_reach, tractus]
|
||||
decision_refs: [D-175]
|
||||
cross_refs: [durban-engineering]
|
||||
|
||||
@@ -9,7 +9,8 @@ updated: 2026-04-05
|
||||
scope: regional
|
||||
faction_type: economic
|
||||
headquarters: Rand (GJ 601A)
|
||||
corp_specialization: general_industrial
|
||||
# T-1076 M1 re-tag from general_industrial — drills, haulers, excavators to Compact spec; heavy_equipment-anchored works (the Bastion-pattern industrial complex)
|
||||
corp_specialization: military_industrial
|
||||
tags: [heavy_equipment, vehicles, compact, mark, west_reach, german_heritage]
|
||||
decision_refs: [D-175, D-172]
|
||||
cross_refs: [erzgebirge-instruments]
|
||||
|
||||
@@ -15,74 +15,75 @@
|
||||
# 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.
|
||||
# D-195/D-197 vocabulary). CityTenant stanzas do not carry
|
||||
# this field — no settlement is emitted for them.
|
||||
#
|
||||
# THE BOUNDARY (Jeroen's ruling, T-1076 item 0 — supersedes the earlier
|
||||
# bulk_class-derived default, which over-generalized: it mapped every
|
||||
# physical-goods value Standalone and produced a winery and a leatherworks
|
||||
# as standalone asteroid-belt settlements). Standalone applies ONLY where
|
||||
# the HQ is ITSELF an industrial complex / company town:
|
||||
#
|
||||
# - extraction / mining / quarrying (the deposit IS the site)
|
||||
# - heavy vehicle + shipyard manufacturing (the yard IS the site)
|
||||
# - heavy energy: refinery / extraction complexes (the plant IS the site)
|
||||
# - gate / orbital infrastructure fabrication (the works IS the site)
|
||||
#
|
||||
# EVERYTHING else is CityTenant — explicitly including craft/consumer
|
||||
# manufacturing (distilleries, breweries, wineries, ceramics, textiles,
|
||||
# furniture), agriculture of every scale (the estate/agribusiness OFFICE
|
||||
# sits in a market town; the fields are not the HQ), and high-tech fabs.
|
||||
# Jeroen's rendering note (recorded in D-242): CityTenant HQs often sit on
|
||||
# a settlement's EDGE or in industrial clusters — that is rendering /
|
||||
# district-placement color for later phases, NOT a third placement class.
|
||||
#
|
||||
# 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 4 corp-only Services-extension values added
|
||||
# alongside this file (trade_distribution, hospitality_hub,
|
||||
# professional_services, licensed_clinical_services — real gaps the T-1074
|
||||
# corp categorization pass found: ~10% of corps do logistics/hospitality/
|
||||
# consulting/licensed-clinical work no original value covered; the fourth
|
||||
# landed via PR #177 M1). 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).
|
||||
# system-authored values plus 4 corp-only Services-extension values
|
||||
# (trade_distribution, hospitality_hub, professional_services,
|
||||
# licensed_clinical_services). Flipped stanzas below carry a one-line
|
||||
# justification against the boundary.
|
||||
#
|
||||
# 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.
|
||||
# Decisions: D-242 (this map + the boundary), D-237 (the reused
|
||||
# vocabulary). Tickets: T-1074 (map created), T-1076 item 0 (boundary
|
||||
# correction). PR #177 review created the 4th extension value (M1).
|
||||
# ==========================================================================
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Agricultural — Standalone (farms/estates need land)
|
||||
# Agricultural — ALL CityTenant (T-1076 item 0). The land/terroir is the
|
||||
# production site, but the HQ is the estate/agribusiness office, and offices
|
||||
# sit in market towns. (Previously Standalone via the bulk_class shortcut.)
|
||||
# -------------------------------------------------------------------------
|
||||
[estate_farming]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "agricultural"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: estate agriculture — Jeroen's explicit example; the estate office
|
||||
# sits in a market town, the manor grounds are not a corporate settlement.
|
||||
|
||||
[breadbasket]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "agricultural"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: industrial-scale agribusiness is still agriculture — the grain
|
||||
# combine's HQ is an office in the region's market hub, not a company town
|
||||
# on the fields.
|
||||
|
||||
[terroir_agriculture]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "agricultural"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: agriculture; the terroir lock is about the LAND, not the HQ.
|
||||
|
||||
[terroir_spirits]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "agricultural"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: distilleries are craft manufacturing — Jeroen's explicit example
|
||||
# (Groot Karoo Cellars as a standalone belt settlement was the named
|
||||
# failure mode this correction eliminates).
|
||||
|
||||
[terroir_organics]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "agricultural"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: herd/ecosystem-origin organics are agriculture; the fiber/dye
|
||||
# house's HQ is a town office.
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Energy — Standalone (extraction/refinery infrastructure)
|
||||
# Energy — Standalone (heavy energy: the refinery/extraction complex IS the
|
||||
# corporate site — explicitly inside Jeroen's boundary).
|
||||
# -------------------------------------------------------------------------
|
||||
[fuel_production]
|
||||
hq_placement = "Standalone"
|
||||
@@ -93,7 +94,8 @@ hq_placement = "Standalone"
|
||||
standalone_economic_role = "extraction"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Extraction — Standalone (mine/quarry sits on the deposit)
|
||||
# Extraction — Standalone (mining/quarrying: the deposit IS the site; the
|
||||
# company town is the archetype of the whole Standalone class).
|
||||
# -------------------------------------------------------------------------
|
||||
[ore_extraction]
|
||||
hq_placement = "Standalone"
|
||||
@@ -116,11 +118,17 @@ hq_placement = "Standalone"
|
||||
standalone_economic_role = "extraction"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Manufacturing — Standalone (factory/yard footprint)
|
||||
# Manufacturing — SPLIT by the boundary: heavy vehicle/shipyard/gate works
|
||||
# stay Standalone; craft/consumer fabrication flips to CityTenant.
|
||||
# -------------------------------------------------------------------------
|
||||
[general_industrial]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "manufacturing"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: this value is carried overwhelmingly by craft/consumer
|
||||
# fabricators (leatherworks, furniture, ceramics, glassworks, apparel —
|
||||
# Kalahari Leatherworks as a standalone belt settlement was a named failure
|
||||
# mode). Not heavy vehicle/shipyard work. A future true steel-complex
|
||||
# identity would need its own vocabulary value rather than re-coarsening
|
||||
# this one.
|
||||
|
||||
[shipbuilding]
|
||||
hq_placement = "Standalone"
|
||||
@@ -131,8 +139,9 @@ hq_placement = "Standalone"
|
||||
standalone_economic_role = "manufacturing"
|
||||
|
||||
[consumer_goods_bazaar]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "manufacturing"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: consumer manufacturing, and literally an urban bazaar of small
|
||||
# workshops (Lu Ban) — the epitome of in-city production.
|
||||
|
||||
[gate_fabrication]
|
||||
hq_placement = "Standalone"
|
||||
@@ -143,19 +152,21 @@ hq_placement = "Standalone"
|
||||
standalone_economic_role = "military"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# High-tech — Standalone (fabrication/aquaculture plant)
|
||||
# High-tech — CityTenant (T-1076 item 0: "high-tech fabs" are explicitly
|
||||
# on Jeroen's CityTenant side of the boundary).
|
||||
# -------------------------------------------------------------------------
|
||||
[precision_tech]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "manufacturing"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: high-tech fabs — Jeroen's explicit example; a cleanroom campus
|
||||
# is an urban-edge tenant, not a company town.
|
||||
|
||||
[marine_farming]
|
||||
hq_placement = "Standalone"
|
||||
standalone_economic_role = "agricultural"
|
||||
hq_placement = "CityTenant"
|
||||
# FLIPPED: aquaculture is agriculture's marine twin — the operator's HQ
|
||||
# sits in the harbor town, the kelp beds are not a settlement.
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Services (all NonPhysical) — CityTenant (an office, no plant); no
|
||||
# standalone_economic_role — no settlement is ever emitted for these.
|
||||
# Services (all NonPhysical) — CityTenant (unchanged; an office, no plant).
|
||||
# -------------------------------------------------------------------------
|
||||
[financial_hub]
|
||||
hq_placement = "CityTenant"
|
||||
@@ -179,11 +190,7 @@ hq_placement = "CityTenant"
|
||||
hq_placement = "CityTenant"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Services extension (4, all NonPhysical) — added alongside the vocabulary
|
||||
# extension in specialization_vocabulary.toml (D-242/T-1074; the fourth via
|
||||
# PR #177 M1). Same rule as the rest of Services: NonPhysical -> CityTenant
|
||||
# (an office/depot/clinic, not a factory — this is exactly what made these
|
||||
# corps hard to place in the original 27).
|
||||
# Services extension (4, all NonPhysical) — CityTenant (unchanged).
|
||||
# -------------------------------------------------------------------------
|
||||
[trade_distribution]
|
||||
hq_placement = "CityTenant"
|
||||
|
||||
@@ -31,17 +31,24 @@
|
||||
# markers.json pool. Every entry below is verified against real, repeated
|
||||
# `make regen-db` runs — see the note on each stanza for its actual source
|
||||
# row. Note (PR #177 H1/H3): corporations.headquarters_body is RECOMPUTED
|
||||
# from source on every run now, so the three corp-named Standalone-HQ pins
|
||||
# below (Cygni Combines, The Gate Corporation, Mastroianni Vehicle Group)
|
||||
# hold because their HQ bodies carry real authored population — the
|
||||
# population-first tiebreak re-derives the same body every run (verified:
|
||||
# two consecutive regens, all 8 pins applied, zero placement drift) — not
|
||||
# because a stale first-run value is being kept. 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 — T-1115
|
||||
# authors the missing names; the bake cannot invent a name to pin).
|
||||
# from source on every run now, so the two corp-named Standalone-HQ pins
|
||||
# below (The Gate Corporation, Mastroianni Vehicle Group) hold because
|
||||
# their HQ bodies carry real authored population — the population-first
|
||||
# tiebreak re-derives the same body every run — not because a stale
|
||||
# first-run value is being kept. (T-1076 item 0: the former third
|
||||
# corp-named pin, "Cygni Combines", was retired when Jeroen's placement
|
||||
# boundary flipped general_industrial to CityTenant; Cygni B's pin moved to
|
||||
# its pure-pool city 'Metropolis'. PR #178 M1 then re-tagged cygni-combines
|
||||
# to shipbuilding, so its Standalone-HQ settlement RETURNS on GJ820Bc — the
|
||||
# pin deliberately STAYS on Metropolis: Miri verified the pool city as the
|
||||
# canon-clean hero anchor for the system, and the combines' company yard is
|
||||
# a place beside the city, not the city itself.) 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 — T-1115 authors the missing names; the bake
|
||||
# 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
|
||||
@@ -60,8 +67,8 @@ note = "Kallast / GJ 144 (Ran) — THE breadbasket; lore-anchored plains body (D
|
||||
|
||||
[[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)."
|
||||
name = "Metropolis"
|
||||
note = "Cygni B / GJ 820B — shipbuilding cluster, 7 competing combines (D-237). Pure-pool city. (T-1076 item 0 moved the pin here from the 'Cygni Combines' HQ settlement; PR #178 M1's shipbuilding re-tag brings that settlement back, but the pin stays on Metropolis — Miri-verified canon-clean hero anchor; the combines' yard is a place beside the city, not the city itself.)"
|
||||
|
||||
[[hero]]
|
||||
body_id = "GJ251c"
|
||||
|
||||
Reference in New Issue
Block a user