Files
settled-reach/server/src/atlas/attractor_matching.rs
T
jpmschweitzerandClaude Opus 4.8 b73b4e6d92 feat(simulation): Layer-1 water bearing + D-234 waterfront rule (#957)
Close the last D-234 piece — terrain water-direction extraction wired through
to founding orientation and the quarter waterfront rule:

- Layer 1: TerrainAnalysis::water_bearing — 8-octant integer bearing toward the
  nearest water from the water_dist gradient (D-010, no atan2). Stored on
  GeographicAttractor.water_bearing (360 = none).
- #956 founding orientation: coastal/river settlements now get a real
  water-facing bearing (the anchoring attractor's), replacing the 0 stub.
- #957 waterfront rule (D-234b): the water-facing quarter edge (from the
  settlement's Coastal founding orientation) drops its block setback to 0 so
  buildings present flush to the quay (dock-orthogonal). Typed Edge + coastal_edge
  + per-block gating.

Golden + atlas_response fixture rebaked (additive water_bearing field only).
8 new tests. All integer-deterministic (D-010).

Pending: the waterfront rule reads context.founding_orientation, which
city_context_reader still stubs to Cardinal — real per-settlement orientation
reaches quarter generation once the Layer-3 placement -> Layer-4 GenerateSkeleton
dispatch is wired (the remaining cross-layer integration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:22:14 +02:00

958 lines
34 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,
}
// ---------------------------------------------------------------------------
// 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,
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,
}
// ---------------------------------------------------------------------------
// 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,
position: attractors[ai].position,
attractor_type: attractors[ai].attractor_type,
score,
synthetic: false,
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
});
}
}
// -------------------------------------------------------------------------
// 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,
position: attractors[ai].position,
attractor_type: attractors[ai].attractor_type,
score,
synthetic: false,
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
});
}
}
// -------------------------------------------------------------------------
// 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,
position: synthetic.position,
attractor_type: AttractorType::PlainCenter,
score,
synthetic: true,
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
});
}
// -------------------------------------------------------------------------
// 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(),
}
}
#[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);
}
#[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);
}
#[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(),
},
CityRecord {
city_id: 2,
name: "Hub".to_string(),
settlement_class: SettlementClass::PopulationBudget,
population: 80_000,
economic_role: "transit_hub".to_string(),
},
];
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
);
}
}