✅ test(@projects/@magic-civilization): add bias test for building category scoring
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
58b76c5e89
commit
1fbc1c310e
1 changed files with 239 additions and 0 deletions
239
src/simulator/crates/mc-ai/tests/personality_building_bias.rs
Normal file
239
src/simulator/crates/mc-ai/tests/personality_building_bias.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue