feat(simulation): add server/src/atlas/ — full Phase 1 generation pipeline
Ten-module atlas package implementing the D-194–D-218 district generation stack: heightmap loader, BodyWorldState LRU cache, D8 drainage routing, background generation queue, five-phase attractor-matching, three-component district mix, block irregularity, tile condition thresholds, and the Phase 1 skeleton generator that wires them into DistrictSkeleton. Closes #916 #917 #918 #919 #920 #922 #923 #924 #899. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
//! 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 < 0.35 → WARNING
|
||||
//! - score < 0.15 → ERROR (flagged for manual review; generation continues)
|
||||
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::simulation::generator::{
|
||||
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CityPlacement {
|
||||
pub city_id: u64,
|
||||
pub position: (u16, u16),
|
||||
pub attractor_type: AttractorType,
|
||||
pub score: f32,
|
||||
pub synthetic: 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,
|
||||
"residential" | _ => 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.
|
||||
/// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost).
|
||||
fn cell_score(
|
||||
city: &CityRecord,
|
||||
attractor: &GeographicAttractor,
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_cost: f32,
|
||||
) -> f32 {
|
||||
let row = role_row(&city.economic_role);
|
||||
let col = attractor_col(&attractor.attractor_type);
|
||||
let weight = matrix.weights[row][col];
|
||||
let cost_factor = if terrain_cost > 0.0 { 1.0 / terrain_cost } else { 1.0 };
|
||||
weight * attractor.strength * cost_factor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<f32>]) -> 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];
|
||||
}
|
||||
|
||||
// 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<f32>> = vec![vec![0.0; 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 j in m..sz {
|
||||
c[i][j] = f32::MAX / 2.0;
|
||||
}
|
||||
}
|
||||
// Pad extra rows with 0 cost (dummy workers).
|
||||
// Already initialized to 0.
|
||||
|
||||
// Standard O(n³) Hungarian.
|
||||
let inf = f32::MAX / 2.0;
|
||||
let mut u = vec![0.0f32; sz + 1];
|
||||
let mut v = vec![0.0f32; 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).abs() as u16;
|
||||
let dc2 = (p.position.1 as i32 - c as i32).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: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 1.0.
|
||||
pub fn match_cities(
|
||||
cities: &[CityRecord],
|
||||
attractors: &[GeographicAttractor],
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_costs: Option<&[f32]>,
|
||||
grid_w: u32,
|
||||
grid_h: u32,
|
||||
) -> Vec<CityPlacement> {
|
||||
let default_cost = vec![1.0f32; 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<f32>> = 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.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
if let Some((ai, &score)) = best {
|
||||
used_attractors[ai] = true;
|
||||
flag_mismatch(&cities[ci].name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: cities[ci].city_id,
|
||||
position: attractors[ai].position,
|
||||
attractor_type: attractors[ai].attractor_type.clone(),
|
||||
score,
|
||||
synthetic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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: f32 = tier_bc_indices
|
||||
.iter()
|
||||
.flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai]))
|
||||
.fold(0.0f32, f32::max);
|
||||
|
||||
let cost: Vec<Vec<f32>> = 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);
|
||||
placements.push(CityPlacement {
|
||||
city_id: cities[ci].city_id,
|
||||
position: attractors[ai].position,
|
||||
attractor_type: attractors[ai].attractor_type.clone(),
|
||||
score,
|
||||
synthetic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 4: Synthetic overflow — all remaining cities
|
||||
// -------------------------------------------------------------------------
|
||||
let placed_ids: std::collections::HashSet<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, 1.0);
|
||||
flag_mismatch(&city.name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: city.city_id,
|
||||
position: synthetic.position,
|
||||
attractor_type: AttractorType::PlainCenter,
|
||||
score,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 5: Name fulfillment check
|
||||
// -------------------------------------------------------------------------
|
||||
let placed_ids: std::collections::HashSet<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: f32) {
|
||||
if score < 0.15 {
|
||||
error!(
|
||||
city = %city_name,
|
||||
score,
|
||||
"attractor mismatch score < 0.15 — flagged for manual review"
|
||||
);
|
||||
} else if score < 0.35 {
|
||||
warn!(
|
||||
city = %city_name,
|
||||
score,
|
||||
"attractor mismatch score < 0.35 — below expected quality"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FoundingOrientation derivation from matched attractor (D-211, D-213)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::simulation::generator::FoundingOrientation;
|
||||
use crate::simulation::generator::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.
|
||||
/// Pass 0 as default when the terrain doesn't dictate a specific bearing.
|
||||
pub fn founding_orientation(
|
||||
attractor_type: &AttractorType,
|
||||
territorial_status: &TerritorialStatus,
|
||||
river_bearing: u16,
|
||||
coastal_facing: 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: 0 }
|
||||
}
|
||||
}
|
||||
AttractorType::PassEntrance | AttractorType::LakeShore => {
|
||||
FoundingOrientation::TerrainFollowing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor};
|
||||
|
||||
fn uniform_matrix() -> CompatibilityMatrix {
|
||||
CompatibilityMatrix { weights: [[1.0; 7]; 10] }
|
||||
}
|
||||
|
||||
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor {
|
||||
GeographicAttractor { position: (row, col), attractor_type: at, strength }
|
||||
}
|
||||
|
||||
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, 0.8)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
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, 0.9), // best
|
||||
make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
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, 1.0)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
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, 0.9),
|
||||
make_attractor(20, 20, AttractorType::CoastalAccess, 0.7),
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
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] = 3.0;
|
||||
// transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0
|
||||
matrix.weights[6][2] = 3.0;
|
||||
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, 1.0),
|
||||
make_attractor(10, 10, AttractorType::RiverCrossing, 1.0),
|
||||
];
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
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() {
|
||||
use crate::simulation::generator::TerritorialStatus;
|
||||
let status = TerritorialStatus::FrontierUnclaimed;
|
||||
let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270);
|
||||
assert!(matches!(o, FoundingOrientation::Coastal { facing_degrees: 270 }));
|
||||
|
||||
let o2 = founding_orientation(
|
||||
&AttractorType::PlainCenter,
|
||||
&TerritorialStatus::CommissionControlled,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(o2, FoundingOrientation::Cardinal));
|
||||
|
||||
let o3 = founding_orientation(
|
||||
&AttractorType::ValleyFloor,
|
||||
&status,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(o3, FoundingOrientation::TerrainFollowing));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user