Skip to main content

proof_engine/terrain/
biome.rs

1//! Biome classification, climate simulation, and biome-driven parameters.
2//!
3//! Implements a Whittaker biome diagram classifier driven by temperature,
4//! humidity, altitude, and slope. Also provides a climate simulator that
5//! derives these parameters from a heightmap using atmospheric physics.
6
7use glam::Vec3;
8use crate::terrain::heightmap::HeightMap;
9
10// ── BiomeType ─────────────────────────────────────────────────────────────────
11
12/// All supported biome types. Each represents a distinct ecological zone.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum BiomeType {
15    Ocean,
16    DeepOcean,
17    Beach,
18    Desert,
19    Savanna,
20    Grassland,
21    Shrubland,
22    TemperateForest,
23    TropicalForest,
24    Boreal,
25    Taiga,
26    Tundra,
27    Arctic,
28    Mountain,
29    AlpineGlacier,
30    Swamp,
31    Mangrove,
32    Volcanic,
33    Badlands,
34    Mushroom,
35}
36
37impl BiomeType {
38    /// Human-readable name of the biome.
39    pub fn name(self) -> &'static str {
40        match self {
41            BiomeType::Ocean           => "Ocean",
42            BiomeType::DeepOcean       => "Deep Ocean",
43            BiomeType::Beach           => "Beach",
44            BiomeType::Desert          => "Desert",
45            BiomeType::Savanna         => "Savanna",
46            BiomeType::Grassland       => "Grassland",
47            BiomeType::Shrubland       => "Shrubland",
48            BiomeType::TemperateForest => "Temperate Forest",
49            BiomeType::TropicalForest  => "Tropical Forest",
50            BiomeType::Boreal          => "Boreal Forest",
51            BiomeType::Taiga           => "Taiga",
52            BiomeType::Tundra          => "Tundra",
53            BiomeType::Arctic          => "Arctic",
54            BiomeType::Mountain        => "Mountain",
55            BiomeType::AlpineGlacier   => "Alpine Glacier",
56            BiomeType::Swamp           => "Swamp",
57            BiomeType::Mangrove        => "Mangrove",
58            BiomeType::Volcanic        => "Volcanic",
59            BiomeType::Badlands        => "Badlands",
60            BiomeType::Mushroom        => "Mushroom Island",
61        }
62    }
63
64    /// Whether this biome is aquatic.
65    pub fn is_aquatic(self) -> bool {
66        matches!(self, BiomeType::Ocean | BiomeType::DeepOcean | BiomeType::Swamp | BiomeType::Mangrove)
67    }
68
69    /// Whether this biome is cold.
70    pub fn is_cold(self) -> bool {
71        matches!(self, BiomeType::Tundra | BiomeType::Arctic | BiomeType::AlpineGlacier | BiomeType::Taiga)
72    }
73
74    /// Whether this biome has trees.
75    pub fn has_trees(self) -> bool {
76        matches!(self,
77            BiomeType::TemperateForest | BiomeType::TropicalForest |
78            BiomeType::Boreal | BiomeType::Taiga | BiomeType::Swamp |
79            BiomeType::Mangrove | BiomeType::Mushroom
80        )
81    }
82
83    /// Index for array lookups (0-based, matches enum order).
84    pub fn index(self) -> usize {
85        self as usize
86    }
87}
88
89// ── BiomeParams ───────────────────────────────────────────────────────────────
90
91/// Input parameters for biome classification.
92#[derive(Clone, Copy, Debug, Default)]
93pub struct BiomeParams {
94    /// Temperature normalized to [0, 1]. 0 = freezing, 1 = tropical.
95    pub temperature: f32,
96    /// Humidity (precipitation) normalized to [0, 1]. 0 = arid, 1 = rainforest.
97    pub humidity: f32,
98    /// Altitude normalized to [0, 1]. 0 = sea level, 1 = highest peak.
99    pub altitude: f32,
100    /// Slope [0, 1]. 0 = flat, 1 = cliff.
101    pub slope: f32,
102    /// Distance to nearest coast [0, 1]. 0 = on coast, 1 = deep inland.
103    pub coast_distance: f32,
104    /// Whether this is near a volcanic hot spot.
105    pub volcanic: bool,
106}
107
108// ── BiomeClassifier ───────────────────────────────────────────────────────────
109
110/// Classifies a location into a `BiomeType` based on `BiomeParams`.
111///
112/// Uses a Whittaker-style biome diagram (temperature × precipitation)
113/// with altitude and slope overrides.
114pub struct BiomeClassifier;
115
116impl BiomeClassifier {
117    /// Classify a location given its biome parameters.
118    pub fn classify(p: &BiomeParams) -> BiomeType {
119        // Special cases first
120        if p.volcanic { return BiomeType::Volcanic; }
121        if p.altitude < 0.05 { return if p.altitude < 0.02 { BiomeType::DeepOcean } else { BiomeType::Ocean }; }
122        if p.altitude < 0.1 && p.coast_distance < 0.05 { return BiomeType::Beach; }
123        if p.altitude < 0.1 && p.humidity > 0.7 && p.temperature > 0.5 { return BiomeType::Mangrove; }
124
125        // Altitude override: high peaks become glaciers or mountains
126        if p.altitude > 0.85 {
127            if p.temperature < 0.3 || p.altitude > 0.95 { return BiomeType::AlpineGlacier; }
128            return BiomeType::Mountain;
129        }
130        if p.altitude > 0.7 {
131            if p.slope > 0.5 { return BiomeType::Mountain; }
132            if p.temperature < 0.2 { return BiomeType::AlpineGlacier; }
133        }
134
135        // Temperature-based overrides
136        if p.temperature < 0.1 { return BiomeType::Arctic; }
137        if p.temperature < 0.25 {
138            if p.humidity < 0.3 { return BiomeType::Tundra; }
139            return BiomeType::Taiga;
140        }
141        if p.temperature < 0.4 {
142            if p.humidity > 0.5 { return BiomeType::Boreal; }
143            return BiomeType::Tundra;
144        }
145
146        // Whittaker diagram: temp × humidity grid
147        // High humidity zone
148        if p.humidity > 0.75 {
149            if p.temperature > 0.65 { return BiomeType::TropicalForest; }
150            if p.temperature > 0.45 { return BiomeType::TemperateForest; }
151            return BiomeType::Boreal;
152        }
153
154        if p.humidity > 0.55 {
155            if p.temperature > 0.65 {
156                if p.altitude < 0.15 && p.coast_distance < 0.1 { return BiomeType::Mangrove; }
157                return BiomeType::TropicalForest;
158            }
159            if p.temperature > 0.45 {
160                if p.humidity > 0.65 && p.altitude < 0.15 { return BiomeType::Swamp; }
161                return BiomeType::TemperateForest;
162            }
163            return BiomeType::Boreal;
164        }
165
166        if p.humidity > 0.35 {
167            if p.temperature > 0.65 { return BiomeType::Savanna; }
168            if p.temperature > 0.45 { return BiomeType::Grassland; }
169            return BiomeType::Shrubland;
170        }
171
172        if p.humidity > 0.2 {
173            if p.temperature > 0.55 { return BiomeType::Savanna; }
174            if p.temperature > 0.4  { return BiomeType::Grassland; }
175            return BiomeType::Shrubland;
176        }
177
178        // Dry zone
179        if p.humidity < 0.15 {
180            if p.temperature > 0.5 { return BiomeType::Desert; }
181            if p.temperature > 0.3 { return BiomeType::Badlands; }
182            return BiomeType::Tundra;
183        }
184
185        // Medium humidity, varying temperature
186        if p.temperature > 0.6 { return BiomeType::Savanna; }
187        if p.temperature > 0.4 { return BiomeType::Shrubland; }
188        BiomeType::Tundra
189    }
190
191    /// Return a blend weight for each neighboring biome type.
192    /// Used for smooth transitions.
193    pub fn classify_blended(p: &BiomeParams) -> [(BiomeType, f32); 4] {
194        let base = Self::classify(p);
195        // Slightly perturbed versions to find neighbors
196        let p_warm  = BiomeParams { temperature: p.temperature + 0.05, ..*p };
197        let p_wet   = BiomeParams { humidity:    p.humidity    + 0.05, ..*p };
198        let p_high  = BiomeParams { altitude:    p.altitude    + 0.05, ..*p };
199        let b1 = Self::classify(&p_warm);
200        let b2 = Self::classify(&p_wet);
201        let b3 = Self::classify(&p_high);
202        [
203            (base, 0.7),
204            (b1, if b1 != base { 0.1 } else { 0.0 }),
205            (b2, if b2 != base { 0.1 } else { 0.0 }),
206            (b3, if b3 != base { 0.1 } else { 0.0 }),
207        ]
208    }
209}
210
211// ── ClimateSimulator ──────────────────────────────────────────────────────────
212
213/// Simulates climate across a terrain given a heightmap.
214///
215/// Models temperature gradients, Hadley/Ferrel circulation cells,
216/// rain shadow from mountains, and ocean current effects.
217pub struct ClimateSimulator {
218    /// World latitude range [south, north] in degrees.
219    pub latitude_range: (f32, f32),
220    /// Global temperature offset in normalized units.
221    pub base_temperature: f32,
222    /// Global precipitation scale factor.
223    pub precipitation_scale: f32,
224    /// Prevailing wind direction (normalized x, z).
225    pub wind_direction: (f32, f32),
226}
227
228impl Default for ClimateSimulator {
229    fn default() -> Self {
230        Self {
231            latitude_range: (-60.0, 60.0),
232            base_temperature: 0.5,
233            precipitation_scale: 1.0,
234            wind_direction: (1.0, 0.0),
235        }
236    }
237}
238
239impl ClimateSimulator {
240    pub fn new() -> Self { Self::default() }
241
242    /// Compute temperature at a given normalized position (x, y in \[0,1\]) and altitude.
243    pub fn temperature(&self, nx: f32, ny: f32, altitude: f32) -> f32 {
244        // Latitude gradient: equator hot, poles cold
245        let (lat_s, lat_n) = self.latitude_range;
246        let lat = lat_s + ny * (lat_n - lat_s);
247        let lat_factor = (lat.to_radians().cos()).powf(0.5).clamp(0.0, 1.0);
248
249        // Altitude cooling: lapse rate ~6.5°C per 1000m (normalized)
250        let altitude_cooling = altitude * 0.5;
251
252        // Hadley cells: tropics (|lat| < 30) hot and dry, ITC convergence
253        let hadley_bonus = if lat.abs() < 30.0 {
254            (1.0 - lat.abs() / 30.0) * 0.1
255        } else {
256            0.0
257        };
258
259        (self.base_temperature + lat_factor * 0.4 + hadley_bonus - altitude_cooling)
260            .clamp(0.0, 1.0)
261    }
262
263    /// Compute precipitation at a given position, accounting for rain shadow.
264    pub fn precipitation(
265        &self,
266        nx: f32,
267        ny: f32,
268        altitude: f32,
269        heightmap: &HeightMap,
270    ) -> f32 {
271        let w = heightmap.width as f32;
272        let h = heightmap.height as f32;
273        let x = nx * w;
274        let y = ny * h;
275
276        // Base precipitation from Ferrel/Hadley cells
277        let (lat_s, lat_n) = self.latitude_range;
278        let lat = lat_s + ny * (lat_n - lat_s);
279        let base_precip = {
280            // High precipitation at ITCZ (~0°) and mid-latitudes (~50-60°)
281            let p1 = (-(lat / 10.0).powi(2)).exp();            // ITCZ
282            let p2 = (-(((lat.abs() - 55.0) / 15.0)).powi(2)).exp(); // mid-lat
283            // Low at subtropical highs (~30°) and poles
284            let desert_suppress = if lat.abs() > 25.0 && lat.abs() < 35.0 { 0.5 } else { 1.0 };
285            (p1 * 0.6 + p2 * 0.4) * desert_suppress
286        };
287
288        // Orographic lift: upwind slope receives more rain
289        let wind_x = self.wind_direction.0;
290        let wind_y = self.wind_direction.1;
291        let upwind_x = (x - wind_x * 20.0).clamp(0.0, w - 1.0);
292        let upwind_y = (y - wind_y * 20.0).clamp(0.0, h - 1.0);
293        let upwind_h = heightmap.sample_bilinear(upwind_x, upwind_y);
294        let orographic = if altitude > upwind_h + 0.05 {
295            // Rising air → more precipitation
296            0.2 * ((altitude - upwind_h) / 0.3).clamp(0.0, 1.0)
297        } else if altitude < upwind_h - 0.05 {
298            // Rain shadow → less precipitation
299            -0.3 * ((upwind_h - altitude) / 0.3).clamp(0.0, 1.0)
300        } else {
301            0.0
302        };
303
304        // Ocean proximity increases humidity
305        let coast_bonus = (1.0 - Self::coast_distance(heightmap, x as usize, y as usize)) * 0.15;
306
307        (base_precip * self.precipitation_scale + orographic + coast_bonus)
308            .clamp(0.0, 1.0)
309    }
310
311    /// Compute ocean current warming/cooling effect at a coastal point.
312    pub fn ocean_current_effect(&self, nx: f32, ny: f32) -> f32 {
313        let (lat_s, lat_n) = self.latitude_range;
314        let lat = lat_s + ny * (lat_n - lat_s);
315        // Western boundary currents: warm on east side of ocean, cold on west
316        // Simplified: longitude affects current temperature
317        let warm_current = if nx > 0.5 && lat.abs() < 40.0 { 0.1 } else { 0.0 };
318        let cold_current = if nx < 0.2 && lat.abs() > 20.0 { -0.08 } else { 0.0 };
319        warm_current + cold_current
320    }
321
322    /// Compute normalized distance to coast (nearest land-sea boundary).
323    fn coast_distance(heightmap: &HeightMap, x: usize, y: usize) -> f32 {
324        let sea_level = 0.1;
325        let is_land = heightmap.get(x, y) > sea_level;
326        let max_search = 32usize;
327        for r in 0..max_search {
328            for dy in -(r as i32)..=(r as i32) {
329                for dx in -(r as i32)..=(r as i32) {
330                    if dx.abs() != r as i32 && dy.abs() != r as i32 { continue; }
331                    let nx2 = x as i32 + dx;
332                    let ny2 = y as i32 + dy;
333                    if nx2 < 0 || nx2 >= heightmap.width as i32 || ny2 < 0 || ny2 >= heightmap.height as i32 { continue; }
334                    let other_land = heightmap.get(nx2 as usize, ny2 as usize) > sea_level;
335                    if other_land != is_land {
336                        return r as f32 / max_search as f32;
337                    }
338                }
339            }
340        }
341        1.0
342    }
343
344    /// Generate a full `ClimateMap` from a heightmap.
345    pub fn simulate(&self, heightmap: &HeightMap) -> ClimateMap {
346        let w = heightmap.width;
347        let h = heightmap.height;
348        let mut temperature = HeightMap::new(w, h);
349        let mut humidity    = HeightMap::new(w, h);
350        for y in 0..h {
351            for x in 0..w {
352                let nx = x as f32 / w as f32;
353                let ny = y as f32 / h as f32;
354                let alt = heightmap.get(x, y);
355                let t = self.temperature(nx, ny, alt)
356                    + self.ocean_current_effect(nx, ny);
357                let p = self.precipitation(nx, ny, alt, heightmap);
358                temperature.set(x, y, t.clamp(0.0, 1.0));
359                humidity.set(x, y, p.clamp(0.0, 1.0));
360            }
361        }
362        // Slight spatial smoothing for realism
363        temperature.blur(2);
364        humidity.blur(2);
365        ClimateMap { temperature, humidity }
366    }
367}
368
369/// Output of the climate simulator: temperature and humidity maps.
370#[derive(Clone, Debug)]
371pub struct ClimateMap {
372    pub temperature: HeightMap,
373    pub humidity:    HeightMap,
374}
375
376// ── BiomeMap ──────────────────────────────────────────────────────────────────
377
378/// A 2D map of biome assignments.
379#[derive(Clone, Debug)]
380pub struct BiomeMap {
381    pub width:  usize,
382    pub height: usize,
383    pub biomes: Vec<BiomeType>,
384}
385
386impl BiomeMap {
387    /// Create from explicit biome data.
388    pub fn new(width: usize, height: usize, biomes: Vec<BiomeType>) -> Self {
389        assert_eq!(biomes.len(), width * height);
390        Self { width, height, biomes }
391    }
392
393    /// Build a biome map from a heightmap and a precomputed climate map.
394    pub fn from_heightmap(heightmap: &HeightMap, climate: &ClimateMap) -> Self {
395        let w = heightmap.width;
396        let h = heightmap.height;
397        let slope_map = heightmap.slope_map();
398        let mut biomes = Vec::with_capacity(w * h);
399
400        for y in 0..h {
401            for x in 0..w {
402                let altitude    = heightmap.get(x, y);
403                let temperature = climate.temperature.get(x, y);
404                let humidity    = climate.humidity.get(x, y);
405                let slope       = slope_map.get(x, y);
406                let coast_dist  = ClimateSimulator::coast_distance(heightmap, x, y);
407
408                // Volcanic detection: steep slopes on hot spots (placeholder heuristic)
409                let volcanic = altitude > 0.75 && slope > 0.7 && temperature > 0.6;
410
411                let params = BiomeParams {
412                    temperature,
413                    humidity,
414                    altitude,
415                    slope,
416                    coast_distance: coast_dist,
417                    volcanic,
418                };
419                biomes.push(BiomeClassifier::classify(&params));
420            }
421        }
422        Self { width: w, height: h, biomes }
423    }
424
425    /// Get the biome at integer coordinates.
426    pub fn get(&self, x: usize, y: usize) -> BiomeType {
427        if x < self.width && y < self.height {
428            self.biomes[y * self.width + x]
429        } else {
430            BiomeType::Ocean
431        }
432    }
433
434    /// Get blend weights for smooth biome transitions at floating-point coordinates.
435    /// Returns up to 4 (biome, weight) pairs that sum to ~1.
436    pub fn blend_weights(&self, x: f32, z: f32) -> Vec<(BiomeType, f32)> {
437        let cx = x.clamp(0.0, (self.width  - 1) as f32);
438        let cz = z.clamp(0.0, (self.height - 1) as f32);
439        let x0 = cx.floor() as usize;
440        let z0 = cz.floor() as usize;
441        let x1 = (x0 + 1).min(self.width  - 1);
442        let z1 = (z0 + 1).min(self.height - 1);
443        let tx = cx - x0 as f32;
444        let tz = cz - z0 as f32;
445
446        let b00 = self.get(x0, z0);
447        let b10 = self.get(x1, z0);
448        let b01 = self.get(x0, z1);
449        let b11 = self.get(x1, z1);
450
451        let w00 = (1.0 - tx) * (1.0 - tz);
452        let w10 = tx * (1.0 - tz);
453        let w01 = (1.0 - tx) * tz;
454        let w11 = tx * tz;
455
456        // Merge duplicate biomes
457        let mut result: Vec<(BiomeType, f32)> = Vec::new();
458        for (b, w) in [(b00, w00), (b10, w10), (b01, w01), (b11, w11)] {
459            if let Some(entry) = result.iter_mut().find(|(bt, _)| *bt == b) {
460                entry.1 += w;
461            } else {
462                result.push((b, w));
463            }
464        }
465        result
466    }
467}
468
469// ── VegetationDensity ─────────────────────────────────────────────────────────
470
471/// Vegetation density parameters for a biome.
472#[derive(Clone, Copy, Debug, Default)]
473pub struct VegetationDensity {
474    /// Trees per unit area [0, 1].
475    pub tree_density: f32,
476    /// Grass coverage [0, 1].
477    pub grass_density: f32,
478    /// Rock/boulder frequency [0, 1].
479    pub rock_density: f32,
480    /// Shrub density [0, 1].
481    pub shrub_density: f32,
482    /// Flower density [0, 1].
483    pub flower_density: f32,
484}
485
486impl VegetationDensity {
487    /// Return the vegetation density parameters for a given biome.
488    pub fn for_biome(biome: BiomeType) -> Self {
489        match biome {
490            BiomeType::Ocean | BiomeType::DeepOcean => Self::default(),
491            BiomeType::Beach => Self {
492                grass_density: 0.05, rock_density: 0.1,
493                ..Default::default()
494            },
495            BiomeType::Desert => Self {
496                tree_density: 0.02, rock_density: 0.3, shrub_density: 0.05,
497                ..Default::default()
498            },
499            BiomeType::Savanna => Self {
500                tree_density: 0.1, grass_density: 0.7, shrub_density: 0.1,
501                flower_density: 0.05, ..Default::default()
502            },
503            BiomeType::Grassland => Self {
504                tree_density: 0.05, grass_density: 0.9,
505                flower_density: 0.15, rock_density: 0.05, ..Default::default()
506            },
507            BiomeType::Shrubland => Self {
508                tree_density: 0.1, grass_density: 0.4, shrub_density: 0.6,
509                rock_density: 0.1, ..Default::default()
510            },
511            BiomeType::TemperateForest => Self {
512                tree_density: 0.7, grass_density: 0.3, shrub_density: 0.2,
513                flower_density: 0.1, rock_density: 0.05,
514            },
515            BiomeType::TropicalForest => Self {
516                tree_density: 0.9, grass_density: 0.2, shrub_density: 0.4,
517                flower_density: 0.3, rock_density: 0.02,
518            },
519            BiomeType::Boreal => Self {
520                tree_density: 0.6, grass_density: 0.1, shrub_density: 0.15,
521                rock_density: 0.1, ..Default::default()
522            },
523            BiomeType::Taiga => Self {
524                tree_density: 0.5, grass_density: 0.05, shrub_density: 0.1,
525                rock_density: 0.15, ..Default::default()
526            },
527            BiomeType::Tundra => Self {
528                tree_density: 0.01, grass_density: 0.3, shrub_density: 0.15,
529                rock_density: 0.3, flower_density: 0.05,
530            },
531            BiomeType::Arctic => Self {
532                rock_density: 0.4, ..Default::default()
533            },
534            BiomeType::Mountain => Self {
535                tree_density: 0.15, grass_density: 0.2, rock_density: 0.6,
536                shrub_density: 0.1, ..Default::default()
537            },
538            BiomeType::AlpineGlacier => Self {
539                rock_density: 0.2, ..Default::default()
540            },
541            BiomeType::Swamp => Self {
542                tree_density: 0.5, grass_density: 0.4, shrub_density: 0.3,
543                flower_density: 0.05, rock_density: 0.01,
544            },
545            BiomeType::Mangrove => Self {
546                tree_density: 0.6, grass_density: 0.1, shrub_density: 0.2,
547                ..Default::default()
548            },
549            BiomeType::Volcanic => Self {
550                rock_density: 0.8, ..Default::default()
551            },
552            BiomeType::Badlands => Self {
553                grass_density: 0.05, rock_density: 0.5, shrub_density: 0.05,
554                ..Default::default()
555            },
556            BiomeType::Mushroom => Self {
557                tree_density: 0.05, grass_density: 0.6, shrub_density: 0.2,
558                flower_density: 0.4, rock_density: 0.05,
559            },
560        }
561    }
562}
563
564// ── BiomeColor ────────────────────────────────────────────────────────────────
565
566/// Color palette for a biome.
567#[derive(Clone, Copy, Debug)]
568pub struct BiomeColor {
569    /// Primary ground/soil color.
570    pub ground: Vec3,
571    /// Grass tint color.
572    pub grass:  Vec3,
573    /// Sky tint color (affects fog/atmosphere).
574    pub sky:    Vec3,
575    /// Water color (if applicable).
576    pub water:  Vec3,
577    /// Rock color.
578    pub rock:   Vec3,
579}
580
581impl BiomeColor {
582    /// Color palette for a given biome type.
583    pub fn for_biome(biome: BiomeType) -> Self {
584        match biome {
585            BiomeType::Ocean => Self {
586                ground: Vec3::new(0.05, 0.1,  0.3),
587                grass:  Vec3::new(0.0,  0.3,  0.5),
588                sky:    Vec3::new(0.4,  0.65, 0.9),
589                water:  Vec3::new(0.0,  0.2,  0.8),
590                rock:   Vec3::new(0.3,  0.3,  0.4),
591            },
592            BiomeType::DeepOcean => Self {
593                ground: Vec3::new(0.02, 0.04, 0.2),
594                grass:  Vec3::new(0.0,  0.1,  0.3),
595                sky:    Vec3::new(0.3,  0.5,  0.8),
596                water:  Vec3::new(0.0,  0.1,  0.6),
597                rock:   Vec3::new(0.2,  0.2,  0.3),
598            },
599            BiomeType::Beach => Self {
600                ground: Vec3::new(0.87, 0.80, 0.55),
601                grass:  Vec3::new(0.7,  0.75, 0.3),
602                sky:    Vec3::new(0.5,  0.75, 0.95),
603                water:  Vec3::new(0.1,  0.5,  0.9),
604                rock:   Vec3::new(0.6,  0.55, 0.45),
605            },
606            BiomeType::Desert => Self {
607                ground: Vec3::new(0.85, 0.65, 0.3),
608                grass:  Vec3::new(0.7,  0.6,  0.25),
609                sky:    Vec3::new(0.9,  0.75, 0.45),
610                water:  Vec3::new(0.3,  0.5,  0.8),
611                rock:   Vec3::new(0.75, 0.55, 0.35),
612            },
613            BiomeType::Savanna => Self {
614                ground: Vec3::new(0.75, 0.6,  0.25),
615                grass:  Vec3::new(0.7,  0.65, 0.2),
616                sky:    Vec3::new(0.7,  0.8,  0.9),
617                water:  Vec3::new(0.2,  0.5,  0.8),
618                rock:   Vec3::new(0.65, 0.55, 0.4),
619            },
620            BiomeType::Grassland => Self {
621                ground: Vec3::new(0.45, 0.5,  0.2),
622                grass:  Vec3::new(0.35, 0.6,  0.15),
623                sky:    Vec3::new(0.5,  0.7,  0.95),
624                water:  Vec3::new(0.15, 0.45, 0.8),
625                rock:   Vec3::new(0.5,  0.5,  0.45),
626            },
627            BiomeType::Shrubland => Self {
628                ground: Vec3::new(0.5,  0.45, 0.25),
629                grass:  Vec3::new(0.4,  0.5,  0.2),
630                sky:    Vec3::new(0.55, 0.7,  0.9),
631                water:  Vec3::new(0.1,  0.4,  0.75),
632                rock:   Vec3::new(0.55, 0.5,  0.4),
633            },
634            BiomeType::TemperateForest => Self {
635                ground: Vec3::new(0.3,  0.35, 0.15),
636                grass:  Vec3::new(0.25, 0.55, 0.15),
637                sky:    Vec3::new(0.45, 0.65, 0.85),
638                water:  Vec3::new(0.1,  0.35, 0.7),
639                rock:   Vec3::new(0.45, 0.45, 0.4),
640            },
641            BiomeType::TropicalForest => Self {
642                ground: Vec3::new(0.2,  0.3,  0.1),
643                grass:  Vec3::new(0.15, 0.55, 0.1),
644                sky:    Vec3::new(0.5,  0.7,  0.75),
645                water:  Vec3::new(0.05, 0.4,  0.6),
646                rock:   Vec3::new(0.35, 0.4,  0.3),
647            },
648            BiomeType::Boreal => Self {
649                ground: Vec3::new(0.3,  0.35, 0.2),
650                grass:  Vec3::new(0.2,  0.45, 0.2),
651                sky:    Vec3::new(0.55, 0.65, 0.8),
652                water:  Vec3::new(0.1,  0.3,  0.65),
653                rock:   Vec3::new(0.4,  0.42, 0.38),
654            },
655            BiomeType::Taiga => Self {
656                ground: Vec3::new(0.35, 0.35, 0.25),
657                grass:  Vec3::new(0.25, 0.4,  0.25),
658                sky:    Vec3::new(0.6,  0.65, 0.8),
659                water:  Vec3::new(0.1,  0.3,  0.6),
660                rock:   Vec3::new(0.45, 0.45, 0.4),
661            },
662            BiomeType::Tundra => Self {
663                ground: Vec3::new(0.55, 0.5,  0.4),
664                grass:  Vec3::new(0.5,  0.55, 0.3),
665                sky:    Vec3::new(0.7,  0.75, 0.85),
666                water:  Vec3::new(0.1,  0.3,  0.6),
667                rock:   Vec3::new(0.55, 0.52, 0.48),
668            },
669            BiomeType::Arctic => Self {
670                ground: Vec3::new(0.9,  0.92, 0.95),
671                grass:  Vec3::new(0.85, 0.88, 0.92),
672                sky:    Vec3::new(0.7,  0.8,  0.95),
673                water:  Vec3::new(0.6,  0.75, 0.9),
674                rock:   Vec3::new(0.6,  0.62, 0.65),
675            },
676            BiomeType::Mountain => Self {
677                ground: Vec3::new(0.5,  0.48, 0.44),
678                grass:  Vec3::new(0.35, 0.45, 0.25),
679                sky:    Vec3::new(0.55, 0.65, 0.85),
680                water:  Vec3::new(0.1,  0.3,  0.7),
681                rock:   Vec3::new(0.55, 0.52, 0.48),
682            },
683            BiomeType::AlpineGlacier => Self {
684                ground: Vec3::new(0.85, 0.9,  0.95),
685                grass:  Vec3::new(0.8,  0.85, 0.9),
686                sky:    Vec3::new(0.65, 0.75, 0.95),
687                water:  Vec3::new(0.7,  0.85, 0.95),
688                rock:   Vec3::new(0.6,  0.62, 0.65),
689            },
690            BiomeType::Swamp => Self {
691                ground: Vec3::new(0.25, 0.3,  0.15),
692                grass:  Vec3::new(0.2,  0.4,  0.15),
693                sky:    Vec3::new(0.45, 0.55, 0.65),
694                water:  Vec3::new(0.1,  0.2,  0.25),
695                rock:   Vec3::new(0.3,  0.32, 0.28),
696            },
697            BiomeType::Mangrove => Self {
698                ground: Vec3::new(0.3,  0.35, 0.2),
699                grass:  Vec3::new(0.2,  0.5,  0.15),
700                sky:    Vec3::new(0.5,  0.65, 0.8),
701                water:  Vec3::new(0.1,  0.3,  0.5),
702                rock:   Vec3::new(0.35, 0.38, 0.3),
703            },
704            BiomeType::Volcanic => Self {
705                ground: Vec3::new(0.15, 0.1,  0.08),
706                grass:  Vec3::new(0.2,  0.18, 0.1),
707                sky:    Vec3::new(0.5,  0.35, 0.25),
708                water:  Vec3::new(0.8,  0.4,  0.05),
709                rock:   Vec3::new(0.1,  0.08, 0.07),
710            },
711            BiomeType::Badlands => Self {
712                ground: Vec3::new(0.75, 0.45, 0.25),
713                grass:  Vec3::new(0.6,  0.45, 0.2),
714                sky:    Vec3::new(0.8,  0.65, 0.45),
715                water:  Vec3::new(0.25, 0.45, 0.75),
716                rock:   Vec3::new(0.7,  0.5,  0.3),
717            },
718            BiomeType::Mushroom => Self {
719                ground: Vec3::new(0.55, 0.3,  0.55),
720                grass:  Vec3::new(0.5,  0.2,  0.6),
721                sky:    Vec3::new(0.6,  0.5,  0.8),
722                water:  Vec3::new(0.4,  0.2,  0.7),
723                rock:   Vec3::new(0.45, 0.3,  0.5),
724            },
725        }
726    }
727}
728
729// ── TransitionZone ────────────────────────────────────────────────────────────
730
731/// Describes the blend zone between two adjacent biomes.
732#[derive(Clone, Debug)]
733pub struct TransitionZone {
734    pub biome_a: BiomeType,
735    pub biome_b: BiomeType,
736    /// Blend width in world units.
737    pub blend_width: f32,
738    /// Whether the transition has a distinct visual marker (e.g. treeline).
739    pub sharp_boundary: bool,
740}
741
742impl TransitionZone {
743    pub fn new(biome_a: BiomeType, biome_b: BiomeType, blend_width: f32) -> Self {
744        let sharp = matches!(
745            (biome_a, biome_b),
746            (BiomeType::Grassland, BiomeType::Desert)   |
747            (BiomeType::Desert,    BiomeType::Grassland) |
748            (BiomeType::Mountain,  BiomeType::AlpineGlacier) |
749            (BiomeType::AlpineGlacier, BiomeType::Mountain)
750        );
751        Self { biome_a, biome_b, blend_width, sharp_boundary: sharp }
752    }
753
754    /// Compute the blend factor from biome_a to biome_b.
755    /// `position` is 0.0 at biome_a center and 1.0 at biome_b center.
756    pub fn blend_factor(&self, position: f32) -> f32 {
757        let t = position.clamp(0.0, 1.0);
758        if self.sharp_boundary {
759            if t < 0.5 { 0.0 } else { 1.0 }
760        } else {
761            // Smooth step through 0.5 with zero slope at both ends. The
762            // extra "* 0.5" made it run from 0.25 to 0.75, so neither biome
763            // was ever fully reached.
764            let x = t * 2.0 - 1.0;
765            0.5 + x * (1.0 - x.abs() * 0.5)
766        }
767    }
768}
769
770// ── Seasonal Variation ────────────────────────────────────────────────────────
771
772/// Describes how a biome changes by season.
773#[derive(Clone, Copy, Debug)]
774pub struct SeasonFactor {
775    /// Color multiplier for vegetation (0 = dead/snow-covered, 1 = full green).
776    pub vegetation_green:  f32,
777    /// Color shift toward autumn browns/oranges.
778    pub autumn_shift:      f32,
779    /// Snow cover [0, 1].
780    pub snow_cover:        f32,
781    /// Effective vegetation density multiplier.
782    pub density_scale:     f32,
783}
784
785impl SeasonFactor {
786    /// Compute seasonal factor for a biome in month 0–11.
787    pub fn season_factor(biome: BiomeType, month: u32) -> Self {
788        let month = (month % 12) as f32;
789        // Northern hemisphere seasons: summer peak = month 6
790        let summer_t = ((month - 6.0) * std::f32::consts::PI / 6.0).cos() * 0.5 + 0.5;
791        // summer_t: 1.0 = peak summer, 0.0 = peak winter
792        let winter_t = 1.0 - summer_t;
793
794        match biome {
795            BiomeType::TemperateForest | BiomeType::Boreal => Self {
796                vegetation_green: 0.2 + summer_t * 0.8,
797                autumn_shift: if month > 7.0 && month < 11.0 { (month - 7.0) * 0.25 } else { 0.0 },
798                snow_cover: (winter_t - 0.6).max(0.0) * 2.5,
799                density_scale: 0.3 + summer_t * 0.7,
800            },
801            BiomeType::Taiga | BiomeType::Tundra => Self {
802                vegetation_green: 0.1 + summer_t * 0.7,
803                autumn_shift: 0.0,
804                snow_cover: winter_t * 0.9,
805                density_scale: 0.1 + summer_t * 0.6,
806            },
807            BiomeType::Arctic | BiomeType::AlpineGlacier => Self {
808                vegetation_green: summer_t * 0.2,
809                autumn_shift: 0.0,
810                snow_cover: 0.5 + winter_t * 0.5,
811                density_scale: summer_t * 0.15,
812            },
813            BiomeType::Grassland | BiomeType::Savanna => Self {
814                vegetation_green: 0.4 + summer_t * 0.5,
815                autumn_shift: (winter_t - 0.3).max(0.0) * 0.5,
816                snow_cover: (winter_t - 0.8).max(0.0) * 2.0,
817                density_scale: 0.5 + summer_t * 0.5,
818            },
819            BiomeType::Desert | BiomeType::Badlands => Self {
820                vegetation_green: 0.1,
821                autumn_shift: 0.0,
822                snow_cover: 0.0,
823                density_scale: 0.8 + summer_t * 0.2,
824            },
825            // Tropical/equatorial biomes: minimal seasonality
826            BiomeType::TropicalForest | BiomeType::Mangrove | BiomeType::Swamp => Self {
827                vegetation_green: 0.9,
828                autumn_shift: 0.0,
829                snow_cover: 0.0,
830                density_scale: 1.0,
831            },
832            _ => Self {
833                vegetation_green: 0.5 + summer_t * 0.5,
834                autumn_shift: 0.0,
835                snow_cover: winter_t * 0.3,
836                density_scale: 0.6 + summer_t * 0.4,
837            },
838        }
839    }
840}
841
842// ── Tests ─────────────────────────────────────────────────────────────────────
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use crate::terrain::heightmap::FractalNoise;
848
849    #[test]
850    fn test_biome_type_names() {
851        assert_eq!(BiomeType::Desert.name(), "Desert");
852        assert_eq!(BiomeType::TropicalForest.name(), "Tropical Forest");
853        assert_eq!(BiomeType::AlpineGlacier.name(), "Alpine Glacier");
854    }
855
856    #[test]
857    fn test_biome_type_properties() {
858        assert!(BiomeType::Ocean.is_aquatic());
859        assert!(!BiomeType::Desert.is_aquatic());
860        assert!(BiomeType::Arctic.is_cold());
861        assert!(!BiomeType::Desert.is_cold());
862        assert!(BiomeType::TropicalForest.has_trees());
863        assert!(!BiomeType::Arctic.has_trees());
864    }
865
866    #[test]
867    fn test_biome_classifier_desert() {
868        let p = BiomeParams {
869            temperature: 0.8, humidity: 0.1, altitude: 0.3, slope: 0.05,
870            coast_distance: 0.9, volcanic: false,
871        };
872        assert_eq!(BiomeClassifier::classify(&p), BiomeType::Desert);
873    }
874
875    #[test]
876    fn test_biome_classifier_ocean() {
877        let p = BiomeParams {
878            temperature: 0.5, humidity: 0.8, altitude: 0.01, slope: 0.0,
879            coast_distance: 0.0, volcanic: false,
880        };
881        assert!(matches!(
882            BiomeClassifier::classify(&p),
883            BiomeType::Ocean | BiomeType::DeepOcean
884        ));
885    }
886
887    #[test]
888    fn test_biome_classifier_alpine() {
889        let p = BiomeParams {
890            temperature: 0.2, humidity: 0.3, altitude: 0.96, slope: 0.3,
891            coast_distance: 0.8, volcanic: false,
892        };
893        assert_eq!(BiomeClassifier::classify(&p), BiomeType::AlpineGlacier);
894    }
895
896    #[test]
897    fn test_biome_classifier_tropical() {
898        let p = BiomeParams {
899            temperature: 0.9, humidity: 0.9, altitude: 0.4, slope: 0.05,
900            coast_distance: 0.5, volcanic: false,
901        };
902        assert_eq!(BiomeClassifier::classify(&p), BiomeType::TropicalForest);
903    }
904
905    #[test]
906    fn test_biome_classifier_volcanic() {
907        let p = BiomeParams {
908            temperature: 0.7, humidity: 0.2, altitude: 0.8, slope: 0.75,
909            coast_distance: 0.7, volcanic: true,
910        };
911        assert_eq!(BiomeClassifier::classify(&p), BiomeType::Volcanic);
912    }
913
914    #[test]
915    fn test_climate_simulator() {
916        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
917        let sim = ClimateSimulator::default();
918        let climate = sim.simulate(&hm);
919        assert_eq!(climate.temperature.data.len(), 32 * 32);
920        assert_eq!(climate.humidity.data.len(), 32 * 32);
921        assert!(climate.temperature.min_value() >= 0.0);
922        assert!(climate.temperature.max_value() <= 1.0);
923    }
924
925    #[test]
926    fn test_biome_map_from_heightmap() {
927        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
928        let sim = ClimateSimulator::default();
929        let climate = sim.simulate(&hm);
930        let bm = BiomeMap::from_heightmap(&hm, &climate);
931        assert_eq!(bm.biomes.len(), 32 * 32);
932    }
933
934    #[test]
935    fn test_biome_map_blend_weights() {
936        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
937        let sim = ClimateSimulator::default();
938        let climate = sim.simulate(&hm);
939        let bm = BiomeMap::from_heightmap(&hm, &climate);
940        let weights = bm.blend_weights(16.5, 16.5);
941        let total: f32 = weights.iter().map(|(_, w)| w).sum();
942        assert!((total - 1.0).abs() < 1e-4);
943    }
944
945    #[test]
946    fn test_vegetation_density() {
947        let d = VegetationDensity::for_biome(BiomeType::TropicalForest);
948        assert!(d.tree_density > 0.5);
949        let d2 = VegetationDensity::for_biome(BiomeType::Arctic);
950        assert!(d2.tree_density < 0.1);
951    }
952
953    #[test]
954    fn test_biome_colors_all_defined() {
955        let all = [
956            BiomeType::Ocean, BiomeType::DeepOcean, BiomeType::Beach,
957            BiomeType::Desert, BiomeType::Savanna, BiomeType::Grassland,
958            BiomeType::Shrubland, BiomeType::TemperateForest, BiomeType::TropicalForest,
959            BiomeType::Boreal, BiomeType::Taiga, BiomeType::Tundra,
960            BiomeType::Arctic, BiomeType::Mountain, BiomeType::AlpineGlacier,
961            BiomeType::Swamp, BiomeType::Mangrove, BiomeType::Volcanic,
962            BiomeType::Badlands, BiomeType::Mushroom,
963        ];
964        for biome in all {
965            let color = BiomeColor::for_biome(biome);
966            // All color components in [0, 1]
967            assert!(color.ground.x >= 0.0 && color.ground.x <= 1.0);
968        }
969    }
970
971    #[test]
972    fn test_season_factor() {
973        let summer = SeasonFactor::season_factor(BiomeType::TemperateForest, 6);
974        let winter = SeasonFactor::season_factor(BiomeType::TemperateForest, 0);
975        assert!(summer.vegetation_green > winter.vegetation_green);
976        assert!(winter.snow_cover >= summer.snow_cover);
977    }
978
979    #[test]
980    fn test_transition_zone() {
981        // Grassland to desert is one of the pairs declared sharp, so the old
982        // test's "mid strictly between 0 and 1" could never hold for it.
983        let sharp = TransitionZone::new(BiomeType::Grassland, BiomeType::Desert, 10.0);
984        assert_eq!(sharp.blend_factor(0.25), 0.0);
985        assert_eq!(sharp.blend_factor(0.75), 1.0);
986        let tz = TransitionZone::new(BiomeType::Grassland, BiomeType::TemperateForest, 10.0);
987        assert!((tz.blend_factor(0.0) - 0.0).abs() < 0.01);
988        assert!((tz.blend_factor(1.0) - 1.0).abs() < 0.01);
989        let mid = tz.blend_factor(0.5);
990        assert!(mid > 0.0 && mid < 1.0);
991        assert!(tz.blend_factor(0.3) < tz.blend_factor(0.7));
992    }
993}
994
995// ── Extended Biome Analysis ────────────────────────────────────────────────────
996
997/// Statistics about biome distribution in a region.
998#[derive(Clone, Debug, Default)]
999pub struct BiomeStats {
1000    /// Count of each biome type (indexed by BiomeType as usize).
1001    pub counts: [usize; 20],
1002    /// Total cells counted.
1003    pub total:  usize,
1004}
1005
1006impl BiomeStats {
1007    /// Compute biome statistics from a BiomeMap.
1008    pub fn from_map(bm: &BiomeMap) -> Self {
1009        let mut stats = Self::default();
1010        stats.total = bm.biomes.len();
1011        for &b in &bm.biomes {
1012            let idx = b as usize;
1013            if idx < 20 { stats.counts[idx] += 1; }
1014        }
1015        stats
1016    }
1017
1018    /// Fraction of the map covered by a given biome (0..1).
1019    pub fn fraction(&self, biome: BiomeType) -> f32 {
1020        if self.total == 0 { return 0.0; }
1021        self.counts[biome as usize] as f32 / self.total as f32
1022    }
1023
1024    /// Most common biome.
1025    pub fn dominant_biome(&self) -> BiomeType {
1026        let idx = self.counts.iter().enumerate()
1027            .max_by_key(|(_, &c)| c)
1028            .map(|(i, _)| i)
1029            .unwrap_or(0);
1030        biome_from_index(idx)
1031    }
1032
1033    /// Biomes sorted by prevalence (most common first).
1034    pub fn sorted_biomes(&self) -> Vec<(BiomeType, usize)> {
1035        let mut pairs: Vec<(BiomeType, usize)> = self.counts.iter()
1036            .enumerate()
1037            .filter(|(_, &c)| c > 0)
1038            .map(|(i, &c)| (biome_from_index(i), c))
1039            .collect();
1040        pairs.sort_by(|a, b| b.1.cmp(&a.1));
1041        pairs
1042    }
1043
1044    /// Biome diversity index (Shannon entropy, normalized).
1045    pub fn diversity_index(&self) -> f32 {
1046        if self.total == 0 { return 0.0; }
1047        let n = self.total as f32;
1048        let entropy: f32 = self.counts.iter()
1049            .filter(|&&c| c > 0)
1050            .map(|&c| {
1051                let p = c as f32 / n;
1052                -p * p.ln()
1053            })
1054            .sum();
1055        // Normalize by max possible entropy (log of num biomes)
1056        entropy / (20.0f32).ln()
1057    }
1058}
1059
1060pub fn biome_from_index(idx: usize) -> BiomeType {
1061    match idx {
1062        0  => BiomeType::Ocean,
1063        1  => BiomeType::DeepOcean,
1064        2  => BiomeType::Beach,
1065        3  => BiomeType::Desert,
1066        4  => BiomeType::Savanna,
1067        5  => BiomeType::Grassland,
1068        6  => BiomeType::Shrubland,
1069        7  => BiomeType::TemperateForest,
1070        8  => BiomeType::TropicalForest,
1071        9  => BiomeType::Boreal,
1072        10 => BiomeType::Taiga,
1073        11 => BiomeType::Tundra,
1074        12 => BiomeType::Arctic,
1075        13 => BiomeType::Mountain,
1076        14 => BiomeType::AlpineGlacier,
1077        15 => BiomeType::Swamp,
1078        16 => BiomeType::Mangrove,
1079        17 => BiomeType::Volcanic,
1080        18 => BiomeType::Badlands,
1081        _  => BiomeType::Mushroom,
1082    }
1083}
1084
1085// ── Biome Adjacency and Connectivity ──────────────────────────────────────────
1086
1087/// Tracks which biomes border each other in a map.
1088#[derive(Clone, Debug, Default)]
1089pub struct BiomeAdjacency {
1090    /// `adjacency[a][b]` = number of boundary cells where biome `a` borders `b`.
1091    pub adjacency: [[usize; 20]; 20],
1092}
1093
1094impl BiomeAdjacency {
1095    pub fn from_map(bm: &BiomeMap) -> Self {
1096        let mut adj = Self::default();
1097        let dirs: [(i32, i32); 4] = [(1,0),(-1,0),(0,1),(0,-1)];
1098        for y in 0..bm.height {
1099            for x in 0..bm.width {
1100                let b0 = bm.get(x, y) as usize;
1101                for (dx, dy) in &dirs {
1102                    let nx = x as i32 + dx;
1103                    let ny = y as i32 + dy;
1104                    if nx >= 0 && nx < bm.width as i32 && ny >= 0 && ny < bm.height as i32 {
1105                        let b1 = bm.get(nx as usize, ny as usize) as usize;
1106                        if b0 != b1 && b0 < 20 && b1 < 20 {
1107                            adj.adjacency[b0][b1] += 1;
1108                        }
1109                    }
1110                }
1111            }
1112        }
1113        adj
1114    }
1115
1116    /// Total boundary length for a given biome.
1117    pub fn boundary_length(&self, biome: BiomeType) -> usize {
1118        self.adjacency[biome as usize].iter().sum()
1119    }
1120
1121    /// List biomes that are adjacent to the given biome.
1122    pub fn neighbors(&self, biome: BiomeType) -> Vec<BiomeType> {
1123        self.adjacency[biome as usize].iter()
1124            .enumerate()
1125            .filter(|(_, &c)| c > 0)
1126            .map(|(i, _)| biome_from_index(i))
1127            .collect()
1128    }
1129}
1130
1131// ── Biome Noise Variation ──────────────────────────────────────────────────────
1132
1133/// Adds noise variation to biome parameters for more organic transitions.
1134pub struct BiomeNoiseVariator {
1135    temperature_noise_scale: f32,
1136    humidity_noise_scale:    f32,
1137    seed: u64,
1138}
1139
1140impl BiomeNoiseVariator {
1141    pub fn new(temperature_scale: f32, humidity_scale: f32, seed: u64) -> Self {
1142        Self {
1143            temperature_noise_scale: temperature_scale,
1144            humidity_noise_scale: humidity_scale,
1145            seed,
1146        }
1147    }
1148
1149    /// Add noise variation to climate params at given position.
1150    pub fn vary(&self, params: &BiomeParams, x: f32, y: f32) -> BiomeParams {
1151        let noise = crate::terrain::heightmap::GradientNoisePublic::new(self.seed);
1152        let tn = noise.noise2d(x * 0.05, y * 0.05) * 2.0 - 1.0;
1153        let hn = noise.noise2d(x * 0.05 + 100.0, y * 0.05 + 100.0) * 2.0 - 1.0;
1154        BiomeParams {
1155            temperature: (params.temperature + tn * self.temperature_noise_scale).clamp(0.0, 1.0),
1156            humidity:    (params.humidity    + hn * self.humidity_noise_scale).clamp(0.0, 1.0),
1157            ..*params
1158        }
1159    }
1160}
1161
1162// ── Extended Climate Simulation ───────────────────────────────────────────────
1163
1164/// Simulates rivers as paths of high precipitation runoff.
1165pub struct RiverSimulator;
1166
1167impl RiverSimulator {
1168    /// Generate river paths as a binary heightmap (1 = river cell).
1169    /// Uses flow accumulation: cells with high total upstream flow become rivers.
1170    pub fn generate(heightmap: &crate::terrain::heightmap::HeightMap, threshold: f32) -> crate::terrain::heightmap::HeightMap {
1171        let w = heightmap.width;
1172        let h = heightmap.height;
1173        let flow_dirs = heightmap.flow_map();
1174        // Accumulate flow: simple flood-fill counting upstream cells
1175        let mut accumulation = vec![1.0f32; w * h];
1176        // Sort cells by height (process high first)
1177        let mut order: Vec<(usize, usize)> = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).collect();
1178        order.sort_by(|&(ax, ay), &(bx, by)| {
1179            heightmap.get(bx, by).partial_cmp(&heightmap.get(ax, ay))
1180                .unwrap_or(std::cmp::Ordering::Equal)
1181        });
1182        let dirs: [(f32, f32); 8] = [
1183            (-1.0,-1.0),(0.0,-1.0),(1.0,-1.0),
1184            (-1.0, 0.0),           (1.0, 0.0),
1185            (-1.0, 1.0),(0.0, 1.0),(1.0, 1.0),
1186        ];
1187        for (x, y) in &order {
1188            let dir_idx = (flow_dirs.get(*x, *y) * 8.0) as usize;
1189            if dir_idx >= 8 { continue; }
1190            let (dx, dy) = dirs[dir_idx];
1191            let nx = (*x as i32 + dx as i32) as usize;
1192            let ny = (*y as i32 + dy as i32) as usize;
1193            if nx < w && ny < h {
1194                let val = accumulation[y * w + x];
1195                accumulation[ny * w + nx] += val;
1196            }
1197        }
1198        // Normalize and threshold
1199        let max_acc = accumulation.iter().cloned().fold(0.0f32, f32::max);
1200        let mut out = crate::terrain::heightmap::HeightMap::new(w, h);
1201        if max_acc > 0.0 {
1202            for i in 0..(w*h) {
1203                let norm = accumulation[i] / max_acc;
1204                out.data[i] = if norm > threshold { 1.0 } else { 0.0 };
1205            }
1206        }
1207        out
1208    }
1209}
1210
1211// ── Biome Transition Map ───────────────────────────────────────────────────────
1212
1213/// A map where each cell stores the blend factor between its biome and its neighbors.
1214#[derive(Clone, Debug)]
1215pub struct BiomeTransitionMap {
1216    pub width:  usize,
1217    pub height: usize,
1218    /// 0 = pure biome, 1 = at a transition boundary.
1219    pub transitions: Vec<f32>,
1220}
1221
1222impl BiomeTransitionMap {
1223    /// Compute transition map from a BiomeMap using a Gaussian kernel.
1224    pub fn from_map(bm: &BiomeMap, radius: usize) -> Self {
1225        let w = bm.width;
1226        let h = bm.height;
1227        let mut transitions = vec![0.0f32; w * h];
1228        for y in 0..h {
1229            for x in 0..w {
1230                let base = bm.get(x, y);
1231                let mut diff_count = 0usize;
1232                let mut total = 0usize;
1233                for dy in -(radius as i32)..=(radius as i32) {
1234                    for dx in -(radius as i32)..=(radius as i32) {
1235                        let nx = x as i32 + dx;
1236                        let ny = y as i32 + dy;
1237                        if nx >= 0 && nx < w as i32 && ny >= 0 && ny < h as i32 {
1238                            total += 1;
1239                            if bm.get(nx as usize, ny as usize) != base {
1240                                diff_count += 1;
1241                            }
1242                        }
1243                    }
1244                }
1245                transitions[y * w + x] = if total > 0 { diff_count as f32 / total as f32 } else { 0.0 };
1246            }
1247        }
1248        Self { width: w, height: h, transitions }
1249    }
1250
1251    /// Get transition intensity at (x, y).
1252    pub fn get(&self, x: usize, y: usize) -> f32 {
1253        if x < self.width && y < self.height {
1254            self.transitions[y * self.width + x]
1255        } else {
1256            0.0
1257        }
1258    }
1259}
1260
1261// ── Climate Zones ─────────────────────────────────────────────────────────────
1262
1263/// Major climate zone classification (Koppen simplified).
1264#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1265pub enum ClimateZone {
1266    /// Tropical (hot all year, frost-free).
1267    Tropical,
1268    /// Arid (very little precipitation).
1269    Arid,
1270    /// Temperate (mild temperatures, moderate precipitation).
1271    Temperate,
1272    /// Continental (large temperature swings, cold winters).
1273    Continental,
1274    /// Polar (very cold, permanent snow/ice).
1275    Polar,
1276}
1277
1278impl ClimateZone {
1279    pub fn from_params(temp: f32, humidity: f32, altitude: f32) -> Self {
1280        if altitude > 0.85 { return Self::Polar; }
1281        if temp < 0.15 { return Self::Polar; }
1282        if temp > 0.7 && humidity < 0.2 { return Self::Arid; }
1283        if temp > 0.6 { return Self::Tropical; }
1284        if temp > 0.35 && humidity > 0.3 { return Self::Temperate; }
1285        if temp > 0.25 { return Self::Continental; }
1286        Self::Polar
1287    }
1288
1289    pub fn name(self) -> &'static str {
1290        match self {
1291            Self::Tropical    => "Tropical",
1292            Self::Arid        => "Arid",
1293            Self::Temperate   => "Temperate",
1294            Self::Continental => "Continental",
1295            Self::Polar       => "Polar",
1296        }
1297    }
1298}
1299
1300// ── Precipitation Patterns ─────────────────────────────────────────────────────
1301
1302/// Models seasonal precipitation patterns for a biome.
1303#[derive(Clone, Debug)]
1304pub struct PrecipitationPattern {
1305    /// Monthly precipitation values (index 0 = January).
1306    pub monthly: [f32; 12],
1307    /// Total annual precipitation.
1308    pub annual:  f32,
1309    /// Peak rainfall month (0–11).
1310    pub peak_month: usize,
1311    /// Whether precipitation is mostly snow (temperature-dependent).
1312    pub mostly_snow: bool,
1313}
1314
1315impl PrecipitationPattern {
1316    pub fn for_biome(biome: BiomeType) -> Self {
1317        let monthly: [f32; 12] = match biome {
1318            BiomeType::TropicalForest => [250.0, 230.0, 240.0, 280.0, 300.0, 350.0, 380.0, 370.0, 320.0, 290.0, 260.0, 240.0],
1319            BiomeType::Desert         => [5.0, 3.0, 4.0, 8.0, 10.0, 2.0, 1.0, 1.0, 3.0, 6.0, 5.0, 4.0],
1320            BiomeType::Grassland      => [30.0, 35.0, 45.0, 60.0, 80.0, 90.0, 85.0, 75.0, 55.0, 45.0, 35.0, 28.0],
1321            BiomeType::TemperateForest=> [80.0, 75.0, 85.0, 90.0, 95.0, 100.0, 90.0, 85.0, 90.0, 95.0, 90.0, 85.0],
1322            BiomeType::Savanna        => [10.0, 15.0, 30.0, 60.0, 100.0, 120.0, 130.0, 120.0, 100.0, 60.0, 25.0, 12.0],
1323            BiomeType::Tundra         => [15.0, 12.0, 14.0, 18.0, 22.0, 30.0, 35.0, 33.0, 25.0, 20.0, 17.0, 14.0],
1324            BiomeType::Arctic         => [5.0, 4.0, 5.0, 6.0, 8.0, 12.0, 15.0, 14.0, 10.0, 7.0, 6.0, 5.0],
1325            _                         => [50.0; 12],
1326        };
1327        let annual: f32 = monthly.iter().sum();
1328        let peak_month = monthly.iter().enumerate()
1329            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1330            .map(|(i, _)| i).unwrap_or(0);
1331        let mostly_snow = matches!(biome, BiomeType::Arctic | BiomeType::AlpineGlacier | BiomeType::Tundra);
1332        Self { monthly, annual, peak_month, mostly_snow }
1333    }
1334
1335    pub fn monthly_mm(&self, month: usize) -> f32 {
1336        self.monthly[month % 12]
1337    }
1338
1339    pub fn is_dry_season(&self, month: usize) -> bool {
1340        let m = month % 12;
1341        self.monthly[m] < self.annual / 12.0 * 0.5
1342    }
1343}
1344
1345// ── Temperature Range ─────────────────────────────────────────────────────────
1346
1347/// Monthly temperature range for a biome.
1348#[derive(Clone, Debug)]
1349pub struct TemperatureRange {
1350    /// Monthly average temperatures in °C.
1351    pub monthly_avg: [f32; 12],
1352    pub annual_mean: f32,
1353    pub annual_min:  f32,
1354    pub annual_max:  f32,
1355}
1356
1357impl TemperatureRange {
1358    pub fn for_biome(biome: BiomeType) -> Self {
1359        let monthly_avg: [f32; 12] = match biome {
1360            BiomeType::TropicalForest => [27.0, 27.5, 28.0, 28.0, 27.5, 27.0, 26.5, 26.5, 27.0, 27.0, 27.0, 27.0],
1361            BiomeType::Desert         => [15.0, 18.0, 23.0, 28.0, 33.0, 38.0, 40.0, 39.0, 35.0, 28.0, 21.0, 16.0],
1362            BiomeType::Grassland      => [2.0, 4.0, 9.0, 14.0, 19.0, 23.0, 25.0, 24.0, 20.0, 14.0, 7.0, 3.0],
1363            BiomeType::TemperateForest=> [3.0, 5.0, 9.0, 14.0, 18.0, 21.0, 23.0, 22.0, 18.0, 13.0, 7.0, 4.0],
1364            BiomeType::Tundra         => [-20.0,-18.0,-12.0,-3.0, 3.0, 8.0, 11.0, 10.0, 5.0, -2.0,-10.0,-17.0],
1365            BiomeType::Arctic         => [-35.0,-33.0,-28.0,-15.0,-5.0, 1.0, 3.0, 2.0, -3.0,-14.0,-25.0,-32.0],
1366            BiomeType::AlpineGlacier  => [-15.0,-14.0,-10.0,-4.0, 0.0, 3.0, 5.0, 4.0, 1.0, -4.0,-10.0,-14.0],
1367            _                         => [10.0; 12],
1368        };
1369        let annual_mean: f32 = monthly_avg.iter().sum::<f32>() / 12.0;
1370        let annual_min:  f32 = monthly_avg.iter().cloned().fold(f32::INFINITY, f32::min);
1371        let annual_max:  f32 = monthly_avg.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1372        Self { monthly_avg, annual_mean, annual_min, annual_max }
1373    }
1374
1375    pub fn is_frozen(&self, month: usize) -> bool {
1376        self.monthly_avg[month % 12] < 0.0
1377    }
1378
1379    pub fn frost_free_months(&self) -> usize {
1380        self.monthly_avg.iter().filter(|&&t| t > 0.0).count()
1381    }
1382}
1383
1384// ── Biome Succession ──────────────────────────────────────────────────────────
1385
1386/// Models how biomes transition over time (ecological succession).
1387pub struct BiomeSuccession;
1388
1389impl BiomeSuccession {
1390    /// Given current biome and time elapsed (years), return the probable successor.
1391    pub fn successor(biome: BiomeType, years: f32) -> BiomeType {
1392        match biome {
1393            BiomeType::Badlands if years > 50.0    => BiomeType::Shrubland,
1394            BiomeType::Shrubland if years > 100.0  => BiomeType::Grassland,
1395            BiomeType::Grassland if years > 200.0  => BiomeType::TemperateForest,
1396            BiomeType::Tundra if years > 500.0     => BiomeType::Taiga,
1397            BiomeType::Taiga if years > 1000.0     => BiomeType::Boreal,
1398            BiomeType::Desert if years > 100.0     => BiomeType::Shrubland,
1399            BiomeType::Volcanic if years > 20.0    => BiomeType::Badlands,
1400            BiomeType::Beach if years > 30.0       => BiomeType::Grassland,
1401            _                                       => biome,
1402        }
1403    }
1404
1405    /// How many years until the next succession event.
1406    pub fn time_to_next(biome: BiomeType) -> f32 {
1407        match biome {
1408            BiomeType::Volcanic  => 20.0,
1409            BiomeType::Beach     => 30.0,
1410            BiomeType::Badlands  => 50.0,
1411            BiomeType::Shrubland => 100.0,
1412            BiomeType::Desert    => 100.0,
1413            BiomeType::Grassland => 200.0,
1414            BiomeType::Tundra    => 500.0,
1415            BiomeType::Taiga     => 1000.0,
1416            _                    => f32::INFINITY,
1417        }
1418    }
1419}
1420
1421// ── Extended Biome Tests ──────────────────────────────────────────────────────
1422
1423#[cfg(test)]
1424mod extended_biome_tests {
1425    use super::*;
1426    use crate::terrain::heightmap::FractalNoise;
1427
1428    #[test]
1429    fn test_biome_stats_from_map() {
1430        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1431        let sim = ClimateSimulator::default();
1432        let climate = sim.simulate(&hm);
1433        let bm = BiomeMap::from_heightmap(&hm, &climate);
1434        let stats = BiomeStats::from_map(&bm);
1435        assert_eq!(stats.total, 32 * 32);
1436        let total: usize = stats.counts.iter().sum();
1437        assert_eq!(total, 32 * 32);
1438    }
1439
1440    #[test]
1441    fn test_biome_stats_diversity() {
1442        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1443        let sim = ClimateSimulator::default();
1444        let climate = sim.simulate(&hm);
1445        let bm = BiomeMap::from_heightmap(&hm, &climate);
1446        let stats = BiomeStats::from_map(&bm);
1447        let div = stats.diversity_index();
1448        assert!(div >= 0.0 && div <= 1.0);
1449    }
1450
1451    #[test]
1452    fn test_biome_adjacency() {
1453        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1454        let sim = ClimateSimulator::default();
1455        let climate = sim.simulate(&hm);
1456        let bm = BiomeMap::from_heightmap(&hm, &climate);
1457        let adj = BiomeAdjacency::from_map(&bm);
1458        // Symmetry: adjacency[a][b] == adjacency[b][a]
1459        for i in 0..20 {
1460            for j in 0..20 {
1461                assert_eq!(adj.adjacency[i][j], adj.adjacency[j][i],
1462                    "Adjacency should be symmetric");
1463            }
1464        }
1465    }
1466
1467    #[test]
1468    fn test_precipitation_pattern() {
1469        let pat = PrecipitationPattern::for_biome(BiomeType::TropicalForest);
1470        assert!(pat.annual > 1000.0, "Tropical forest should be wet");
1471        let dry = PrecipitationPattern::for_biome(BiomeType::Desert);
1472        assert!(dry.annual < 100.0, "Desert should be dry");
1473    }
1474
1475    #[test]
1476    fn test_temperature_range() {
1477        let tr = TemperatureRange::for_biome(BiomeType::Arctic);
1478        assert!(tr.annual_max < 10.0, "Arctic should be cold year-round");
1479        let tropic = TemperatureRange::for_biome(BiomeType::TropicalForest);
1480        assert!(tropic.annual_min > 20.0, "Tropical should be warm year-round");
1481    }
1482
1483    #[test]
1484    fn test_climate_zone_classification() {
1485        assert_eq!(ClimateZone::from_params(0.9, 0.9, 0.3), ClimateZone::Tropical);
1486        assert_eq!(ClimateZone::from_params(0.8, 0.1, 0.3), ClimateZone::Arid);
1487        assert_eq!(ClimateZone::from_params(0.5, 0.6, 0.3), ClimateZone::Temperate);
1488        assert_eq!(ClimateZone::from_params(0.1, 0.3, 0.3), ClimateZone::Polar);
1489    }
1490
1491    #[test]
1492    fn test_biome_succession() {
1493        assert_eq!(BiomeSuccession::successor(BiomeType::Volcanic, 25.0), BiomeType::Badlands);
1494        assert_eq!(BiomeSuccession::successor(BiomeType::Volcanic, 5.0),  BiomeType::Volcanic);
1495        assert_eq!(BiomeSuccession::successor(BiomeType::Badlands, 100.0), BiomeType::Shrubland);
1496    }
1497
1498    #[test]
1499    fn test_biome_transition_map() {
1500        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1501        let sim = ClimateSimulator::default();
1502        let climate = sim.simulate(&hm);
1503        let bm = BiomeMap::from_heightmap(&hm, &climate);
1504        let tm = BiomeTransitionMap::from_map(&bm, 2);
1505        assert_eq!(tm.transitions.len(), 32 * 32);
1506        assert!(tm.transitions.iter().all(|&v| v >= 0.0 && v <= 1.0));
1507    }
1508
1509    #[test]
1510    fn test_biome_from_index_coverage() {
1511        for i in 0..20 {
1512            let b = biome_from_index(i);
1513            assert_eq!(b as usize, i);
1514        }
1515    }
1516
1517    #[test]
1518    fn test_river_simulator() {
1519        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1520        let rivers = RiverSimulator::generate(&hm, 0.9);
1521        assert_eq!(rivers.data.len(), 32 * 32);
1522    }
1523}