feat(simulator): Add fauna product simulation module with fauna_product.rs logic and lib.rs module declaration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-27 02:56:12 -07:00
parent a62ab721de
commit 91d7516049
2 changed files with 151 additions and 0 deletions

View file

@ -0,0 +1,149 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::engine::EcologyEngine;
/// Mirrors the fauna_product JSON shape for a single product entry.
/// Source files are single-element arrays; callers iterate and pass slices here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaunaProduct {
pub id: String,
pub source_fauna: Vec<String>,
pub min_population: i32,
pub harvest_rate: f64,
}
/// Compute how many units of each fauna product the player can harvest
/// from their owned tiles this turn.
///
/// For each product, the total live population of all listed source_fauna
/// species across player_owned_tiles is summed. If the total strictly
/// exceeds min_population, `floor(total * harvest_rate)` units are produced.
/// Products below threshold are absent from the returned map.
pub fn fauna_product_supply(
player_owned_tiles: &[(i32, i32)],
engine: &EcologyEngine,
products: &[FaunaProduct],
) -> BTreeMap<String, i32> {
if player_owned_tiles.is_empty() || products.is_empty() {
return BTreeMap::new();
}
// Build a reverse map: species_key → total population across owned tiles.
// We accumulate f64 for precision before applying harvest_rate.
let mut species_totals: BTreeMap<String, f64> = BTreeMap::new();
for &(col, row) in player_owned_tiles {
let slots = match engine.tile_populations.get(&(col, row)) {
Some(s) => s,
None => continue,
};
for slot in slots {
let species = match engine.species_registry.get(&slot.species_id) {
Some(sp) => sp,
None => continue,
};
if !species.species_key.is_empty() {
*species_totals.entry(species.species_key.clone()).or_insert(0.0) +=
slot.population as f64;
}
}
}
let mut result = BTreeMap::new();
for product in products {
let total: f64 = product
.source_fauna
.iter()
.map(|key| species_totals.get(key).copied().unwrap_or(0.0))
.sum();
if total > product.min_population as f64 {
let units = (total * product.harvest_rate).floor() as i32;
if units > 0 {
result.insert(product.id.clone(), units);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::EcologyEngine;
use crate::population::PopulationSlot;
use crate::species::Species;
use crate::traits::{
Diet, Habitat, Locomotion, Reproduction, Size, Social, Thermal, TraitSet,
};
fn make_species(id: u32, key: &str) -> Species {
let mut sp = Species::derive_from_traits(
id,
key.to_string(),
TraitSet {
size: Size::Large,
diet: Diet::Omnivore,
habitat: Habitat::Terrestrial,
locomotion: Locomotion::Walking,
reproduction: Reproduction::KStrategy,
thermal: Thermal::WarmBlooded,
social: Social::Solitary,
},
);
sp.species_key = key.to_string();
sp
}
fn bear_product() -> FaunaProduct {
FaunaProduct {
id: "bear_pelt".to_string(),
source_fauna: vec!["cave_bear".to_string()],
min_population: 50,
harvest_rate: 0.05,
}
}
#[test]
fn fauna_product_supply_empty_tiles_returns_empty_map() {
let engine = EcologyEngine::new();
let products = vec![bear_product()];
let result = fauna_product_supply(&[], &engine, &products);
assert!(result.is_empty());
}
#[test]
fn fauna_product_supply_population_below_threshold_yields_zero() {
let mut engine = EcologyEngine::new();
let bear = make_species(1, "cave_bear");
engine.register_species(bear);
// Seed exactly at threshold — must NOT produce (strict >)
engine.seed_population(0, 0, PopulationSlot::new(1, 50.0));
let products = vec![bear_product()];
let result = fauna_product_supply(&[(0, 0)], &engine, &products);
assert!(
result.get("bear_pelt").copied().unwrap_or(0) == 0,
"At-threshold population should not produce any supply"
);
}
#[test]
fn fauna_product_supply_population_above_threshold_applies_harvest_rate() {
let mut engine = EcologyEngine::new();
let bear = make_species(1, "cave_bear");
engine.register_species(bear);
// 100 bears, threshold 50, harvest_rate 0.05 → floor(100 * 0.05) = 5
engine.seed_population(0, 0, PopulationSlot::new(1, 100.0));
let products = vec![bear_product()];
let result = fauna_product_supply(&[(0, 0)], &engine, &products);
assert_eq!(
result.get("bear_pelt").copied().unwrap_or(0),
5,
"100 bears × 0.05 harvest_rate = 5 pelts"
);
}
}

View file

@ -7,6 +7,7 @@
pub mod behavior;
pub mod classification;
pub mod fauna_product;
pub mod combat;
pub mod config;
pub mod dynamics;
@ -34,6 +35,7 @@ pub use events::{EventCategory, EventTierData, load_event_categories};
pub use species::load_species_library;
pub use evolution::{run_evolution, ClimateStep, EvolutionResult, EventConfig, WorldAgeConfig};
pub use combat::CombatStatsConfig;
pub use fauna_product::{FaunaProduct, fauna_product_supply};
pub use wilds::{generate_lairs, check_lair_formation, check_lair_abandonment,
check_lair_state_transitions, lair_inheritable, LairConfig,
locomotion_str_from_u8, size_str_from_u8, LairType, LairState,