//! Attractor-matching five-phase pipeline for settlement placement (D-211). //! //! Given a body's `Vec` and a list of cities, assigns //! each city to the terrain feature that best fits its economic role and //! population tier. //! //! **Phases (D-211):** //! 1. Score matrix build: `CompatibilityMatrix[economic_role][attractor_type] × strength × (1/cost)` //! 2. Tier A greedy: `NameLocked` or pop ≥ 1,000,000 — assigned first, highest-score greedy. //! 3. Hungarian (Tier B+C): pop 50,000–999,999 cities — optimal global assignment. //! 4. Synthetic overflow: any remaining city gets a synthetic `PlainCenter` attractor. //! 5. Name fulfillment check: warn if any atlas city was not placed. //! //! **Mismatch flagging (D-211):** //! - score < 1500 → WARNING //! - score < 500 → ERROR (flagged for manual review; generation continues) use tracing::{error, warn}; use crate::atlas::features::NO_WATER_BEARING; use crate::seed::{splitmix64, SeedChain}; use crate::simulation::generator::{ AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, SubBiomeVariant, }; // --------------------------------------------------------------------------- // Input types // --------------------------------------------------------------------------- /// One city record from atlas_city_names, projected for matching. #[derive(Debug, Clone)] pub struct CityRecord { pub city_id: u64, pub name: String, pub settlement_class: SettlementClass, pub population: i64, /// One of: manufacturing, financial, agricultural, extraction, /// service_mixed, institutional, transit_hub, research, military, residential. pub economic_role: String, /// `atlas_city_names.kind == 'capital'` (authored, not derived from /// population). Threaded onto [`CityPlacement`] for the Atlas /// [`SettlementLayer`](crate::atlas::layer_proxy::SettlementLayer) (T-960 §2). /// Every current reader (`city_context_reader` and the believability /// 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, } // --------------------------------------------------------------------------- // Output // --------------------------------------------------------------------------- /// Result of matching one city to one attractor (real or synthetic), enriched /// with its spatial character (#956, D-213/214/215). #[derive(Debug, Clone)] pub struct CityPlacement { pub city_id: u64, /// Carried straight from the matched [`CityRecord`] (T-960 §2 — the Atlas /// `SettlementLayer` needs a display name without a cache-hit DB read). pub name: String, pub position: (u16, u16), pub attractor_type: AttractorType, /// Integer match score (D-010). See [`cell_score`]. pub score: i64, pub synthetic: bool, /// Power structure expressed in layout (D-214). Derived from the body's /// `TerritorialStatus` + the city's economic role. pub political_archetype: PoliticalArchetype, /// 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. 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, } // --------------------------------------------------------------------------- // Score matrix helpers // --------------------------------------------------------------------------- /// Row index in CompatibilityMatrix for an economic_role string. /// Order from D-195: manufacturing(0), financial(1), agricultural(2), extraction(3), /// service_mixed(4), institutional(5), transit_hub(6), research(7), military(8), residential(9). fn role_row(economic_role: &str) -> usize { match economic_role { "manufacturing" => 0, "financial" => 1, "agricultural" => 2, "extraction" => 3, "service_mixed" => 4, "institutional" => 5, "transit_hub" => 6, "research" => 7, "military" => 8, _ => 9, } } /// Column index in CompatibilityMatrix for an AttractorType. /// Order from D-195: RiverMouth(0), CoastalAccess(1), RiverCrossing(2), ValleyFloor(3), /// PassEntrance(4), LakeShore(5), PlainCenter(6). fn attractor_col(at: &AttractorType) -> usize { match at { AttractorType::RiverMouth => 0, AttractorType::CoastalAccess => 1, AttractorType::RiverCrossing => 2, AttractorType::ValleyFloor => 3, AttractorType::PassEntrance => 4, AttractorType::LakeShore => 5, AttractorType::PlainCenter => 6, } } /// Compute the raw match score between a city and an attractor (integer, D-010). /// /// `score = weight(0–100) × strength(0–100) × 100 / cost_pct`, where `cost_pct` /// is the terrain build cost as a percent of baseline (100 = 1.0×). Higher cost /// → lower score. All integer — no f32 in any placement decision (#955). fn cell_score( city: &CityRecord, attractor: &GeographicAttractor, matrix: &CompatibilityMatrix, terrain_cost_pct: i32, ) -> i64 { let row = role_row(&city.economic_role); let col = attractor_col(&attractor.attractor_type); let weight = matrix.weights[row][col] as i64; let strength = attractor.strength as i64; let cost = if terrain_cost_pct > 0 { terrain_cost_pct as i64 } else { 100 }; weight * strength * 100 / cost } // --------------------------------------------------------------------------- // Phase 3: Hungarian algorithm (minimization) // --------------------------------------------------------------------------- /// O(n³) Hungarian algorithm for assignment problem. /// /// Input: `cost[i][j]` — cost of assigning task j to worker i. /// Lower cost = better fit. Converts the maximization problem to minimization /// by using `max_score - score` as cost. /// /// Returns `assignment[i] = j` for each row i. fn hungarian(cost: &[Vec]) -> Vec { let n = cost.len(); if n == 0 { return Vec::new(); } let m = cost[0].len(); if m == 0 { return vec![usize::MAX; n]; } // Sentinel "infinity" — far above any real cost (scores ≤ ~10^4, padded // costs accumulate well under this) yet far below i64::MAX so the potential // updates can't overflow. let inf: i64 = 1 << 60; // Pad to square n×n if m < n (more cities than attractors handled by overflow). let sz = n.max(m); let mut c: Vec> = vec![vec![0i64; sz]; sz]; for i in 0..n { for j in 0..m { c[i][j] = cost[i][j]; } // Pad extra columns with high cost so overflow cities pick them last. for item in c[i].iter_mut().take(sz).skip(m) { *item = inf; } } // Pad extra rows with 0 cost (dummy workers). // Already initialized to 0. // Standard O(n³) Hungarian. let mut u = vec![0i64; sz + 1]; let mut v = vec![0i64; sz + 1]; let mut p = vec![0usize; sz + 1]; // p[j] = row assigned to column j (1-indexed) let mut way = vec![0usize; sz + 1]; for i in 1..=sz { p[0] = i; let mut j0 = 0usize; let mut minv = vec![inf; sz + 1]; let mut used = vec![false; sz + 1]; loop { used[j0] = true; let i0 = p[j0]; let mut delta = inf; let mut j1 = 0usize; for j in 1..=sz { if used[j] { continue; } let cur = c[i0 - 1][j - 1] - u[i0] - v[j]; if cur < minv[j] { minv[j] = cur; way[j] = j0; } if minv[j] < delta { delta = minv[j]; j1 = j; } } for j in 0..=sz { if used[j] { u[p[j]] += delta; v[j] -= delta; } else { minv[j] -= delta; } } j0 = j1; if p[j0] == 0 { break; } } loop { let j1 = way[j0]; p[j0] = p[j1]; j0 = j1; if j0 == 0 { break; } } } // Extract assignment: for each row i (1-indexed), find column j where p[j] == i. let mut result = vec![usize::MAX; n]; for j in 1..=sz { if p[j] > 0 && p[j] <= n { let col = j - 1; if col < m { result[p[j] - 1] = col; } } } result } // --------------------------------------------------------------------------- // Synthetic PlainCenter placement // --------------------------------------------------------------------------- /// Minimum pixel separation between synthetic attractor positions. const MIN_SPACING: u16 = 15; fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> GeographicAttractor { // Place at grid center as default, then walk until spacing is satisfied. let mut row = (grid_h / 2) as u16; let mut col = (grid_w / 4) as u16; // Simple search: try positions in a grid until spacing is met. 'outer: for dr in 0..(grid_h as u16 / MIN_SPACING) { for dc in 0..(grid_w as u16 / MIN_SPACING) { let r = (dr * MIN_SPACING).min(grid_h as u16 - 1); let c = (dc * MIN_SPACING).min(grid_w as u16 - 1); let ok = placed.iter().all(|p| { let dr2 = (p.position.0 as i32 - r as i32).unsigned_abs() as u16; let dc2 = (p.position.1 as i32 - c as i32).unsigned_abs() as u16; dr2.max(dc2) >= MIN_SPACING }); if ok { row = r; col = c; break 'outer; } } } GeographicAttractor { position: (row, col), attractor_type: AttractorType::PlainCenter, strength: 50, sub_biome: SubBiomeVariant::TemperateGrassland, terrain_modification_cost: 100, water_bearing: NO_WATER_BEARING, // inland synthetic — no water direction } } // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- /// Run the five-phase attractor-matching pipeline (D-211). /// /// `terrain_costs` maps attractor index → terrain_modification_cost (1.0 = baseline). /// If `None`, all costs default to 100 (1.0× baseline). /// Build the #956 spatial-character enrichment (D-213/214/215) for one placed /// city: its `PoliticalArchetype`, `ArrangementPattern`, and `FoundingOrientation`. /// The pioneer/open-terrain `Free` bearing is seed-derived per settlement so /// grids vary per seed (D-213) while staying deterministic (D-010). fn city_character( attractor_type: AttractorType, water_bearing: u16, economic_role: &str, territorial_status: &TerritorialStatus, city_id: u64, seed: SeedChain, ) -> (PoliticalArchetype, ArrangementPattern, FoundingOrientation) { let archetype = political_archetype(territorial_status, economic_role); let pattern = arrangement_pattern(&archetype, economic_role); let free_bearing = (splitmix64(seed.seed() ^ city_id) % 360) as u16; // Layer-1 water bearing (#957) drives both the coastal facing and the river // bearing (the anchoring attractor's direction-to-water); 360 = none → 0. let wb = if water_bearing >= 360 { 0 } else { water_bearing }; let orientation = founding_orientation(&attractor_type, territorial_status, wb, wb, free_bearing); (archetype, pattern, orientation) } pub fn match_cities( cities: &[CityRecord], attractors: &[GeographicAttractor], matrix: &CompatibilityMatrix, terrain_costs: Option<&[i32]>, grid_w: u32, grid_h: u32, territorial_status: &TerritorialStatus, seed: SeedChain, ) -> Vec { let default_cost = vec![100i32; attractors.len()]; let costs = terrain_costs.unwrap_or(&default_cost); let mut placements: Vec = Vec::with_capacity(cities.len()); let mut used_attractors: Vec = vec![false; attractors.len()]; // ------------------------------------------------------------------------- // Phase 1: Score matrix // ------------------------------------------------------------------------- let scores: Vec> = cities .iter() .map(|city| { attractors .iter() .zip(costs.iter()) .map(|(att, &cost)| cell_score(city, att, matrix, cost)) .collect() }) .collect(); // ------------------------------------------------------------------------- // Phase 2: Tier A greedy — NameLocked or pop ≥ 1_000_000 // ------------------------------------------------------------------------- let tier_a_indices: Vec = cities .iter() .enumerate() .filter(|(_, c)| { c.settlement_class == SettlementClass::NameLocked || c.population >= 1_000_000 }) .map(|(i, _)| i) .collect(); for &ci in &tier_a_indices { if attractors.is_empty() { break; } // Highest-scoring unused attractor. let best = scores[ci] .iter() .enumerate() .filter(|(ai, _)| !used_attractors[*ai]) .max_by(|(_, a), (_, b)| a.cmp(b)); if let Some((ai, &score)) = best { used_attractors[ai] = true; flag_mismatch(&cities[ci].name, score); let (archetype, pattern, orientation) = city_character( attractors[ai].attractor_type, attractors[ai].water_bearing, &cities[ci].economic_role, territorial_status, cities[ci].city_id, seed, ); placements.push(CityPlacement { city_id: cities[ci].city_id, name: cities[ci].name.clone(), position: attractors[ai].position, attractor_type: attractors[ai].attractor_type, score, synthetic: false, political_archetype: archetype, arrangement_pattern: pattern, founding_orientation: orientation, population: cities[ci].population, is_capital: cities[ci].is_capital, }); } } // ------------------------------------------------------------------------- // Phase 3: Hungarian — Tier B+C (50,000–999,999) // ------------------------------------------------------------------------- let tier_bc_indices: Vec = cities .iter() .enumerate() .filter(|(i, c)| { !tier_a_indices.contains(i) && c.population >= 50_000 && c.population < 1_000_000 }) .map(|(i, _)| i) .collect(); let free_attractors: Vec = (0..attractors.len()) .filter(|&ai| !used_attractors[ai]) .collect(); if !tier_bc_indices.is_empty() && !free_attractors.is_empty() { // Build cost sub-matrix (maximization → minimization via complement). let scores_ref = &scores; let max_score: i64 = tier_bc_indices .iter() .flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai])) .max() .unwrap_or(0); let cost: Vec> = tier_bc_indices .iter() .map(|&ci| { free_attractors .iter() .map(|&ai| max_score - scores_ref[ci][ai]) .collect() }) .collect(); let assignment = hungarian(&cost); for (local_i, &ci) in tier_bc_indices.iter().enumerate() { let local_j = assignment[local_i]; if local_j == usize::MAX || local_j >= free_attractors.len() { continue; // overflow — handled in phase 4 } let ai = free_attractors[local_j]; let score = scores[ci][ai]; used_attractors[ai] = true; flag_mismatch(&cities[ci].name, score); let (archetype, pattern, orientation) = city_character( attractors[ai].attractor_type, attractors[ai].water_bearing, &cities[ci].economic_role, territorial_status, cities[ci].city_id, seed, ); placements.push(CityPlacement { city_id: cities[ci].city_id, name: cities[ci].name.clone(), position: attractors[ai].position, attractor_type: attractors[ai].attractor_type, score, synthetic: false, political_archetype: archetype, arrangement_pattern: pattern, founding_orientation: orientation, population: cities[ci].population, is_capital: cities[ci].is_capital, }); } } // ------------------------------------------------------------------------- // Phase 4: Synthetic overflow — all remaining cities // ------------------------------------------------------------------------- let placed_ids: std::collections::BTreeSet = placements.iter().map(|p| p.city_id).collect(); for city in cities { if placed_ids.contains(&city.city_id) { continue; } let synthetic = synthetic_attractor(&placements, grid_w, grid_h); let score = cell_score(city, &synthetic, matrix, 100); flag_mismatch(&city.name, score); let (archetype, pattern, orientation) = city_character( AttractorType::PlainCenter, synthetic.water_bearing, &city.economic_role, territorial_status, city.city_id, seed, ); placements.push(CityPlacement { city_id: city.city_id, name: city.name.clone(), position: synthetic.position, attractor_type: AttractorType::PlainCenter, score, synthetic: true, political_archetype: archetype, arrangement_pattern: pattern, founding_orientation: orientation, population: city.population, is_capital: city.is_capital, }); } // ------------------------------------------------------------------------- // Phase 5: Name fulfillment check // ------------------------------------------------------------------------- let placed_ids: std::collections::BTreeSet = placements.iter().map(|p| p.city_id).collect(); for city in cities { if !placed_ids.contains(&city.city_id) { warn!( city = %city.name, city_id = city.city_id, "atlas city was not placed — missing from pipeline output" ); } } placements } fn flag_mismatch(city_name: &str, score: i64) { // Integer score space (#955): a baseline-cost perfect match peaks at ~10000, // neutral ~2500. < 500 is a severe mismatch; < 1500 below expected quality. if score < 500 { error!( city = %city_name, score, "attractor mismatch score < 500 — flagged for manual review" ); } else if score < 1500 { warn!( city = %city_name, score, "attractor mismatch score < 1500 — below expected quality" ); } } // --------------------------------------------------------------------------- // FoundingOrientation derivation from matched attractor (D-211, D-213) // --------------------------------------------------------------------------- use crate::simulation::generator::{ ArrangementPattern, FoundingOrientation, PoliticalArchetype, TerritorialStatus, }; /// Derive `FoundingOrientation` from the attractor type that anchored the city (D-211, D-213). /// /// `river_bearing` and `coastal_facing` are compass degrees 0–359 (terrain /// bearings; not yet extracted by Layer 1 — pass 0 until D-209 exposes them). /// `free_bearing` is a seed-derived 0–359 bearing used only for the `Free` /// (pioneer / open-terrain) case so pioneer grids vary per seed (D-213). pub fn founding_orientation( attractor_type: &AttractorType, territorial_status: &TerritorialStatus, river_bearing: u16, coastal_facing: u16, free_bearing: u16, ) -> FoundingOrientation { match attractor_type { AttractorType::RiverMouth | AttractorType::CoastalAccess => FoundingOrientation::Coastal { facing_degrees: coastal_facing, }, AttractorType::RiverCrossing => FoundingOrientation::RiverAligned { bearing_degrees: river_bearing, }, AttractorType::ValleyFloor => FoundingOrientation::TerrainFollowing, AttractorType::PlainCenter => { if matches!(territorial_status, TerritorialStatus::CommissionControlled) { FoundingOrientation::Cardinal } else { FoundingOrientation::Free { bearing_degrees: free_bearing, } } } AttractorType::PassEntrance | AttractorType::LakeShore => { FoundingOrientation::TerrainFollowing } } } /// Map a system's authored `dominant_faction` (8-value vocabulary, D-237) to a /// province `TerritorialStatus` (D-212, amended 2026-06-05). /// /// D-212's original derivation reads numeric per-faction influence thresholds, /// but only a single authored `dominant_faction` exists in the data — so this /// collapses the thresholds to a faction→status lookup, grounded in the faction /// canon (`wiki/factions/`): /// - `concord_assembly`, `veil_institute` → `CommissionControlled` (the Assembly /// is the Reach's central government; the Institute is Assembly-aligned) /// - `syndic_dominant` → `CorpTerritory` (Syndics are the commercial network) /// - `compact`, `compact_sympathetic` → `AutonomistHeld` (the Compact of /// Westphalia governs itself and rejects Assembly authority) /// - `disputed`, `mixed` → `ContestedZone` /// - `independent`, NULL, or any unknown value → `FrontierUnclaimed` /// /// `IndigenousHeld` and `Derelict` are not reachable from `dominant_faction` /// alone (they need the cultural-corridor autonomy flag / population density); /// those derivations are deferred. pub fn territorial_status_from_faction(dominant_faction: Option<&str>) -> TerritorialStatus { match dominant_faction { Some("concord_assembly") | Some("veil_institute") => { TerritorialStatus::CommissionControlled } Some("syndic_dominant") => TerritorialStatus::CorpTerritory, Some("compact") | Some("compact_sympathetic") => TerritorialStatus::AutonomistHeld, Some("disputed") | Some("mixed") => TerritorialStatus::ContestedZone, _ => TerritorialStatus::FrontierUnclaimed, } } /// Derive `PoliticalArchetype` from `TerritorialStatus` + `economic_role` (D-214). /// /// `TerritorialStatus` takes precedence over `economic_role` (D-214): a /// Commission-controlled manufacturing hub is `Commission`, not `Industrial`. /// Statuses that don't dictate an archetype (`ContestedZone`, `IndigenousHeld`, /// `Derelict`) fall through to the economic role. `AutonomistHeld` and /// `FrontierUnclaimed` → `Pioneer` (self-organized, no central planner). pub fn political_archetype( territorial_status: &TerritorialStatus, economic_role: &str, ) -> PoliticalArchetype { match territorial_status { TerritorialStatus::CommissionControlled => return PoliticalArchetype::Commission, TerritorialStatus::CorpTerritory => return PoliticalArchetype::Corporate, TerritorialStatus::FrontierUnclaimed | TerritorialStatus::AutonomistHeld => { return PoliticalArchetype::Pioneer } TerritorialStatus::ContestedZone | TerritorialStatus::IndigenousHeld | TerritorialStatus::Derelict => {} } match economic_role { "military" => PoliticalArchetype::Military, "research" => PoliticalArchetype::Academic, "manufacturing" | "extraction" => PoliticalArchetype::Industrial, _ => PoliticalArchetype::Pioneer, } } /// Derive the spatial `ArrangementPattern` from archetype + economic_role (D-215). /// /// `transit_hub` is a cross-archetype override → `HubAndSpoke`. Otherwise: /// Commission/Academic → `RadialCore`, Corporate → `CampusGrid`, /// Pioneer/Industrial → `RibbonDevelopment`, Military → `FortifiedPerimeter`. pub fn arrangement_pattern( archetype: &PoliticalArchetype, economic_role: &str, ) -> ArrangementPattern { if economic_role == "transit_hub" { return ArrangementPattern::HubAndSpoke; } match archetype { PoliticalArchetype::Commission | PoliticalArchetype::Academic => { ArrangementPattern::RadialCore } PoliticalArchetype::Corporate => ArrangementPattern::CampusGrid, PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => { ArrangementPattern::RibbonDevelopment } PoliticalArchetype::Military => ArrangementPattern::FortifiedPerimeter, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::seed::SeedChain; use crate::simulation::generator::{ ArrangementPattern, CompatibilityMatrix, FoundingOrientation, GeographicAttractor, PoliticalArchetype, TerritorialStatus, }; fn uniform_matrix() -> CompatibilityMatrix { CompatibilityMatrix { weights: [[50; 7]; 10], } } fn make_attractor(row: u16, col: u16, at: AttractorType, strength: i32) -> GeographicAttractor { GeographicAttractor { position: (row, col), attractor_type: at, strength, sub_biome: SubBiomeVariant::TemperateGrassland, terrain_modification_cost: 100, water_bearing: NO_WATER_BEARING, } } fn make_city(id: u64, class: SettlementClass, pop: i64) -> CityRecord { CityRecord { city_id: id, name: format!("City{id}"), settlement_class: class, population: pop, economic_role: "manufacturing".to_string(), is_capital: false, } } #[test] fn single_city_single_attractor() { let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)]; let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 80)]; let matrix = uniform_matrix(); let placements = match_cities( &cities, &attractors, &matrix, None, 512, 256, &TerritorialStatus::FrontierUnclaimed, SeedChain::root(42), ); assert_eq!(placements.len(), 1); assert_eq!(placements[0].city_id, 1); assert_eq!(placements[0].position, (10, 20)); assert!(!placements[0].synthetic); } /// T-960 §2: `name`/`population`/`is_capital` are carried straight from the /// matched `CityRecord` onto every `CityPlacement`, across all three /// placement phases (Tier A greedy, Hungarian, synthetic overflow) — the /// Atlas `SettlementLayer` reads these from the cache with no DB access. #[test] fn city_record_fields_propagate_to_placement_in_every_phase() { let mut capital = make_city(1, SettlementClass::NameLocked, 2_000_000); capital.is_capital = true; let tier_bc = make_city(2, SettlementClass::PopulationBudget, 80_000); let overflow = make_city(3, SettlementClass::PopulationBudget, 10_000); let cities = vec![capital, tier_bc, overflow]; // Two real attractors (enough for Tier A + Tier B); city 3 overflows to // a synthetic attractor (phase 4). let attractors = vec![ make_attractor(5, 5, AttractorType::RiverMouth, 90), make_attractor(10, 10, AttractorType::ValleyFloor, 60), ]; let matrix = uniform_matrix(); let placements = match_cities( &cities, &attractors, &matrix, None, 512, 256, &TerritorialStatus::FrontierUnclaimed, SeedChain::root(42), ); assert_eq!(placements.len(), 3); let p1 = placements.iter().find(|p| p.city_id == 1).unwrap(); assert_eq!(p1.name, "City1"); assert_eq!(p1.population, 2_000_000); assert!(p1.is_capital, "capital flag must survive Tier A placement"); let p2 = placements.iter().find(|p| p.city_id == 2).unwrap(); assert_eq!(p2.name, "City2"); assert_eq!(p2.population, 80_000); assert!(!p2.is_capital); let p3 = placements.iter().find(|p| p.city_id == 3).unwrap(); assert_eq!(p3.name, "City3"); assert_eq!(p3.population, 10_000); assert!(!p3.is_capital); assert!(p3.synthetic, "the third city must overflow to phase 4"); } #[test] fn tier_a_gets_priority() { // NameLocked city should get the best attractor (high strength). let cities = vec![ make_city(1, SettlementClass::NameLocked, 100_000), make_city(2, SettlementClass::PopulationBudget, 200_000), ]; let attractors = vec![ make_attractor(5, 5, AttractorType::RiverMouth, 90), // best make_attractor(10, 10, AttractorType::ValleyFloor, 40), // second ]; let matrix = uniform_matrix(); let placements = match_cities( &cities, &attractors, &matrix, None, 512, 256, &TerritorialStatus::FrontierUnclaimed, SeedChain::root(42), ); let p1 = placements.iter().find(|p| p.city_id == 1).unwrap(); assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor"); } #[test] fn overflow_produces_synthetic() { // 2 cities, 1 attractor → second city gets synthetic. let cities = vec![ make_city(1, SettlementClass::NameLocked, 2_000_000), make_city(2, SettlementClass::PopulationBudget, 60_000), ]; let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 100)]; let matrix = uniform_matrix(); let placements = match_cities( &cities, &attractors, &matrix, None, 512, 256, &TerritorialStatus::FrontierUnclaimed, SeedChain::root(42), ); assert_eq!(placements.len(), 2); let p2 = placements.iter().find(|p| p.city_id == 2).unwrap(); assert!(p2.synthetic); } /// D-242: "hubs are the significant cities, standalone HQs are typically /// minor nodes." `match_cities` has no concept of "corp-originated" /// settlement — a Standalone-HQ CityRecord competes on population/class /// exactly like an ordinary pooled city, so a modest-population HQ must /// NOT out-rank a genuinely major city for the best attractor, and a /// major HQ (large population, or NameLocked) DOES win Tier A on the same /// terms any other settlement would. Both directions verified here. #[test] fn standalone_hq_settlement_competes_on_equal_terms_with_pooled_cities() { let major_city = make_city(1, SettlementClass::PopulationBudget, 5_000_000); // A "minor node" Standalone HQ (D-242's own framing) — modest // population, ordinary PopulationBudget class, nothing marking it as // corp-originated (mirrors populate_standalone_hq_settlements' output // shape: an atlas_city_names row with no corp_id, same as any city). let minor_hq = make_city(2, SettlementClass::PopulationBudget, 80_000); let cities = vec![major_city, minor_hq]; let attractors = vec![ make_attractor(5, 5, AttractorType::RiverMouth, 95), // best make_attractor(10, 10, AttractorType::ValleyFloor, 30), // worst ]; let matrix = uniform_matrix(); let placements = match_cities( &cities, &attractors, &matrix, None, 512, 256, &TerritorialStatus::FrontierUnclaimed, SeedChain::root(42), ); let major = placements.iter().find(|p| p.city_id == 1).unwrap(); let minor = placements.iter().find(|p| p.city_id == 2).unwrap(); assert_eq!( major.position, (5, 5), "the major city (5M pop) must win the best attractor over an 80k-pop HQ" ); assert_eq!( minor.position, (10, 10), "the minor HQ gets whatever's left — no special corp-origin priority" ); // Flip it: a NameLocked HQ (the T-1075 hero-pin path — e.g. "The Gate // Corporation" in the real data) DOES win Tier A, on the same // NameLocked-priority terms as any hero-pinned city (tier_a_gets_priority, // above) — population size never mattered for NameLocked, corp-origin // doesn't add or subtract anything either. let hero_hq = make_city(3, SettlementClass::NameLocked, 200_000); let ordinary_city = make_city(4, SettlementClass::PopulationBudget, 3_000_000); let cities2 = vec![hero_hq, ordinary_city]; let placements2 = match_cities( &cities2, &attractors, &matrix, None, 512, 256, &TerritorialStatus::FrontierUnclaimed, SeedChain::root(42), ); let hero = placements2.iter().find(|p| p.city_id == 3).unwrap(); assert_eq!( hero.position, (5, 5), "NameLocked HQ wins Tier A over a larger ordinary city — same rule as any NameLocked settlement" ); } #[test] fn all_cities_placed() { let cities: Vec = (1..=5) .map(|i| make_city(i, SettlementClass::PopulationBudget, 100_000)) .collect(); let attractors = vec![ make_attractor(10, 10, AttractorType::RiverMouth, 90), make_attractor(20, 20, AttractorType::CoastalAccess, 70), ]; let matrix = uniform_matrix(); let placements = match_cities( &cities, &attractors, &matrix, None, 512, 256, &TerritorialStatus::FrontierUnclaimed, SeedChain::root(42), ); assert_eq!(placements.len(), 5, "all cities must be placed"); } #[test] fn hungarian_assigns_optimally() { // 2 cities, 2 attractors. City A scores best on attractor 0, city B best on attractor 1. let mut matrix = uniform_matrix(); // agricultural (row 2) scores high on ValleyFloor (col 3) = 3.0 matrix.weights[2][3] = 100; // transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0 matrix.weights[6][2] = 100; let cities = vec![ CityRecord { city_id: 1, name: "Farm".to_string(), settlement_class: SettlementClass::PopulationBudget, population: 60_000, economic_role: "agricultural".to_string(), is_capital: false, }, CityRecord { city_id: 2, name: "Hub".to_string(), settlement_class: SettlementClass::PopulationBudget, population: 80_000, economic_role: "transit_hub".to_string(), is_capital: false, }, ]; let attractors = vec![ make_attractor(5, 5, AttractorType::ValleyFloor, 100), make_attractor(10, 10, AttractorType::RiverCrossing, 100), ]; let placements = match_cities( &cities, &attractors, &matrix, None, 512, 256, &TerritorialStatus::FrontierUnclaimed, SeedChain::root(42), ); assert_eq!(placements.len(), 2); let farm = placements.iter().find(|p| p.city_id == 1).unwrap(); let hub = placements.iter().find(|p| p.city_id == 2).unwrap(); // Farm should be on ValleyFloor (5,5), Hub on RiverCrossing (10,10). assert_eq!(farm.position, (5, 5)); assert_eq!(hub.position, (10, 10)); } #[test] fn founding_orientation_from_attractor() { let status = TerritorialStatus::FrontierUnclaimed; let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270, 0); assert!(matches!( o, FoundingOrientation::Coastal { facing_degrees: 270 } )); let o2 = founding_orientation( &AttractorType::PlainCenter, &TerritorialStatus::CommissionControlled, 0, 0, 0, ); assert!(matches!(o2, FoundingOrientation::Cardinal)); let o3 = founding_orientation(&AttractorType::ValleyFloor, &status, 0, 0, 0); assert!(matches!(o3, FoundingOrientation::TerrainFollowing)); // Free (pioneer/open terrain) uses the seed-derived free_bearing. let o4 = founding_orientation( &AttractorType::PlainCenter, &TerritorialStatus::FrontierUnclaimed, 0, 0, 217, ); assert!(matches!( o4, FoundingOrientation::Free { bearing_degrees: 217 } )); } #[test] fn territorial_status_faction_mapping() { assert_eq!( territorial_status_from_faction(Some("concord_assembly")), TerritorialStatus::CommissionControlled ); assert_eq!( territorial_status_from_faction(Some("veil_institute")), TerritorialStatus::CommissionControlled ); assert_eq!( territorial_status_from_faction(Some("syndic_dominant")), TerritorialStatus::CorpTerritory ); assert_eq!( territorial_status_from_faction(Some("compact")), TerritorialStatus::AutonomistHeld ); assert_eq!( territorial_status_from_faction(Some("compact_sympathetic")), TerritorialStatus::AutonomistHeld ); assert_eq!( territorial_status_from_faction(Some("disputed")), TerritorialStatus::ContestedZone ); assert_eq!( territorial_status_from_faction(Some("mixed")), TerritorialStatus::ContestedZone ); assert_eq!( territorial_status_from_faction(None), TerritorialStatus::FrontierUnclaimed ); assert_eq!( territorial_status_from_faction(Some("independent")), TerritorialStatus::FrontierUnclaimed ); } #[test] fn political_archetype_status_precedence() { // Status wins over economic_role (D-214): Commission manufacturing → Commission. assert_eq!( political_archetype(&TerritorialStatus::CommissionControlled, "manufacturing"), PoliticalArchetype::Commission ); assert_eq!( political_archetype(&TerritorialStatus::CorpTerritory, "research"), PoliticalArchetype::Corporate ); assert_eq!( political_archetype(&TerritorialStatus::AutonomistHeld, "financial"), PoliticalArchetype::Pioneer ); // Non-dictating status → economic_role drives. assert_eq!( political_archetype(&TerritorialStatus::ContestedZone, "research"), PoliticalArchetype::Academic ); assert_eq!( political_archetype(&TerritorialStatus::ContestedZone, "extraction"), PoliticalArchetype::Industrial ); } #[test] fn arrangement_pattern_mapping() { assert_eq!( arrangement_pattern(&PoliticalArchetype::Commission, "financial"), ArrangementPattern::RadialCore ); assert_eq!( arrangement_pattern(&PoliticalArchetype::Corporate, "financial"), ArrangementPattern::CampusGrid ); assert_eq!( arrangement_pattern(&PoliticalArchetype::Industrial, "manufacturing"), ArrangementPattern::RibbonDevelopment ); assert_eq!( arrangement_pattern(&PoliticalArchetype::Military, "military"), ArrangementPattern::FortifiedPerimeter ); // transit_hub role overrides archetype. assert_eq!( arrangement_pattern(&PoliticalArchetype::Commission, "transit_hub"), ArrangementPattern::HubAndSpoke ); } }