diff --git a/src/simulator/crates/mc-ai/tests/personality_building_bias.rs b/src/simulator/crates/mc-ai/tests/personality_building_bias.rs new file mode 100644 index 00000000..31e84ccd --- /dev/null +++ b/src/simulator/crates/mc-ai/tests/personality_building_bias.rs @@ -0,0 +1,239 @@ +//! p1-42b — per-personality `building_category_weights` integration test. +//! +//! Demonstrates that the catalog-driven building scorer +//! (`mc_ai::tactical::production::score_building`) flips its top pick +//! between `forge` (production category) and `marketplace` +//! (infrastructure category) purely as a function of the priors authored +//! on `TacticalPlayerState::building_priors`. +//! +//! Two clans share an identical city / unit / catalog state. The only +//! difference is their `BuildingPriors`: +//! - production-axis clan → `production = 2.0`, `infrastructure = 0.5` +//! - wealth-axis clan → `production = 0.5`, `infrastructure = 2.0` +//! +//! Acceptance bullet (`p1-42b`): the production clan must pick `forge`, +//! the wealth clan must pick `marketplace`. +//! +//! Driven through the public `decide_tactical_actions` entry point so the +//! test exercises the same call path as the live AI driver. + +use mc_ai::evaluator::ScoringWeights; +use mc_ai::mcts::XorShift64; +use mc_ai::tactical::state::{BuildingPriors, TacticalBuildingSpec}; +use mc_ai::tactical::{ + decide_tactical_actions, Action, TacticalCity, TacticalMap, TacticalPlayerState, + TacticalState, TacticalUnit, +}; + +fn empty_map() -> TacticalMap { + TacticalMap { + width: 0, + height: 0, + tiles: Vec::new(), + } +} + +/// Two-building catalog: `forge` (production cat, yield_production=2) and +/// `marketplace` (infrastructure cat, yield_gold=3). Without priors the two +/// buildings score similarly under the baseline `ScoringWeights`; the +/// category multiplier is the dominating signal. +fn forge_vs_marketplace_catalog() -> Vec { + fn b( + id: &str, + category: &str, + prod: i32, + gold: i32, + ) -> TacticalBuildingSpec { + TacticalBuildingSpec { + id: id.into(), + tier: 1, + category: category.into(), + cost: 60, + tech_required: None, + race_required: None, + wonder_type: None, + requires_resource: None, + requires_existing: None, + yield_food: 0, + yield_production: prod, + yield_gold: gold, + yield_science: 0, + yield_culture: 0, + yield_defense: 0, + yield_gpp: 0, + great_work_slots: 0, + yield_happiness: 0, + } + } + vec![ + b("forge", "production", 2, 0), + b("marketplace", "infrastructure", 0, 3), + ] +} + +fn dwarf_warrior(id: u32, hex: (i32, i32)) -> TacticalUnit { + TacticalUnit { + id, + kind: "dwarf_warrior".into(), + hex, + hp: 100, + hp_max: 100, + moves_left: 2, + fortified: false, + can_found_city: false, + patrol_order: None, + ..Default::default() + } +} + +/// City with enough population + no buildings yet so both candidates are +/// buildable. Two cities so the settle / expansion path doesn't preempt +/// production (mirrors the pattern used by `test_ai_picks_from_full_catalog`). +fn two_empty_cities() -> Vec { + vec![ + TacticalCity { + id: 10, + hex: (1, 1), + population: 4, + tiles_worked: vec![(1, 1)], + production_queue: vec![], + buildings: vec![], + health: 100, + is_capital: true, + }, + TacticalCity { + id: 20, + hex: (5, 5), + population: 3, + tiles_worked: vec![(5, 5)], + production_queue: vec![], + buildings: vec![], + health: 100, + is_capital: false, + }, + ] +} + +fn player_with_priors(priors: BuildingPriors) -> TacticalPlayerState { + // Six warriors so the military-quota gate doesn't force a defence pick + // (mirrors the militia fixture used in the existing production tests). + let units: Vec = (1..7).map(|i| dwarf_warrior(i, (1, 1))).collect(); + TacticalPlayerState { + index: 0, + clan_id: "test_clan".into(), + gold: 200, + happiness_pool: 5, + units, + cities: two_empty_cities(), + researched_techs: Vec::new(), + relations: vec![0], + race_id: None, + strategic_resources: Vec::new(), + // Neutral axes — no axis-driven multiplier fires, so the priors + // map is the *only* category bias source. This isolates the + // p1-42b plumbing under test from the cycle-5 axis fall-through. + strategic_axes: ::std::collections::BTreeMap::new(), + promotion_offense_weight: 1.0, + promotion_defense_weight: 1.0, + promotion_mobility_weight: 1.0, + building_priors: priors, + } +} + +fn state_with(player: TacticalPlayerState) -> TacticalState { + let mut catalog = forge_vs_marketplace_catalog(); + // Sort so the catalog order is deterministic across runs. + catalog.sort_by(|a, b| a.id.cmp(&b.id)); + TacticalState { + current_player: 0, + turn: 200, + map: empty_map(), + players: vec![player], + unit_catalog: Vec::new(), + building_catalog: catalog, + difficulty_threshold_mult: 1.0, + } +} + +fn enqueued_building_ids(actions: &[Action]) -> Vec { + actions + .iter() + .filter_map(|a| match a { + Action::EnqueueBuild { item_id, .. } => Some(item_id.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn production_priors_pick_forge_wealth_priors_pick_marketplace() { + // ── production-biased priors ──────────────────────────────────────── + let mut prod_priors = BuildingPriors::default(); + prod_priors + .building_category_weights + .insert("production".into(), 2.0); + prod_priors + .building_category_weights + .insert("infrastructure".into(), 0.5); + let state_prod = state_with(player_with_priors(prod_priors)); + let weights = ScoringWeights::baseline(); + let mut rng_prod = XorShift64::new(0xC0FF_EE_42); + let out_prod = decide_tactical_actions(&state_prod, &weights, &mut rng_prod, None); + let prod_picks = enqueued_building_ids(&out_prod); + assert!( + !prod_picks.is_empty(), + "production-biased clan must emit at least one EnqueueBuild" + ); + for id in &prod_picks { + assert_eq!( + id, "forge", + "production-axis priors must pick `forge` over `marketplace`; got {id} (picks={prod_picks:?})" + ); + } + + // ── wealth-biased priors ──────────────────────────────────────────── + let mut wealth_priors = BuildingPriors::default(); + wealth_priors + .building_category_weights + .insert("production".into(), 0.5); + wealth_priors + .building_category_weights + .insert("infrastructure".into(), 2.0); + let state_wealth = state_with(player_with_priors(wealth_priors)); + let mut rng_wealth = XorShift64::new(0xC0FF_EE_42); + let out_wealth = decide_tactical_actions(&state_wealth, &weights, &mut rng_wealth, None); + let wealth_picks = enqueued_building_ids(&out_wealth); + assert!( + !wealth_picks.is_empty(), + "wealth-biased clan must emit at least one EnqueueBuild" + ); + for id in &wealth_picks { + assert_eq!( + id, "marketplace", + "wealth-axis priors must pick `marketplace` over `forge`; got {id} (picks={wealth_picks:?})" + ); + } +} + +#[test] +fn empty_priors_fall_through_to_default_behaviour() { + // Sanity: when both maps are empty, the scorer must NOT panic and must + // still emit a pick. With neutral axes and no priors the baseline + // ScoringWeights values + tier bonus pick whatever scores highest; + // the exact id isn't load-bearing — only that fall-through is safe. + let state = state_with(player_with_priors(BuildingPriors::default())); + let weights = ScoringWeights::baseline(); + let mut rng = XorShift64::new(42); + let out = decide_tactical_actions(&state, &weights, &mut rng, None); + let picks = enqueued_building_ids(&out); + assert!( + !picks.is_empty(), + "empty priors must not block production: catalog scorer should fall through" + ); + for id in &picks { + assert!( + id == "forge" || id == "marketplace", + "expected fall-through pick from catalog, got {id}" + ); + } +}