Files
settled-reach/server/src/atlas/attractor_matching.rs
T

1102 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
);
}
}