Skip to main content

manabrew_engine/combat/
combat_util.rs

1//! Static utility methods related to combat.
2use forge_foundation::ZoneType;
3
4use super::attack_constraints::AttackConstraints;
5use super::{CombatState, DefenderId, LureType};
6use crate::card::{valid_filter, Card};
7use crate::game::GameState;
8use crate::ids::{CardId, PlayerId};
9use crate::staticability::static_ability::StaticMode;
10use crate::staticability::static_ability_cant_attack_block;
11
12pub fn get_available_attackers(game: &GameState, player: PlayerId) -> Vec<CardId> {
13    let defending = game.opponent_of(player);
14    game.creatures_on_battlefield(player)
15        .into_iter()
16        .filter(|&cid| {
17            let card = game.card(cid);
18            if card.can_attack() {
19                return true;
20            }
21            card.is_creature()
22                && !card.tapped
23                && !card.cant_attack_static
24                && !card.detained
25                && (card.has_haste() || !card.summoning_sick)
26                && card.zone == ZoneType::Battlefield
27                && card.has_defender()
28                && crate::staticability::static_ability_can_attack_defender::can_attack_defender(
29                    &game.cards,
30                    card,
31                    defending,
32                )
33        })
34        .collect()
35}
36
37pub fn can_attack_player(game: &GameState, player: PlayerId) -> bool {
38    !get_available_attackers(game, player).is_empty()
39}
40
41pub fn can_attack_defender(game: &GameState, attacker_id: CardId, defender: DefenderId) -> bool {
42    let card = game.card(attacker_id);
43
44    // Basic creature checks
45    if !card.is_creature() || card.tapped || card.phased_out {
46        return false;
47    }
48    // Summoning sickness (unless haste)
49    if card.summoning_sick && !card.has_haste() {
50        return false;
51    }
52
53    // CantAttack static abilities
54    if card.cant_attack_static {
55        return false;
56    }
57    if card.detained {
58        return false;
59    }
60
61    // Check per-defender CantAttack static abilities
62    if let DefenderId::Player(pid) = defender {
63        if !crate::staticability::static_ability_can_attack_defender::can_attack_defender(
64            &game.cards,
65            card,
66            pid,
67        ) {
68            return false;
69        }
70    }
71
72    true
73}
74
75pub fn validate_attackers(
76    game: &GameState,
77    constraints: &AttackConstraints,
78    current_attackers: &[(CardId, DefenderId)],
79) -> bool {
80    let my_violations = constraints.count_violations(current_attackers, &game.cards);
81    if my_violations == -1 {
82        return false;
83    }
84    let (_, best_violations) = constraints.get_legal_attackers(&game.cards);
85    my_violations <= best_violations
86}
87
88pub fn get_possible_defenders(game: &GameState, attacking_player: PlayerId) -> Vec<DefenderId> {
89    let mut defenders = Vec::new();
90    for pid in game.alive_players() {
91        if pid == attacking_player {
92            continue;
93        }
94        defenders.push(DefenderId::Player(pid));
95        // Planeswalkers controlled by this opponent
96        for &cid in game.cards_in_zone(ZoneType::Battlefield, pid) {
97            let card = game.card(cid);
98            if card.type_line.is_planeswalker() {
99                defenders.push(DefenderId::Permanent(cid));
100            }
101        }
102    }
103    defenders
104}
105
106pub fn get_available_blockers(game: &GameState, player: PlayerId) -> Vec<CardId> {
107    game.creatures_on_battlefield(player)
108        .into_iter()
109        .filter(|&cid| can_block(game, cid))
110        .collect()
111}
112
113pub fn can_creature_block(game: &GameState, blocker_id: CardId, attacker_id: CardId) -> bool {
114    let attacker = game.card(attacker_id);
115    let blocker = game.card(blocker_id);
116
117    if !blocker.can_block() {
118        return false;
119    }
120
121    // Flying: only blocked by flying or reach
122    if attacker.has_flying() && !blocker.has_flying() && !blocker.has_reach() {
123        return false;
124    }
125    // Fear: only blocked by artifact or black creatures
126    if attacker.has_fear() && !blocker.type_line.is_artifact() && !blocker.color.has_black() {
127        return false;
128    }
129    // Intimidate: only blocked by artifact or creatures sharing a color
130    if attacker.has_intimidate()
131        && !blocker.type_line.is_artifact()
132        && !blocker.color.shares_color_with(attacker.color)
133    {
134        return false;
135    }
136    // Shadow: shadow only blocked by shadow, non-shadow not blocked by shadow
137    if attacker.has_shadow() != blocker.has_shadow() {
138        return false;
139    }
140    // Horsemanship: only blocked by horsemanship
141    if attacker.has_horsemanship() && !blocker.has_horsemanship() {
142        return false;
143    }
144    // Skulk: can't be blocked by creatures with greater power
145    if attacker.has_skulk() && blocker.power() > attacker.power() {
146        return false;
147    }
148    // Protection: can't be blocked by matching creatures
149    if attacker.is_protected_from(blocker) {
150        return false;
151    }
152    // CantBlockBy static abilities
153    if cant_block_by(game, attacker_id, blocker_id) {
154        return false;
155    }
156    true
157}
158
159/// Check if any `CantBlockBy` static ability prevents blocking.
160fn cant_block_by(game: &GameState, attacker_id: CardId, blocker_id: CardId) -> bool {
161    let attacker = game.card(attacker_id);
162    let blocker = game.card(blocker_id);
163
164    for source in game
165        .cards
166        .iter()
167        .filter(|c| c.zone == ZoneType::Battlefield || c.zone == ZoneType::Command)
168    {
169        for sa in &source.static_abilities {
170            if !sa.check_mode(&StaticMode::CantBlockBy) {
171                continue;
172            }
173
174            if let Some(valid_attacker) = sa.ir.valid_attacker.as_ref() {
175                if !valid_filter::matches_valid_card_selector_in_game(
176                    valid_attacker,
177                    attacker,
178                    source,
179                    game,
180                ) {
181                    continue;
182                }
183            }
184
185            if let Some(valid_blocker) = sa.ir.valid_blocker.as_ref() {
186                if !valid_filter::matches_valid_card_selector_in_game(
187                    valid_blocker,
188                    blocker,
189                    source,
190                    game,
191                ) {
192                    continue;
193                }
194            }
195
196            return true;
197        }
198    }
199    false
200}
201
202/// Filter blockers to only those that can legally block at least one attacker.
203pub fn filter_legal_blockers(
204    game: &GameState,
205    attackers: &[CardId],
206    blockers: &[CardId],
207) -> Vec<CardId> {
208    blockers
209        .iter()
210        .filter(|&&blocker_id| {
211            attackers
212                .iter()
213                .any(|&attacker_id| can_creature_block(game, blocker_id, attacker_id))
214        })
215        .copied()
216        .collect()
217}
218
219pub fn validate_blocks(game: &GameState, combat: &CombatState) -> Vec<(CardId, CardId)> {
220    let mut invalid = Vec::new();
221
222    for &(attacker_id, _) in &combat.attackers {
223        let blockers_for = combat.get_blockers_for(attacker_id);
224        let num_blockers = blockers_for.len();
225
226        if num_blockers == 0 {
227            continue;
228        }
229
230        // Check blockers with "can't block alone" keyword
231        for &blocker_id in &blockers_for {
232            let blocker = game.card(blocker_id);
233            let cant_block_alone = blocker
234                .keywords
235                .iter_strings()
236                .chain(blocker.granted_keywords.iter_strings())
237                .chain(blocker.pump_keywords.iter_strings())
238                .any(|kw| kw.to_lowercase().contains("can't block alone"));
239
240            if cant_block_alone && num_blockers == 1 {
241                invalid.push((blocker_id, attacker_id));
242            }
243        }
244    }
245
246    invalid
247}
248
249/// Check if a blocker must block an attacker this combat.
250pub fn must_block_an_attacker(game: &GameState, combat: &CombatState, blocker_id: CardId) -> bool {
251    let blocker = game.card(blocker_id);
252    if blocker.must_block {
253        return true;
254    }
255    !compute_must_block_targets(game, combat, blocker_id).is_empty()
256}
257
258/// Determine the lure type of an attacker.
259pub fn get_lure_type(card: &Card) -> LureType {
260    for kw in card
261        .keywords
262        .iter_strings()
263        .chain(card.granted_keywords.iter_strings())
264        .chain(card.pump_keywords.iter_strings())
265    {
266        let lower = kw.to_lowercase();
267        if lower.contains("all creatures able to block") && lower.contains("do so") {
268            return LureType::AllMustBlock;
269        }
270        if lower.contains("must be blocked if able") {
271            return LureType::MustBeBlockedIfAble;
272        }
273    }
274    LureType::None
275}
276
277/// Get attackers that `blocker_id` MUST block (if able).
278pub fn compute_must_block_targets(
279    game: &GameState,
280    combat: &CombatState,
281    blocker_id: CardId,
282) -> Vec<CardId> {
283    let mut targets = Vec::new();
284    let blocker = game.card(blocker_id);
285
286    for &(attacker_id, _) in &combat.attackers {
287        let attacker = game.card(attacker_id);
288        let lure = get_lure_type(attacker);
289        match lure {
290            LureType::AllMustBlock | LureType::MustBeBlockedIfAble => {
291                if can_creature_block(game, blocker_id, attacker_id) {
292                    targets.push(attacker_id);
293                }
294            }
295            LureType::None => {}
296        }
297    }
298
299    // Check explicit must_block_cards on the blocker
300    for &attacker_id in &blocker.must_block_cards {
301        if combat.is_attacking(attacker_id)
302            && can_creature_block(game, blocker_id, attacker_id)
303            && !targets.contains(&attacker_id)
304        {
305            targets.push(attacker_id);
306        }
307    }
308
309    targets
310}
311
312/// Check if blocker can block more creatures than it currently is.
313pub fn can_block_more_creatures(
314    game: &GameState,
315    combat: &CombatState,
316    blocker_id: CardId,
317) -> bool {
318    let currently_blocking = combat
319        .blockers
320        .iter()
321        .filter(|(b, _)| *b == blocker_id)
322        .count();
323    if currently_blocking == 0 {
324        return true;
325    }
326    // Check for "can block additional creature" type keywords
327    let card = game.card(blocker_id);
328    for kw in card
329        .keywords
330        .iter_strings()
331        .chain(card.granted_keywords.iter_strings())
332        .chain(card.pump_keywords.iter_strings())
333    {
334        let lower = kw.to_lowercase();
335        if lower.contains("can block any number of creatures") {
336            return true;
337        }
338        if lower.contains("can block an additional creature") && currently_blocking < 2 {
339            return true;
340        }
341    }
342    false
343}
344
345/// Get the minimum number of blockers required to block an attacker.
346pub fn get_min_num_blockers_for_attacker(game: &GameState, attacker_id: CardId) -> usize {
347    let card = game.card(attacker_id);
348    if card.has_menace() {
349        2
350    } else {
351        1
352    }
353}
354
355/// Check if a creature can be blocked with a given number of blockers.
356pub fn can_attacker_be_blocked_with_amount(
357    game: &GameState,
358    attacker_id: CardId,
359    amount: usize,
360) -> bool {
361    amount >= get_min_num_blockers_for_attacker(game, attacker_id)
362}
363
364/// Declared-attacker trigger helper.
365pub fn check_declared_attacker(game: &mut GameState, attacker_id: CardId, defender: DefenderId) {
366    let controlling = defender.controlling_player(game);
367    game.card_mut(attacker_id).set_attacking_player(controlling);
368}
369
370/// Get attack constraints for a combat.
371pub fn get_all_requirements(
372    game: &GameState,
373    attacking_player: PlayerId,
374    possible_defenders: &[DefenderId],
375) -> AttackConstraints {
376    AttackConstraints::new(game, attacking_player, possible_defenders)
377}
378
379/// Check if a creature can attack any legal defender.
380pub fn can_attack(game: &GameState, attacker_id: CardId) -> bool {
381    let card = game.card(attacker_id);
382    let possible_defenders = get_possible_defenders(game, card.controller);
383    possible_defenders
384        .iter()
385        .any(|&defender| can_attack_defender(game, attacker_id, defender))
386}
387
388/// Check if a creature could attack next turn (ignores tap/summoning sickness).
389pub fn can_attack_next_turn(game: &GameState, attacker_id: CardId, defender: DefenderId) -> bool {
390    let card = game.card(attacker_id);
391
392    // Skip tap/summoning sickness checks for next-turn evaluation
393    if !card.is_creature() || card.phased_out {
394        return false;
395    }
396    if card.cant_attack_static || card.detained {
397        return false;
398    }
399    if let DefenderId::Player(pid) = defender {
400        if !crate::staticability::static_ability_can_attack_defender::can_attack_defender(
401            &game.cards,
402            card,
403            pid,
404        ) {
405            return false;
406        }
407    }
408    true
409}
410
411/// Check if a creature could attack but is not currently attacking.
412pub fn could_attack_but_not_attacking(
413    game: &GameState,
414    combat: &CombatState,
415    attacker_id: CardId,
416) -> bool {
417    if combat.is_attacking(attacker_id) {
418        return false;
419    }
420    can_attack(game, attacker_id)
421}
422
423/// Check propaganda-style effects that require paying a cost to attack.
424pub fn check_propaganda_effects(
425    game: &GameState,
426    attacker_id: CardId,
427    defender: DefenderId,
428) -> bool {
429    let attacker = game.card(attacker_id);
430    let cost = super::attack_cost::get_attack_cost(&game.cards, attacker, defender);
431    cost == 0
432}
433
434/// Pay required block costs for a blocker.
435pub fn pay_required_block_costs(game: &GameState, blocker_id: CardId, attacker_id: CardId) -> bool {
436    let blocker = game.card(blocker_id);
437    let attacker = game.card(attacker_id);
438    let cost = super::block_cost::get_block_cost(&game.cards, blocker, attacker);
439    cost == 0
440}
441
442/// Check if a creature can block (basic check: untapped creature).
443pub fn can_block(game: &GameState, blocker_id: CardId) -> bool {
444    let blocker = game.card(blocker_id);
445
446    if !blocker.is_creature() || blocker.phased_out {
447        return false;
448    }
449
450    if blocker.tapped
451        && !static_ability_cant_attack_block::can_block_tapped(game, &game.cards, blocker)
452    {
453        return false;
454    }
455
456    if blocker.has_keyword("CARDNAME can't block.")
457        || blocker.has_keyword("CARDNAME can't attack or block.")
458    {
459        return false;
460    }
461
462    if static_ability_cant_attack_block::cant_block(game, &game.cards, blocker) {
463        return false;
464    }
465
466    blocker.zone == ZoneType::Battlefield
467}
468
469/// Check if an attacker can be blocked by the given set of potential blockers.
470pub fn can_be_blocked(
471    game: &GameState,
472    attacker_id: CardId,
473    potential_blockers: &[CardId],
474) -> bool {
475    potential_blockers
476        .iter()
477        .any(|&blocker_id| can_creature_block(game, blocker_id, attacker_id))
478}
479
480/// Check if a blocker can block at least one attacker from a list.
481pub fn can_block_at_least_one(game: &GameState, blocker_id: CardId, attackers: &[CardId]) -> bool {
482    attackers
483        .iter()
484        .any(|&attacker_id| can_creature_block(game, blocker_id, attacker_id))
485}
486
487/// Find blockers that are not yet assigned to block anything.
488pub fn find_free_blockers(
489    game: &GameState,
490    combat: &CombatState,
491    defending_player: PlayerId,
492) -> Vec<CardId> {
493    let available = get_available_blockers(game, defending_player);
494    available
495        .into_iter()
496        .filter(|&blocker_id| !combat.is_blocking(blocker_id))
497        .collect()
498}