feat(mc-turn): Introduce worker categories and expertise tiers in game state and processing logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 23:07:57 -07:00
parent de9944a6ed
commit 4131642d0b
2 changed files with 104 additions and 1 deletions

View file

@ -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).

View file

@ -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]