1102 lines
41 KiB
Rust
1102 lines
41 KiB
Rust
//! Attractor-matching five-phase pipeline for settlement placement (D-211).
|
||
//!
|
||
//! Given a body's `Vec<GeographicAttractor>` 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<i64>]) -> Vec<usize> {
|
||
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<i64>> = 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<CityPlacement> {
|
||
let default_cost = vec![100i32; attractors.len()];
|
||
let costs = terrain_costs.unwrap_or(&default_cost);
|
||
|
||
let mut placements: Vec<CityPlacement> = Vec::with_capacity(cities.len());
|
||
let mut used_attractors: Vec<bool> = vec![false; attractors.len()];
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Phase 1: Score matrix
|
||
// -------------------------------------------------------------------------
|
||
let scores: Vec<Vec<i64>> = 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<usize> = 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<usize> = 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<usize> = (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<Vec<i64>> = 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<u64> =
|
||
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<u64> =
|
||
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<CityRecord> = (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
|
||
);
|
||
}
|
||
}
|