From 501927dc33434dda9f6a4e4b28db4258d69d5d10 Mon Sep 17 00:00:00 2001 From: autocommit Date: Tue, 28 Apr 2026 15:46:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(simulator-specific):=20=E2=9C=A8=20Introdu?= =?UTF-8?q?ce=20configurable=20soft=20carrying-capacity=20cap=20for=20biom?= =?UTF-8?q?e=20yields=20and=20integrate=20with=20improvement=20mechanics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-city/src/biome_yield.rs | 130 ++++++++++++++++++ .../crates/mc-core/src/improvement.rs | 86 ++++++++++++ src/simulator/crates/mc-core/src/lib.rs | 1 + 3 files changed, 217 insertions(+) create mode 100644 src/simulator/crates/mc-core/src/improvement.rs diff --git a/src/simulator/crates/mc-city/src/biome_yield.rs b/src/simulator/crates/mc-city/src/biome_yield.rs index b945d604..cbd4136c 100644 --- a/src/simulator/crates/mc-city/src/biome_yield.rs +++ b/src/simulator/crates/mc-city/src/biome_yield.rs @@ -74,6 +74,78 @@ pub fn ecology_food_modifier( 1.0 + cfg.canopy_food_factor * canopy + cfg.understory_food_factor * understory } +// ── Phase D — soft carrying-capacity cap ─────────────────────────────── +// +// Companion to `EcologyYieldsConfig`. Loaded from the bridge JSON snapshot +// authored by the guide-app classifier at +// `public/games/age-of-dwarves/data/balance/biome_capacity.json`. The TS +// source-of-truth lives at `src/packages/guide/src/data/ecology.ts::carryingCapacityForBiome`. +// +// Ships INERT by default (`enabled: false`) so `p016b` balance bands are +// preserved. Flipping `enabled` to `true` requires Shipwright sign-off +// plus a 10-seed regression batch — see `p1-05`. + +/// Per-biome carrying capacity drawn from the guide-app classifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BiomeCapacity { + pub base: i32, + pub decay_per_excess_pop: f64, +} + +/// Configuration for the soft carrying-capacity cap. +/// +/// `floor_factor` clamps the worst-case decay so an over-capacity city +/// never drops below `floor_factor * total_food_yield` — keeps the growth +/// curve recoverable rather than collapsing the city. `0.5` matches the +/// Phase D plan in `~/.claude/plans/hi-so-in-valiant-mango.md`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BiomeCapacityConfig { + /// Master switch. `false` (default) makes `carrying_capacity_modifier` + /// always return 1.0, leaving the existing balance bands untouched. + pub enabled: bool, + /// Lower bound on the modifier when over capacity. + pub floor_factor: f64, +} + +impl Default for BiomeCapacityConfig { + fn default() -> Self { + Self { + enabled: false, + floor_factor: 0.5, + } + } +} + +/// Soft cap modifier for a city's population vs. its biome capacity. +/// +/// `effective_food = total_food * carrying_capacity_modifier(pop, capacity, cfg)` +/// applied AFTER summing tile yields and BEFORE the happiness +/// `growth_modifier`. Returns `1.0` when: +/// - the config is `enabled: false` (the shipping default), +/// - or the population is at or below the biome capacity (no penalty). +/// +/// When over capacity, the modifier decays linearly with excess population: +/// `1.0 - (pop - capacity) * decay_per_excess`, clamped to `[floor_factor, 1.0]`. +/// This is a soft cap — an over-pop city slows but never starves to halt +/// purely from biome pressure. +pub fn carrying_capacity_modifier( + population: u32, + capacity: &BiomeCapacity, + cfg: &BiomeCapacityConfig, +) -> f64 { + if !cfg.enabled { + return 1.0; + } + let pop = population as i64; + let cap = capacity.base as i64; + if pop <= cap { + return 1.0; + } + let excess = (pop - cap) as f64; + let raw = 1.0 - excess * capacity.decay_per_excess_pop; + raw.max(cfg.floor_factor).min(1.0) +} + #[cfg(test)] mod tests { use super::*; @@ -128,4 +200,62 @@ mod tests { 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); } + + fn default_capacity() -> BiomeCapacity { + BiomeCapacity { + base: 10, + decay_per_excess_pop: 0.05, + } + } + + #[test] + fn capacity_default_is_disabled() { + assert!(!BiomeCapacityConfig::default().enabled); + } + + #[test] + fn disabled_capacity_returns_one_regardless_of_pop() { + let cap = default_capacity(); + let cfg = BiomeCapacityConfig::default(); + assert!((carrying_capacity_modifier(1, &cap, &cfg) - 1.0).abs() < 1e-9); + assert!((carrying_capacity_modifier(50, &cap, &cfg) - 1.0).abs() < 1e-9); + assert!((carrying_capacity_modifier(1_000, &cap, &cfg) - 1.0).abs() < 1e-9); + } + + #[test] + fn enabled_at_or_below_capacity_returns_one() { + let cap = default_capacity(); + let cfg = BiomeCapacityConfig { + enabled: true, + floor_factor: 0.5, + }; + assert!((carrying_capacity_modifier(1, &cap, &cfg) - 1.0).abs() < 1e-9); + assert!((carrying_capacity_modifier(10, &cap, &cfg) - 1.0).abs() < 1e-9); + } + + #[test] + fn enabled_over_capacity_decays_linearly_to_floor() { + let cap = default_capacity(); // base 10, decay 0.05 + let cfg = BiomeCapacityConfig { + enabled: true, + floor_factor: 0.5, + }; + // pop 11 → excess 1 → raw = 0.95 + assert!((carrying_capacity_modifier(11, &cap, &cfg) - 0.95).abs() < 1e-9); + // pop 20 → excess 10 → raw = 0.50 (exactly the floor) + assert!((carrying_capacity_modifier(20, &cap, &cfg) - 0.50).abs() < 1e-9); + // pop 100 → excess 90 → raw = -3.5 → clamped to floor 0.5 + assert!((carrying_capacity_modifier(100, &cap, &cfg) - 0.50).abs() < 1e-9); + } + + #[test] + fn enabled_floor_factor_is_respected() { + let cap = default_capacity(); + let cfg_strict = BiomeCapacityConfig { + enabled: true, + floor_factor: 0.8, // tight floor — slows but never below 80% + }; + // pop 100 → would be -3.5 → clamped to 0.8 + assert!((carrying_capacity_modifier(100, &cap, &cfg_strict) - 0.80).abs() < 1e-9); + } } diff --git a/src/simulator/crates/mc-core/src/improvement.rs b/src/simulator/crates/mc-core/src/improvement.rs new file mode 100644 index 00000000..4f9561ff --- /dev/null +++ b/src/simulator/crates/mc-core/src/improvement.rs @@ -0,0 +1,86 @@ +//! Per-hex tile improvement types. +//! +//! `TileImprovementSpec` is loaded from `public/resources/improvements/*.json` +//! into a registry at game start. `TileImprovement` is the live instance +//! anchored at a specific (col, row) in `GameState.tile_improvements`. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Static data loaded from an improvement JSON file. One entry per improvement +/// id in the registry; never mutated at runtime. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TileImprovementSpec { + pub id: String, + /// Base hit-points. 0 means the improvement has no independent HP (it is + /// removed instantly when pillaged rather than damaged first). + #[serde(default)] + pub hp: i32, + /// Whether the improvement can be pillaged (set `pillaged: true`) without + /// being fully destroyed. True for severable infrastructure (steam_track, + /// resonance_wire). False for structures that are razed outright. + #[serde(default)] + pub severable: bool, + /// Keyword flags from the JSON `flags` array (e.g. `"courier_infrastructure"`). + #[serde(default)] + pub flags: BTreeSet, +} + +impl TileImprovementSpec { + /// Build a spec from the raw JSON shape used in `public/resources/improvements/`. + /// The JSON stores `severable` inside `effects.severable` and `flags` at the + /// top level; this constructor normalises both paths. + pub fn from_json(raw: &RawImprovementJson) -> Self { + let severable = raw + .effects + .as_ref() + .and_then(|e| e.severable) + .unwrap_or(false); + let hp = raw.hp.unwrap_or(0); + let flags = raw.flags.clone().unwrap_or_default(); + Self { + id: raw.id.clone(), + hp, + severable, + flags, + } + } +} + +/// Raw shape of `public/resources/improvements/.json` — only the fields +/// relevant to the simulation layer. Extra fields (description, encyclopedia, +/// etc.) are silently ignored via `#[serde(default)]`. +#[derive(Debug, Clone, Deserialize)] +pub struct RawImprovementJson { + pub id: String, + #[serde(default)] + pub hp: Option, + #[serde(default)] + pub effects: Option, + #[serde(default)] + pub flags: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RawImprovementEffects { + #[serde(default)] + pub severable: Option, +} + +/// Live instance of an improvement anchored at a specific hex. Derived from +/// `TileImprovementSpec` when `set_improvement` is called; mutable thereafter. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TileImprovement { + /// Improvement type id (matches the spec registry key). + pub id: String, + /// Remaining hit-points. Starts at `spec.hp`; 0 means destroyed. + pub hp: i32, + /// Whether this improvement type can be pillaged rather than destroyed. + pub severable: bool, + /// True after a pillage action on a severable improvement. The improvement + /// still exists but its route-gating effect is suspended. + pub pillaged: bool, + /// Mirror of the spec's `flags` set for fast membership tests without a + /// registry lookup. + pub flags: BTreeSet, +} diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index af0420fe..5389e0f0 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod collectibles; pub mod formation; pub mod gd_compat; pub mod grid; +pub mod improvement; pub mod perf; pub mod player; pub mod wonder;