Skip to main content

manabrew_engine/agent/
creature_evaluator.rs

1//! Creature evaluation scoring for AI decision-making.
2//!
3//! Mirrors Java `forge/forge-ai/src/main/java/forge/ai/CreatureEvaluator.java`.
4//! Used by AttachAi to decide whether to re-equip equipment.
5
6use crate::card::Card;
7
8/// Evaluate a creature's worth for AI decisions.
9/// Returns a numeric score where higher = better creature.
10///
11/// Mirrors Java `CreatureEvaluator.evaluateCreature(Card, true, true)`.
12pub fn evaluate_creature(card: &Card) -> i32 {
13    evaluate_creature_full(card, true, true)
14}
15
16/// Evaluate a creature with optional P/T and CMC consideration.
17///
18/// Mirrors Java `CreatureEvaluator.evaluateCreature(Card, boolean, boolean)`.
19pub fn evaluate_creature_full(card: &Card, consider_pt: bool, consider_cmc: bool) -> i32 {
20    let mut value: i32 = 80;
21
22    // Tokens are worth less than actual cards
23    if !card.is_token {
24        value += 20;
25    }
26
27    let power = if card.has_keyword("Prevent all combat damage that would be dealt by CARDNAME.")
28        || card.has_keyword("Prevent all damage that would be dealt by CARDNAME.")
29        || card
30            .has_keyword("Prevent all combat damage that would be dealt to and dealt by CARDNAME.")
31        || card.has_keyword("Prevent all damage that would be dealt to and dealt by CARDNAME.")
32    {
33        0
34    } else if card.toughness_assigns_damage() {
35        card.toughness()
36    } else {
37        card.power()
38    };
39    let toughness = card.toughness();
40
41    if consider_pt {
42        value += power * 15;
43        value += toughness * 10;
44
45        // Double-faced cards with daybound are stronger due to potential
46        if card.has_keyword("Daybound") && card.is_double_faced() {
47            value += power * 10;
48        }
49    }
50
51    if consider_cmc {
52        value += card.mana_value() * 5;
53    }
54
55    // ── Evasion keywords ──────────────────────────────────────────────
56    if card.has_flying() {
57        value += power * 10;
58    }
59    if card.has_keyword("Horsemanship") {
60        value += power * 10;
61    }
62    // Unblockable check (simplified — Java uses StaticAbilityCantAttackBlock.cantBlockBy)
63    if card.has_keyword("CARDNAME can't be blocked.") {
64        value += power * 10;
65    } else {
66        if card.has_keyword("Fear") {
67            value += power * 6;
68        }
69        if card.has_keyword("Intimidate") {
70            value += power * 6;
71        }
72        if card.has_keyword("Menace") {
73            value += power * 4;
74        }
75        if card.has_keyword("Skulk") {
76            value += power * 3;
77        }
78    }
79
80    // ── Combat keywords (only if power > 0) ───────────────────────────
81    if power > 0 {
82        if card.has_keyword("Double Strike") {
83            value += 10 + (power * 15);
84        } else if card.has_first_strike() {
85            value += 10 + (power * 5);
86        }
87        if card.has_deathtouch() {
88            value += 25;
89        }
90        if card.has_lifelink() {
91            value += power * 10;
92        }
93        if power > 1 && card.has_keyword("Trample") {
94            value += (power - 1) * 5;
95        }
96        if card.has_keyword("Vigilance") {
97            value += (power * 5) + (toughness * 5);
98        }
99        if card.has_keyword("Infect") {
100            value += power * 15;
101        } else if card.has_keyword("Wither") {
102            value += power * 10;
103        }
104        value += keyword_magnitude(card, "Toxic") * 5;
105        value += keyword_magnitude(card, "Afflict") * 5;
106        value += keyword_magnitude(card, "Rampage");
107    }
108
109    value += keyword_magnitude(card, "Annihilator") * 50;
110    value += keyword_magnitude(card, "Absorb") * 11;
111
112    // Keywords that produce buffs over time
113    if card.has_keyword("Outlast") {
114        value += 10;
115    }
116    value += keyword_magnitude(card, "Bushido") * 16;
117    value += keyword_count(card, "Flanking") * 15;
118    value += keyword_count(card, "Exalted") * 15;
119    value += keyword_count(card, "Melee") * 18;
120    value += keyword_count(card, "Prowess") * 5;
121
122    // ── Defensive keywords ────────────────────────────────────────────
123    if card.has_keyword("Reach") && !card.has_flying() {
124        value += 5;
125    }
126
127    // ── Protection ────────────────────────────────────────────────────
128    if card.has_indestructible() {
129        value += 70;
130    }
131    // Shield counters: +20 per shield (CounterType::Shield may not exist yet)
132    // else { value += 20 * card.counter_count(&CounterType::Shield); }
133    if card.has_keyword("Prevent all damage that would be dealt to CARDNAME.") {
134        value += 60;
135    } else if card.has_keyword("Prevent all combat damage that would be dealt to CARDNAME.") {
136        value += 50;
137    }
138    if card.has_hexproof() {
139        value += 35;
140    } else if card.has_keyword("Shroud") {
141        value += 30;
142    } else if card.has_keyword("Ward") {
143        value += 10;
144    }
145    if card.has_keyword("Protection") {
146        value += 20;
147    }
148
149    // Undying/Persist
150    if card.has_keyword("Undying") || card.has_keyword("Persist") {
151        value += 30;
152    }
153
154    // ── Bad keywords ──────────────────────────────────────────────────
155    if card.has_defender() || card.has_keyword("CARDNAME can't attack.") {
156        value -= (power * 9) + 40;
157    }
158    if card.has_keyword("CARDNAME can't attack or block.") {
159        value = 50 + (card.mana_value() * 5); // reset — useless
160    } else if card.has_keyword("CARDNAME can't block.") {
161        value -= 10;
162    }
163
164    // Tapped + can't untap = useless
165    if card.tapped && !card.can_untap() {
166        value = 50 + (card.mana_value() * 5); // reset — useless
167    }
168
169    if !card.tapped {
170        value += 1; // slight bonus for being untapped
171    }
172
173    // Mana abilities add value
174    if card.activated_abilities.iter().any(|ab| ab.is_mana_ability) {
175        value += 10;
176    }
177
178    // Phasing reduces value
179    if card.has_keyword("Phasing") {
180        value -= 20.max(value / 2);
181    }
182
183    // End of turn leaves play
184    if card
185        .svars
186        .get("EndOfTurnLeavePlay")
187        .map(|v| v == "True")
188        .unwrap_or(false)
189    {
190        value -= 50;
191    }
192
193    value
194}
195
196/// Check if a creature is "useless" (can't attack or block effectively).
197///
198/// Mirrors Java `ComputerUtilCard.isUselessCreature()`.
199pub fn is_useless_creature(card: &Card) -> bool {
200    if card.has_defender() {
201        return true;
202    }
203    if card.has_keyword("CARDNAME can't attack.") {
204        return true;
205    }
206    if card.has_keyword("CARDNAME can't attack or block.") {
207        return true;
208    }
209    if card.tapped && !card.can_untap() {
210        return true;
211    }
212    false
213}
214
215/// Get the magnitude of a keyword (e.g. "Toxic:2" → 2).
216/// Returns 0 if not found.
217fn keyword_magnitude(card: &Card, kw: &str) -> i32 {
218    card.get_keyword_cost(kw)
219        .and_then(|s| s.parse::<i32>().ok())
220        .unwrap_or(0)
221}
222
223/// Count how many times a keyword appears (e.g. multiple Exalted instances).
224/// Returns 0 if not found.
225fn keyword_count(card: &Card, kw: &str) -> i32 {
226    card.keywords
227        .as_string_list()
228        .iter()
229        .filter(|k| k.starts_with(kw))
230        .count() as i32
231}