diff --git a/public/games/age-of-dwarves/data/ai_personalities.json b/public/games/age-of-dwarves/data/ai_personalities.json index 13154501..22fe2e19 100644 --- a/public/games/age-of-dwarves/data/ai_personalities.json +++ b/public/games/age-of-dwarves/data/ai_personalities.json @@ -249,6 +249,21 @@ "destroy_civilian_aggression": 0.6, "promotion_offense_weight": 1.0, "promotion_defense_weight": 1.0, - "promotion_mobility_weight": 1.0 + "promotion_mobility_weight": 1.0, + "building_category_weights": { + "production": 1.0, + "infrastructure": 1.1, + "military": 1.0, + "knowledge": 1.4, + "religious": 1.0, + "wonder": 1.1, + "naval": 0.9 + }, + "wonder_priorities": { + "the_great_forge": 1.2, + "ancestral_forge": 1.2, + "undermount_vault": 1.2, + "ancient_lighthouse": 1.1 + } } } diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 24a134f6..be0dec88 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -1576,6 +1576,59 @@ mod tests { assert_eq!(t.players[0].promotion_mobility_weight, 1.0); } + #[test] + fn tactical_building_priors_round_trip_from_player_state() { + // p1-42b — `building_priors` is now a first-class PlayerState field + // populated by `GdGameState::set_player_personality_json`. The + // projection must read it straight through into TacticalPlayerState + // (rather than seeding `BuildingPriors::default()`) so the catalog- + // driven scorer in `mc_ai::tactical::production::score_building` + // sees per-personality `building_category_weights` and + // `wonder_priorities`. + let mut state = GameState::default(); + let mut ps = PlayerState::default(); + ps.player_index = 0; + ps.building_priors + .building_category_weights + .insert("production".into(), 1.6); + ps.building_priors + .building_category_weights + .insert("military".into(), 0.9); + ps.building_priors + .wonder_priorities + .insert("the_great_forge".into(), 2.0); + state.players.push(ps); + + let t = project_tactical(&state, 0); + let p = &t.players[0]; + assert!( + (p.building_priors.building_category_weights["production"] - 1.6).abs() < 1e-6, + "production category weight must round-trip through projection" + ); + assert!( + (p.building_priors.building_category_weights["military"] - 0.9).abs() < 1e-6, + "military category weight must round-trip through projection" + ); + assert!( + (p.building_priors.wonder_priorities["the_great_forge"] - 2.0).abs() < 1e-6, + "wonder priority must round-trip through projection" + ); + } + + #[test] + fn tactical_building_priors_default_for_unstamped_player() { + // Fixtures predating p1-42b leave `PlayerState::building_priors` at + // its serde-default (empty maps). The projection must surface that + // unchanged so the scorer's axis-driven fall-through still fires. + let state = make_state(1, 0, vec![]); + let t = project_tactical(&state, 0); + assert!(t.players[0] + .building_priors + .building_category_weights + .is_empty()); + assert!(t.players[0].building_priors.wonder_priorities.is_empty()); + } + #[test] fn tactical_difficulty_mult_defaults_to_one_when_unset() { // Same coercion logic as promotion weights — Default::default() gives diff --git a/src/simulator/crates/mc-turn/tests/capture_caravan.rs b/src/simulator/crates/mc-turn/tests/capture_caravan.rs new file mode 100644 index 00000000..97fd5254 --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/capture_caravan.rs @@ -0,0 +1,218 @@ +//! p2-55b — Caravan Master / Merchant capture mechanics (end-to-end PvP). +//! +//! Trade-route persistence (g6-02) is out-of-scope post-v10, so in Game 1 +//! a captured caravan_master / merchant is structurally identical to any +//! other support civilian (worker, founder, engineer): owner flips, AP +//! resets, ransom price = build_cost × ransom_multiplier. The trade-GP +//! premium lives entirely in the JSON `ransom_multiplier` (merchant 3.0, +//! caravan_master 3.5). +//! +//! Pins three properties end-to-end through the TurnProcessor: +//! +//! 1. A captured caravan_master flips ownership like any civilian and emits +//! exactly one `TurnEvent::UnitCaptured`. +//! 2. The captor does NOT inherit a partially-charged action — AP resets +//! to zero on transfer (universal Specialist policy). +//! 3. A ransomed merchant carries the tier-1 trade-GP premium price +//! (build_cost 80 × ransom_multiplier 3.0 = 240), strictly above the +//! worker baseline at equal build cost. + +use mc_core::units::ActionPoints; +use mc_replay::TurnEvent; +use mc_turn::{ + capture::CapturePosture, AttackRequest, GameState, MapUnit, PlayerState, TurnProcessor, +}; +use mc_units::{UnitStats as CatalogUnitStats, UnitsCatalog}; + +const CARAVAN_ID: u32 = 900; +const MERCHANT_ID: u32 = 901; + +fn build_trade_catalog() -> UnitsCatalog { + let mut cat = UnitsCatalog::new(); + cat.insert(CatalogUnitStats { + id: "dwarf_warrior".into(), + base_moves: 2, + domain: "land".into(), + action_point_capacity: None, + capturable: false, + ransom_multiplier: 2.0, + build_cost: 0, + }); + // merchant.json: tier-1 trade GP — premium ransom multiplier, modest cost. + cat.insert(CatalogUnitStats { + id: "merchant".into(), + base_moves: 4, + domain: "land".into(), + action_point_capacity: Some(6), + capturable: true, + ransom_multiplier: 3.0, + build_cost: 80, + }); + // caravan_master.json: tier-3 — higher cost AND higher multiplier. + cat.insert(CatalogUnitStats { + id: "caravan_master".into(), + base_moves: 4, + domain: "land".into(), + action_point_capacity: Some(6), + capturable: true, + ransom_multiplier: 3.5, + build_cost: 160, + }); + cat +} + +fn fixture(posture: CapturePosture, defender_unit_id: &str, defender_id: u32, ap_current: u8) -> GameState { + let mut state = GameState { + turn: 0, + players: vec![ + PlayerState { + player_index: 0, + default_civilian_posture: posture, + ..Default::default() + }, + PlayerState { + player_index: 1, + ..Default::default() + }, + ], + grid: None, + ..Default::default() + }; + state.units_catalog = build_trade_catalog(); + + state.players[0].units.push(MapUnit { + id: 311, + col: 10, + row: 10, + hp: 60, + max_hp: 60, + attack: 50, + defense: 10, + unit_id: "dwarf_warrior".into(), + ..Default::default() + }); + + state.players[1].units.push(MapUnit { + id: defender_id, + col: 10, + row: 10, + hp: 1, + max_hp: 50, + attack: 0, + defense: 0, + unit_id: defender_unit_id.into(), + action_points: Some(ActionPoints { + current: ap_current, + capacity: 6, + }), + ..Default::default() + }); + + state.pending_pvp_attacks.push(AttackRequest { + attacker_player: 0, + attacker_unit: 0, + defender_player: 1, + defender_unit: 0, + }); + state +} + +#[test] +fn captured_caravan_master_flips_owner_and_emits_unit_captured_event() { + let mut state = fixture(CapturePosture::Capture, "caravan_master", CARAVAN_ID, 5); + let processor = TurnProcessor::new(500); + let result = processor.step(&mut state); + + assert_eq!( + state.players[1] + .units + .iter() + .filter(|u| u.id == CARAVAN_ID) + .count(), + 0, + "captured caravan_master must leave prior owner's units vec" + ); + let moved = state.players[0] + .units + .iter() + .find(|u| u.id == CARAVAN_ID) + .expect("caravan_master must be re-owned by captor"); + assert_eq!(moved.unit_id, "caravan_master"); + + let captured_count = result + .events_emitted + .iter() + .filter( + |e| matches!(e, TurnEvent::UnitCaptured { unit_id, .. } if *unit_id == CARAVAN_ID), + ) + .count(); + assert_eq!( + captured_count, 1, + "exactly one TurnEvent::UnitCaptured for the caravan_master; got {:?}", + result.events_emitted + ); +} + +#[test] +fn captured_caravan_master_resets_action_points_to_zero() { + // Caravan Master carried 5/6 AP at capture time. Under the new owner, + // AP must be 0/6 — universal Specialist AP-reset policy applies to + // trade specialists the same as it does to engineers. + let mut state = fixture(CapturePosture::Capture, "caravan_master", CARAVAN_ID, 5); + let processor = TurnProcessor::new(500); + let _ = processor.step(&mut state); + + let moved = state.players[0] + .units + .iter() + .find(|u| u.id == CARAVAN_ID) + .expect("captured caravan_master must land in captor's units vec"); + let ap = moved + .action_points + .expect("caravan_master keeps its AP pool after capture (capacity intact)"); + assert_eq!( + ap.current, 0, + "p2-55b: captured caravan_master's AP.current must reset to 0; got {ap:?}" + ); + assert_eq!( + ap.capacity, 6, + "AP capacity is per-unit-type and survives capture" + ); +} + +#[test] +fn merchant_ransom_offer_uses_great_merchant_multiplier() { + // merchant.json: build_cost 80, ransom_multiplier 3.0 → price 240. + // Worker baseline at the same build_cost would be 80 × 2.0 = 160 — the + // great_merchant premium must surface as a strictly higher offer. + let mut state = fixture(CapturePosture::Ransom, "merchant", MERCHANT_ID, 3); + let processor = TurnProcessor::new(500); + let result = processor.step(&mut state); + + let captive = state.players[1] + .units + .iter() + .find(|u| u.id == MERCHANT_ID) + .expect("ransomed merchant stays in prior-owner vec until accept/refuse/expire"); + assert_eq!( + captive.captive_of, + Some(0), + "captive_of must point at captor while offer is open" + ); + + let offer = result + .ransom_offers_created + .iter() + .find(|o| o.unit_id == MERCHANT_ID) + .expect("UnitRansomOfferedEvent on TurnResult.ransom_offers_created"); + assert_eq!( + offer.price, 240, + "merchant ransom price = build_cost (80) × great_merchant multiplier (3.0); got {}", + offer.price + ); + assert!( + offer.price > 160, + "merchant ransom (240) must exceed worker baseline at equal build_cost (160); got {}", + offer.price + ); +}