feat(@projects/@magic-civilization): add fauna and flora ecosystem modules

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 23:42:43 -04:00
parent 11b95f103e
commit 211c0e368d
5 changed files with 1062 additions and 6 deletions

View file

@ -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"

View file

@ -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

View 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);
}
}

View 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");
}
}

View 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 04 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 (04)
/// - `p_band` — precipitation band (04)
/// - `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);
}
}