test(@projects/@magic-civilization): add bias test for building category scoring

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-14 23:06:59 -07:00
parent 58b76c5e89
commit 1fbc1c310e

View file

@ -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<TacticalBuildingSpec> {
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<TacticalCity> {
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<TacticalUnit> = (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<String> {
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}"
);
}
}