diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 9f52024f..a3d1c177 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -342,6 +342,15 @@ pub struct GameState { /// persisted in save files. #[serde(skip)] pub units_catalog: mc_units::UnitsCatalog, + /// p3-05e: civic-axis modifier catalog loaded from + /// `public/resources/civics/*.json`. Used at turn-end to resolve each + /// player's `ResolvedModifiers` (notably `specialist_xp_rate`, consumed by + /// the expertise-XP tick in `process_city_production`). `#[serde(skip)]` + /// mirrors `units_catalog` — boot-loaded, not save-persisted. An empty + /// (Default) catalog resolves to identity modifiers (rate 1.0), so test + /// and pre-load paths see no civic effect. + #[serde(skip)] + pub civic_catalog: mc_civics::CivicCatalog, /// p2-71: tactical-AI view of the producible-unit catalog. Mirrors /// `TacticalState::unit_catalog` and is populated once at harness boot /// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests). diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index bc8f14aa..0ada99b2 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -1067,6 +1067,19 @@ impl TurnProcessor { use mc_city::Queueable; use mc_core::WonderId; + // p3-05e: resolve the Labor-axis `specialist_xp_rate` for this player + // from their active civics + the boot-loaded civic catalog, *before* + // the mutable `player` borrow below (immutable borrows of + // `state.players[pi].civic_state` + `state.civic_catalog` must drop + // first). An empty catalog (tests / pre-boot) resolves to identity + // (rate 1.0), so no civic effect — see `resolve_modifiers`. The rate + // scales XP earned by assigned workers in `tick_xp_gain` below. + let specialist_xp_rate = mc_civics::resolve_modifiers( + &state.players[pi].civic_state, + &state.civic_catalog, + ) + .specialist_xp_rate; + let player = &mut state.players[pi]; let prod_axis = *player.strategic_axes.get("production").unwrap_or(&3) as i32; let exp_axis = *player.strategic_axes.get("expansion").unwrap_or(&3) as u32; @@ -1142,11 +1155,24 @@ impl TurnProcessor { ]; for (cat, amount) in earned { if amount > 0 { + // p3-05e: scale the earned XP by the player's civic + // `specialist_xp_rate` (Labor axis). Round to nearest and + // clamp into `u32`; a positive yield with a positive rate + // <1.0 keeps at least 1 XP so a slow-but-nonzero rate never + // silently truncates a working worker to zero progress. + let scaled = (amount as f64 * specialist_xp_rate).round(); + let xp_amount: u32 = if scaled <= 0.0 { + if specialist_xp_rate > 0.0 { 1 } else { 0 } + } else if scaled >= u32::MAX as f64 { + u32::MAX + } else { + scaled as u32 + }; let entry = city .worker_expertise .entry(cat) .or_insert_with(WorkerExpertise::default); - entry.tick_xp_gain(amount as u32, &expertise_cfg); + entry.tick_xp_gain(xp_amount, &expertise_cfg); } } // Idle decay: any category with a ledger entry that did NOT earn @@ -6802,6 +6828,74 @@ mod tests { assert_eq!(state.players[0].city_buildings[0], vec!["longhouse"]); } + /// p3-05e: the Labor-axis civic `specialist_xp_rate` scales the XP that + /// assigned workers earn in `process_city_production`. A player running + /// `guild_apprenticeship` (rate 1.50) must accumulate strictly more + /// Construction expertise over the same number of ticks than a player with + /// no active civic (anarchy / identity rate 1.0), all else equal. + #[test] + fn test_specialist_xp_rate_scales_expertise_gain() { + use mc_civics::CivicCatalog; + use mc_core::civic::{AxisChoice, CivicState}; + + // Weighted "progress" reading so a higher tier always outranks more + // residual XP — tier index dominates, xp_in_tier breaks ties. + fn construction_progress(state: &GameState) -> u64 { + let we = state.players[0] + .cities[0] + .worker_expertise + .get(&WorkerCategory::Construction) + .copied() + .unwrap_or_default(); + let tier_ord = mc_core::expertise::ALL + .iter() + .position(|t| *t == we.tier) + .unwrap_or(0) as u64; + tier_ord * 1_000_000 + we.xp_in_tier as u64 + } + + // Real Game-1 civic catalog (CARGO_MANIFEST_DIR-rooted; tests only). + let catalog = + CivicCatalog::load_from_dir(&CivicCatalog::workspace_default_path()) + .expect("load civic catalog"); + + // Build one runner; identical except the Labor-axis civic choice. + fn build_state(catalog: &CivicCatalog, labor: AxisChoice) -> GameState { + let mut player = systems_b_player(3, 5, 3, 3); + push_starter_city(&mut player, 0, 0); + // High pop so neither growth nor starvation perturbs the run; the + // Construction (prod) XP path is independent of the food path. + player.cities[0].population = 30; + let mut cs = CivicState::default(); + cs.labor = labor; + cs.anarchy_turns_remaining = 0; + player.civic_state = cs; + let mut state = systems_b_state(player); + state.civic_catalog = catalog.clone(); + state + } + + let mut guilds = build_state(&catalog, AxisChoice::Guilds); // rate 1.50 + let mut anarchy = build_state(&catalog, AxisChoice::Anarchy); // rate 1.00 + + let processor = TurnProcessor::new(100); + for _ in 0..12 { + processor.process_city_production(&mut guilds, 0, &mut Vec::new()); + processor.process_city_production(&mut anarchy, 0, &mut Vec::new()); + } + + let g = construction_progress(&guilds); + let a = construction_progress(&anarchy); + assert!( + g > a, + "guild_apprenticeship (specialist_xp_rate 1.50) must out-accumulate \ + anarchy (1.00): guilds={g} vs anarchy={a}" + ); + // Sanity: the baseline actually earned something, so the comparison is + // meaningful (not 0 vs 0). + assert!(a > 0, "baseline anarchy run must earn some Construction XP"); + } + /// `try_found_city` seeds the founding palace into the new city's /// building list, so the auto-evolution hook has a starting tier. #[test]