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>
619 lines
22 KiB
Rust
619 lines
22 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 < 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));
|
||
}
|
||
}
|