refactor(mc-city): ♻️ Refactor biome yield calculations and city behavior logic in the mc-city crate

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-27 02:35:49 -07:00
parent dfc9fdcc03
commit fc61f8e913
3 changed files with 166 additions and 3 deletions

View file

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

View file

@ -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<CollectibleRoll>,
/// 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;

View file

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