feat(simulator-specific): ✨ Introduce configurable soft carrying-capacity cap for biome yields and integrate with improvement mechanics
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
afa600849b
commit
501927dc33
3 changed files with 217 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
src/simulator/crates/mc-core/src/improvement.rs
Normal file
86
src/simulator/crates/mc-core/src/improvement.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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/<id>.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<i32>,
|
||||
#[serde(default)]
|
||||
pub effects: Option<RawImprovementEffects>,
|
||||
#[serde(default)]
|
||||
pub flags: Option<BTreeSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RawImprovementEffects {
|
||||
#[serde(default)]
|
||||
pub severable: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue