//! Sub-biome variant classification and terrain_modification_cost (D-210). //! //! Each `GeographicAttractor` (D-209) carries a `SubBiomeVariant` and a //! `terrain_modification_cost`. Classification uses four heightmap-derivable //! signals (D-210): //! - elevation percentile (of body total) — from `TerrainAnalysis::elev_pct` //! - local slope — `TerrainAnalysis::slope_deg` //! - moisture proxy — distance to nearest river mouth / coast (`water_dist`) //! - temperature proxy — latitude of the equirectangular pixel (`row`) //! //! `Volcanic` is never emitted here: the Layer-1 inputs carry no volcanic //! signal (D-210). It remains in the enum for a future volcanic data source. //! //! **Determinism:** pure function of integer/float inputs with fixed //! thresholds; no RNG, no map iteration. `terrain_modification_cost` is f32 //! but is never used as a sort key. use crate::atlas::features::TerrainAnalysis; use crate::simulation::generator::SubBiomeVariant; /// Temperature proxy [0,1] from latitude: 1.0 at the equator (`row == h/2`), /// 0.0 at the poles (`row == 0` or `row == h-1`). #[inline] fn temperature(row: usize, h: usize) -> f32 { if h <= 1 { return 1.0; } let lat = row as f32 / (h - 1) as f32; // 0 = north pole, 1 = south pole 1.0 - (lat - 0.5).abs() * 2.0 } /// Base infrastructure-build cost per sub-biome (D-210 anchors: grassland 1.0, /// coastal lowland 1.4, wetland 3.2, alpine 3.8, volcanic 4.5; the rest /// interpolated by buildability). /// Base build cost as a percent of baseline (100 = 1.0× grassland). Integer for /// D-010 determinism (#955). fn base_cost(v: SubBiomeVariant) -> i32 { match v { SubBiomeVariant::TemperateGrassland => 100, SubBiomeVariant::Savanna => 110, SubBiomeVariant::Desert => 120, SubBiomeVariant::TemperateForest => 130, SubBiomeVariant::CoastalLowland => 140, SubBiomeVariant::BorealForest => 150, SubBiomeVariant::Tundra => 160, SubBiomeVariant::TropicalWet => 200, SubBiomeVariant::Wetland => 320, SubBiomeVariant::Alpine => 380, SubBiomeVariant::Volcanic => 450, } } /// Classify the sub-biome and compute `terrain_modification_cost` for the cell /// at `(row, col)`. Returns `(variant, cost)`. pub fn classify(ta: &TerrainAnalysis, row: usize, col: usize) -> (SubBiomeVariant, i32) { let i = row * ta.w + col; let elev_pct = ta.elev_pct[i]; let slope = ta.slope_deg[i]; let water_dist = ta.water_dist[i]; let temp = temperature(row, ta.h); let variant = classify_variant(elev_pct, slope, water_dist, temp); // Cost = sub-biome base + a slope surcharge (steeper terrain costs more to // build on), capped so a steep grassland never out-costs flat volcanic. // Surcharge in percent points (0–150): the f32 slope is quantized here, the // single f32→integer boundary; the cost itself is integer (D-010, #955). let slope_surcharge = ((slope / 12.0).min(1.5) * 100.0).round() as i32; let cost = base_cost(variant) + slope_surcharge; (variant, cost) } fn classify_variant(elev_pct: f32, _slope: f32, water_dist: u16, temp: f32) -> SubBiomeVariant { // High elevation dominates → Alpine (mountains, regardless of latitude). if elev_pct > 0.80 { return SubBiomeVariant::Alpine; } // Saturated low ground next to water → Wetland. if water_dist <= 2 && elev_pct < 0.30 { return SubBiomeVariant::Wetland; } // Low ground near a coast → Coastal lowland. if water_dist <= 5 && elev_pct < 0.40 { return SubBiomeVariant::CoastalLowland; } // Cold poleward zones. if temp < 0.20 { return SubBiomeVariant::Tundra; } if temp < 0.40 { return SubBiomeVariant::BorealForest; } // Hot equatorial zones split by moisture. if temp > 0.75 { return if water_dist < 20 { SubBiomeVariant::TropicalWet } else if water_dist < 45 { SubBiomeVariant::Savanna } else { SubBiomeVariant::Desert }; } // Temperate mid-latitudes split by moisture. if water_dist > 60 { SubBiomeVariant::Desert } else if water_dist < 25 { SubBiomeVariant::TemperateForest } else { SubBiomeVariant::TemperateGrassland } } #[cfg(test)] mod tests { use super::*; use crate::atlas::drainage; use crate::atlas::heightmap::BodyHeightmap; fn slope_grid(w: u32, h: u32) -> Vec { let n = (w * h) as usize; (0..n) .map(|i| { let r = i / w as usize; let c = i % w as usize; 1.0 - (r as f32 / h as f32 * 0.5 + c as f32 / w as f32 * 0.5) }) .collect() } #[test] fn temperature_peaks_at_equator() { assert!((temperature(0, 256) - 0.0).abs() < 0.01); assert!((temperature(255, 256) - 0.0).abs() < 0.01); assert!(temperature(128, 256) > 0.98); } #[test] fn alpine_for_high_elevation() { // elev_pct > 0.8 → Alpine regardless of other signals. let (v, cost) = ( classify_variant(0.95, 30.0, 100, 0.5), base_cost(SubBiomeVariant::Alpine), ); assert_eq!(v, SubBiomeVariant::Alpine); assert!(cost > 300, "alpine cost well above the 100 baseline"); } #[test] fn classify_is_deterministic_and_bounded() { let h = BodyHeightmap { body_id: "T".into(), width: 64, height: 32, data: slope_grid(64, 32), sea_level: 0.3, }; let dr = drainage::analyze(&h.data, 64, 32, 0.3); let ta = TerrainAnalysis::analyze(&h, &dr); let (v1, c1) = classify(&ta, 10, 20); let (v2, c2) = classify(&ta, 10, 20); assert_eq!(v1, v2); assert_eq!(c1, c2); assert!(c1 >= 100, "cost is at least the grassland baseline"); } #[test] fn each_variant_reachable_and_volcanic_never_emitted() { use SubBiomeVariant::*; // (elev_pct, slope, water_dist, temp) → expected variant, per the // classify_variant branch order. Covers all 10 derivable variants. let cases: &[(f32, f32, u16, f32, SubBiomeVariant)] = &[ (0.95, 0.0, 100, 0.5, Alpine), // high elevation dominates (0.10, 0.0, 1, 0.5, Wetland), // saturated low ground (0.35, 0.0, 4, 0.5, CoastalLowland), // near coast, low (0.50, 0.0, 100, 0.10, Tundra), // cold pole (0.50, 0.0, 100, 0.30, BorealForest), // cool (0.50, 0.0, 10, 0.90, TropicalWet), // hot + moist (0.50, 0.0, 30, 0.90, Savanna), // hot + mid-dry (0.50, 0.0, 50, 0.90, Desert), // hot + dry (0.50, 0.0, 10, 0.50, TemperateForest), // temperate + moist (0.50, 0.0, 40, 0.50, TemperateGrassland), // temperate + mid (0.50, 0.0, 70, 0.50, Desert), // temperate + arid ]; for &(e, s, w, t, expected) in cases { let got = classify_variant(e, s, w, t); assert_eq!(got, expected, "classify_variant({e},{s},{w},{t})"); assert_ne!( got, Volcanic, "Volcanic must never be emitted (no L1 signal)" ); } } }