feat(@projects/@magic-civilization): ✨ add civic resolver system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c5062822ed
commit
c2de4b43f9
7 changed files with 557 additions and 0 deletions
10
src/simulator/Cargo.lock
generated
10
src/simulator/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
12
src/simulator/crates/mc-civics/Cargo.toml
Normal file
12
src/simulator/crates/mc-civics/Cargo.toml
Normal file
|
|
@ -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
|
||||
363
src/simulator/crates/mc-civics/src/lib.rs
Normal file
363
src/simulator/crates/mc-civics/src/lib.rs
Normal file
|
|
@ -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<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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<String, CatalogEntry>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<String> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
mc-core = { path = "../mc-core" }
|
||||
mc-civics = { path = "../mc-civics" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
|
|
|
|||
168
src/simulator/crates/mc-economy/src/city_yield.rs
Normal file
168
src/simulator/crates/mc-economy/src/city_yield.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue