feat(@projects/@magic-civilization): ✨ add drill/overdrive building action candidates
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
006b27b6ee
commit
954e3a6fd8
2 changed files with 260 additions and 0 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue