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 sigmoid
762            let x = t * 2.0 - 1.0;
763            0.5 + x * (1.0 - x.abs() * 0.5) * 0.5
764        }
765    }
766}
767
768// ── Seasonal Variation ────────────────────────────────────────────────────────
769
770/// Describes how a biome changes by season.
771#[derive(Clone, Copy, Debug)]
772pub struct SeasonFactor {
773    /// Color multiplier for vegetation (0 = dead/snow-covered, 1 = full green).
774    pub vegetation_green:  f32,
775    /// Color shift toward autumn browns/oranges.
776    pub autumn_shift:      f32,
777    /// Snow cover [0, 1].
778    pub snow_cover:        f32,
779    /// Effective vegetation density multiplier.
780    pub density_scale:     f32,
781}
782
783impl SeasonFactor {
784    /// Compute seasonal factor for a biome in month 0–11.
785    pub fn season_factor(biome: BiomeType, month: u32) -> Self {
786        let month = (month % 12) as f32;
787        // Northern hemisphere seasons: summer peak = month 6
788        let summer_t = ((month - 6.0) * std::f32::consts::PI / 6.0).cos() * 0.5 + 0.5;
789        // summer_t: 1.0 = peak summer, 0.0 = peak winter
790        let winter_t = 1.0 - summer_t;
791
792        match biome {
793            BiomeType::TemperateForest | BiomeType::Boreal => Self {
794                vegetation_green: 0.2 + summer_t * 0.8,
795                autumn_shift: if month > 7.0 && month < 11.0 { (month - 7.0) * 0.25 } else { 0.0 },
796                snow_cover: (winter_t - 0.6).max(0.0) * 2.5,
797                density_scale: 0.3 + summer_t * 0.7,
798            },
799            BiomeType::Taiga | BiomeType::Tundra => Self {
800                vegetation_green: 0.1 + summer_t * 0.7,
801                autumn_shift: 0.0,
802                snow_cover: winter_t * 0.9,
803                density_scale: 0.1 + summer_t * 0.6,
804            },
805            BiomeType::Arctic | BiomeType::AlpineGlacier => Self {
806                vegetation_green: summer_t * 0.2,
807                autumn_shift: 0.0,
808                snow_cover: 0.5 + winter_t * 0.5,
809                density_scale: summer_t * 0.15,
810            },
811            BiomeType::Grassland | BiomeType::Savanna => Self {
812                vegetation_green: 0.4 + summer_t * 0.5,
813                autumn_shift: (winter_t - 0.3).max(0.0) * 0.5,
814                snow_cover: (winter_t - 0.8).max(0.0) * 2.0,
815                density_scale: 0.5 + summer_t * 0.5,
816            },
817            BiomeType::Desert | BiomeType::Badlands => Self {
818                vegetation_green: 0.1,
819                autumn_shift: 0.0,
820                snow_cover: 0.0,
821                density_scale: 0.8 + summer_t * 0.2,
822            },
823            // Tropical/equatorial biomes: minimal seasonality
824            BiomeType::TropicalForest | BiomeType::Mangrove | BiomeType::Swamp => Self {
825                vegetation_green: 0.9,
826                autumn_shift: 0.0,
827                snow_cover: 0.0,
828                density_scale: 1.0,
829            },
830            _ => Self {
831                vegetation_green: 0.5 + summer_t * 0.5,
832                autumn_shift: 0.0,
833                snow_cover: winter_t * 0.3,
834                density_scale: 0.6 + summer_t * 0.4,
835            },
836        }
837    }
838}
839
840// ── Tests ─────────────────────────────────────────────────────────────────────
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use crate::terrain::heightmap::FractalNoise;
846
847    #[test]
848    fn test_biome_type_names() {
849        assert_eq!(BiomeType::Desert.name(), "Desert");
850        assert_eq!(BiomeType::TropicalForest.name(), "Tropical Forest");
851        assert_eq!(BiomeType::AlpineGlacier.name(), "Alpine Glacier");
852    }
853
854    #[test]
855    fn test_biome_type_properties() {
856        assert!(BiomeType::Ocean.is_aquatic());
857        assert!(!BiomeType::Desert.is_aquatic());
858        assert!(BiomeType::Arctic.is_cold());
859        assert!(!BiomeType::Desert.is_cold());
860        assert!(BiomeType::TropicalForest.has_trees());
861        assert!(!BiomeType::Arctic.has_trees());
862    }
863
864    #[test]
865    fn test_biome_classifier_desert() {
866        let p = BiomeParams {
867            temperature: 0.8, humidity: 0.1, altitude: 0.3, slope: 0.05,
868            coast_distance: 0.9, volcanic: false,
869        };
870        assert_eq!(BiomeClassifier::classify(&p), BiomeType::Desert);
871    }
872
873    #[test]
874    fn test_biome_classifier_ocean() {
875        let p = BiomeParams {
876            temperature: 0.5, humidity: 0.8, altitude: 0.01, slope: 0.0,
877            coast_distance: 0.0, volcanic: false,
878        };
879        assert!(matches!(
880            BiomeClassifier::classify(&p),
881            BiomeType::Ocean | BiomeType::DeepOcean
882        ));
883    }
884
885    #[test]
886    fn test_biome_classifier_alpine() {
887        let p = BiomeParams {
888            temperature: 0.2, humidity: 0.3, altitude: 0.96, slope: 0.3,
889            coast_distance: 0.8, volcanic: false,
890        };
891        assert_eq!(BiomeClassifier::classify(&p), BiomeType::AlpineGlacier);
892    }
893
894    #[test]
895    fn test_biome_classifier_tropical() {
896        let p = BiomeParams {
897            temperature: 0.9, humidity: 0.9, altitude: 0.4, slope: 0.05,
898            coast_distance: 0.5, volcanic: false,
899        };
900        assert_eq!(BiomeClassifier::classify(&p), BiomeType::TropicalForest);
901    }
902
903    #[test]
904    fn test_biome_classifier_volcanic() {
905        let p = BiomeParams {
906            temperature: 0.7, humidity: 0.2, altitude: 0.8, slope: 0.75,
907            coast_distance: 0.7, volcanic: true,
908        };
909        assert_eq!(BiomeClassifier::classify(&p), BiomeType::Volcanic);
910    }
911
912    #[test]
913    fn test_climate_simulator() {
914        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
915        let sim = ClimateSimulator::default();
916        let climate = sim.simulate(&hm);
917        assert_eq!(climate.temperature.data.len(), 32 * 32);
918        assert_eq!(climate.humidity.data.len(), 32 * 32);
919        assert!(climate.temperature.min_value() >= 0.0);
920        assert!(climate.temperature.max_value() <= 1.0);
921    }
922
923    #[test]
924    fn test_biome_map_from_heightmap() {
925        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
926        let sim = ClimateSimulator::default();
927        let climate = sim.simulate(&hm);
928        let bm = BiomeMap::from_heightmap(&hm, &climate);
929        assert_eq!(bm.biomes.len(), 32 * 32);
930    }
931
932    #[test]
933    fn test_biome_map_blend_weights() {
934        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
935        let sim = ClimateSimulator::default();
936        let climate = sim.simulate(&hm);
937        let bm = BiomeMap::from_heightmap(&hm, &climate);
938        let weights = bm.blend_weights(16.5, 16.5);
939        let total: f32 = weights.iter().map(|(_, w)| w).sum();
940        assert!((total - 1.0).abs() < 1e-4);
941    }
942
943    #[test]
944    fn test_vegetation_density() {
945        let d = VegetationDensity::for_biome(BiomeType::TropicalForest);
946        assert!(d.tree_density > 0.5);
947        let d2 = VegetationDensity::for_biome(BiomeType::Arctic);
948        assert!(d2.tree_density < 0.1);
949    }
950
951    #[test]
952    fn test_biome_colors_all_defined() {
953        let all = [
954            BiomeType::Ocean, BiomeType::DeepOcean, BiomeType::Beach,
955            BiomeType::Desert, BiomeType::Savanna, BiomeType::Grassland,
956            BiomeType::Shrubland, BiomeType::TemperateForest, BiomeType::TropicalForest,
957            BiomeType::Boreal, BiomeType::Taiga, BiomeType::Tundra,
958            BiomeType::Arctic, BiomeType::Mountain, BiomeType::AlpineGlacier,
959            BiomeType::Swamp, BiomeType::Mangrove, BiomeType::Volcanic,
960            BiomeType::Badlands, BiomeType::Mushroom,
961        ];
962        for biome in all {
963            let color = BiomeColor::for_biome(biome);
964            // All color components in [0, 1]
965            assert!(color.ground.x >= 0.0 && color.ground.x <= 1.0);
966        }
967    }
968
969    #[test]
970    fn test_season_factor() {
971        let summer = SeasonFactor::season_factor(BiomeType::TemperateForest, 6);
972        let winter = SeasonFactor::season_factor(BiomeType::TemperateForest, 0);
973        assert!(summer.vegetation_green > winter.vegetation_green);
974        assert!(winter.snow_cover >= summer.snow_cover);
975    }
976
977    #[test]
978    fn test_transition_zone() {
979        let tz = TransitionZone::new(BiomeType::Grassland, BiomeType::Desert, 10.0);
980        assert!((tz.blend_factor(0.0) - 0.0).abs() < 0.01);
981        let mid = tz.blend_factor(0.5);
982        assert!(mid > 0.0 && mid < 1.0);
983    }
984}
985
986// ── Extended Biome Analysis ────────────────────────────────────────────────────
987
988/// Statistics about biome distribution in a region.
989#[derive(Clone, Debug, Default)]
990pub struct BiomeStats {
991    /// Count of each biome type (indexed by BiomeType as usize).
992    pub counts: [usize; 20],
993    /// Total cells counted.
994    pub total:  usize,
995}
996
997impl BiomeStats {
998    /// Compute biome statistics from a BiomeMap.
999    pub fn from_map(bm: &BiomeMap) -> Self {
1000        let mut stats = Self::default();
1001        stats.total = bm.biomes.len();
1002        for &b in &bm.biomes {
1003            let idx = b as usize;
1004            if idx < 20 { stats.counts[idx] += 1; }
1005        }
1006        stats
1007    }
1008
1009    /// Fraction of the map covered by a given biome (0..1).
1010    pub fn fraction(&self, biome: BiomeType) -> f32 {
1011        if self.total == 0 { return 0.0; }
1012        self.counts[biome as usize] as f32 / self.total as f32
1013    }
1014
1015    /// Most common biome.
1016    pub fn dominant_biome(&self) -> BiomeType {
1017        let idx = self.counts.iter().enumerate()
1018            .max_by_key(|(_, &c)| c)
1019            .map(|(i, _)| i)
1020            .unwrap_or(0);
1021        biome_from_index(idx)
1022    }
1023
1024    /// Biomes sorted by prevalence (most common first).
1025    pub fn sorted_biomes(&self) -> Vec<(BiomeType, usize)> {
1026        let mut pairs: Vec<(BiomeType, usize)> = self.counts.iter()
1027            .enumerate()
1028            .filter(|(_, &c)| c > 0)
1029            .map(|(i, &c)| (biome_from_index(i), c))
1030            .collect();
1031        pairs.sort_by(|a, b| b.1.cmp(&a.1));
1032        pairs
1033    }
1034
1035    /// Biome diversity index (Shannon entropy, normalized).
1036    pub fn diversity_index(&self) -> f32 {
1037        if self.total == 0 { return 0.0; }
1038        let n = self.total as f32;
1039        let entropy: f32 = self.counts.iter()
1040            .filter(|&&c| c > 0)
1041            .map(|&c| {
1042                let p = c as f32 / n;
1043                -p * p.ln()
1044            })
1045            .sum();
1046        // Normalize by max possible entropy (log of num biomes)
1047        entropy / (20.0f32).ln()
1048    }
1049}
1050
1051pub fn biome_from_index(idx: usize) -> BiomeType {
1052    match idx {
1053        0  => BiomeType::Ocean,
1054        1  => BiomeType::DeepOcean,
1055        2  => BiomeType::Beach,
1056        3  => BiomeType::Desert,
1057        4  => BiomeType::Savanna,
1058        5  => BiomeType::Grassland,
1059        6  => BiomeType::Shrubland,
1060        7  => BiomeType::TemperateForest,
1061        8  => BiomeType::TropicalForest,
1062        9  => BiomeType::Boreal,
1063        10 => BiomeType::Taiga,
1064        11 => BiomeType::Tundra,
1065        12 => BiomeType::Arctic,
1066        13 => BiomeType::Mountain,
1067        14 => BiomeType::AlpineGlacier,
1068        15 => BiomeType::Swamp,
1069        16 => BiomeType::Mangrove,
1070        17 => BiomeType::Volcanic,
1071        18 => BiomeType::Badlands,
1072        _  => BiomeType::Mushroom,
1073    }
1074}
1075
1076// ── Biome Adjacency and Connectivity ──────────────────────────────────────────
1077
1078/// Tracks which biomes border each other in a map.
1079#[derive(Clone, Debug, Default)]
1080pub struct BiomeAdjacency {
1081    /// `adjacency[a][b]` = number of boundary cells where biome `a` borders `b`.
1082    pub adjacency: [[usize; 20]; 20],
1083}
1084
1085impl BiomeAdjacency {
1086    pub fn from_map(bm: &BiomeMap) -> Self {
1087        let mut adj = Self::default();
1088        let dirs: [(i32, i32); 4] = [(1,0),(-1,0),(0,1),(0,-1)];
1089        for y in 0..bm.height {
1090            for x in 0..bm.width {
1091                let b0 = bm.get(x, y) as usize;
1092                for (dx, dy) in &dirs {
1093                    let nx = x as i32 + dx;
1094                    let ny = y as i32 + dy;
1095                    if nx >= 0 && nx < bm.width as i32 && ny >= 0 && ny < bm.height as i32 {
1096                        let b1 = bm.get(nx as usize, ny as usize) as usize;
1097                        if b0 != b1 && b0 < 20 && b1 < 20 {
1098                            adj.adjacency[b0][b1] += 1;
1099                        }
1100                    }
1101                }
1102            }
1103        }
1104        adj
1105    }
1106
1107    /// Total boundary length for a given biome.
1108    pub fn boundary_length(&self, biome: BiomeType) -> usize {
1109        self.adjacency[biome as usize].iter().sum()
1110    }
1111
1112    /// List biomes that are adjacent to the given biome.
1113    pub fn neighbors(&self, biome: BiomeType) -> Vec<BiomeType> {
1114        self.adjacency[biome as usize].iter()
1115            .enumerate()
1116            .filter(|(_, &c)| c > 0)
1117            .map(|(i, _)| biome_from_index(i))
1118            .collect()
1119    }
1120}
1121
1122// ── Biome Noise Variation ──────────────────────────────────────────────────────
1123
1124/// Adds noise variation to biome parameters for more organic transitions.
1125pub struct BiomeNoiseVariator {
1126    temperature_noise_scale: f32,
1127    humidity_noise_scale:    f32,
1128    seed: u64,
1129}
1130
1131impl BiomeNoiseVariator {
1132    pub fn new(temperature_scale: f32, humidity_scale: f32, seed: u64) -> Self {
1133        Self {
1134            temperature_noise_scale: temperature_scale,
1135            humidity_noise_scale: humidity_scale,
1136            seed,
1137        }
1138    }
1139
1140    /// Add noise variation to climate params at given position.
1141    pub fn vary(&self, params: &BiomeParams, x: f32, y: f32) -> BiomeParams {
1142        let noise = crate::terrain::heightmap::GradientNoisePublic::new(self.seed);
1143        let tn = noise.noise2d(x * 0.05, y * 0.05) * 2.0 - 1.0;
1144        let hn = noise.noise2d(x * 0.05 + 100.0, y * 0.05 + 100.0) * 2.0 - 1.0;
1145        BiomeParams {
1146            temperature: (params.temperature + tn * self.temperature_noise_scale).clamp(0.0, 1.0),
1147            humidity:    (params.humidity    + hn * self.humidity_noise_scale).clamp(0.0, 1.0),
1148            ..*params
1149        }
1150    }
1151}
1152
1153// ── Extended Climate Simulation ───────────────────────────────────────────────
1154
1155/// Simulates rivers as paths of high precipitation runoff.
1156pub struct RiverSimulator;
1157
1158impl RiverSimulator {
1159    /// Generate river paths as a binary heightmap (1 = river cell).
1160    /// Uses flow accumulation: cells with high total upstream flow become rivers.
1161    pub fn generate(heightmap: &crate::terrain::heightmap::HeightMap, threshold: f32) -> crate::terrain::heightmap::HeightMap {
1162        let w = heightmap.width;
1163        let h = heightmap.height;
1164        let flow_dirs = heightmap.flow_map();
1165        // Accumulate flow: simple flood-fill counting upstream cells
1166        let mut accumulation = vec![1.0f32; w * h];
1167        // Sort cells by height (process high first)
1168        let mut order: Vec<(usize, usize)> = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).collect();
1169        order.sort_by(|&(ax, ay), &(bx, by)| {
1170            heightmap.get(bx, by).partial_cmp(&heightmap.get(ax, ay))
1171                .unwrap_or(std::cmp::Ordering::Equal)
1172        });
1173        let dirs: [(f32, f32); 8] = [
1174            (-1.0,-1.0),(0.0,-1.0),(1.0,-1.0),
1175            (-1.0, 0.0),           (1.0, 0.0),
1176            (-1.0, 1.0),(0.0, 1.0),(1.0, 1.0),
1177        ];
1178        for (x, y) in &order {
1179            let dir_idx = (flow_dirs.get(*x, *y) * 8.0) as usize;
1180            if dir_idx >= 8 { continue; }
1181            let (dx, dy) = dirs[dir_idx];
1182            let nx = (*x as i32 + dx as i32) as usize;
1183            let ny = (*y as i32 + dy as i32) as usize;
1184            if nx < w && ny < h {
1185                let val = accumulation[y * w + x];
1186                accumulation[ny * w + nx] += val;
1187            }
1188        }
1189        // Normalize and threshold
1190        let max_acc = accumulation.iter().cloned().fold(0.0f32, f32::max);
1191        let mut out = crate::terrain::heightmap::HeightMap::new(w, h);
1192        if max_acc > 0.0 {
1193            for i in 0..(w*h) {
1194                let norm = accumulation[i] / max_acc;
1195                out.data[i] = if norm > threshold { 1.0 } else { 0.0 };
1196            }
1197        }
1198        out
1199    }
1200}
1201
1202// ── Biome Transition Map ───────────────────────────────────────────────────────
1203
1204/// A map where each cell stores the blend factor between its biome and its neighbors.
1205#[derive(Clone, Debug)]
1206pub struct BiomeTransitionMap {
1207    pub width:  usize,
1208    pub height: usize,
1209    /// 0 = pure biome, 1 = at a transition boundary.
1210    pub transitions: Vec<f32>,
1211}
1212
1213impl BiomeTransitionMap {
1214    /// Compute transition map from a BiomeMap using a Gaussian kernel.
1215    pub fn from_map(bm: &BiomeMap, radius: usize) -> Self {
1216        let w = bm.width;
1217        let h = bm.height;
1218        let mut transitions = vec![0.0f32; w * h];
1219        for y in 0..h {
1220            for x in 0..w {
1221                let base = bm.get(x, y);
1222                let mut diff_count = 0usize;
1223                let mut total = 0usize;
1224                for dy in -(radius as i32)..=(radius as i32) {
1225                    for dx in -(radius as i32)..=(radius as i32) {
1226                        let nx = x as i32 + dx;
1227                        let ny = y as i32 + dy;
1228                        if nx >= 0 && nx < w as i32 && ny >= 0 && ny < h as i32 {
1229                            total += 1;
1230                            if bm.get(nx as usize, ny as usize) != base {
1231                                diff_count += 1;
1232                            }
1233                        }
1234                    }
1235                }
1236                transitions[y * w + x] = if total > 0 { diff_count as f32 / total as f32 } else { 0.0 };
1237            }
1238        }
1239        Self { width: w, height: h, transitions }
1240    }
1241
1242    /// Get transition intensity at (x, y).
1243    pub fn get(&self, x: usize, y: usize) -> f32 {
1244        if x < self.width && y < self.height {
1245            self.transitions[y * self.width + x]
1246        } else {
1247            0.0
1248        }
1249    }
1250}
1251
1252// ── Climate Zones ─────────────────────────────────────────────────────────────
1253
1254/// Major climate zone classification (Koppen simplified).
1255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1256pub enum ClimateZone {
1257    /// Tropical (hot all year, frost-free).
1258    Tropical,
1259    /// Arid (very little precipitation).
1260    Arid,
1261    /// Temperate (mild temperatures, moderate precipitation).
1262    Temperate,
1263    /// Continental (large temperature swings, cold winters).
1264    Continental,
1265    /// Polar (very cold, permanent snow/ice).
1266    Polar,
1267}
1268
1269impl ClimateZone {
1270    pub fn from_params(temp: f32, humidity: f32, altitude: f32) -> Self {
1271        if altitude > 0.85 { return Self::Polar; }
1272        if temp < 0.15 { return Self::Polar; }
1273        if temp > 0.7 && humidity < 0.2 { return Self::Arid; }
1274        if temp > 0.6 { return Self::Tropical; }
1275        if temp > 0.35 && humidity > 0.3 { return Self::Temperate; }
1276        if temp > 0.25 { return Self::Continental; }
1277        Self::Polar
1278    }
1279
1280    pub fn name(self) -> &'static str {
1281        match self {
1282            Self::Tropical    => "Tropical",
1283            Self::Arid        => "Arid",
1284            Self::Temperate   => "Temperate",
1285            Self::Continental => "Continental",
1286            Self::Polar       => "Polar",
1287        }
1288    }
1289}
1290
1291// ── Precipitation Patterns ─────────────────────────────────────────────────────
1292
1293/// Models seasonal precipitation patterns for a biome.
1294#[derive(Clone, Debug)]
1295pub struct PrecipitationPattern {
1296    /// Monthly precipitation values (index 0 = January).
1297    pub monthly: [f32; 12],
1298    /// Total annual precipitation.
1299    pub annual:  f32,
1300    /// Peak rainfall month (0–11).
1301    pub peak_month: usize,
1302    /// Whether precipitation is mostly snow (temperature-dependent).
1303    pub mostly_snow: bool,
1304}
1305
1306impl PrecipitationPattern {
1307    pub fn for_biome(biome: BiomeType) -> Self {
1308        let monthly: [f32; 12] = match biome {
1309            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],
1310            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],
1311            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],
1312            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],
1313            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],
1314            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],
1315            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],
1316            _                         => [50.0; 12],
1317        };
1318        let annual: f32 = monthly.iter().sum();
1319        let peak_month = monthly.iter().enumerate()
1320            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1321            .map(|(i, _)| i).unwrap_or(0);
1322        let mostly_snow = matches!(biome, BiomeType::Arctic | BiomeType::AlpineGlacier | BiomeType::Tundra);
1323        Self { monthly, annual, peak_month, mostly_snow }
1324    }
1325
1326    pub fn monthly_mm(&self, month: usize) -> f32 {
1327        self.monthly[month % 12]
1328    }
1329
1330    pub fn is_dry_season(&self, month: usize) -> bool {
1331        let m = month % 12;
1332        self.monthly[m] < self.annual / 12.0 * 0.5
1333    }
1334}
1335
1336// ── Temperature Range ─────────────────────────────────────────────────────────
1337
1338/// Monthly temperature range for a biome.
1339#[derive(Clone, Debug)]
1340pub struct TemperatureRange {
1341    /// Monthly average temperatures in °C.
1342    pub monthly_avg: [f32; 12],
1343    pub annual_mean: f32,
1344    pub annual_min:  f32,
1345    pub annual_max:  f32,
1346}
1347
1348impl TemperatureRange {
1349    pub fn for_biome(biome: BiomeType) -> Self {
1350        let monthly_avg: [f32; 12] = match biome {
1351            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],
1352            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],
1353            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],
1354            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],
1355            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],
1356            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],
1357            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],
1358            _                         => [10.0; 12],
1359        };
1360        let annual_mean: f32 = monthly_avg.iter().sum::<f32>() / 12.0;
1361        let annual_min:  f32 = monthly_avg.iter().cloned().fold(f32::INFINITY, f32::min);
1362        let annual_max:  f32 = monthly_avg.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1363        Self { monthly_avg, annual_mean, annual_min, annual_max }
1364    }
1365
1366    pub fn is_frozen(&self, month: usize) -> bool {
1367        self.monthly_avg[month % 12] < 0.0
1368    }
1369
1370    pub fn frost_free_months(&self) -> usize {
1371        self.monthly_avg.iter().filter(|&&t| t > 0.0).count()
1372    }
1373}
1374
1375// ── Biome Succession ──────────────────────────────────────────────────────────
1376
1377/// Models how biomes transition over time (ecological succession).
1378pub struct BiomeSuccession;
1379
1380impl BiomeSuccession {
1381    /// Given current biome and time elapsed (years), return the probable successor.
1382    pub fn successor(biome: BiomeType, years: f32) -> BiomeType {
1383        match biome {
1384            BiomeType::Badlands if years > 50.0    => BiomeType::Shrubland,
1385            BiomeType::Shrubland if years > 100.0  => BiomeType::Grassland,
1386            BiomeType::Grassland if years > 200.0  => BiomeType::TemperateForest,
1387            BiomeType::Tundra if years > 500.0     => BiomeType::Taiga,
1388            BiomeType::Taiga if years > 1000.0     => BiomeType::Boreal,
1389            BiomeType::Desert if years > 100.0     => BiomeType::Shrubland,
1390            BiomeType::Volcanic if years > 20.0    => BiomeType::Badlands,
1391            BiomeType::Beach if years > 30.0       => BiomeType::Grassland,
1392            _                                       => biome,
1393        }
1394    }
1395
1396    /// How many years until the next succession event.
1397    pub fn time_to_next(biome: BiomeType) -> f32 {
1398        match biome {
1399            BiomeType::Volcanic  => 20.0,
1400            BiomeType::Beach     => 30.0,
1401            BiomeType::Badlands  => 50.0,
1402            BiomeType::Shrubland => 100.0,
1403            BiomeType::Desert    => 100.0,
1404            BiomeType::Grassland => 200.0,
1405            BiomeType::Tundra    => 500.0,
1406            BiomeType::Taiga     => 1000.0,
1407            _                    => f32::INFINITY,
1408        }
1409    }
1410}
1411
1412// ── Extended Biome Tests ──────────────────────────────────────────────────────
1413
1414#[cfg(test)]
1415mod extended_biome_tests {
1416    use super::*;
1417    use crate::terrain::heightmap::FractalNoise;
1418
1419    #[test]
1420    fn test_biome_stats_from_map() {
1421        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1422        let sim = ClimateSimulator::default();
1423        let climate = sim.simulate(&hm);
1424        let bm = BiomeMap::from_heightmap(&hm, &climate);
1425        let stats = BiomeStats::from_map(&bm);
1426        assert_eq!(stats.total, 32 * 32);
1427        let total: usize = stats.counts.iter().sum();
1428        assert_eq!(total, 32 * 32);
1429    }
1430
1431    #[test]
1432    fn test_biome_stats_diversity() {
1433        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1434        let sim = ClimateSimulator::default();
1435        let climate = sim.simulate(&hm);
1436        let bm = BiomeMap::from_heightmap(&hm, &climate);
1437        let stats = BiomeStats::from_map(&bm);
1438        let div = stats.diversity_index();
1439        assert!(div >= 0.0 && div <= 1.0);
1440    }
1441
1442    #[test]
1443    fn test_biome_adjacency() {
1444        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1445        let sim = ClimateSimulator::default();
1446        let climate = sim.simulate(&hm);
1447        let bm = BiomeMap::from_heightmap(&hm, &climate);
1448        let adj = BiomeAdjacency::from_map(&bm);
1449        // Symmetry: adjacency[a][b] == adjacency[b][a]
1450        for i in 0..20 {
1451            for j in 0..20 {
1452                assert_eq!(adj.adjacency[i][j], adj.adjacency[j][i],
1453                    "Adjacency should be symmetric");
1454            }
1455        }
1456    }
1457
1458    #[test]
1459    fn test_precipitation_pattern() {
1460        let pat = PrecipitationPattern::for_biome(BiomeType::TropicalForest);
1461        assert!(pat.annual > 1000.0, "Tropical forest should be wet");
1462        let dry = PrecipitationPattern::for_biome(BiomeType::Desert);
1463        assert!(dry.annual < 100.0, "Desert should be dry");
1464    }
1465
1466    #[test]
1467    fn test_temperature_range() {
1468        let tr = TemperatureRange::for_biome(BiomeType::Arctic);
1469        assert!(tr.annual_max < 10.0, "Arctic should be cold year-round");
1470        let tropic = TemperatureRange::for_biome(BiomeType::TropicalForest);
1471        assert!(tropic.annual_min > 20.0, "Tropical should be warm year-round");
1472    }
1473
1474    #[test]
1475    fn test_climate_zone_classification() {
1476        assert_eq!(ClimateZone::from_params(0.9, 0.9, 0.3), ClimateZone::Tropical);
1477        assert_eq!(ClimateZone::from_params(0.8, 0.1, 0.3), ClimateZone::Arid);
1478        assert_eq!(ClimateZone::from_params(0.5, 0.6, 0.3), ClimateZone::Temperate);
1479        assert_eq!(ClimateZone::from_params(0.1, 0.3, 0.3), ClimateZone::Polar);
1480    }
1481
1482    #[test]
1483    fn test_biome_succession() {
1484        assert_eq!(BiomeSuccession::successor(BiomeType::Volcanic, 25.0), BiomeType::Badlands);
1485        assert_eq!(BiomeSuccession::successor(BiomeType::Volcanic, 5.0),  BiomeType::Volcanic);
1486        assert_eq!(BiomeSuccession::successor(BiomeType::Badlands, 100.0), BiomeType::Shrubland);
1487    }
1488
1489    #[test]
1490    fn test_biome_transition_map() {
1491        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1492        let sim = ClimateSimulator::default();
1493        let climate = sim.simulate(&hm);
1494        let bm = BiomeMap::from_heightmap(&hm, &climate);
1495        let tm = BiomeTransitionMap::from_map(&bm, 2);
1496        assert_eq!(tm.transitions.len(), 32 * 32);
1497        assert!(tm.transitions.iter().all(|&v| v >= 0.0 && v <= 1.0));
1498    }
1499
1500    #[test]
1501    fn test_biome_from_index_coverage() {
1502        for i in 0..20 {
1503            let b = biome_from_index(i);
1504            assert_eq!(b as usize, i);
1505        }
1506    }
1507
1508    #[test]
1509    fn test_river_simulator() {
1510        let hm = FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1511        let rivers = RiverSimulator::generate(&hm, 0.9);
1512        assert_eq!(rivers.data.len(), 32 * 32);
1513    }
1514}