diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 53539a2af..8447b546e 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -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 diff --git a/server/data/systems.db b/server/data/systems.db index 3080f69a6..22523070f 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/atlas/attractor_matching.rs b/server/src/atlas/attractor_matching.rs index 1b00c7464..aa9abbf9a 100644 --- a/server/src/atlas/attractor_matching.rs +++ b/server/src/atlas/attractor_matching.rs @@ -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![ diff --git a/server/src/atlas/believability.rs b/server/src/atlas/believability.rs index 6c754303a..866192318 100644 --- a/server/src/atlas/believability.rs +++ b/server/src/atlas/believability.rs @@ -534,28 +534,47 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result Result, 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 = 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" + ); + } } diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 2d14d9220..33aedccca 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -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 = (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 = 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; + } } diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index 0cd2ee12e..ad11903c4 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -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>(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) ──────────────────────────────────────── diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 621209562..253221afd 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -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, diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index cce7eb40f..69551e4a0 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -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 { diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs index c9ce7ea31..99c7bdc6f 100644 --- a/server/src/atlas/road_graph.rs +++ b/server/src/atlas/road_graph.rs @@ -45,12 +45,14 @@ use std::cmp::Reverse; use std::collections::{BTreeSet, BinaryHeap}; use serde::{Deserialize, Serialize}; -use tracing::debug; +use tracing::{debug, warn}; use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::features::TerrainAnalysis; use crate::atlas::subbiome; -use crate::simulation::generator::{MaintenanceAuthority, PoliticalArchetype, TerritorialStatus}; +use crate::simulation::generator::{ + FoundingOrientation, MaintenanceAuthority, PoliticalArchetype, TerritorialStatus, +}; // --------------------------------------------------------------------------- // Tunables (workshop-anchored) @@ -100,6 +102,32 @@ const LONG_HAUL_FRAC: f64 = 0.30; /// junctions (the candidates a `RailHeadFacing` second pass would target). pub const JUNCTION_DEGREE: u16 = 3; +/// One trunk hub is allowed per this many grid-px of body diagonal (T-1076 §1, +/// D-242 "scaled-cap hubs"). The working planet grid is a fixed 512×256, whose +/// diagonal (including the ×8 routing downsample) is ≈572 px → a cap of ≈8 +/// trunk hubs on a planet; smaller grids (tests, future sub-planet bodies) +/// scale down and are floored by [`HUB_CAP_MIN`]. "Body size" here is the grid +/// diagonal ([`RouteGrid::body_scale`]) — the same measure every other +/// workshop tunable in this file keys off (`SECONDARY_*_FRAC`, +/// `LONG_HAUL_FRAC`); physical radius is not available to this layer. +/// Fixed-grid premise: revisit when [D-243]'s elastic planetary seam gives +/// bodies varying grid sizes (`round(2πR / 204.8 km)` regions) — the diagonal +/// then genuinely scales with `body_radius_km` and this constant starts doing +/// real per-body work (PR #178 T1). +const HUB_SPACING_DIAG_PX: f64 = 64.0; + +/// Floor for the hub cap: small grids keep at least this many trunk hubs so +/// the trunk stays a real network (an MST of ≥3 nodes) rather than degenerating +/// to hub-and-spoke on every test-sized body. +const HUB_CAP_MIN: usize = 6; + +/// A minor settlement within this many grid-px of an existing road edge snaps +/// onto it (nearest-point-on-polyline; the edge is split at the junction) +/// instead of earning its own spur to a hub (T-1076 §3). Chosen as ~half of +/// D-211's 15-px minimum city spacing: a road already passing within half a +/// city-spacing of a settlement realistically serves it. +const SNAP_MAX_PX: f64 = 8.0; + // --------------------------------------------------------------------------- // Output types // --------------------------------------------------------------------------- @@ -111,21 +139,33 @@ pub enum RoadNodeKind { Settlement, /// A sub-settlement waypoint at the midpoint of a long edge. Waypoint, + /// A topological split point where a minor settlement's spur joins an + /// existing edge (T-1076 §3). Carries no `city_id`. The Atlas client's + /// marker overlay filters to `Settlement` kind and skips these, like + /// waypoints. + Junction, } -/// One node in the road graph — a settlement or a waypoint. +/// One node in the road graph — a settlement, a waypoint, or a spur junction. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoadNode { - /// The placed city's id; `None` for waypoints. + /// The placed city's id; `None` for waypoints/junctions. pub city_id: Option, /// Position in working-heightmap-grid coordinates `(row, col)`. pub position: (u16, u16), pub kind: RoadNodeKind, /// Number of incident edges. Settlement junctions (`degree ≥ JUNCTION_DEGREE`) - /// are the candidates for a `RailHeadFacing` second pass (T-1038 §6). + /// are the candidates for the `RailHeadFacing` pass (T-1038 §6, T-1076 §4). pub degree: u16, - /// For a waypoint: the edge it sits on. `None` for settlements. + /// For a waypoint: the edge it sits on. `None` for settlements/junctions. pub parent_edge: Option, + /// `true` if this settlement is a trunk hub (T-1076 §1): among the top + /// hub-cap non-standalone-HQ settlements by `(population DESC, city_id + /// ASC)`. The trunk (MST + secondary links) connects hubs only; every + /// other settlement — standalone corp HQs regardless of population, and + /// below-cap cities — is a minor node attached by snap-or-spur (§3). + /// Always `false` for waypoints/junctions. + pub is_hub: bool, } /// One inter-settlement road/rail edge. @@ -180,7 +220,8 @@ impl RoadGraph { // Public entry point // --------------------------------------------------------------------------- -/// Build the inter-settlement road/rail graph for one body (D-211, T-1038). +/// Build the inter-settlement road/rail graph for one body (D-211, T-1038; +/// hub/minor refinement T-1076). /// /// `placements` are the Layer-3 city placements; `ta` is the Layer-1 terrain /// analysis (A\* cost inputs); `river_cells` are the Layer-1 river cells (grid @@ -188,6 +229,25 @@ impl RoadGraph { /// `TerritorialStatus`, D-212) and the endpoint archetypes drive /// `MaintenanceAuthority`. `named_routes` is the `systems.db` named-route pool /// (empty today — D-223). +/// +/// **T-1076 flow:** +/// 0. Co-located duplicates (exact name match — post-D-242 this should never +/// fire) collapse to one node keeping the lowest `city_id`, with a loud +/// warning (§2). +/// 1. Settlements partition into **hubs** — the top hub-cap by `(population +/// DESC, city_id ASC)` among non-standalone-HQ settlements — and +/// **minors** (standalone corp HQs regardless of population, and +/// below-cap cities). The cap scales with the grid diagonal +/// ([`HUB_SPACING_DIAG_PX`], floored by [`HUB_CAP_MIN`]). A body whose +/// settlements are ALL standalone HQs falls back to ranking the HQs +/// themselves (the graph must still connect; an HQ-only body's biggest +/// HQ is its de-facto hub). +/// 2. The trunk (MST + secondary links, A\*-routed) connects hubs only. +/// 3. Each minor then attaches (§3): snapped onto the nearest point of an +/// existing edge if within [`SNAP_MAX_PX`] (the edge is split at a new +/// [`RoadNodeKind::Junction`] node), else A\*-spurred to the nearest hub. +/// Minors attach in node-index order and may snap onto edges created by +/// earlier attachments (roads accrete). pub fn build_road_graph( placements: &[CityPlacement], ta: &TerrainAnalysis, @@ -201,43 +261,55 @@ pub fn build_road_graph( return RoadGraph::default(); } - // Settlement nodes, one per placement, in placement order (deterministic). + // --- (0) co-location collapse (T-1076 §2) ------------------------------- + let placements = collapse_colocated(placements); + + // --- hub / minor partition (T-1076 §1) ---------------------------------- + // Nodes stay in placement order (deterministic); hubs are flagged. + let grid = RouteGrid::build(ta, river_cells, grid_w, grid_h); + let body_scale = grid.body_scale(); + let hub_indices = select_hubs(&placements, body_scale); + let mut nodes: Vec = placements .iter() - .map(|p| RoadNode { + .enumerate() + .map(|(i, p)| RoadNode { city_id: Some(p.city_id), position: p.position, kind: RoadNodeKind::Settlement, degree: 0, parent_edge: None, + is_hub: hub_indices.contains(&i), }) .collect(); - let n = nodes.len(); // Single city — nothing to connect. - if n == 1 { + if nodes.len() == 1 { return RoadGraph { nodes, edges: Vec::new(), }; } - // --- routing grid + cost field ----------------------------------------- - let grid = RouteGrid::build(ta, river_cells, grid_w, grid_h); - let body_scale = grid.body_scale(); - - // --- (1) MST over Euclidean distances (strict Kruskal) ----------------- let positions: Vec<(f64, f64)> = nodes .iter() .map(|node| (node.position.0 as f64, node.position.1 as f64)) .collect(); - let mst_pairs = minimum_spanning_tree(&positions); - // --- (2) secondary links: long MST detours within the body-scale band -- - let tree_dist = all_pairs_tree_distance(n, &mst_pairs, &positions); + // --- (1) trunk MST over HUB Euclidean distances (strict Kruskal) -------- + let hub_positions: Vec<(f64, f64)> = hub_indices.iter().map(|&i| positions[i]).collect(); + let mst_local = minimum_spanning_tree(&hub_positions); + let mst_pairs: Vec<(usize, usize)> = mst_local + .iter() + .map(|&(a, b)| (hub_indices[a], hub_indices[b])) + .collect(); + + // --- (2) secondary links among hubs: long MST detours in the band ------- + let tree_dist = all_pairs_tree_distance(nodes.len(), &mst_pairs, &positions); let mut wanted: BTreeSet<(usize, usize)> = mst_pairs.iter().copied().collect(); - for i in 0..n { - for j in (i + 1)..n { + for (a, &i) in hub_indices.iter().enumerate() { + for &j in hub_indices.iter().skip(a + 1) { + let (i, j) = if i < j { (i, j) } else { (j, i) }; let euclid = euclid(positions[i], positions[j]); if euclid < SECONDARY_MIN_FRAC * body_scale || euclid > SECONDARY_MAX_FRAC * body_scale { @@ -250,7 +322,7 @@ pub fn build_road_graph( } } - // --- (3) A* route every wanted edge; drop unroutable pairs (oceans) ---- + // --- (3) A* route every trunk edge; drop unroutable pairs (oceans) ------ let mut edges: Vec = Vec::new(); let mut dropped = 0usize; for (i, j) in wanted { @@ -268,7 +340,7 @@ pub fn build_road_graph( body_scale, body_status, ); - edges.push(RoadEdge { + edges.push(norm_edge(RoadEdge { from: i, to: j, path, @@ -276,31 +348,423 @@ pub fn build_road_graph( maintenance, named_route_id: None, is_rail: false, - }); + })); } if dropped > 0 { debug!( dropped, kept = edges.len(), - "road_graph: some settlement pairs are unroutable by land (separated by water)" + "road_graph: some hub pairs are unroutable by land (separated by water)" ); } - // --- degree (settlement junction detection) ---------------------------- - for e in &edges { - nodes[e.from].degree += 1; - nodes[e.to].degree += 1; + // --- (4) minor-settlement attach: snap-to-edge or spur-to-hub (§3) ------ + attach_minors( + &mut nodes, + &mut edges, + &placements, + &hub_indices, + &grid, + body_scale, + body_status, + ); + + // --- degrees recomputed from the final edge list ------------------------ + for node in nodes.iter_mut() { + node.degree = 0; + } + let incident: Vec<(usize, usize)> = edges.iter().map(|e| (e.from, e.to)).collect(); + for (f, t) in incident { + nodes[f].degree += 1; + nodes[t].degree += 1; } - // --- (4) named-route identity join: trunk (longest) edges first -------- + // --- (5) named-route identity join: trunk (longest) edges first --------- assign_named_routes(&mut edges, named_routes); - // --- (5) waypoints at midpoints of long edges -------------------------- + // --- (6) waypoints at midpoints of long edges --------------------------- add_waypoints(&mut nodes, &edges); RoadGraph { nodes, edges } } +/// T-1076 §2 — collapse exact-name duplicate placements to one graph node, +/// keeping the lowest `city_id`. Post-D-242 the pool has no duplicate +/// `(body_id, name)` groups (verified on every regen), so this should never +/// fire — when it does, it means upstream data regressed, hence the loud +/// warning rather than a silent dedupe. +fn collapse_colocated(placements: &[CityPlacement]) -> Vec { + let mut kept: Vec = Vec::with_capacity(placements.len()); + for p in placements { + if let Some(prev) = kept.iter().find(|k| k.name == p.name) { + warn!( + name = %p.name, + kept_city_id = prev.city_id, + dropped_city_id = p.city_id, + "road_graph: co-located duplicate settlement collapsed — \ + duplicate (body, name) groups should not exist post-D-242" + ); + continue; + } + kept.push(p.clone()); + } + // Keep-lowest-id: placements arrive in `atlas_city_names.id` order from + // the readers, so first-seen == lowest city_id. Guard the assumption: + // if a lower id appears later (caller-reordered input), swap it in. + for p in placements { + if let Some(slot) = kept + .iter_mut() + .find(|k| k.name == p.name && p.city_id < k.city_id) + { + *slot = p.clone(); + } + } + kept +} + +/// T-1076 §1 — the trunk-hub index set: top hub-cap settlements by +/// `(population DESC, city_id ASC)` among non-standalone-HQ placements. +/// Returns indices into `placements`, sorted ascending. Falls back to ranking +/// ALL placements when every settlement on the body is a standalone HQ. +fn select_hubs(placements: &[CityPlacement], body_scale: f64) -> Vec { + let cap = ((body_scale / HUB_SPACING_DIAG_PX) as usize).max(HUB_CAP_MIN); + let mut eligible: Vec = (0..placements.len()) + .filter(|&i| !placements[i].is_standalone_hq) + .collect(); + if eligible.is_empty() { + // HQ-only body: the graph must still connect — rank the HQs. + eligible = (0..placements.len()).collect(); + } + eligible.sort_by(|&a, &b| { + placements[b] + .population + .cmp(&placements[a].population) + .then(placements[a].city_id.cmp(&placements[b].city_id)) + }); + let mut chosen: Vec = eligible.into_iter().take(cap).collect(); + chosen.sort_unstable(); + chosen +} + +/// Normalize an edge to the `from < to` invariant, reversing the path when the +/// endpoints swap (the path always runs `nodes[from] → nodes[to]`). +fn norm_edge(mut e: RoadEdge) -> RoadEdge { + if e.from > e.to { + std::mem::swap(&mut e.from, &mut e.to); + e.path.reverse(); + } + e +} + +/// T-1076 §3 — attach every minor settlement (non-hub node) to the network: +/// snap onto the nearest point of an existing edge when within +/// [`SNAP_MAX_PX`] (splitting that edge at a new [`RoadNodeKind::Junction`] +/// node), else A\*-spur to the nearest hub. Minors attach in ascending node +/// order; each attachment's new edges are visible to later minors (roads +/// accrete deterministically). +#[allow(clippy::too_many_arguments)] +fn attach_minors( + nodes: &mut Vec, + edges: &mut Vec, + placements: &[CityPlacement], + hub_indices: &[usize], + grid: &RouteGrid, + body_scale: f64, + body_status: &TerritorialStatus, +) { + let minor_indices: Vec = (0..placements.len()) + .filter(|i| !hub_indices.contains(i)) + .collect(); + + for &mi in &minor_indices { + let mpos = nodes[mi].position; + let march = placements[mi].political_archetype; + + // --- nearest point on any existing edge polyline -------------------- + let mut best: Option<(f64, usize, usize, (u16, u16))> = None; // (dist², edge, seg, proj) + for (ei, e) in edges.iter().enumerate() { + for si in 0..e.path.len().saturating_sub(1) { + let (d2, proj) = project_onto_segment(mpos, e.path[si], e.path[si + 1]); + // Strictly-less keeps the first-found (lowest edge/segment + // index) on exact ties — deterministic given fixed iteration. + if best.is_none() || d2 < best.unwrap().0 { + best = Some((d2, ei, si, proj)); + } + } + } + + if let Some((d2, ei, si, proj)) = best { + if d2 <= SNAP_MAX_PX * SNAP_MAX_PX { + // Snap: junction at the projection — unless it lands exactly on + // an existing endpoint node, in which case attach there (no + // degenerate zero-length split halves). + let attach_node = if proj == edges[ei].path[0] { + edges[ei].from + } else if proj == *edges[ei].path.last().unwrap() { + edges[ei].to + } else { + split_edge_at(nodes, edges, ei, si, proj) + }; + let apos = nodes[attach_node].position; + let spur_len = euclid( + (apos.0 as f64, apos.1 as f64), + (mpos.0 as f64, mpos.1 as f64), + ); + // A snapped spur is ≤ SNAP_MAX_PX — a straight local road, not + // worth an A* run. Maintenance is credited to the minor it + // serves (a junction has no archetype of its own). + let maintenance = + maintenance_authority(march, march, spur_len, body_scale, body_status); + edges.push(norm_edge(RoadEdge { + from: attach_node, + to: mi, + path: vec![apos, mpos], + length_cells: 0, + maintenance, + named_route_id: None, + is_rail: false, + })); + continue; + } + } + + // --- no snap: A*-spur to the nearest hub ---------------------------- + let mut best_hub: Option<(i64, usize)> = None; + for &hi in hub_indices { + if hi == mi { + continue; + } + let hpos = nodes[hi].position; + let dr = hpos.0 as i64 - mpos.0 as i64; + let dc = hpos.1 as i64 - mpos.1 as i64; + let d2 = dr * dr + dc * dc; + if best_hub.is_none() || d2 < best_hub.unwrap().0 { + best_hub = Some((d2, hi)); + } + } + let Some((_, hi)) = best_hub else { + continue; // no hubs at all (single-node graphs return earlier) + }; + let hpos = nodes[hi].position; + let mposf = (mpos.0 as f64, mpos.1 as f64); + let hposf = (hpos.0 as f64, hpos.1 as f64); + let Some((route_cells, length_cells)) = grid.astar(mposf, hposf) else { + debug!( + minor = mi, + hub = hi, + "road_graph: minor settlement unroutable to its nearest hub (water) — left isolated" + ); + continue; + }; + let path = grid.to_grid_path(mpos, hpos, &route_cells); + let maintenance = maintenance_authority( + march, + placements[hi].political_archetype, + euclid(mposf, hposf), + body_scale, + body_status, + ); + edges.push(norm_edge(RoadEdge { + from: mi, + to: hi, + path, + length_cells, + maintenance, + named_route_id: None, + is_rail: false, + })); + } +} + +/// Project grid point `p` onto the segment `a→b` (f64, clamped to the segment). +/// Returns `(squared distance, projected point rounded to grid coords)`. +fn project_onto_segment(p: (u16, u16), a: (u16, u16), b: (u16, u16)) -> (f64, (u16, u16)) { + let (pr, pc) = (p.0 as f64, p.1 as f64); + let (ar, ac) = (a.0 as f64, a.1 as f64); + let (br, bc) = (b.0 as f64, b.1 as f64); + let (dr, dc) = (br - ar, bc - ac); + let len2 = dr * dr + dc * dc; + let t = if len2 == 0.0 { + 0.0 + } else { + (((pr - ar) * dr + (pc - ac) * dc) / len2).clamp(0.0, 1.0) + }; + let (jr, jc) = (ar + t * dr, ac + t * dc); + let d2 = (pr - jr) * (pr - jr) + (pc - jc) * (pc - jc); + (d2, (jr.round() as u16, jc.round() as u16)) +} + +/// Geometric length of a polyline in grid px — Euclidean segment lengths +/// summed in path order (fixed order + correctly-rounded f64 ops, so the sum +/// is bit-reproducible per the module's determinism doctrine). +fn polyline_len(path: &[(u16, u16)]) -> f64 { + let mut total = 0.0; + for w in path.windows(2) { + total += euclid( + (w[0].0 as f64, w[0].1 as f64), + (w[1].0 as f64, w[1].1 as f64), + ); + } + total +} + +/// Split `edges[ei]` at `jpos` on segment `si`: a new [`RoadNodeKind::Junction`] +/// node replaces the single edge with two halves meeting at the junction. +/// Both halves inherit the parent's maintenance/named-route identity; +/// `length_cells` splits **geometric-distance proportionally** — each half +/// gets the parent's routed length scaled by its polyline's share of the +/// total geometry (PR #178 H1: the earlier vertex-count proxy went +/// all-or-nothing on 2-point parents — `l1 = full, l2 = 0` wherever the +/// junction fell — and 2-point parents are routine: every snap spur is one, +/// so chained snaps always hit it). The halves always sum exactly to the +/// parent's `length_cells` (l2 is the remainder), and an interior junction +/// on a parent with `length_cells > 0` gives both halves a non-zero share +/// whenever its geometry does. Returns the junction's node index. Caller +/// guarantees `jpos` is not an endpoint of the edge's path (guarded at the +/// call site). +fn split_edge_at( + nodes: &mut Vec, + edges: &mut Vec, + ei: usize, + si: usize, + jpos: (u16, u16), +) -> usize { + let jn = nodes.len(); + nodes.push(RoadNode { + city_id: None, + position: jpos, + kind: RoadNodeKind::Junction, + degree: 0, + parent_edge: None, + is_hub: false, + }); + + let old = edges[ei].clone(); + let mut path1: Vec<(u16, u16)> = old.path[..=si].to_vec(); + if path1.last() != Some(&jpos) { + path1.push(jpos); + } + let mut path2: Vec<(u16, u16)> = vec![jpos]; + if old.path[si + 1..].first() == Some(&jpos) { + path2.extend_from_slice(&old.path[si + 2..]); + } else { + path2.extend_from_slice(&old.path[si + 1..]); + } + + let (len1, len2) = (polyline_len(&path1), polyline_len(&path2)); + let total = len1 + len2; + let l1 = if total > 0.0 { + ((old.length_cells as f64 * len1 / total).round() as u32).min(old.length_cells) + } else { + 0 // fully degenerate geometry (all points coincide) — nothing to apportion + }; + let l2 = old.length_cells - l1; + + edges[ei] = norm_edge(RoadEdge { + from: old.from, + to: jn, + path: path1, + length_cells: l1, + maintenance: old.maintenance, + named_route_id: old.named_route_id.clone(), + is_rail: old.is_rail, + }); + edges.push(norm_edge(RoadEdge { + from: jn, + to: old.to, + path: path2, + length_cells: l2, + maintenance: old.maintenance, + named_route_id: old.named_route_id, + is_rail: old.is_rail, + })); + jn +} + +// --------------------------------------------------------------------------- +// RailHeadFacing assignment (T-1076 §4, D-213 amended) +// --------------------------------------------------------------------------- + +/// Octant-snapped compass bearing (0 = N, clockwise, one of +/// {0, 45, 90, 135, 180, 225, 270, 315}) from `from` toward `to` in grid +/// coordinates (rows grow south, columns grow east). Integer-only (D-010): +/// the diagonal band is `|minor| * 2 > |major|` (sector boundaries at +/// ≈26.6°/63.4° instead of the exact 22.5°/67.5° — a deliberate integer +/// approximation; the consumer snaps to quarter-edges anyway, so the +/// half-octant boundary shift never changes a rendered outcome class). +fn octant_bearing(from: (u16, u16), to: (u16, u16)) -> u16 { + let dr = to.0 as i64 - from.0 as i64; // + = south + let dc = to.1 as i64 - from.1 as i64; // + = east + if dr == 0 && dc == 0 { + return 0; + } + let (adr, adc) = (dr.abs(), dc.abs()); + let diagonal = adr.min(adc) * 2 > adr.max(adc); + match (diagonal, dr.signum(), dc.signum()) { + (false, _, _) if adr >= adc => { + if dr < 0 { + 0 // N + } else { + 180 // S + } + } + (false, _, _) => { + if dc > 0 { + 90 // E + } else { + 270 // W + } + } + (true, r, c) => match (r < 0, c > 0) { + (true, true) => 45, // NE + (false, true) => 135, // SE + (false, false) => 225, // SW + (true, false) => 315, // NW + }, + } +} + +/// T-1076 §4 — override `founding_orientation` to +/// [`FoundingOrientation::RailHeadFacing`] for every settlement that is a +/// high-connectivity junction (`degree ≥` [`JUNCTION_DEGREE`], the D-213 +/// workshop criterion — paula-round3.md). The bearing faces the junction's +/// **dominant incident edge** — longest `length_cells`, ties broken by lowest +/// edge index — toward that edge's other endpoint, octant-snapped +/// ([`octant_bearing`]). Runs AFTER [`build_road_graph`] in the cascade +/// (orientation is a Layer-3 output, but rail-head facing is knowable only +/// once Layer 2 exists); the Layer-4 skeleton picks the override up at +/// dispatch (`plugin.rs` copies `placement.founding_orientation` into the +/// generation context). +pub fn assign_railhead_orientations(placements: &mut [CityPlacement], graph: &RoadGraph) { + for idx in graph.high_connectivity_junctions() { + let node = &graph.nodes[idx]; + let Some(city_id) = node.city_id else { + continue; // settlement junctions always carry a city_id + }; + // Dominant incident edge: longest; first (lowest index) on ties. + let mut dominant: Option<(u32, usize)> = None; + for (ei, e) in graph.edges.iter().enumerate() { + if e.from != idx && e.to != idx { + continue; + } + if dominant.is_none() || e.length_cells > dominant.unwrap().0 { + dominant = Some((e.length_cells, ei)); + } + } + let Some((_, ei)) = dominant else { + continue; // degree ≥ 3 guarantees incident edges; defensive + }; + let e = &graph.edges[ei]; + let other = if e.from == idx { e.to } else { e.from }; + let bearing = octant_bearing(node.position, graph.nodes[other].position); + if let Some(p) = placements.iter_mut().find(|p| p.city_id == city_id) { + p.founding_orientation = FoundingOrientation::RailHeadFacing { + bearing_degrees: bearing, + }; + } + } +} + // --------------------------------------------------------------------------- // MaintenanceAuthority derivation (workshop OQ-R3-4) // --------------------------------------------------------------------------- @@ -373,6 +837,7 @@ fn add_waypoints(nodes: &mut Vec, edges: &[RoadEdge]) { kind: RoadNodeKind::Waypoint, degree: 0, parent_edge: Some(ei), + is_hub: false, }); } } @@ -749,9 +1214,18 @@ mod tests { founding_orientation: FoundingOrientation::Cardinal, population: 100_000, is_capital: false, + is_standalone_hq: false, } } + /// `placement` variant marked as a D-242 standalone corp HQ (T-1076 §1). + fn hq_placement(city_id: u64, pos: (u16, u16), population: i64) -> CityPlacement { + let mut p = placement(city_id, pos, PoliticalArchetype::Corporate); + p.population = population; + p.is_standalone_hq = true; + p + } + #[test] fn empty_input_empty_graph() { let hm = flat_hm(64, 32); @@ -1065,4 +1539,413 @@ mod tests { "a central hub should be a high-connectivity junction" ); } + + // ─── T-1076 §1 — hub selection excludes standalone HQs ────────────────── + + #[test] + fn standalone_hq_is_minor_regardless_of_population() { + // Gate-Corporation shape: an HQ with a HUGE population must still be a + // minor node — hubs are the significant CITIES (D-242). The two small + // ordinary cities are the hubs; the HQ attaches with a single edge. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let placements = vec![ + hq_placement(1, (16, 48), 909_090_165), // massive HQ, far from the cities + placement(2, (8, 8), PoliticalArchetype::Pioneer), // pop 100k + placement(3, (24, 8), PoliticalArchetype::Pioneer), // pop 100k + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let hq = g.nodes.iter().find(|n| n.city_id == Some(1)).unwrap(); + assert!(!hq.is_hub, "standalone HQ must never be a trunk hub"); + assert_eq!(hq.degree, 1, "the HQ hangs off the network by one spur"); + for n in g.nodes.iter().filter(|n| matches!(n.city_id, Some(2 | 3))) { + assert!(n.is_hub, "ordinary cities are the hubs"); + } + } + + #[test] + fn hq_only_body_falls_back_to_hq_hubs() { + // A body whose settlements are ALL standalone HQs still gets a + // connected trunk (the HQs are its de-facto hubs). + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let placements = vec![ + hq_placement(1, (8, 8), 500_000), + hq_placement(2, (24, 40), 200_000), + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + assert!( + g.nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Settlement) + .all(|n| n.is_hub), + "fallback: HQs become hubs (waypoints are never hubs)" + ); + assert_eq!(g.edges.len(), 1, "two hubs → one trunk edge"); + } + + // ─── T-1076 §2 — co-location collapse ──────────────────────────────────── + + #[test] + fn colocated_duplicates_collapse_to_lowest_id() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + // Two placements with the SAME name (the post-D-242 impossible case) + + // one distinct city. The duplicate collapses to the lowest city_id. + let mut dup_hi = placement(7, (10, 10), PoliticalArchetype::Pioneer); + dup_hi.name = "Twinned".into(); + let mut dup_lo = placement(2, (12, 12), PoliticalArchetype::Pioneer); + dup_lo.name = "Twinned".into(); + let other = placement(3, (24, 40), PoliticalArchetype::Pioneer); + let g = build_road_graph( + &[dup_hi, dup_lo, other], + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let settlements: Vec<&RoadNode> = g + .nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Settlement) + .collect(); + assert_eq!(settlements.len(), 2, "duplicate collapsed to one node"); + assert!( + settlements.iter().any(|n| n.city_id == Some(2)), + "the LOWEST city_id survives the collapse" + ); + assert!( + settlements.iter().all(|n| n.city_id != Some(7)), + "the higher-id duplicate is dropped" + ); + } + + // ─── T-1076 §3 — hybrid minor-settlement attach ────────────────────────── + + #[test] + fn minor_snaps_onto_nearby_trunk_edge_with_split() { + // Trunk between two far-apart hubs runs roughly along a row; a minor + // (7th settlement, beyond the test-grid hub cap of 6) sits within + // SNAP_MAX_PX of it → the edge splits at a Junction and the minor + // spurs to it. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let mut placements: Vec = vec![ + placement(1, (16, 4), PoliticalArchetype::Pioneer), + placement(2, (16, 60), PoliticalArchetype::Pioneer), + placement(3, (4, 4), PoliticalArchetype::Pioneer), + placement(4, (4, 60), PoliticalArchetype::Pioneer), + placement(5, (28, 4), PoliticalArchetype::Pioneer), + placement(6, (28, 60), PoliticalArchetype::Pioneer), + ]; + // Give the six hubs clear population dominance; the 7th is below-cap. + for p in placements.iter_mut() { + p.population = 1_000_000; + } + let mut minor = placement(7, (13, 32), PoliticalArchetype::Pioneer); + minor.population = 10_000; // below the six → minor by cap + placements.push(minor); + + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let minor_node = g.nodes.iter().find(|n| n.city_id == Some(7)).unwrap(); + assert!(!minor_node.is_hub, "7th settlement is beyond the hub cap"); + let junctions: Vec<&RoadNode> = g + .nodes + .iter() + .filter(|n| n.kind == RoadNodeKind::Junction) + .collect(); + assert_eq!( + junctions.len(), + 1, + "the minor within snap range splits exactly one edge" + ); + // Invariants hold for every edge, including the split halves + spur. + for e in &g.edges { + assert!(e.from < e.to, "from < to must hold after splits"); + assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap()); + assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap()); + } + // The junction carries trunk halves + the spur → degree 3. + let ji = g + .nodes + .iter() + .position(|n| n.kind == RoadNodeKind::Junction) + .unwrap(); + assert_eq!(g.nodes[ji].degree, 3, "two split halves + one spur"); + } + + #[test] + fn minor_far_from_edges_spurs_to_nearest_hub() { + // 7 settlements; the 7th (below-cap minor) sits far from every trunk + // edge → it gets an A*-routed spur to its nearest hub, no junction. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let mut placements: Vec = vec![ + placement(1, (4, 4), PoliticalArchetype::Pioneer), + placement(2, (4, 30), PoliticalArchetype::Pioneer), + placement(3, (4, 56), PoliticalArchetype::Pioneer), + placement(4, (12, 4), PoliticalArchetype::Pioneer), + placement(5, (12, 30), PoliticalArchetype::Pioneer), + placement(6, (12, 56), PoliticalArchetype::Pioneer), + ]; + for p in placements.iter_mut() { + p.population = 1_000_000; + } + let mut minor = placement(7, (30, 30), PoliticalArchetype::Pioneer); + minor.population = 10_000; + placements.push(minor); + + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + assert!( + g.nodes.iter().all(|n| n.kind != RoadNodeKind::Junction), + "far minor must not split any edge" + ); + let mi = g.nodes.iter().position(|n| n.city_id == Some(7)).unwrap(); + let spur = g + .edges + .iter() + .find(|e| e.from == mi || e.to == mi) + .expect("minor must be connected by a spur"); + let other = if spur.from == mi { spur.to } else { spur.from }; + assert!(g.nodes[other].is_hub, "the spur lands on a hub"); + // Nearest hub to (30,30) is node 5 at (12,30). + assert_eq!(g.nodes[other].city_id, Some(5)); + } + + // ─── T-1076 §4 — RailHeadFacing assignment ─────────────────────────────── + + #[test] + fn octant_bearing_snaps_to_compass_octants() { + // Rows grow south, columns grow east; 0 = N, clockwise. + assert_eq!(octant_bearing((10, 10), (0, 10)), 0); // due north + assert_eq!(octant_bearing((10, 10), (0, 20)), 45); // north-east + assert_eq!(octant_bearing((10, 10), (10, 20)), 90); // due east + assert_eq!(octant_bearing((10, 10), (20, 20)), 135); // south-east + assert_eq!(octant_bearing((10, 10), (20, 10)), 180); // due south + assert_eq!(octant_bearing((10, 10), (20, 0)), 225); // south-west + assert_eq!(octant_bearing((10, 10), (10, 0)), 270); // due west + assert_eq!(octant_bearing((10, 10), (0, 0)), 315); // north-west + assert_eq!(octant_bearing((10, 10), (10, 10)), 0); // degenerate + } + + #[test] + fn railhead_orientation_assigned_at_high_connectivity_junctions() { + // The junction_detection topology: a central hub wired to 4 others. + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let mut placements = vec![ + placement(1, (16, 32), PoliticalArchetype::Pioneer), // central hub + placement(2, (4, 8), PoliticalArchetype::Pioneer), + placement(3, (4, 56), PoliticalArchetype::Pioneer), + placement(4, (28, 8), PoliticalArchetype::Pioneer), + placement(5, (28, 56), PoliticalArchetype::Pioneer), + ]; + let g = build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ); + let junctions = g.high_connectivity_junctions(); + assert!(!junctions.is_empty(), "central hub is a junction"); + + assign_railhead_orientations(&mut placements, &g); + for &ji in &junctions { + let cid = g.nodes[ji].city_id.unwrap(); + let p = placements.iter().find(|p| p.city_id == cid).unwrap(); + assert!( + matches!( + p.founding_orientation, + FoundingOrientation::RailHeadFacing { .. } + ), + "junction settlement {cid} gets RailHeadFacing" + ); + if let FoundingOrientation::RailHeadFacing { bearing_degrees } = p.founding_orientation + { + assert!(bearing_degrees < 360 && bearing_degrees % 45 == 0); + } + } + // Non-junction settlements keep their attractor-derived orientation. + for p in placements.iter().filter(|p| { + !junctions + .iter() + .any(|&ji| g.nodes[ji].city_id == Some(p.city_id)) + }) { + assert!(matches!( + p.founding_orientation, + FoundingOrientation::Cardinal + )); + } + } + + // ─── PR #178 H1/H2 — edge-split length arithmetic ──────────────────────── + + /// Direct unit test of the H1 bug shape: a 2-POINT parent path with real + /// `length_cells`. The old vertex-count proxy computed + /// `l1 = length × (path1.len()-1) / total_segs` = all-or-nothing whenever + /// `total_segs == 1`; the geometric split apportions by where the junction + /// actually falls. + #[test] + fn split_edge_at_two_point_parent_splits_geometrically() { + let mk_node = |pos: (u16, u16)| RoadNode { + city_id: Some(99), + position: pos, + kind: RoadNodeKind::Settlement, + degree: 0, + parent_edge: None, + is_hub: true, + }; + // Midpoint split: 10 cells → 5 + 5. + let mut nodes = vec![mk_node((10, 10)), mk_node((10, 30))]; + let mut edges = vec![RoadEdge { + from: 0, + to: 1, + path: vec![(10, 10), (10, 30)], + length_cells: 10, + maintenance: MaintenanceAuthority::Communal, + named_route_id: None, + is_rail: false, + }]; + let jn = split_edge_at(&mut nodes, &mut edges, 0, 0, (10, 20)); + assert_eq!(nodes[jn].kind, RoadNodeKind::Junction); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].length_cells, 5, "midpoint → equal halves"); + assert_eq!(edges[1].length_cells, 5); + + // Quarter-point split: 10 cells at t=0.25 → 2/3 + remainder split, but + // NEVER all-or-nothing: both halves non-zero, summing to the parent. + let mut nodes = vec![mk_node((10, 10)), mk_node((10, 30))]; + let mut edges = vec![RoadEdge { + from: 0, + to: 1, + path: vec![(10, 10), (10, 30)], + length_cells: 10, + maintenance: MaintenanceAuthority::Communal, + named_route_id: None, + is_rail: false, + }]; + split_edge_at(&mut nodes, &mut edges, 0, 0, (10, 15)); + let (l1, l2) = (edges[0].length_cells, edges[1].length_cells); + assert_eq!(l1 + l2, 10, "halves always sum to the parent"); + assert!(l1 > 0 && l2 > 0, "interior split is never all-or-nothing"); + assert!(l1 < l2, "the shorter geometric half gets the smaller share"); + } + + /// H2(a) — chained snap: minor B snaps onto minor A's already-created spur + /// (the accreting behavior), splitting a 2-point spur path. Also the H2(b) + /// determinism pin on a scenario that actually exercises attach_minors + /// (the pre-existing determinism test's 5 placements all become hubs under + /// HUB_CAP_MIN = 6, so it never reaches the attach path). + #[test] + fn chained_snap_onto_earlier_spur_and_attach_determinism() { + let hm = flat_hm(64, 32); + let ta = ta_for(&hm); + let mut placements: Vec = vec![ + placement(1, (16, 4), PoliticalArchetype::Pioneer), + placement(2, (16, 60), PoliticalArchetype::Pioneer), + placement(3, (4, 4), PoliticalArchetype::Pioneer), + placement(4, (4, 60), PoliticalArchetype::Pioneer), + placement(5, (28, 4), PoliticalArchetype::Pioneer), + placement(6, (28, 60), PoliticalArchetype::Pioneer), + ]; + for p in placements.iter_mut() { + p.population = 1_000_000; // the six trunk hubs + } + // Minor A: 8 px above the row-16 trunk edge → boundary snap; its spur + // runs down col 32 from the trunk to (8,32). + let mut minor_a = placement(7, (8, 32), PoliticalArchetype::Pioneer); + minor_a.population = 10_000; + // Minor B: 3 px from A's spur segment, 4 px from the trunk → B's + // nearest edge is the spur an earlier attachment created. + let mut minor_b = placement(8, (12, 35), PoliticalArchetype::Pioneer); + minor_b.population = 5_000; + placements.push(minor_a); + placements.push(minor_b); + + let run = || { + build_road_graph( + &placements, + &ta, + &[], + 64, + 32, + &TerritorialStatus::FrontierUnclaimed, + &[], + ) + }; + let g = run(); + + let junctions: Vec = g + .nodes + .iter() + .enumerate() + .filter(|(_, n)| n.kind == RoadNodeKind::Junction) + .map(|(i, _)| i) + .collect(); + assert_eq!( + junctions.len(), + 2, + "A splits the trunk, B splits A's spur — two junctions" + ); + // B's junction sits ON A's spur (col 32, strictly between the trunk + // row and A's row) — proving the chain hit a 2-point spur parent. + let chained = junctions + .iter() + .map(|&ji| g.nodes[ji].position) + .find(|p| p.1 == 32 && p.0 > 8 && p.0 < 16) + .expect("a junction must sit interior to A's spur on col 32"); + assert_eq!(chained.1, 32); + // Both minors are connected; every edge honours the invariants + // (including the split halves of the 2-point spur). + for mi in [6usize, 7usize] { + assert!( + g.edges.iter().any(|e| e.from == mi || e.to == mi), + "minor node {mi} must be attached" + ); + } + for e in &g.edges { + assert!(e.from < e.to); + assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap()); + assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap()); + } + // H2(b): the whole accreting attach sequence is deterministic. + assert_eq!(run(), run(), "attach_minors must be deterministic"); + } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index b90577673..610c15f96 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -813,6 +813,25 @@ fn coastal_edge(orientation: &FoundingOrientation) -> Option { } } +/// 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 { + 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> { - // 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 diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index f496cf0a4..4e694fa87 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -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 diff --git a/wiki/corporations/cygni-combines.md b/wiki/corporations/cygni-combines.md index 1ee35b4ab..a317add1a 100644 --- a/wiki/corporations/cygni-combines.md +++ b/wiki/corporations/cygni-combines.md @@ -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] diff --git a/wiki/corporations/sede-chemical-works.md b/wiki/corporations/sede-chemical-works.md index c2a66e170..e65572e63 100644 --- a/wiki/corporations/sede-chemical-works.md +++ b/wiki/corporations/sede-chemical-works.md @@ -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] diff --git a/wiki/corporations/sova-station-works.md b/wiki/corporations/sova-station-works.md index 3fac37669..838d48152 100644 --- a/wiki/corporations/sova-station-works.md +++ b/wiki/corporations/sova-station-works.md @@ -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] diff --git a/wiki/corporations/stalownia-kowalski.md b/wiki/corporations/stalownia-kowalski.md index f9361b7a8..3ad2006fa 100644 --- a/wiki/corporations/stalownia-kowalski.md +++ b/wiki/corporations/stalownia-kowalski.md @@ -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] diff --git a/wiki/corporations/westphalia-heavy-works.md b/wiki/corporations/westphalia-heavy-works.md index 45651bd88..cd4e0b415 100644 --- a/wiki/corporations/westphalia-heavy-works.md +++ b/wiki/corporations/westphalia-heavy-works.md @@ -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] diff --git a/wiki/economics/corp_hq_placement.toml b/wiki/economics/corp_hq_placement.toml index c3689d9d5..f1a404083 100644 --- a/wiki/economics/corp_hq_placement.toml +++ b/wiki/economics/corp_hq_placement.toml @@ -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" diff --git a/wiki/economics/settlement_name_locked.toml b/wiki/economics/settlement_name_locked.toml index 61b658805..9601a5564 100644 --- a/wiki/economics/settlement_name_locked.toml +++ b/wiki/economics/settlement_name_locked.toml @@ -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"