diff --git a/src/simulator/crates/mc-city/src/biome_yield.rs b/src/simulator/crates/mc-city/src/biome_yield.rs new file mode 100644 index 00000000..b945d604 --- /dev/null +++ b/src/simulator/crates/mc-city/src/biome_yield.rs @@ -0,0 +1,131 @@ +//! Biome-driven food modifier for the live-ecology coupling. +//! +//! Phase A of the biome→economy coupling work (see plan +//! `~/.claude/plans/hi-so-in-valiant-mango.md` and the new objective +//! `.project/objectives/p1-XX-biome-economy-coupling.md`). The ecology +//! simulation in `mc-flora` produces canopy and undergrowth densities per +//! tile; this module converts those into a multiplier the city economy can +//! apply to base food yields. +//! +//! `mc-city` deliberately stays free of `mc-flora` / `mc-ecology` deps for +//! this commit — the caller (Rust turn processor or GDScript bridge) reads +//! the densities from `FloraEngine` and supplies them via +//! `TileYield::food_modifier` after calling `ecology_food_modifier`. +//! +//! `fallback_when_dormant = "static_terrain"` is the shipping default so the +//! `p016b` balance baseline (median `pop_peak = 69`) is preserved. Flipping +//! to `"coupled"` requires balance-lead sign-off plus a 10-seed regression +//! batch — see `.project/objectives/p1-05-balance-tuning.md`. + +use serde::{Deserialize, Serialize}; + +/// Configuration for ecology-driven yield scaling. Loaded from +/// `public/games/age-of-dwarves/data/balance/ecology_yields.json` by the +/// GDScript / GDExtension layer and passed in here. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EcologyYieldsConfig { + /// Multiplier applied to canopy density when the modifier is computed. + /// `1.0 + canopy_food_factor * canopy_density` is the canopy contribution. + pub canopy_food_factor: f64, + /// Multiplier applied to undergrowth density. + pub understory_food_factor: f64, + /// `"static_terrain"` (default) returns 1.0 from `ecology_food_modifier` + /// regardless of canopy/undergrowth. `"coupled"` activates the live + /// signal. Anything else is treated as `"static_terrain"`. + pub fallback_when_dormant: String, +} + +impl Default for EcologyYieldsConfig { + fn default() -> Self { + Self { + canopy_food_factor: 0.5, + understory_food_factor: 0.3, + fallback_when_dormant: "static_terrain".to_string(), + } + } +} + +impl EcologyYieldsConfig { + /// True when the live ecology signal should drive food yields. False (the + /// default) means the modifier always returns 1.0 and the caller's static + /// terrain food remains authoritative. + pub fn coupled(&self) -> bool { + self.fallback_when_dormant == "coupled" + } +} + +/// Per-tile food multiplier from canopy + undergrowth densities (each in +/// `[0.0, 1.0]`). Returns `1.0` when the config is in `"static_terrain"` +/// fallback mode OR when both densities are zero (interpreted as a dormant +/// or empty tile). Caller stores the result in `TileYield::food_modifier`. +pub fn ecology_food_modifier( + canopy_density: f64, + understory_density: f64, + cfg: &EcologyYieldsConfig, +) -> f64 { + if !cfg.coupled() { + return 1.0; + } + let canopy = canopy_density.clamp(0.0, 1.0); + let understory = understory_density.clamp(0.0, 1.0); + if canopy == 0.0 && understory == 0.0 { + return 1.0; + } + 1.0 + cfg.canopy_food_factor * canopy + cfg.understory_food_factor * understory +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_is_static_terrain_fallback() { + let cfg = EcologyYieldsConfig::default(); + assert_eq!(cfg.fallback_when_dormant, "static_terrain"); + assert!(!cfg.coupled()); + } + + #[test] + fn static_terrain_mode_returns_one_regardless_of_density() { + let cfg = EcologyYieldsConfig::default(); + assert!((ecology_food_modifier(0.0, 0.0, &cfg) - 1.0).abs() < 1e-9); + assert!((ecology_food_modifier(1.0, 1.0, &cfg) - 1.0).abs() < 1e-9); + assert!((ecology_food_modifier(0.7, 0.3, &cfg) - 1.0).abs() < 1e-9); + } + + #[test] + fn coupled_mode_zero_density_returns_one() { + let cfg = EcologyYieldsConfig { + fallback_when_dormant: "coupled".to_string(), + ..EcologyYieldsConfig::default() + }; + assert!((ecology_food_modifier(0.0, 0.0, &cfg) - 1.0).abs() < 1e-9); + } + + #[test] + fn coupled_mode_full_canopy_applies_factor() { + let cfg = EcologyYieldsConfig { + canopy_food_factor: 0.5, + understory_food_factor: 0.3, + fallback_when_dormant: "coupled".to_string(), + }; + // Full canopy (1.0), no understory: 1.0 + 0.5*1.0 = 1.5 + assert!((ecology_food_modifier(1.0, 0.0, &cfg) - 1.5).abs() < 1e-9); + // Full understory only: 1.0 + 0.3*1.0 = 1.3 + assert!((ecology_food_modifier(0.0, 1.0, &cfg) - 1.3).abs() < 1e-9); + // Both at half: 1.0 + 0.25 + 0.15 = 1.4 + assert!((ecology_food_modifier(0.5, 0.5, &cfg) - 1.4).abs() < 1e-9); + } + + #[test] + fn density_is_clamped_to_unit_range() { + let cfg = EcologyYieldsConfig { + canopy_food_factor: 0.5, + understory_food_factor: 0.3, + fallback_when_dormant: "coupled".to_string(), + }; + // Out-of-range densities clamp to [0, 1] — guards against simulator bugs. + assert!((ecology_food_modifier(2.0, 0.0, &cfg) - 1.5).abs() < 1e-9); + assert!((ecology_food_modifier(-1.0, 0.5, &cfg) - 1.15).abs() < 1e-9); + } +} diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index 6b994b04..af005beb 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -89,7 +89,7 @@ impl CityFocus { /// Per-tile yield data for citizen assignment. The full tile-yield system /// lives in mc-economy; this is the projection that cities need for citizen /// allocation. Populated by the caller (GDScript/GDExtension reads tiles). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TileYield { pub coord: (i32, i32), pub food: f64, @@ -101,6 +101,32 @@ pub struct TileYield { /// Empty vec is the zero case (ocean, uncollected, or biome with no table). #[serde(default)] pub collectibles: Vec, + /// Per-tile food multiplier set by the caller from the live ecology + /// signal (canopy + undergrowth density) via `biome_yield::ecology_food_modifier`. + /// Defaults to `1.0` (no-op) when missing in deserialised JSON or when + /// the ecology tick is dormant — preserves the static-terrain baseline + /// until the balance lead flips `fallback_when_dormant` to `coupled`. + #[serde(default = "tile_yield_default_modifier")] + pub food_modifier: f64, +} + +fn tile_yield_default_modifier() -> f64 { + 1.0 +} + +impl Default for TileYield { + fn default() -> Self { + Self { + coord: (0, 0), + food: 0.0, + production: 0.0, + gold: 0.0, + culture: 0.0, + science: 0.0, + collectibles: Vec::new(), + food_modifier: 1.0, + } + } } impl TileYield { @@ -393,11 +419,13 @@ impl City { yields.science += CITY_CENTER_BASELINE_SCIENCE; yields.culture += CITY_CENTER_BASELINE_CULTURE; - // Sum worked tile yields (flat fields + collectible rolls) + // Sum worked tile yields (flat fields + collectible rolls). Base food + // is scaled by `food_modifier` (live-ecology signal); other yields and + // collectible drops are not — fauna/flora tie to food specifically. for wt in &self.worked_tiles { if let Some(ty) = tile_yields.iter().find(|t| t.coord == *wt) { let t = ty.total(); - yields.food += t.food; + yields.food += t.food * ty.food_modifier; yields.production += t.production; yields.gold += t.gold; yields.culture += t.culture; diff --git a/src/simulator/crates/mc-city/src/lib.rs b/src/simulator/crates/mc-city/src/lib.rs index 6bc9278b..d45147e4 100644 --- a/src/simulator/crates/mc-city/src/lib.rs +++ b/src/simulator/crates/mc-city/src/lib.rs @@ -3,6 +3,7 @@ pub mod city; pub mod yield_fold; pub mod building; pub mod placement; +pub mod biome_yield; use serde::{Deserialize, Serialize}; @@ -31,6 +32,9 @@ pub use yield_fold::{ tile_yields_from_collectibles, CollectiblesIndex, ResourceYieldMap, ResourceYields, }; +// Re-export biome-yield types +pub use biome_yield::{ecology_food_modifier, EcologyYieldsConfig}; + /// Lightweight per-city state used by the bench-grade turn processor. /// /// This is the minimal struct the headless `fauna_pressure_bench`, `solo_dominion`,