feat(@projects/@magic-civilization): ✨ add fauna and flora ecosystem modules
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
11b95f103e
commit
211c0e368d
5 changed files with 1062 additions and 6 deletions
22
src/simulator/Cargo.lock
generated
22
src/simulator/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
123
src/simulator/crates/mc-ecology/src/fauna_glyphs.rs
Normal file
123
src/simulator/crates/mc-ecology/src/fauna_glyphs.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
506
src/simulator/crates/mc-ecology/src/fauna_select.rs
Normal file
506
src/simulator/crates/mc-ecology/src/fauna_select.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub domain: String,
|
||||
#[serde(default)]
|
||||
pub trophic_level: String,
|
||||
#[serde(default)]
|
||||
pub prey: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub ecology_tier: u8,
|
||||
#[serde(default)]
|
||||
pub lineage: String,
|
||||
#[serde(default)]
|
||||
pub traits: Vec<String>,
|
||||
}
|
||||
|
||||
impl FaunaSpec {
|
||||
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
||||
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<u32>,
|
||||
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<String>>,
|
||||
/// Species data keyed by id — needed for selection and trophic checks.
|
||||
pub specs: HashMap<String, FaunaSpec>,
|
||||
}
|
||||
|
||||
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<String, FaunaSpec>,
|
||||
manifest: &[String],
|
||||
biome_bands: &HashMap<String, Vec<(u8, u8)>>,
|
||||
) -> Self {
|
||||
let mut index: HashMap<(String, u8, u8), Vec<String>> = 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<String, FaunaSpec> = 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<String, Vec<(u8, u8)>>,
|
||||
) -> Self {
|
||||
let all_species: HashMap<String, FaunaSpec> = 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<u32>,
|
||||
riparian_distance: u8,
|
||||
col: u32,
|
||||
row: u32,
|
||||
adjacent_fauna: &[&str],
|
||||
) -> Vec<SelectedFauna> {
|
||||
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<f32> = 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<f32> = 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<String, FaunaSpec>,
|
||||
) -> Vec<SelectedFauna> {
|
||||
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<SelectedFauna>,
|
||||
specs: &HashMap<String, FaunaSpec>,
|
||||
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<FaunaSpec>, manifest: Vec<&str>) -> TerrainFaunaIndex {
|
||||
let all: HashMap<String, FaunaSpec> =
|
||||
species.into_iter().map(|s| (s.id.clone(), s)).collect();
|
||||
let manifest_ids: Vec<String> = 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");
|
||||
}
|
||||
}
|
||||
416
src/simulator/crates/mc-ecology/src/flora_select.rs
Normal file
416
src/simulator/crates/mc-ecology/src/flora_select.rs
Normal file
|
|
@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[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<Self, serde_json::Error> {
|
||||
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<String>>,
|
||||
/// Species data keyed by id — needed for weight lookup during selection.
|
||||
specs: HashMap<String, FloraSpec>,
|
||||
}
|
||||
|
||||
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<String, Vec<(u8, u8)>>,
|
||||
) -> Self {
|
||||
let mut index: HashMap<(String, u8, u8), Vec<String>> = 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<String, FloraSpec> =
|
||||
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<String, Vec<(u8, u8)>>,
|
||||
) -> Self {
|
||||
let species: Vec<FloraSpec> = 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<SelectedFlora> {
|
||||
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<f32> = 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<f32> = 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<String, FloraSpec>,
|
||||
) -> Vec<SelectedFlora> {
|
||||
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<FloraSpec> = (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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue