//! Three-component district mix algorithm for city district type distribution (D-194). //! //! Given a city's population, economic role, and political archetype, produces //! a district type distribution (count of each DistrictType) used by the //! Phase 1 district skeleton generator. //! //! **Components (D-194):** //! 1. Population tier guarantees — minimum district counts by city size. //! 2. 10×9 economic multiplier table — economic role × DistrictType weights. //! 3. Political archetype modifiers — shift weights for specific district types. //! //! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the //! district count computation. Seed-driven noise uses seeded RNG. use crate::seed::AtlasRng; use crate::simulation::generator::{DistrictType, PoliticalArchetype}; // --------------------------------------------------------------------------- // Population tier // --------------------------------------------------------------------------- /// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5]. pub fn population_tier(population: i64) -> u8 { if population <= 0 { return 0; } let ratio = population as f64 / 1_000_000.0; if ratio <= 0.0 { return 0; } let tier = ratio.log10().floor() as i32; tier.clamp(0, 5) as u8 } /// Minimum district counts guaranteed by population tier (D-194). /// /// Returns `(transit_min, commercial_min, residential_min)`. pub fn tier_guarantees(tier: u8) -> (u32, u32, u32) { match tier { 0 => (0, 0, 1), 1 => (0, 1, 1), 2 => (1, 1, 2), 3 => (1, 2, 3), 4 => (2, 3, 4), 5 => (3, 4, 6), _ => (3, 4, 6), } } // --------------------------------------------------------------------------- // Economic multiplier table (10×9, integer weights × 10 for precision) // --------------------------------------------------------------------------- /// District type column order (0–8). /// Matches DistrictType enum variants: LogisticsHub, Residential, Commercial, /// Industrial, Administrative, Entertainment, MixedUse, Transit, Specialized. const DIST_COLS: [DistrictType; 9] = [ DistrictType::LogisticsHub, DistrictType::Residential, DistrictType::Commercial, DistrictType::Industrial, DistrictType::Administrative, DistrictType::Entertainment, DistrictType::MixedUse, DistrictType::Transit, DistrictType::Specialized, ]; /// Map a DistrictType to its column index. fn dist_col(dt: &DistrictType) -> usize { match dt { DistrictType::LogisticsHub => 0, DistrictType::Residential => 1, DistrictType::Commercial => 2, DistrictType::Industrial => 3, DistrictType::Administrative => 4, DistrictType::Entertainment => 5, DistrictType::MixedUse => 6, DistrictType::Transit => 7, DistrictType::Specialized => 8, } } /// Map an economic role to its row index (0–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, } } /// 10×9 economic multiplier table. Values are integer weights × 10. /// Rows: manufacturing(0), financial(1), agricultural(2), extraction(3), /// service_mixed(4), institutional(5), transit_hub(6), research(7), /// military(8), residential(9). /// Columns: LogisticsHub(0), Residential(1), Commercial(2), Industrial(3), /// Administrative(4), Entertainment(5), MixedUse(6), Transit(7), /// Specialized(8). #[rustfmt::skip] const ECON_TABLE: [[u32; 9]; 10] = [ // LH Re Co In Ad En Mu Tr Sp [25, 10, 15, 30, 10, 5, 10, 20, 10], // manufacturing [10, 15, 30, 10, 20, 15, 20, 15, 10], // financial [20, 20, 10, 15, 10, 5, 20, 10, 5], // agricultural [30, 10, 10, 30, 10, 5, 5, 15, 10], // extraction [15, 20, 25, 10, 10, 20, 25, 20, 10], // service_mixed [10, 15, 10, 10, 30, 10, 10, 10, 20], // institutional [25, 10, 15, 10, 10, 10, 10, 30, 10], // transit_hub [10, 15, 10, 15, 20, 10, 10, 10, 30], // research [10, 20, 5, 15, 20, 5, 5, 10, 15], // military [10, 30, 15, 5, 10, 15, 25, 10, 5], // residential ]; // --------------------------------------------------------------------------- // Political archetype modifiers // --------------------------------------------------------------------------- /// Additive integer modifiers to column weights based on `PoliticalArchetype`. /// Returns `[mod; 9]` for columns in `DIST_COLS` order. fn archetype_modifiers(archetype: &PoliticalArchetype) -> [i32; 9] { match archetype { PoliticalArchetype::Commission => { // Boosts Administrative + Institutional-style Specialized. [0, 0, 0, 0, 10, 0, 0, 0, 5] } PoliticalArchetype::Corporate => { // Boosts Commercial + Specialized (restricted campus zones). [0, -5, 15, 0, 0, 5, 0, 0, 10] } PoliticalArchetype::Pioneer => { // Boosts MixedUse + organic Residential. [0, 10, 5, 0, -5, 5, 15, 0, 0] } PoliticalArchetype::Military => { // Boosts Administrative + reduces Entertainment. [0, 5, -5, 5, 15, -10, 0, 0, 10] } PoliticalArchetype::Academic => { // Boosts Specialized (research labs) + Administrative. [0, 5, 0, 0, 10, 5, 5, 0, 20] } PoliticalArchetype::Industrial => { // Boosts Industrial + LogisticsHub. [10, -5, 5, 20, 0, -5, 0, 5, 5] } } } // --------------------------------------------------------------------------- // District mix computation // --------------------------------------------------------------------------- /// The district type distribution for a generated city. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DistrictMix { /// Ordered list of district types for the city, with repetition (district_count items total). pub districts: Vec, /// Total district count. pub total: u32, } /// Compute the district mix for one city (D-194). /// /// `total_districts` is the number of districts to allocate. A good default is /// `max(4, population_tier * 2)`. /// /// `seed` is the city-level RNG seed (D-010 determinism). pub fn compute_district_mix( population: i64, economic_role: &str, archetype: &PoliticalArchetype, total_districts: u32, seed: u64, ) -> DistrictMix { let tier = population_tier(population); let (transit_min, commercial_min, residential_min) = tier_guarantees(tier); let row = role_row(economic_role); let arch_mods = archetype_modifiers(archetype); // Build effective weights (integer, clamped to ≥ 1). let mut weights: [u32; 9] = [0; 9]; for col in 0..9 { let base = ECON_TABLE[row][col] as i32; let modified = base + arch_mods[col]; weights[col] = modified.max(1) as u32; } // Allocate districts proportionally from weights using a seeded LCG. // We avoid f32 by using integer weighted random selection. let weight_sum: u32 = weights.iter().sum(); let mut counts: [u32; 9] = [0; 9]; let mut lcg = AtlasRng::new(seed.wrapping_add(1)); for _ in 0..total_districts { let mut pick = lcg.next_u32() % weight_sum; for col in 0..9 { if pick < weights[col] { counts[col] += 1; break; } pick -= weights[col]; } } // Apply tier guarantees (add if under minimum). let transit_col = dist_col(&DistrictType::Transit); let commercial_col = dist_col(&DistrictType::Commercial); let residential_col = dist_col(&DistrictType::Residential); if counts[transit_col] < transit_min { counts[transit_col] = transit_min; } if counts[commercial_col] < commercial_min { counts[commercial_col] = commercial_min; } if counts[residential_col] < residential_min { counts[residential_col] = residential_min; } // Build the flat ordered list. let mut districts: Vec = Vec::new(); for (col, &count) in counts.iter().enumerate() { for _ in 0..count { districts.push(DIST_COLS[col].clone()); } } let total = districts.len() as u32; DistrictMix { districts, total } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn population_tier_values() { assert_eq!(population_tier(0), 0); assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0 assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0 assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1 assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2 assert_eq!(population_tier(1_000_000_000_000), 5); // capped at 5 } #[test] fn mix_sums_at_least_to_requested() { let mix = compute_district_mix( 5_000_000, "manufacturing", &PoliticalArchetype::Industrial, 8, 42, ); // total may exceed requested due to guarantees assert!(mix.total >= 8, "district count should be >= requested"); } #[test] fn tier_guarantees_applied() { // Tier 2 city: pop/1M = 100–999, log10(100) = 2. // 100M population → pop_tier = floor(log10(100)) = 2 → (1 Transit, 1 Commercial, 2 Residential). let mix = compute_district_mix( 100_000_000, "service_mixed", &PoliticalArchetype::Pioneer, 6, 7, ); let transit = mix .districts .iter() .filter(|d| matches!(d, DistrictType::Transit)) .count(); let commercial = mix .districts .iter() .filter(|d| matches!(d, DistrictType::Commercial)) .count(); let residential = mix .districts .iter() .filter(|d| matches!(d, DistrictType::Residential)) .count(); assert!(transit >= 1, "transit guarantee not met: {transit}"); assert!( commercial >= 1, "commercial guarantee not met: {commercial}" ); assert!( residential >= 2, "residential guarantee not met: {residential}" ); } #[test] fn determinism_same_seed() { let mix1 = compute_district_mix( 5_000_000, "financial", &PoliticalArchetype::Commission, 6, 99, ); let mix2 = compute_district_mix( 5_000_000, "financial", &PoliticalArchetype::Commission, 6, 99, ); assert_eq!(mix1, mix2, "same inputs must produce identical output"); } #[test] fn different_archetypes_produce_different_mixes() { let mix_corp = compute_district_mix( 5_000_000, "financial", &PoliticalArchetype::Corporate, 8, 42, ); let mix_pioneer = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42); // Should differ in at least one district type count. assert_ne!( mix_corp.districts, mix_pioneer.districts, "Corporate and Pioneer archetypes should produce different district mixes" ); } #[test] fn military_archetype_has_administrative() { let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10); let admin = mix .districts .iter() .filter(|d| matches!(d, DistrictType::Administrative)) .count(); assert!( admin >= 1, "military archetype should have Administrative districts" ); } #[test] fn all_district_types_can_appear() { // With enough districts and a balanced role, every type should appear at least once. let mix = compute_district_mix( 50_000_000, "service_mixed", &PoliticalArchetype::Pioneer, 50, 0, ); for dt in &DIST_COLS { let present = mix .districts .iter() .any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt)); assert!( present, "DistrictType {:?} never appeared in 50-district mix", dt ); } } }