Files
settled-reach/server/src/atlas/district_mix.rs
T
jpmschweitzerandClaude Opus 4.7 de9142ce37 feat(simulation): SeedChain seed-derivation primitive in server/src/seed.rs (#952, D-224)
New top-level seed module that owns every deterministic-RNG primitive:
- splitmix64 — the one canonical mixer (was duplicated as a private fn in
  simulation/rng.rs; EntityRng now imports the shared one, no behavior change)
- AtlasRng — moved here from atlas/rng.rs (it is a generation-RNG primitive,
  not atlas-specific); atlas now depends on seed, not the reverse
- SeedDomain — append-only domain tags (Body/Layer1Topography/Layer3Settlement/
  Layer4Quarter/Block/Npc) for collision-proof per-domain seed separation
- SeedChain — root(world_seed) → derive(domain, id) → atlas_rng()/seed(), per
  the D-224 formula splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id)

Structural move only — SeedChain is not yet threaded through the cascade
callers (skeleton_gen still uses ad-hoc wrapping_add pre-mixing); that is the
next step. Unit tests cover the splitmix64 known-vector, avalanche,
determinism, domain/id separation, and chain composition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:51:09 +02:00

382 lines
13 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.
//! 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 (08).
/// 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 (09).
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<DistrictType>,
/// 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<DistrictType> = 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 = 100999, 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
);
}
}
}