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:
parent
dfc9fdcc03
commit
fc61f8e913
3 changed files with 166 additions and 3 deletions
131
src/simulator/crates/mc-city/src/biome_yield.rs
Normal file
131
src/simulator/crates/mc-city/src/biome_yield.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue