feat(@projects/@magic-civilization): add drill/overdrive building action candidates

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-02 20:40:03 -04:00
parent 006b27b6ee
commit 954e3a6fd8
2 changed files with 260 additions and 0 deletions

View file

@ -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<crate::mcts::Candidate> {
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");
}
}

View file

@ -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 05");
}
#[test]
fn nearest_enemy_within_range() {
let enemies = vec![(10, 10, 1u8), (20, 20, 1)];