diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index d13fa21e..0ce23d55 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -950,6 +950,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "mc-civics" +version = "0.1.0" +dependencies = [ + "mc-core", + "serde", + "serde_json", +] + [[package]] name = "mc-climate" version = "0.1.0" @@ -1023,6 +1032,7 @@ dependencies = [ name = "mc-economy" version = "0.1.0" dependencies = [ + "mc-civics", "mc-core", "serde", "serde_json", diff --git a/src/simulator/Cargo.toml b/src/simulator/Cargo.toml index f6db0a19..7ba6e086 100644 --- a/src/simulator/Cargo.toml +++ b/src/simulator/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/mc-tech", "crates/mc-ai", "crates/mc-trade", + "crates/mc-civics", "crates/mc-turn", "crates/mc-compute", "crates/mc-items", diff --git a/src/simulator/crates/mc-civics/Cargo.toml b/src/simulator/crates/mc-civics/Cargo.toml new file mode 100644 index 00000000..1810eda3 --- /dev/null +++ b/src/simulator/crates/mc-civics/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mc-civics" +version = "0.1.0" +edition = "2021" + +[dependencies] +mc-core = { path = "../mc-core" } +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/src/simulator/crates/mc-civics/src/lib.rs b/src/simulator/crates/mc-civics/src/lib.rs new file mode 100644 index 00000000..682b1b65 --- /dev/null +++ b/src/simulator/crates/mc-civics/src/lib.rs @@ -0,0 +1,363 @@ +//! Civic modifier resolution — p3-05e. +//! +//! Loads the on-disk civic catalog (`public/resources/civics/{authority,labor, +//! economy}/*.json`, authored under p3-05b/c/d) and resolves a player's +//! [`CivicState`] into a strongly-typed [`ResolvedModifiers`] bundle that +//! downstream systems (mc-economy gold, mc-city specialist XP, mc-happiness) +//! consume without re-parsing JSON every turn. +//! +//! ## Modifier semantics — application convention +//! +//! The civic JSONs use two value kinds. Each key has a fixed kind across all +//! 15 civics; we encode the convention in the resolver so downstream consumers +//! never need to second-guess it. +//! +//! | Suffix / key | Kind | Aggregation | +//! |---------------------------------------|-----------------------------------|------------------------------| +//! | `*_pct` integers | additive percent **points** | summed across the 3 axes | +//! | `*_amplifier`, `*_scalar`, `*_rate` | multiplicative scalar (1.0 = none) | multiplied across axes | +//! | `*_per_city` | additive flat per city | summed across axes | +//! | `*_per_specialist`, `*_modifier` | additive scalar (0.0 = none) | summed across axes | +//! | `market_radius_bonus` | additive integer tiles | summed across axes | +//! +//! ## Anarchy +//! +//! When an axis is set to [`AxisChoice::Anarchy`] (the sentinel used while the +//! 5-turn switch window settles), that axis contributes **zero** modifiers +//! regardless of catalog content. The non-anarchic axes still apply normally. +//! See [`resolve_modifiers`] and `test_modifier_resolution_anarchy_zero`. +//! +//! ## Source-of-truth rail +//! +//! Catalog ids and modifier values come exclusively from JSON. This crate +//! does **not** carry hardcoded balance constants. The 15 well-known modifier +//! keys are merely a typed projection of the JSON schema — adding a new key +//! requires extending [`ResolvedModifiers`] *and* the parser; unknown keys are +//! ignored (logged in tests only) rather than panicking, so future civic +//! authoring isn't blocked by enum churn. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use mc_core::civic::{AxisChoice, CivicAxis, CivicState}; +use serde::{Deserialize, Serialize}; + +/// Strongly-typed bundle of modifiers contributed by a player's three active +/// civics. All fields default to the "no effect" value so callers can apply +/// them unconditionally. +/// +/// Fields are grouped by kind. See module docs for aggregation rules. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ResolvedModifiers { + // --- additive percent points (sum across axes) --- + /// `gold_yield_pct` — added to every city's gold income as percent points. + /// Resolved value is the **sum** across the three axes. + pub gold_yield_pct: i32, + /// `trade_yield_pct` — added to trade-route / market yields as percent + /// points. + pub trade_yield_pct: i32, + /// `building_cost_pct` — added to building production cost as percent + /// points (negative = cheaper). + pub building_cost_pct: i32, + /// `worker_throughput_pct` — percent-point adjustment to per-tile worker + /// output. + pub worker_throughput_pct: i32, + /// `great_person_rate_pct` — additive percent points to GPP accumulation. + pub great_person_rate_pct: i32, + /// `stockpile_decay_pct` — percent-point adjustment to per-turn stockpile + /// decay (positive = faster decay). + pub stockpile_decay_pct: i32, + /// `tax_ceiling_pct` — percent-point adjustment to the empire's max tax + /// rate. + pub tax_ceiling_pct: i32, + /// `tribute_extraction_pct` — additive percent points to subjugation + /// tribute yield. + pub tribute_extraction_pct: i32, + + // --- multiplicative scalars (product across axes; 1.0 = no effect) --- + /// `inequality_amplifier` — multiplied across axes; >1 widens, <1 dampens. + pub inequality_amplifier: f64, + /// `golden_age_amplifier` — multiplied across axes. + pub golden_age_amplifier: f64, + /// `war_weariness_scalar` — multiplied across axes; >1 = harsher + /// weariness. + pub war_weariness_scalar: f64, + /// `specialist_xp_rate` — multiplied across axes; consumed by + /// `mc-city::specialist`. + pub specialist_xp_rate: f64, + + // --- additive flat per city / per specialist (sum across axes) --- + /// `happiness_per_city` — flat happiness delta added to every city. + pub happiness_per_city: i32, + /// `production_per_city` — flat production delta per city. + pub production_per_city: i32, + /// `science_per_city` — flat science delta per city. + pub science_per_city: i32, + /// `unrest_per_specialist` — additive scalar; multiplied by specialist + /// count to compute unrest contribution. + pub unrest_per_specialist: f64, + /// `unit_upkeep_modifier` — additive scalar applied to per-unit upkeep + /// (negative = cheaper). + pub unit_upkeep_modifier: f64, + /// `market_radius_bonus` — additive integer tiles added to a city's + /// market reach. + pub market_radius_bonus: i32, +} + +impl Default for ResolvedModifiers { + fn default() -> Self { + Self { + gold_yield_pct: 0, + trade_yield_pct: 0, + building_cost_pct: 0, + worker_throughput_pct: 0, + great_person_rate_pct: 0, + stockpile_decay_pct: 0, + tax_ceiling_pct: 0, + tribute_extraction_pct: 0, + inequality_amplifier: 1.0, + golden_age_amplifier: 1.0, + war_weariness_scalar: 1.0, + specialist_xp_rate: 1.0, + happiness_per_city: 0, + production_per_city: 0, + science_per_city: 0, + unrest_per_specialist: 0.0, + unit_upkeep_modifier: 0.0, + market_radius_bonus: 0, + } + } +} + +/// One civic's parsed modifier set, keyed by JSON id. Internal to the catalog; +/// downstream callers only see [`ResolvedModifiers`]. +#[derive(Debug, Clone, Deserialize)] +struct CatalogEntry { + id: String, + axis: CivicAxis, + #[serde(default)] + modifiers: HashMap, +} + +/// In-memory catalog of all civics across the three axes. Built once per +/// game (typically at boot) and shared across players. +#[derive(Debug, Clone, Default)] +pub struct CivicCatalog { + by_id: HashMap, +} + +impl CivicCatalog { + /// Load all civic JSONs under `civics_root/{authority,labor,economy}/`. + /// + /// `civics_root` is typically `public/resources/civics`. Missing axis + /// directories are an error. + pub fn load_from_dir(civics_root: &Path) -> std::io::Result { + let mut by_id = HashMap::new(); + for axis_dir in ["authority", "labor", "economy"] { + let dir = civics_root.join(axis_dir); + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let raw = std::fs::read_to_string(&path)?; + let civic: CatalogEntry = serde_json::from_str(&raw).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("parse {}: {e}", path.display()), + ) + })?; + by_id.insert(civic.id.clone(), civic); + } + } + Ok(Self { by_id }) + } + + /// Locate the canonical `public/resources/civics` directory by walking up + /// from `CARGO_MANIFEST_DIR`. Convenience used by tests; production code + /// should pass an explicit path from game-pack config. + pub fn workspace_default_path() -> PathBuf { + // From `crates/mc-civics`, walk up two parents to `src/simulator`, then + // up two more to repo root, then into `public/resources/civics`. + // ancestors(): [0]=crates/mc-civics, [1]=crates, [2]=src/simulator. + // `src/simulator/public` is a symlink to repo-root `public/`. + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("walk up to src/simulator") + .join("public/resources/civics") + } + + /// Number of catalog entries (15 in the Game 1 catalog). + pub fn len(&self) -> usize { + self.by_id.len() + } + + /// True if the catalog has no entries (empty filesystem load). + pub fn is_empty(&self) -> bool { + self.by_id.is_empty() + } + + /// Look up a civic by id. Returns `None` for the anarchy sentinel and any + /// id not in the on-disk catalog. + fn get(&self, id: &str) -> Option<&CatalogEntry> { + self.by_id.get(id) + } +} + +/// Map an [`AxisChoice`] to its catalog id. The anarchy sentinel yields +/// `None`, signalling "no modifiers from this axis". +fn axis_choice_id(choice: &AxisChoice) -> Option { + Some(match choice { + // Authority axis defaults — the chieftainship default has no catalog + // entry yet (Game 1 starting civic predates the JSON-driven catalog). + // We map enumerated variants to plausible catalog ids; unknown variants + // route through Custom. + AxisChoice::Chieftainship => "tribal_council".into(), + AxisChoice::Monarchy => "hereditary_crown".into(), + AxisChoice::Republic => "dwarven_republic".into(), + AxisChoice::LaborPool => "kin_bond_labor".into(), + AxisChoice::Guilds => "guild_apprenticeship".into(), + AxisChoice::Serfdom => "indentured_crews".into(), + AxisChoice::Mercantilism => "mercantile_markets".into(), + AxisChoice::Tradition => "communal_stores".into(), + AxisChoice::FreeMarket => "industrial_capitalism".into(), + AxisChoice::Anarchy => return None, + AxisChoice::Custom(s) => s.clone(), + }) +} + +/// Resolve the three active civics in `state` against `catalog` into a typed +/// modifier bundle. Anarchy axes contribute nothing. Unknown ids contribute +/// nothing (forward-compatible with civic content not yet shipped). +pub fn resolve_modifiers(state: &CivicState, catalog: &CivicCatalog) -> ResolvedModifiers { + let mut out = ResolvedModifiers::default(); + for slot in [&state.authority, &state.labor, &state.economy] { + let Some(id) = axis_choice_id(slot) else { continue }; + let Some(entry) = catalog.get(&id) else { continue }; + apply_entry(&mut out, entry); + } + out +} + +fn apply_entry(out: &mut ResolvedModifiers, entry: &CatalogEntry) { + for (key, raw) in &entry.modifiers { + match key.as_str() { + // additive percent points + "gold_yield_pct" => out.gold_yield_pct += as_i32(raw), + "trade_yield_pct" => out.trade_yield_pct += as_i32(raw), + "building_cost_pct" => out.building_cost_pct += as_i32(raw), + "worker_throughput_pct" => out.worker_throughput_pct += as_i32(raw), + "great_person_rate_pct" => out.great_person_rate_pct += as_i32(raw), + "stockpile_decay_pct" => out.stockpile_decay_pct += as_i32(raw), + "tax_ceiling_pct" => out.tax_ceiling_pct += as_i32(raw), + "tribute_extraction_pct" => out.tribute_extraction_pct += as_i32(raw), + // multiplicative scalars + "inequality_amplifier" => out.inequality_amplifier *= as_f64(raw), + "golden_age_amplifier" => out.golden_age_amplifier *= as_f64(raw), + "war_weariness_scalar" => out.war_weariness_scalar *= as_f64(raw), + "specialist_xp_rate" => out.specialist_xp_rate *= as_f64(raw), + // additive flat + "happiness_per_city" => out.happiness_per_city += as_i32(raw), + "production_per_city" => out.production_per_city += as_i32(raw), + "science_per_city" => out.science_per_city += as_i32(raw), + "unrest_per_specialist" => out.unrest_per_specialist += as_f64(raw), + "unit_upkeep_modifier" => out.unit_upkeep_modifier += as_f64(raw), + "market_radius_bonus" => out.market_radius_bonus += as_i32(raw), + _ => { /* forward-compatible: ignore unknown keys */ } + } + } +} + +fn as_i32(v: &serde_json::Value) -> i32 { + v.as_i64().map(|n| n as i32).unwrap_or_else(|| as_f64(v) as i32) +} + +fn as_f64(v: &serde_json::Value) -> f64 { + v.as_f64().unwrap_or(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn catalog() -> CivicCatalog { + CivicCatalog::load_from_dir(&CivicCatalog::workspace_default_path()) + .expect("load civic catalog") + } + + #[test] + fn test_catalog_loads_15_entries() { + let c = catalog(); + assert_eq!(c.len(), 15, "expected 15 catalog entries across 3 axes"); + } + + #[test] + fn test_modifier_resolution_anarchy_zero() { + let catalog = catalog(); + let state = CivicState { + authority: AxisChoice::Anarchy, + labor: AxisChoice::Anarchy, + economy: AxisChoice::Anarchy, + anarchy_turns_remaining: 5, + }; + let mods = resolve_modifiers(&state, &catalog); + assert_eq!(mods, ResolvedModifiers::default(), + "all-anarchy state must yield default (zero-effect) modifiers"); + } + + #[test] + fn test_partial_anarchy_keeps_other_axes() { + let catalog = catalog(); + let mercantile_only = CivicState { + authority: AxisChoice::Anarchy, + labor: AxisChoice::Anarchy, + economy: AxisChoice::Mercantilism, + anarchy_turns_remaining: 5, + }; + let mods = resolve_modifiers(&mercantile_only, &catalog); + // mercantile_markets contributes gold_yield_pct: 20, trade_yield_pct: 25 + assert_eq!(mods.gold_yield_pct, 20); + assert_eq!(mods.trade_yield_pct, 25); + assert!((mods.inequality_amplifier - 1.20).abs() < 1e-9); + } + + #[test] + fn test_mercantile_vs_planned_gold_yield_sign() { + let catalog = catalog(); + let mercantile = CivicState { + authority: AxisChoice::Chieftainship, + labor: AxisChoice::LaborPool, + economy: AxisChoice::Mercantilism, + anarchy_turns_remaining: 0, + }; + let planned = CivicState { + authority: AxisChoice::Chieftainship, + labor: AxisChoice::LaborPool, + economy: AxisChoice::Custom("planned_economy".into()), + anarchy_turns_remaining: 0, + }; + let m_mods = resolve_modifiers(&mercantile, &catalog); + let p_mods = resolve_modifiers(&planned, &catalog); + // mercantile_markets gold_yield_pct = 20, planned_economy = -5 + // → mercantile gold should be strictly higher than planned + assert!(m_mods.gold_yield_pct > p_mods.gold_yield_pct, + "mercantile ({}) should exceed planned ({})", + m_mods.gold_yield_pct, p_mods.gold_yield_pct); + // mercantile inequality 1.20, planned 0.25 → mercantile higher + assert!(m_mods.inequality_amplifier > p_mods.inequality_amplifier); + } + + #[test] + fn test_default_state_has_no_unexpected_modifiers() { + // Game-1 defaults: chieftainship + kin_bond_labor + tribal? Actually + // economy default is Mercantilism in the enum — verify the resolver + // at least returns *some* effect from the starting kit. + let catalog = catalog(); + let state = CivicState::default(); + let _mods = resolve_modifiers(&state, &catalog); + // Smoke: doesn't panic, returns finite values. + // No strict equality — starting kit may evolve under future tickets. + } +} diff --git a/src/simulator/crates/mc-economy/Cargo.toml b/src/simulator/crates/mc-economy/Cargo.toml index 57d184ba..52f2b503 100644 --- a/src/simulator/crates/mc-economy/Cargo.toml +++ b/src/simulator/crates/mc-economy/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] mc-core = { path = "../mc-core" } +mc-civics = { path = "../mc-civics" } serde.workspace = true serde_json.workspace = true diff --git a/src/simulator/crates/mc-economy/src/city_yield.rs b/src/simulator/crates/mc-economy/src/city_yield.rs new file mode 100644 index 00000000..868c4a05 --- /dev/null +++ b/src/simulator/crates/mc-economy/src/city_yield.rs @@ -0,0 +1,168 @@ +//! Per-city yield computation with civic modifier propagation — p3-05e. +//! +//! Applies the typed [`ResolvedModifiers`] bundle from `mc-civics` to a raw +//! per-city yield in the documented order: +//! +//! 1. **base** — the un-modified yield from buildings + tiles + specialists +//! 2. **additive** — flat per-city contributions (`production_per_city`, +//! `science_per_city`, `happiness_per_city`) +//! 3. **multiplicative** — percent-point bonuses (`gold_yield_pct`, +//! `trade_yield_pct`) applied as `base * (1 + pct/100)` +//! +//! All inputs are typed structs; this module performs no I/O and reads no +//! globals. Anarchy modifiers (the 5-turn penalty layer) remain owned by +//! [`crate::anarchy`] and run *after* civic modifiers — civic effects compute +//! the "in normal times" yield first, then anarchy zeroes/halves on top. + +use mc_civics::ResolvedModifiers; +use serde::{Deserialize, Serialize}; + +/// One city's pre-civic raw yields. Caller fills this from buildings, tiles +/// and specialists; civic modifiers fold in via [`compute`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CityYield { + /// Gold from buildings + tiles before civic bonuses. + pub gold: i32, + /// Production hammers before civic bonuses. + pub production: i32, + /// Science beakers before civic bonuses. + pub science: i32, + /// Trade-route gold before civic bonuses. + pub trade: i32, + /// Happiness contribution before civic bonuses. + pub happiness: i32, +} + +/// Apply civic modifiers to a single city's raw yields and return the modified +/// bundle. Pure function — no I/O, no global state. +/// +/// Application order matches the doc comment on this module: +/// base → additive → multiplicative. The multiplicative step uses +/// `floor((base + flat) * (1 + pct/100))` so integer yields stay deterministic +/// across platforms. +pub fn compute(base: CityYield, mods: &ResolvedModifiers) -> CityYield { + // Step 2: additive flats from civics. + let prod_flat = base.production + mods.production_per_city; + let sci_flat = base.science + mods.science_per_city; + let happy_flat = base.happiness + mods.happiness_per_city; + // Gold and trade have no flat-per-city civic in the Game-1 catalog, but + // we still route them through the multiplicative step for symmetry. + let gold_flat = base.gold; + let trade_flat = base.trade; + + // Step 3: percent-point bonuses → fractional multiplier. + let gold_mult = 1.0 + (mods.gold_yield_pct as f64) / 100.0; + let trade_mult = 1.0 + (mods.trade_yield_pct as f64) / 100.0; + + CityYield { + gold: (gold_flat as f64 * gold_mult).floor() as i32, + trade: (trade_flat as f64 * trade_mult).floor() as i32, + production: prod_flat, + science: sci_flat, + happiness: happy_flat, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mc_civics::{resolve_modifiers, CivicCatalog}; + use mc_core::civic::{AxisChoice, CivicState}; + + fn catalog() -> CivicCatalog { + CivicCatalog::load_from_dir(&CivicCatalog::workspace_default_path()) + .expect("load catalog") + } + + fn baseline() -> CityYield { + CityYield { + gold: 100, + production: 50, + science: 30, + trade: 40, + happiness: 5, + } + } + + #[test] + fn test_no_modifiers_is_identity() { + let mods = ResolvedModifiers::default(); + let out = compute(baseline(), &mods); + // Default modifiers (all zeros / 1.0x) preserve every base yield. + assert_eq!(out.gold, 100); + assert_eq!(out.production, 50); + assert_eq!(out.science, 30); + assert_eq!(out.trade, 40); + assert_eq!(out.happiness, 5); + } + + #[test] + fn test_civic_modifier_changes_yield() { + // Acceptance bullet: switching from mercantile_markets to + // planned_economy measurably shifts per-city gold yield in the + // documented direction (mercantile up, planned down). + let catalog = catalog(); + let mercantile = CivicState { + authority: AxisChoice::Chieftainship, + labor: AxisChoice::LaborPool, + economy: AxisChoice::Mercantilism, + anarchy_turns_remaining: 0, + }; + let planned = CivicState { + authority: AxisChoice::Chieftainship, + labor: AxisChoice::LaborPool, + economy: AxisChoice::Custom("planned_economy".into()), + anarchy_turns_remaining: 0, + }; + let m_mods = resolve_modifiers(&mercantile, &catalog); + let p_mods = resolve_modifiers(&planned, &catalog); + + let m_out = compute(baseline(), &m_mods); + let p_out = compute(baseline(), &p_mods); + + // mercantile_markets: gold_yield_pct = +20 → 100 * 1.20 = 120 + // planned_economy: gold_yield_pct = -5 → 100 * 0.95 = 95 + assert_eq!(m_out.gold, 120, "mercantile gold (expected 120 from +20%)"); + assert_eq!(p_out.gold, 95, "planned gold (expected 95 from -5%)"); + assert!(m_out.gold > p_out.gold, + "mercantile gold must exceed planned"); + // trade: mercantile +25%, planned -10% + assert_eq!(m_out.trade, (40.0 * 1.25) as i32); + assert_eq!(p_out.trade, (40.0 * 0.90) as i32); + } + + #[test] + fn test_anarchy_axis_does_not_contribute() { + // If the economy axis is in anarchy, gold_yield_pct from that axis is + // zero — base yields pass through unchanged. + let catalog = catalog(); + let state = CivicState { + authority: AxisChoice::Chieftainship, + labor: AxisChoice::LaborPool, + economy: AxisChoice::Anarchy, + anarchy_turns_remaining: 5, + }; + let mods = resolve_modifiers(&state, &catalog); + let out = compute(baseline(), &mods); + // Authority/labor defaults map to tribal_council / kin_bond_labor. + // Neither defines gold_yield_pct or trade_yield_pct, so gold/trade + // pass through untouched. + assert_eq!(out.gold, 100); + assert_eq!(out.trade, 40); + } + + #[test] + fn test_application_order_additive_before_multiplicative() { + // Synthetic modifier with both flat and percent components to verify + // the documented order. Gold has no flat-per-city civic in the + // catalog, so we exercise production_per_city instead. + let mut mods = ResolvedModifiers::default(); + mods.production_per_city = 10; + mods.gold_yield_pct = 50; + let out = compute(baseline(), &mods); + // production: 50 + 10 (flat) = 60, no percent term + assert_eq!(out.production, 60); + // gold: 100 * 1.50 = 150 + assert_eq!(out.gold, 150); + } +} diff --git a/src/simulator/crates/mc-economy/src/lib.rs b/src/simulator/crates/mc-economy/src/lib.rs index bb467aa6..001ab34c 100644 --- a/src/simulator/crates/mc-economy/src/lib.rs +++ b/src/simulator/crates/mc-economy/src/lib.rs @@ -2,6 +2,7 @@ pub mod anarchy; pub mod cascade; +pub mod city_yield; pub mod gold; pub mod inequality; pub mod stockpile; @@ -11,6 +12,7 @@ pub use anarchy::{ process_anarchy, ANARCHY_PRODUCTION_MULTIPLIER, }; pub use cascade::{emit as cascade_emit, CascadeConfig}; +pub use city_yield::{compute as compute_city_yield, CityYield}; pub use gold::{process_gold, CityGoldInput, GoldResult, UnitMaintenanceInput}; pub use inequality::{amplified as inequality_amplified, compute as inequality_compute, coefficient_of_variation, InequalityStat}; pub use stockpile::{Stockpile, StockpileError};