From 954e3a6fd8e82b145a23fabc62855c0a63bd1ffe Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 2 May 2026 20:40:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20drill/overdrive=20building=20action=20candida?= =?UTF-8?q?tes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-ai/src/evaluator.rs | 137 ++++++++++++++++++ src/simulator/crates/mc-turn/src/processor.rs | 123 ++++++++++++++++ 2 files changed, 260 insertions(+) diff --git a/src/simulator/crates/mc-ai/src/evaluator.rs b/src/simulator/crates/mc-ai/src/evaluator.rs index 201bb79b..a887160f 100644 --- a/src/simulator/crates/mc-ai/src/evaluator.rs +++ b/src/simulator/crates/mc-ai/src/evaluator.rs @@ -507,6 +507,74 @@ impl ScoringEvaluator { candidates } + /// Build `building_action` MCTS candidates for Drill and Overdrive (p2-53d). + /// + /// Policy: + /// - **Drill** (Barracks): emit when the city has a barracks-class building, + /// threat_level > 0.3 (preparing or actively at war), and the player's + /// `production` axis is ≤ 5. Drill sacrifices production for +1 XP to + /// garrisoned units; AI prefers it when training quality matters more than + /// throughput. Score scales with threat_level × (1 − production_norm). + /// + /// - **Overdrive** (Workshop/Forge): emit when city has a workshop-class + /// building, threat_level > 0.7 (actively at war — production is the + /// bottleneck), and the city's production yield is the primary constraint + /// on unit output. Score scales with threat_level × production_norm. + /// + /// Both actions are emit-once per city (one candidate per city per turn); + /// the MCTS tree handles de-duplication by choice_id. + pub fn build_building_action_candidates( + &self, + state: &AiPlayerState, + strategic: &StrategicWeights, + ) -> Vec { + let mut candidates = Vec::new(); + + let prod_axis = *state.strategic_axes.get("production").unwrap_or(&5) as f32; + let prod_norm = (prod_axis + 10.0) / 20.0; // normalize −10..+10 → 0..1 + + let barracks_ids = ["barracks", "war_hall", "drill_hall"]; + let workshop_ids = ["workshop", "forge", "deep_mine", "smelter"]; + + for city in &state.cities { + // ── Drill candidate ────────────────────────────────────────────── + let has_barracks = city.existing_buildings.iter() + .any(|b| barracks_ids.contains(&b.as_str())); + if has_barracks && state.threat_level > 0.3 && prod_axis <= 5.0 { + // Higher threat + lower production priority → prefer quality over throughput. + let drill_score = state.threat_level * (1.0 - prod_norm) + * strategic.aggression + * self.weights.military_base; + candidates.push(crate::mcts::Candidate { + choice_type: "building_action".into(), + choice_id: format!("building_action:{}:drill", city.id), + base_score: drill_score, + }); + } + + // ── Overdrive candidate ───────────────────────────────────────── + let has_workshop = city.existing_buildings.iter() + .any(|b| workshop_ids.contains(&b.as_str())); + // Overdrive: 2× output for 3 turns at 10% building HP cost. Use when + // actively at war and production throughput is the limiting factor. + let city_prod = city.yields[1]; // index 1 = production + let is_prod_bottleneck = city_prod < 8.0; // low production = bottleneck + if has_workshop && state.threat_level > 0.7 && is_prod_bottleneck { + let overdrive_score = state.threat_level * prod_norm + * strategic.aggression + * self.weights.military_base + * 1.5; // bonus for urgency multiplier + candidates.push(crate::mcts::Candidate { + choice_type: "building_action".into(), + choice_id: format!("building_action:{}:overdrive", city.id), + base_score: overdrive_score, + }); + } + } + + candidates + } + /// Pick the best tech from available candidates. /// Returns None if no candidates or not researching. pub fn pick_tech( @@ -1520,4 +1588,73 @@ mod tests { bias={score_bias} empty={score_empty}" ); } + + // ── p2-53d: AI Drill/Overdrive policy tests ───────────────────────────── + + #[test] + fn drill_candidate_emitted_for_barracks_under_threat() { + let evaluator = ScoringEvaluator::default(); + let mut state = dummy_state(); + state.threat_level = 0.6; + // dummy_state has production axis = 8 (> 5), so Drill should NOT fire for + // high-production AI. Set it low so Drill triggers. + state.strategic_axes.insert("production".into(), 2); + state.cities[0].existing_buildings.push("barracks".into()); + + let strategic = StrategicWeights::from_race_axes(&state.strategic_axes); + let candidates = evaluator.build_building_action_candidates(&state, &strategic); + + let drill_count = candidates.iter() + .filter(|c| c.choice_id.contains(":drill")) + .count(); + assert!(drill_count >= 1, "must emit Drill candidate when barracks + threat > 0.3 + low prod axis; got {drill_count}"); + } + + #[test] + fn no_drill_candidate_without_barracks() { + let evaluator = ScoringEvaluator::default(); + let mut state = dummy_state(); + state.threat_level = 0.9; + state.strategic_axes.insert("production".into(), 2); + // No barracks in existing_buildings (only granary from dummy_state). + + let strategic = StrategicWeights::from_race_axes(&state.strategic_axes); + let candidates = evaluator.build_building_action_candidates(&state, &strategic); + + let drill_count = candidates.iter().filter(|c| c.choice_id.contains(":drill")).count(); + assert_eq!(drill_count, 0, "no Drill when no barracks-class building"); + } + + #[test] + fn overdrive_candidate_emitted_for_workshop_under_high_threat() { + let evaluator = ScoringEvaluator::default(); + let mut state = dummy_state(); + state.threat_level = 0.9; + // Low production yield (<8) — bottleneck condition. + state.cities[0].yields = [4.0, 3.5, 2.0, 1.0, 1.0]; + state.cities[0].existing_buildings.push("workshop".into()); + + let strategic = StrategicWeights::from_race_axes(&state.strategic_axes); + let candidates = evaluator.build_building_action_candidates(&state, &strategic); + + let overdrive_count = candidates.iter() + .filter(|c| c.choice_id.contains(":overdrive")) + .count(); + assert!(overdrive_count >= 1, "must emit Overdrive when workshop + threat > 0.7 + low prod yield; got {overdrive_count}"); + } + + #[test] + fn no_overdrive_when_threat_below_threshold() { + let evaluator = ScoringEvaluator::default(); + let mut state = dummy_state(); + state.threat_level = 0.4; // below 0.7 threshold + state.cities[0].yields = [4.0, 3.5, 2.0, 1.0, 1.0]; + state.cities[0].existing_buildings.push("workshop".into()); + + let strategic = StrategicWeights::from_race_axes(&state.strategic_axes); + let candidates = evaluator.build_building_action_candidates(&state, &strategic); + + let overdrive_count = candidates.iter().filter(|c| c.choice_id.contains(":overdrive")).count(); + assert_eq!(overdrive_count, 0, "no Overdrive when threat < 0.7"); + } } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 8718c0b2..914c5d1b 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -3467,6 +3467,129 @@ mod tests { assert!(terrain_gold_bonus("coast") >= 1); } + #[test] + fn amphibious_pathfinder_ocean_passable() { + // Non-amphibious unit: ocean = i32::MAX (impassable). + assert_eq!(terrain_movement_cost_for_unit("ocean", false), i32::MAX); + assert_eq!(terrain_movement_cost_for_unit("shallow_ocean", false), i32::MAX); + assert_eq!(terrain_movement_cost_for_unit("deep_ocean", false), i32::MAX); + // Amphibious unit: ocean biomes passable at cost 2. + assert_eq!(terrain_movement_cost_for_unit("ocean", true), 2); + assert_eq!(terrain_movement_cost_for_unit("shallow_ocean", true), 2); + assert_eq!(terrain_movement_cost_for_unit("deep_ocean", true), 2); + assert_eq!(terrain_movement_cost_for_unit("coast", true), 2); + // Mountains remain impassable for both. + assert_eq!(terrain_movement_cost_for_unit("mountain", true), i32::MAX); + assert_eq!(terrain_movement_cost_for_unit("mountain", false), i32::MAX); + // Non-water biomes unaffected. + assert_eq!(terrain_movement_cost_for_unit("plains", true), 1); + assert_eq!(terrain_movement_cost_for_unit("temperate_forest", true), 2); + } + + #[test] + fn volley_request_drains_each_turn() { + use crate::game_state::{PlayerState, MapUnit, VolleyRequest}; + let processor = TurnProcessor::new(100); + let mut state = GameState { + turn: 0, + players: vec![ + PlayerState { + player_index: 0, + units: vec![MapUnit { + col: 0, row: 0, hp: 60, max_hp: 60, attack: 10, defense: 1, + unit_id: "dwarf_archer".into(), + ..Default::default() + }], + ..Default::default() + }, + PlayerState { + player_index: 1, + units: vec![MapUnit { + col: 3, row: 0, hp: 30, max_hp: 30, attack: 8, defense: 1, + unit_id: "goblin".into(), + ..Default::default() + }], + ..Default::default() + }, + ], + ..Default::default() + }; + state.pending_volley_requests.push(VolleyRequest { + attacker_player: 0, + attacker_unit: 0, + target_col: 3, + target_row: 0, + }); + assert_eq!(state.pending_volley_requests.len(), 1, "request queued"); + processor.step(&mut state); + assert_eq!(state.pending_volley_requests.len(), 0, "queue drained after step"); + } + + #[test] + fn charge_request_drains_each_turn() { + use crate::game_state::{PlayerState, MapUnit, ChargeRequest}; + let processor = TurnProcessor::new(100); + let mut state = GameState { + turn: 0, + players: vec![ + PlayerState { + player_index: 0, + units: vec![MapUnit { + col: 0, row: 0, hp: 60, max_hp: 60, attack: 12, defense: 1, + unit_id: "dwarf_cavalry".into(), + ..Default::default() + }], + ..Default::default() + }, + PlayerState { + player_index: 1, + units: vec![MapUnit { + col: 2, row: 0, hp: 40, max_hp: 40, attack: 8, defense: 1, + unit_id: "enemy_unit".into(), + ..Default::default() + }], + ..Default::default() + }, + ], + ..Default::default() + }; + state.pending_charge_requests.push(ChargeRequest { + attacker_player: 0, + attacker_unit: 0, + target_col: 2, + target_row: 0, + }); + assert_eq!(state.pending_charge_requests.len(), 1, "request queued"); + processor.step(&mut state); + assert_eq!(state.pending_charge_requests.len(), 0, "queue drained after step"); + } + + #[test] + fn wheel_updates_facing_edge() { + use crate::game_state::{PlayerState, MapUnit}; + use crate::action::ActionKind; + use crate::action_handlers::invoke as invoke_action; + let mut state = GameState { + turn: 0, + players: vec![PlayerState { + player_index: 0, + units: vec![MapUnit { + col: 0, row: 0, hp: 60, max_hp: 60, attack: 12, defense: 1, + unit_id: "dwarf_cavalry".into(), + facing_edge: 0, + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + let initial_facing = state.players[0].units[0].facing_edge; + invoke_action(&mut state, 0, 0, ActionKind::Wheel).expect("Wheel should succeed"); + let new_facing = state.players[0].units[0].facing_edge; + assert_ne!(new_facing, initial_facing, "Wheel must change facing_edge"); + assert!(new_facing < 6, "facing_edge must be 0–5"); + } + #[test] fn nearest_enemy_within_range() { let enemies = vec![(10, 10, 1u8), (20, 20, 1)];