feat(@projects/@magic-civilization): add civic resolver system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-14 23:19:25 -07:00
parent c5062822ed
commit c2de4b43f9
7 changed files with 557 additions and 0 deletions

View file

@ -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",

View file

@ -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",

View 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

View 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.
}
}

View file

@ -5,6 +5,7 @@ edition = "2021"
[dependencies]
mc-core = { path = "../mc-core" }
mc-civics = { path = "../mc-civics" }
serde.workspace = true
serde_json.workspace = true

View 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);
}
}

View file

@ -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};