manabrew_engine/agent/attach_ai.rs
1//! AI logic for equipment attachment decisions.
2//!
3//! Mirrors Java `forge/forge-ai/src/main/java/forge/ai/ability/AttachAi.java`.
4//! Determines whether the AI should offer an equip ability or skip it.
5
6use forge_foundation::ZoneType;
7
8use crate::card::Card;
9use crate::game::GameState;
10use crate::ids::{CardId, PlayerId};
11
12use super::creature_evaluator;
13
14/// Default threshold for re-equipping: the new creature must score at least
15/// this many points better than the currently equipped creature.
16///
17/// Mirrors Java `AiProps.MOVE_EQUIPMENT_CREATURE_EVAL_THRESHOLD` (default 40).
18const MOVE_EQUIPMENT_CREATURE_EVAL_THRESHOLD: i32 = 40;
19
20/// Returns `true` if the equip ability on `equipment_id` should be skipped
21/// (i.e., the AI would not play it because re-equipping is not worthwhile).
22///
23/// Mirrors Java `AttachAi.attachToCardAIPreferences()` lines 1348-1394:
24/// - Don't equip the same creature again
25/// - Don't move equipment if the current creature is still useful and no
26/// significantly better target exists
27/// - Don't move equipment if it was already moved this turn
28///
29/// Returns `false` (don't skip) when:
30/// - Equipment is not currently attached (needs equipping)
31/// - Equipment is attached to opponent's creature (need to re-equip)
32/// - There's a significantly better creature to equip to
33pub fn should_skip_equip(game: &GameState, equipment_id: CardId, player: PlayerId) -> bool {
34 let equipment = game.card(equipment_id);
35
36 // If equipment is not currently attached, always offer equip
37 let equipped_to = match equipment.attached_to {
38 Some(id) => id,
39 None => return false,
40 };
41
42 let equipped_creature = game.card(equipped_to);
43
44 // If equipped creature is not on the battlefield, don't skip (need to re-equip)
45 if equipped_creature.zone != ZoneType::Battlefield {
46 return false;
47 }
48
49 // If equipped to opponent's creature, don't skip (want to re-equip to own)
50 if equipped_creature.controller != player {
51 return false;
52 }
53
54 // If equipment was already moved this turn, skip to avoid ping-ponging.
55 // Mirrors Java `AiCardMemory.isRememberedCard(ATTACHED_THIS_TURN)`.
56 if equipment.attached_this_turn {
57 return true;
58 }
59
60 // Evaluate the currently equipped creature
61 let current_eval = creature_evaluator::evaluate_creature(equipped_creature);
62 let current_is_useless = creature_evaluator::is_useless_creature(equipped_creature);
63
64 // If current creature is useless, allow re-equip (don't skip)
65 if current_is_useless {
66 return false;
67 }
68
69 // Check if any valid target creature is significantly better.
70 // Mirrors Java: `evaluateCreature(new) - evaluateCreature(old) >= threshold`
71 let battlefield = game.cards_in_zone(ZoneType::Battlefield, player);
72 let has_better_target = battlefield.iter().any(|&cid| {
73 if cid == equipped_to {
74 return false; // Don't compare to self
75 }
76 let candidate = game.card(cid);
77 if !candidate.is_creature() {
78 return false;
79 }
80 if candidate.zone != ZoneType::Battlefield {
81 return false;
82 }
83 let candidate_eval = creature_evaluator::evaluate_creature(candidate);
84 candidate_eval - current_eval >= MOVE_EQUIPMENT_CREATURE_EVAL_THRESHOLD
85 });
86
87 // If no significantly better target, skip equip
88 !has_better_target
89}
90
91/// Check if an activated ability is an equip/attach ability.
92///
93/// Identifies equip abilities by checking if the ability text contains
94/// "Attach" as the API type and the source card is an Equipment.
95pub fn is_equip_ability(card: &Card, ability_text: &str) -> bool {
96 if !card.type_line.has_subtype("Equipment") {
97 return false;
98 }
99 // Check for AB$ Attach (equip activated ability)
100 ability_text.contains("AB$ Attach")
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn threshold_is_positive() {
109 assert!(MOVE_EQUIPMENT_CREATURE_EVAL_THRESHOLD > 0);
110 }
111}