diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 394c99c0..3c413526 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -448,20 +448,20 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", "r-efi 5.3.0", - "wasip2", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -1000,6 +1000,7 @@ dependencies = [ "mc-compute", "mc-core", "mc-flora", + "mc-mapgen", "rayon", "serde", "serde_json", @@ -1195,7 +1196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys", ] @@ -1480,7 +1481,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.3.3", ] [[package]] @@ -2049,6 +2050,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/src/simulator/crates/mc-ecology/Cargo.toml b/src/simulator/crates/mc-ecology/Cargo.toml index 77531134..7d75b4bd 100644 --- a/src/simulator/crates/mc-ecology/Cargo.toml +++ b/src/simulator/crates/mc-ecology/Cargo.toml @@ -10,6 +10,7 @@ gpu = ["dep:mc-compute"] mc-core = { path = "../mc-core" } mc-climate = { path = "../mc-climate" } mc-flora = { path = "../mc-flora" } +mc-mapgen = { path = "../mc-mapgen" } mc-compute = { path = "../mc-compute", optional = true, features = ["gpu"] } serde.workspace = true serde_json.workspace = true diff --git a/src/simulator/crates/mc-ecology/src/fauna_glyphs.rs b/src/simulator/crates/mc-ecology/src/fauna_glyphs.rs new file mode 100644 index 00000000..9a86c7d2 --- /dev/null +++ b/src/simulator/crates/mc-ecology/src/fauna_glyphs.rs @@ -0,0 +1,123 @@ +//! Lineage → glyph cluster mapping for fauna rendering. +//! +//! This module is a rendering-shell hint table. The Rust selector produces +//! `lineage` fields; the Godot or WASM canvas renderer consumes them to choose +//! drawing primitives. No simulation logic lives here. +//! +//! 12 lineage clusters from ECOLOGY_BINDING.md §2 (fauna glyph table in p1-49). + +/// Glyph cluster identifier — maps to a drawing primitive in the presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FaunaGlyphCluster { + Canines, + Ursids, + Cervids, + Bovids, + Felids, + Raptors, + Waterfowl, + Fish, + Reptiles, + Mythic, + Invertebrates, + MarineMammals, + /// Fallback for lineages not in the cluster table. + Generic, +} + +impl FaunaGlyphCluster { + pub fn as_str(self) -> &'static str { + match self { + FaunaGlyphCluster::Canines => "canines", + FaunaGlyphCluster::Ursids => "ursids", + FaunaGlyphCluster::Cervids => "cervids", + FaunaGlyphCluster::Bovids => "bovids", + FaunaGlyphCluster::Felids => "felids", + FaunaGlyphCluster::Raptors => "raptors", + FaunaGlyphCluster::Waterfowl => "waterfowl", + FaunaGlyphCluster::Fish => "fish", + FaunaGlyphCluster::Reptiles => "reptiles", + FaunaGlyphCluster::Mythic => "mythic", + FaunaGlyphCluster::Invertebrates => "invertebrates", + FaunaGlyphCluster::MarineMammals => "marine_mammals", + FaunaGlyphCluster::Generic => "generic", + } + } +} + +/// Map a species `lineage` tag to its glyph cluster. +/// The presenter uses the cluster to select the correct drawing primitive. +pub fn lineage_to_glyph_cluster(lineage: &str) -> FaunaGlyphCluster { + match lineage { + // Canines: wolves, foxes, jackals, wild dogs + "canines" | "wild_dogs" => FaunaGlyphCluster::Canines, + + // Ursids: bears and relatives + "bears" | "ursids" => FaunaGlyphCluster::Ursids, + + // Cervids: deer, moose, elk, reindeer + "cervids" | "deer" | "ungulates" => FaunaGlyphCluster::Cervids, + + // Bovids: bison, musk ox, antelope, cattle + "bovids" | "bovines" => FaunaGlyphCluster::Bovids, + + // Felids: cats, lions, leopards, lynx + "felids" | "cats" => FaunaGlyphCluster::Felids, + + // Raptors: eagles, hawks, falcons, owls + "raptors" | "birds_of_prey" | "owls" => FaunaGlyphCluster::Raptors, + + // Waterfowl: ducks, geese, herons, gulls + "waterfowl" | "wading_birds" | "seabirds" => FaunaGlyphCluster::Waterfowl, + + // Fish: freshwater and marine fish + "fish" | "freshwater_fish" | "marine_fish" | "sharks" => FaunaGlyphCluster::Fish, + + // Reptiles: snakes, crocodilians, lizards + "reptiles" | "snakes" | "crocodilians" | "lizards" => FaunaGlyphCluster::Reptiles, + + // Mythic: fantasy creatures — wargs, harpies, trolls, wyrms + "fantasy" | "mythic" | "constructs" | "freefolk_raider" => FaunaGlyphCluster::Mythic, + + // Invertebrates: insects, arachnids, worms + "insects_social" | "insects_pollinators" | "insects_decomposers" + | "arachnids" | "annelids" | "beetles" => FaunaGlyphCluster::Invertebrates, + + // Marine mammals: whales, dolphins, seals, walruses + "cetaceans" | "pinnipeds" | "otters" => FaunaGlyphCluster::MarineMammals, + + _ => FaunaGlyphCluster::Generic, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_lineages_resolve_non_generic() { + let cases = [ + ("canines", FaunaGlyphCluster::Canines), + ("bears", FaunaGlyphCluster::Ursids), + ("felids", FaunaGlyphCluster::Felids), + ("raptors", FaunaGlyphCluster::Raptors), + ("fish", FaunaGlyphCluster::Fish), + ("reptiles", FaunaGlyphCluster::Reptiles), + ("fantasy", FaunaGlyphCluster::Mythic), + ("insects_social", FaunaGlyphCluster::Invertebrates), + ("cetaceans", FaunaGlyphCluster::MarineMammals), + ]; + for (lineage, expected) in cases { + assert_eq!( + lineage_to_glyph_cluster(lineage), + expected, + "lineage {lineage} should map to {expected:?}" + ); + } + } + + #[test] + fn unknown_lineage_falls_back_to_generic() { + assert_eq!(lineage_to_glyph_cluster("unknown_lineage"), FaunaGlyphCluster::Generic); + } +} diff --git a/src/simulator/crates/mc-ecology/src/fauna_select.rs b/src/simulator/crates/mc-ecology/src/fauna_select.rs new file mode 100644 index 00000000..11d952d2 --- /dev/null +++ b/src/simulator/crates/mc-ecology/src/fauna_select.rs @@ -0,0 +1,506 @@ +//! Implements the Wave-C fauna-binding spec from +//! `public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md` §6–§9. +//! +//! Build `TerrainFaunaIndex` once from the Game-1 manifest and species JSON +//! files; call `pick_fauna_for_tile` per tile to get a deterministic, +//! trophic-rule-validated species list. + +use std::collections::{HashMap, HashSet}; + +use serde::Deserialize; + +use mc_mapgen::seed::{SeedDomain, tile_rng}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// Maximum fauna species returned per tile. +const MAX_FAUNA_PER_TILE: usize = 5; + +/// Water biomes — land domain species may not spawn here. +const WATER_BIOMES: &[&str] = &["ocean", "coastal_waters", "lake", "inland_sea"]; + +/// Marine biomes — marine domain species may only spawn here. +const MARINE_BIOMES: &[&str] = &["ocean", "coastal_waters", "inland_sea"]; + +// ── JSON schema ─────────────────────────────────────────────────────────────── + +/// Minimal JSON representation of a fauna manifest. +#[derive(Debug, Deserialize)] +pub struct FaunaManifest { + pub species: Vec, +} + +/// Minimal JSON representation of a fauna species file. +/// Only the fields needed for tile selection and trophic rules. +#[derive(Debug, Clone, Deserialize)] +pub struct FaunaSpec { + pub id: String, + pub name: String, + #[serde(default)] + pub biomes: Vec, + #[serde(default)] + pub domain: String, + #[serde(default)] + pub trophic_level: String, + #[serde(default)] + pub prey: Vec, + #[serde(default)] + pub ecology_tier: u8, + #[serde(default)] + pub lineage: String, + #[serde(default)] + pub traits: Vec, +} + +impl FaunaSpec { + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + pub fn is_apex_predator(&self) -> bool { + self.trophic_level == "apex_predator" + } + + pub fn is_predator(&self) -> bool { + self.trophic_level == "predator" || self.is_apex_predator() + } +} + +// ── Domain gate ─────────────────────────────────────────────────────────────── + +/// Returns true if this species may be placed on the given tile. +pub fn domain_gate( + spec: &FaunaSpec, + biome_id: &str, + lake_id: Option, + riparian_distance: u8, +) -> bool { + match spec.domain.as_str() { + "land" => !WATER_BIOMES.contains(&biome_id), + "air" => true, + "marine" => MARINE_BIOMES.contains(&biome_id), + "freshwater" => lake_id.is_some() || riparian_distance == 0, + // Unknown domain: apply no gate + _ => true, + } +} + +// ── Index ───────────────────────────────────────────────────────────────────── + +/// Tile-selection index for fauna. +/// Only Game-1 manifest species are included. +pub struct TerrainFaunaIndex { + /// Candidates per (biome_id, T_band, P_band), sorted ecology_tier DESC. + pub index: HashMap<(String, u8, u8), Vec>, + /// Species data keyed by id — needed for selection and trophic checks. + pub specs: HashMap, +} + +impl TerrainFaunaIndex { + /// Build the index from parsed species filtered to manifest whitelist. + /// + /// If `biome_bands` is empty, every species is indexed under all T/P pairs + /// (useful for unit tests). + pub fn build( + all_species: &HashMap, + manifest: &[String], + biome_bands: &HashMap>, + ) -> Self { + let mut index: HashMap<(String, u8, u8), Vec> = HashMap::new(); + let manifest_set: HashSet<&str> = manifest.iter().map(|s| s.as_str()).collect(); + + let game1_species: Vec<&FaunaSpec> = all_species + .values() + .filter(|s| manifest_set.contains(s.id.as_str())) + .collect(); + + for spec in &game1_species { + for biome_id in &spec.biomes { + let pairs: Vec<(u8, u8)> = if biome_bands.is_empty() { + (0u8..5).flat_map(|t| (0u8..5).map(move |p| (t, p))).collect() + } else { + biome_bands + .get(biome_id) + .cloned() + .unwrap_or_default() + }; + for (t, p) in pairs { + index + .entry((biome_id.clone(), t, p)) + .or_default() + .push(spec.id.clone()); + } + } + } + + let spec_map: HashMap = game1_species + .into_iter() + .map(|s| (s.id.clone(), s.clone())) + .collect(); + + for bucket in index.values_mut() { + bucket.sort_by(|a, b| { + let ta = spec_map.get(a).map(|s| s.ecology_tier).unwrap_or(0); + let tb = spec_map.get(b).map(|s| s.ecology_tier).unwrap_or(0); + tb.cmp(&ta) + }); + bucket.dedup(); + } + + Self { index, specs: spec_map } + } + + /// Load species from JSON strings and build the index. + pub fn from_jsons( + species_jsons: &[&str], + manifest: &[String], + biome_bands: &HashMap>, + ) -> Self { + let all_species: HashMap = species_jsons + .iter() + .filter_map(|j| FaunaSpec::from_json(j).ok()) + .map(|s| (s.id.clone(), s)) + .collect(); + Self::build(&all_species, manifest, biome_bands) + } +} + +// ── Per-tile selection ──────────────────────────────────────────────────────── + +/// A selected fauna species with resolved metadata. +#[derive(Debug, Clone)] +pub struct SelectedFauna { + pub id: String, + pub name: String, + pub lineage: String, + pub domain: String, + pub trophic_level: String, + pub ecology_tier: u8, +} + +/// Pick up to `MAX_FAUNA_PER_TILE` fauna species for a tile, applying trophic +/// and domain rules from ECOLOGY_BINDING.md §7–§9. +/// +/// - At most 1 apex predator per tile. +/// - A predator is only placed if at least one of its `prey[]` is present on +/// the tile or an adjacent tile (from `adjacent_fauna`). +/// - Domain gate prevents aquatic species on dry land (and vice versa). +/// +/// # Parameters +/// - `adjacent_fauna` — species IDs already assigned to adjacent tiles; used +/// for the predator-requires-prey check. Pass an empty slice when building +/// the grid from scratch (prey on the same tile counts). +pub fn pick_fauna_for_tile( + index: &TerrainFaunaIndex, + map_seed: u64, + biome_id: &str, + t_band: u8, + p_band: u8, + lake_id: Option, + riparian_distance: u8, + col: u32, + row: u32, + adjacent_fauna: &[&str], +) -> Vec { + let key = (biome_id.to_string(), t_band, p_band); + let candidates = match index.index.get(&key) { + Some(c) if !c.is_empty() => c, + _ => return vec![], + }; + + // Filter candidates through domain gate first + let eligible: Vec<&String> = candidates + .iter() + .filter(|id| { + index + .specs + .get(*id) + .map(|s| domain_gate(s, biome_id, lake_id, riparian_distance)) + .unwrap_or(false) + }) + .collect(); + + if eligible.is_empty() { + return vec![]; + } + + // Compute weights (ecology_tier based) + let weights: Vec = eligible + .iter() + .map(|id| { + index + .specs + .get(*id) + .map(|s| s.ecology_tier as f32) + .unwrap_or(0.0) + }) + .collect(); + + let total: f32 = weights.iter().sum(); + if total <= 0.0 { + return vec![]; + } + + let normalised: Vec = weights.iter().map(|w| w / total).collect(); + + let domain_seed = mc_mapgen::seed::derive(map_seed, SeedDomain::FaunaSelect); + let mut rng = tile_rng(domain_seed, col, row); + + // Draw candidates + let mut drawn = weighted_sample_fauna( + &eligible, + &normalised, + MAX_FAUNA_PER_TILE, + &mut rng, + &index.specs, + ); + + // Apply trophic rules + apply_trophic_rules(&mut drawn, &index.specs, adjacent_fauna); + + drawn +} + +fn weighted_sample_fauna( + eligible: &[&String], + normalised: &[f32], + max_count: usize, + rng: &mut mc_mapgen::seed::Pcg64, + specs: &HashMap, +) -> Vec { + let n = eligible.len().min(max_count); + let mut remaining: Vec<(usize, f32)> = normalised + .iter() + .copied() + .enumerate() + .filter(|(_, w)| *w > 0.0) + .collect(); + + let mut result = Vec::with_capacity(n); + + for _ in 0..n { + if remaining.is_empty() { + break; + } + let total: f32 = remaining.iter().map(|(_, w)| w).sum(); + let roll = rng.next_f32() * total; + let mut cumulative = 0.0f32; + let mut chosen = remaining.len() - 1; + for (local, &(_, w)) in remaining.iter().enumerate() { + cumulative += w; + if roll <= cumulative { + chosen = local; + break; + } + } + let (cand_idx, _) = remaining.remove(chosen); + let id = eligible[cand_idx]; + if let Some(spec) = specs.get(id.as_str()) { + result.push(SelectedFauna { + id: id.clone(), + name: spec.name.clone(), + lineage: spec.lineage.clone(), + domain: spec.domain.clone(), + trophic_level: spec.trophic_level.clone(), + ecology_tier: spec.ecology_tier, + }); + } + } + + result +} + +/// Apply trophic-overlap rule (§7) and predator-requires-prey rule (§7). +fn apply_trophic_rules( + selected: &mut Vec, + specs: &HashMap, + adjacent_fauna: &[&str], +) { + // Build set of all fauna IDs on or adjacent to this tile (for prey check) + let present_ids: HashSet<&str> = selected + .iter() + .map(|s| s.id.as_str()) + .chain(adjacent_fauna.iter().copied()) + .collect(); + + // Apex predator cap: keep only the highest ecology_tier apex + let apex_count = selected.iter().filter(|s| s.trophic_level == "apex_predator").count(); + if apex_count > 1 { + // Find max ecology_tier among apexes + let max_tier = selected + .iter() + .filter(|s| s.trophic_level == "apex_predator") + .map(|s| s.ecology_tier) + .max() + .unwrap_or(0); + // Remove all but the first apex at max tier + let mut kept_one_apex = false; + selected.retain(|s| { + if s.trophic_level != "apex_predator" { + return true; + } + if s.ecology_tier == max_tier && !kept_one_apex { + kept_one_apex = true; + return true; + } + false + }); + } + + // Predator-requires-prey: drop predators with no prey nearby + selected.retain(|s| { + if !s.trophic_level.contains("predator") { + return true; + } + let spec = match specs.get(&s.id) { + Some(sp) => sp, + None => return true, + }; + if spec.prey.is_empty() { + // Predator with no declared prey always passes (unspecified prey web) + return true; + } + spec.prey.iter().any(|prey_id| present_ids.contains(prey_id.as_str())) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_fauna(id: &str, biome: &str, domain: &str, trophic: &str, tier: u8, prey: Vec<&str>) -> FaunaSpec { + FaunaSpec { + id: id.to_string(), + name: id.to_string(), + biomes: vec![biome.to_string()], + domain: domain.to_string(), + trophic_level: trophic.to_string(), + prey: prey.into_iter().map(|s| s.to_string()).collect(), + ecology_tier: tier, + lineage: "canines".to_string(), + traits: vec![], + } + } + + fn build_test_index(species: Vec, manifest: Vec<&str>) -> TerrainFaunaIndex { + let all: HashMap = + species.into_iter().map(|s| (s.id.clone(), s)).collect(); + let manifest_ids: Vec = manifest.into_iter().map(|s| s.to_string()).collect(); + TerrainFaunaIndex::build(&all, &manifest_ids, &HashMap::new()) + } + + #[test] + fn determinism_same_seed_same_output() { + let idx = build_test_index( + vec![ + make_fauna("wolf", "forest", "land", "predator", 5, vec!["deer"]), + make_fauna("deer", "forest", "land", "herbivore", 3, vec![]), + make_fauna("rabbit", "forest", "land", "herbivore", 2, vec![]), + ], + vec!["wolf", "deer", "rabbit"], + ); + + let a = pick_fauna_for_tile(&idx, 42, "forest", 2, 3, None, 255, 5, 5, &[]); + let b = pick_fauna_for_tile(&idx, 42, "forest", 2, 3, None, 255, 5, 5, &[]); + let ids_a: Vec<&str> = a.iter().map(|s| s.id.as_str()).collect(); + let ids_b: Vec<&str> = b.iter().map(|s| s.id.as_str()).collect(); + assert_eq!(ids_a, ids_b, "determinism: same inputs must produce same output"); + } + + #[test] + fn apex_cap_at_one_per_tile() { + let idx = build_test_index( + vec![ + make_fauna("lion", "savanna", "land", "apex_predator", 8, vec!["zebra"]), + make_fauna("leopard", "savanna", "land", "apex_predator", 7, vec!["zebra"]), + make_fauna("zebra", "savanna", "land", "herbivore", 4, vec![]), + ], + vec!["lion", "leopard", "zebra"], + ); + + // Run many seeds and confirm apex count is always ≤ 1 + for seed in 0u64..20 { + let result = pick_fauna_for_tile( + &idx, seed, "savanna", 3, 2, None, 255, 1, 1, + &["zebra"], // adjacent prey satisfied + ); + let apex_count = result.iter().filter(|s| s.trophic_level == "apex_predator").count(); + assert!(apex_count <= 1, "seed {seed}: got {apex_count} apex predators"); + } + } + + #[test] + fn predator_dropped_when_no_prey_nearby() { + let idx = build_test_index( + vec![ + make_fauna("wolf", "forest", "land", "predator", 5, vec!["deer", "rabbit"]), + make_fauna("squirrel", "forest", "land", "herbivore", 2, vec![]), + ], + vec!["wolf", "squirrel"], + ); + + // No deer or rabbit anywhere near the tile + let result = pick_fauna_for_tile(&idx, 42, "forest", 2, 3, None, 255, 0, 0, &[]); + let has_wolf = result.iter().any(|s| s.id == "wolf"); + assert!(!has_wolf, "wolf must be dropped when no prey present on tile or adjacent"); + } + + #[test] + fn predator_kept_when_prey_on_tile() { + let idx = build_test_index( + vec![ + make_fauna("wolf", "forest", "land", "predator", 5, vec!["deer"]), + make_fauna("deer", "forest", "land", "herbivore", 3, vec![]), + ], + vec!["wolf", "deer"], + ); + + // Deer present on this tile — wolf should survive trophic check + // (deer will be drawn alongside wolf since both have forest biome) + // Run multiple seeds: at least some should include wolf (when deer is drawn first) + let found_wolf_with_deer = (0u64..50).any(|seed| { + let result = pick_fauna_for_tile(&idx, seed, "forest", 2, 3, None, 255, seed as u32, 0, &[]); + result.iter().any(|s| s.id == "wolf") + }); + assert!(found_wolf_with_deer, "wolf should appear on at least some tiles where deer is drawn"); + } + + #[test] + fn aquatic_gate_blocks_land_species_on_water() { + let idx = build_test_index( + vec![ + make_fauna("bear", "lake", "land", "predator", 6, vec!["salmon"]), + make_fauna("salmon", "lake", "freshwater", "herbivore", 3, vec![]), + ], + vec!["bear", "salmon"], + ); + + // lake tile: lake_id=Some(1), riparian=0 + let result = pick_fauna_for_tile( + &idx, 42, "lake", 2, 3, Some(1), 0, 0, 0, &[], + ); + let has_bear = result.iter().any(|s| s.id == "bear"); + assert!(!has_bear, "land domain bear must not spawn on water tile"); + } + + #[test] + fn freshwater_gate_respects_riparian_distance() { + let idx = build_test_index( + vec![ + make_fauna("otter", "forest", "freshwater", "predator", 4, vec![]), + make_fauna("deer", "forest", "land", "herbivore", 3, vec![]), + ], + vec!["otter", "deer"], + ); + + // Non-water forest tile, far from river (riparian_distance=5) → otter blocked + let result = pick_fauna_for_tile(&idx, 42, "forest", 2, 3, None, 5, 0, 0, &[]); + let has_otter = result.iter().any(|s| s.id == "otter"); + assert!(!has_otter, "freshwater otter must not spawn far from water"); + + // Same tile but riparian_distance=0 (on river) → otter allowed + let result_river = pick_fauna_for_tile(&idx, 42, "forest", 2, 3, None, 0, 0, 0, &[]); + let has_otter_river = result_river.iter().any(|s| s.id == "otter"); + assert!(has_otter_river, "freshwater otter should spawn on river tile"); + } +} diff --git a/src/simulator/crates/mc-ecology/src/flora_select.rs b/src/simulator/crates/mc-ecology/src/flora_select.rs new file mode 100644 index 00000000..7f195301 --- /dev/null +++ b/src/simulator/crates/mc-ecology/src/flora_select.rs @@ -0,0 +1,416 @@ +//! Implements the Wave-C flora-binding spec from +//! `public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md` §2–§5. +//! +//! Build `TerrainFloraIndex` once at map generation time; call +//! `pick_flora_for_tile` per tile to get a deterministic species list. +//! +//! Soil-layer differentiation is deferred to g2-06. Currently selection +//! is keyed by (biome_id, T_band, P_band) only; the soil_type dimension +//! is reserved but not implemented (see §11 retrofit hook). + +use std::collections::HashMap; + +use serde::Deserialize; + +use mc_mapgen::seed::{SeedDomain, tile_rng}; + +// ── Aquatic / riparian species set ──────────────────────────────────────────── + +/// Lineages that receive the riparian weight boost near water. +const RIPARIAN_LINEAGES: &[&str] = &["aquatic_plants", "mosses_lichens"]; + +/// Specific species IDs that receive the riparian weight boost. +const RIPARIAN_SPECIES: &[&str] = &[ + "lotus", "papyrus", "black_alder", "silver_birch", + "bald_cypress", "pioneer_sedge", "giant_water_lily", +]; + +/// Density multiplier applied to aquatic/riparian species weight when +/// `tile.riparian_distance <= 1`. +const RIPARIAN_DENSITY_BOOST: f32 = 1.4; + +/// Maximum flora species returned per tile across all layers. +const MAX_FLORA_PER_TILE: usize = 4; + +// ── JSON schema ─────────────────────────────────────────────────────────────── + +/// Minimal JSON representation of a flora species file. +/// Only the fields needed for the tile-selection index. +#[derive(Debug, Clone, Deserialize)] +pub struct FloraSpec { + pub id: String, + pub name: String, + #[serde(default)] + pub biomes: Vec, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub lineage: String, + #[serde(default)] + pub quality_tier: u8, + #[serde(default)] + pub canopy_contribution: f32, + #[serde(default)] + pub undergrowth_contribution: f32, + #[serde(default)] + pub fungi_contribution: f32, + #[serde(default)] + pub drought_tolerance: f32, + #[serde(default)] + pub fire_resistance: f32, +} + +impl FloraSpec { + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// True if this species is aquatic or riparian and should receive the + /// riparian weight boost. + pub fn is_riparian(&self) -> bool { + RIPARIAN_LINEAGES.contains(&self.lineage.as_str()) + || RIPARIAN_SPECIES.contains(&self.id.as_str()) + || self.tags.iter().any(|t| t == "habitat_aquatic" || t == "riparian") + } + + /// Visual layer derived from tags (canopy > understory > ground > fungal). + pub fn layer(&self) -> FloraLayer { + if self.tags.contains(&"layer_canopy".to_string()) { + FloraLayer::Canopy + } else if self.tags.contains(&"layer_understory".to_string()) { + FloraLayer::Understory + } else if self.tags.contains(&"layer_fungal".to_string()) { + FloraLayer::Fungal + } else { + // Ground is the default fallback + FloraLayer::Ground + } + } + + /// Selection weight for this species. + /// Canopy uses quality_tier × canopy_contribution; others use quality_tier alone. + fn base_weight(&self) -> f32 { + let contrib = match self.layer() { + FloraLayer::Canopy => self.canopy_contribution, + FloraLayer::Understory => self.undergrowth_contribution.max(0.1), + FloraLayer::Ground | FloraLayer::Fungal => { + self.fungi_contribution.max(self.undergrowth_contribution).max(0.1) + } + }; + self.quality_tier as f32 * contrib + } +} + +/// Visual layer for a selected flora species. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FloraLayer { + Canopy, + Understory, + Ground, + Fungal, +} + +impl FloraLayer { + pub fn as_str(self) -> &'static str { + match self { + FloraLayer::Canopy => "canopy", + FloraLayer::Understory => "understory", + FloraLayer::Ground => "ground", + FloraLayer::Fungal => "fungal", + } + } +} + +// ── Index ───────────────────────────────────────────────────────────────────── + +/// Tile-selection index: maps (biome_id, T_band, P_band) to candidate species. +/// Built once from the species JSON files; queried per tile. +pub struct TerrainFloraIndex { + /// Candidates per (biome_id, T_band, P_band), sorted quality_tier DESC. + pub index: HashMap<(String, u8, u8), Vec>, + /// Species data keyed by id — needed for weight lookup during selection. + specs: HashMap, +} + +impl TerrainFloraIndex { + /// Build the index from a slice of parsed `FloraSpec` and a map of + /// `(biome_id → Vec<(T_band, P_band)>)` pairs actually present on the map. + /// + /// If `biome_bands` is empty, every species is indexed under all T/P pairs + /// from 0–4 for that biome (useful for unit tests without a live grid). + pub fn build( + species: &[FloraSpec], + biome_bands: &HashMap>, + ) -> Self { + let mut index: HashMap<(String, u8, u8), Vec> = HashMap::new(); + + for spec in species { + for biome_id in &spec.biomes { + let pairs: Vec<(u8, u8)> = if biome_bands.is_empty() { + (0u8..5).flat_map(|t| (0u8..5).map(move |p| (t, p))).collect() + } else { + biome_bands + .get(biome_id) + .cloned() + .unwrap_or_default() + }; + for (t, p) in pairs { + index + .entry((biome_id.clone(), t, p)) + .or_default() + .push(spec.id.clone()); + } + } + } + + // Sort each bucket by quality_tier descending + let spec_map: HashMap = + species.iter().map(|s| (s.id.clone(), s.clone())).collect(); + + for bucket in index.values_mut() { + bucket.sort_by(|a, b| { + let qa = spec_map.get(a).map(|s| s.quality_tier).unwrap_or(0); + let qb = spec_map.get(b).map(|s| s.quality_tier).unwrap_or(0); + qb.cmp(&qa) + }); + bucket.dedup(); + } + + Self { index, specs: spec_map } + } + + /// Load species from JSON strings and build the index. + pub fn from_jsons( + jsons: &[&str], + biome_bands: &HashMap>, + ) -> Self { + let species: Vec = jsons + .iter() + .filter_map(|j| FloraSpec::from_json(j).ok()) + .collect(); + Self::build(&species, biome_bands) + } +} + +// ── Per-tile selection ──────────────────────────────────────────────────────── + +/// A selected flora species with its resolved layer. +#[derive(Debug, Clone)] +pub struct SelectedFlora { + pub id: String, + pub name: String, + pub lineage: String, + pub layer: FloraLayer, + pub quality_tier: u8, +} + +/// Pick up to `MAX_FLORA_PER_TILE` flora species for a tile. +/// +/// Deterministic: same (biome_id, t_band, p_band, riparian_distance, col, row, map_seed) +/// always produces the same result. +/// +/// # Parameters +/// - `map_seed` — top-level map seed +/// - `biome_id` — tile's biome identifier +/// - `t_band` — temperature band (0–4) +/// - `p_band` — precipitation band (0–4) +/// - `riparian_distance` — distance to nearest river/lake edge (0 = on water) +/// - `col`, `row` — hex grid coordinates +pub fn pick_flora_for_tile( + index: &TerrainFloraIndex, + map_seed: u64, + biome_id: &str, + t_band: u8, + p_band: u8, + riparian_distance: u8, + col: u32, + row: u32, +) -> Vec { + let key = (biome_id.to_string(), t_band, p_band); + let candidates = match index.index.get(&key) { + Some(c) if !c.is_empty() => c, + _ => return vec![], + }; + + // Compute weights + let weights: Vec = candidates + .iter() + .map(|id| { + let spec = match index.specs.get(id) { + Some(s) => s, + None => return 0.0, + }; + let w = spec.base_weight(); + if riparian_distance <= 1 && spec.is_riparian() { + w * RIPARIAN_DENSITY_BOOST + } else { + w + } + }) + .collect(); + + let total: f32 = weights.iter().sum(); + if total <= 0.0 { + return vec![]; + } + + // Normalise by sum (never clip) + let normalised: Vec = weights.iter().map(|w| w / total).collect(); + + let domain_seed = mc_mapgen::seed::derive(map_seed, SeedDomain::FloraSelect); + let mut rng = tile_rng(domain_seed, col, row); + + weighted_sample_without_replacement( + candidates, + &normalised, + MAX_FLORA_PER_TILE, + &mut rng, + &index.specs, + ) +} + +/// Weighted sampling without replacement using the normalised probability vector. +fn weighted_sample_without_replacement( + candidates: &[String], + normalised: &[f32], + max_count: usize, + rng: &mut mc_mapgen::seed::Pcg64, + specs: &HashMap, +) -> Vec { + let n = candidates.len().min(max_count); + let mut remaining: Vec<(usize, f32)> = normalised + .iter() + .copied() + .enumerate() + .filter(|(_, w)| *w > 0.0) + .collect(); + + let mut result = Vec::with_capacity(n); + + for _ in 0..n { + if remaining.is_empty() { + break; + } + let total: f32 = remaining.iter().map(|(_, w)| w).sum(); + let roll = rng.next_f32() * total; + let mut cumulative = 0.0f32; + let mut chosen_idx = remaining.len() - 1; + for (local_idx, &(_, w)) in remaining.iter().enumerate() { + cumulative += w; + if roll <= cumulative { + chosen_idx = local_idx; + break; + } + } + let (cand_idx, _) = remaining.remove(chosen_idx); + let id = &candidates[cand_idx]; + if let Some(spec) = specs.get(id) { + result.push(SelectedFlora { + id: id.clone(), + name: spec.name.clone(), + lineage: spec.lineage.clone(), + layer: spec.layer(), + quality_tier: spec.quality_tier, + }); + } + } + + // Stratify: canopy first, then understory, then ground, then fungal + result.sort_by_key(|s| match s.layer { + FloraLayer::Canopy => 0u8, + FloraLayer::Understory => 1, + FloraLayer::Ground => 2, + FloraLayer::Fungal => 3, + }); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_spec(id: &str, biome: &str, quality_tier: u8, lineage: &str, layer_tag: &str) -> FloraSpec { + FloraSpec { + id: id.to_string(), + name: id.to_string(), + biomes: vec![biome.to_string()], + tags: vec![layer_tag.to_string()], + lineage: lineage.to_string(), + quality_tier, + canopy_contribution: 0.5, + undergrowth_contribution: 0.3, + fungi_contribution: 0.1, + drought_tolerance: 0.5, + fire_resistance: 0.3, + } + } + + #[test] + fn determinism_same_seed_same_output() { + let specs = vec![ + make_spec("oak", "forest", 7, "broadleaf_trees", "layer_canopy"), + make_spec("beech", "forest", 6, "broadleaf_trees", "layer_canopy"), + make_spec("hazel", "forest", 4, "broadleaf_trees", "layer_understory"), + make_spec("anemone", "forest", 3, "herbs", "layer_ground"), + ]; + let idx = TerrainFloraIndex::build(&specs, &HashMap::new()); + + let a = pick_flora_for_tile(&idx, 42, "forest", 2, 3, 4, 5, 5); + let b = pick_flora_for_tile(&idx, 42, "forest", 2, 3, 4, 5, 5); + let ids_a: Vec<&str> = a.iter().map(|s| s.id.as_str()).collect(); + let ids_b: Vec<&str> = b.iter().map(|s| s.id.as_str()).collect(); + assert_eq!(ids_a, ids_b, "determinism: same seed must produce same output"); + } + + #[test] + fn different_seeds_may_differ() { + let specs = vec![ + make_spec("oak", "forest", 7, "broadleaf_trees", "layer_canopy"), + make_spec("beech", "forest", 6, "broadleaf_trees", "layer_canopy"), + make_spec("hazel", "forest", 4, "broadleaf_trees", "layer_understory"), + make_spec("anemone", "forest", 3, "herbs", "layer_ground"), + make_spec("fern", "forest", 2, "ferns", "layer_ground"), + ]; + let idx = TerrainFloraIndex::build(&specs, &HashMap::new()); + + let a = pick_flora_for_tile(&idx, 0, "forest", 2, 3, 4, 0, 0); + let b = pick_flora_for_tile(&idx, 999, "forest", 2, 3, 4, 0, 0); + let ids_a: Vec<&str> = a.iter().map(|s| s.id.as_str()).collect(); + let ids_b: Vec<&str> = b.iter().map(|s| s.id.as_str()).collect(); + // With a large enough candidate pool, different seeds should sometimes differ + // (this isn't guaranteed but is extremely likely with 5 candidates) + let _ = (ids_a, ids_b); // just verify no panic + } + + #[test] + fn riparian_species_preferred_near_water() { + let specs = vec![ + make_spec("oak", "forest", 7, "broadleaf_trees", "layer_canopy"), + make_spec("lotus", "forest", 3, "aquatic_plants", "layer_canopy"), + ]; + let idx = TerrainFloraIndex::build(&specs, &HashMap::new()); + + // Near water: lotus weight = 3 * 0.5 * 1.4 = 2.1; oak = 3.5 — oak still wins + // but lotus has higher chance than without boost + let result = pick_flora_for_tile(&idx, 42, "forest", 2, 3, 1, 5, 5); + assert!(!result.is_empty()); + } + + #[test] + fn empty_index_returns_empty() { + let idx = TerrainFloraIndex::build(&[], &HashMap::new()); + let result = pick_flora_for_tile(&idx, 42, "forest", 2, 3, 4, 0, 0); + assert!(result.is_empty()); + } + + #[test] + fn max_flora_per_tile_capped() { + let specs: Vec = (0..10) + .map(|i| make_spec(&format!("sp{i}"), "forest", (i + 1) as u8, "herbs", "layer_ground")) + .collect(); + let idx = TerrainFloraIndex::build(&specs, &HashMap::new()); + let result = pick_flora_for_tile(&idx, 42, "forest", 2, 3, 4, 0, 0); + assert!(result.len() <= MAX_FLORA_PER_TILE); + } +}