Skip to main content

manabrew_engine/staticability/
static_ability_cant_attack_block.rs

1use forge_foundation::ZoneType;
2
3use crate::card::{valid_filter, Card};
4use crate::game::GameState;
5use crate::ids::PlayerId;
6use crate::staticability::StaticAbility;
7use crate::staticability::StaticMode;
8
9fn next_player_in_direction(game: &GameState, from: PlayerId, direction: &str) -> PlayerId {
10    let alive: Vec<PlayerId> = game
11        .player_order
12        .iter()
13        .copied()
14        .filter(|&pid| game.player(pid).is_alive())
15        .collect();
16    if alive.is_empty() {
17        return from;
18    }
19    let Some(idx) = alive.iter().position(|&pid| pid == from) else {
20        return alive[0];
21    };
22    let len = alive.len();
23    let is_left = direction.eq_ignore_ascii_case("Left");
24    let next_idx = if is_left {
25        (idx + 1) % len
26    } else {
27        (idx + len - 1) % len
28    };
29    alive[next_idx]
30}
31
32fn nearest_opponent_in_direction(
33    game: &GameState,
34    controller: PlayerId,
35    direction: &str,
36) -> Option<PlayerId> {
37    let alive_count = game
38        .player_order
39        .iter()
40        .filter(|&&pid| game.player(pid).is_alive())
41        .count();
42    if alive_count <= 1 {
43        return None;
44    }
45    let mut next = controller;
46    for _ in 0..alive_count {
47        next = next_player_in_direction(game, next, direction);
48        if next != controller {
49            return Some(next);
50        }
51    }
52    None
53}
54
55// ── cantAttack ──────────────────────────────────────────────────────────────
56
57/// Check if a creature can't attack.
58/// Mirrors Java's `StaticAbilityCantAttackBlock.cantAttack()`.
59pub fn cant_attack(game: &GameState, cards: &[Card], attacker: &Card, defender: PlayerId) -> bool {
60    // Keywords — replace with static ability if able
61    if attacker.has_keyword("CARDNAME can't attack.")
62        || attacker.has_keyword("CARDNAME can't attack or block.")
63    {
64        return true;
65    }
66
67    // Detained check
68    if attacker.detained {
69        return true;
70    }
71
72    for source in cards.iter().filter(|c| c.zone.is_static_ability_source()) {
73        for st_ab in source
74            .static_abilities
75            .iter()
76            .filter(|sa| sa.check_conditions_full(&StaticMode::CantAttack, source, game))
77        {
78            if apply_cant_attack_ability(game, st_ab, attacker, source, defender, cards) {
79                return true;
80            }
81        }
82    }
83    false
84}
85
86/// Mirrors Java's `StaticAbilityCantAttackBlock.applyCantAttackAbility()`.
87pub fn apply_cant_attack_ability(
88    game: &GameState,
89    st_ab: &StaticAbility,
90    card: &Card,
91    source: &Card,
92    defender: PlayerId,
93    cards: &[Card],
94) -> bool {
95    if !valid_filter::matches_valid_card_selector_opt_in_game(
96        st_ab.ir.valid_card.as_ref(),
97        card,
98        source,
99        game,
100    ) {
101        return false;
102    }
103
104    // IgnoreEffectCards — if this card is in the ignore list, skip.
105    if st_ab.ignore_effect_cards.contains(&card.id) {
106        return false;
107    }
108
109    // Target (the defender entity) validation
110    // In Java, `Target` is validated against the GameEntity (defender).
111    // We use player validation since defender is a PlayerId in our model.
112    if !valid_filter::matches_valid_player_opt(
113        st_ab.ir.target_text.as_deref(),
114        defender,
115        source.controller,
116    ) {
117        return false;
118    }
119
120    // Check for "can attack as if didn't have Defender" static.
121    // In Java: if (stAb.isKeyword(Keyword.DEFENDER) && canAttackDefender(card, target))
122    if st_ab
123        .ir
124        .kw_text
125        .as_deref()
126        .is_some_and(|v| v.eq_ignore_ascii_case("Defender"))
127        && can_attack_defender(game, cards, card, defender)
128    {
129        return false;
130    }
131
132    if st_ab.ir.defender_not_nearest_to_you_in_chosen_direction {
133        // Mirrors Java: if no chosen direction exists, this restriction does not apply.
134        let Some(direction) = source.svars.get("ChosenDirection") else {
135            return false;
136        };
137        if nearest_opponent_in_direction(game, card.controller, direction) == Some(defender) {
138            return false;
139        }
140    }
141
142    // UnlessDefender — if the defending player matches the filter, allow the attack.
143    if let Some(unless_type) = st_ab.ir.unless_defender_text.as_deref() {
144        if valid_filter::matches_valid_player(unless_type, defender, source.controller) {
145            return false;
146        }
147    }
148
149    true
150}
151
152// ── canAttackDefender ───────────────────────────────────────────────────────
153
154/// Check if a creature can attack a specific defender despite having Defender keyword.
155/// Mirrors Java's `StaticAbilityCantAttackBlock.canAttackDefender()`.
156pub fn can_attack_defender(
157    game: &GameState,
158    cards: &[Card],
159    card: &Card,
160    defender: PlayerId,
161) -> bool {
162    for source in cards.iter().filter(|c| c.zone.is_static_ability_source()) {
163        for st_ab in source
164            .static_abilities
165            .iter()
166            .filter(|sa| sa.check_conditions_full(&StaticMode::CanAttackDefender, source, game))
167        {
168            if apply_can_attack_defender_ability(game, st_ab, card, source, defender) {
169                return true;
170            }
171        }
172    }
173    false
174}
175
176/// Mirrors Java's `StaticAbilityCantAttackBlock.applyCanAttackDefenderAbility()`.
177pub fn apply_can_attack_defender_ability(
178    game: &GameState,
179    st_ab: &StaticAbility,
180    card: &Card,
181    source: &Card,
182    defender: PlayerId,
183) -> bool {
184    if !valid_filter::matches_valid_card_selector_opt_in_game(
185        st_ab.ir.valid_card.as_ref(),
186        card,
187        source,
188        game,
189    ) {
190        return false;
191    }
192
193    // In Java: matchesValidParam("ValidAttacked", target) — target is the defender entity.
194    if !valid_filter::matches_valid_player_selector_opt(
195        st_ab.ir.valid_attacked.as_ref(),
196        defender,
197        source.controller,
198    ) {
199        return false;
200    }
201
202    true
203}
204
205// ── cantBlock ───────────────────────────────────────────────────────────────
206
207/// Check if a creature can't block.
208/// Mirrors Java's `StaticAbilityCantAttackBlock.cantBlock()`.
209pub fn cant_block(game: &GameState, cards: &[Card], blocker: &Card) -> bool {
210    // Detained check
211    if blocker.detained {
212        return true;
213    }
214
215    // Java builds a list from STATIC_ABILITIES_SOURCE_ZONES + the blocker itself (for LKI)
216    for source in cards
217        .iter()
218        .filter(|c| c.zone.is_static_ability_source() || c.id == blocker.id)
219    {
220        for st_ab in source
221            .static_abilities
222            .iter()
223            .filter(|sa| sa.check_conditions_full(&StaticMode::CantBlock, source, game))
224        {
225            if apply_cant_block_ability(game, st_ab, blocker, source) {
226                return true;
227            }
228        }
229    }
230    false
231}
232
233/// Mirrors Java's `StaticAbilityCantAttackBlock.applyCantBlockAbility()`.
234pub fn apply_cant_block_ability(
235    game: &GameState,
236    st_ab: &StaticAbility,
237    blocker: &Card,
238    source: &Card,
239) -> bool {
240    if !valid_filter::matches_valid_card_selector_opt_in_game(
241        st_ab.ir.valid_card.as_ref(),
242        blocker,
243        source,
244        game,
245    ) {
246        return false;
247    }
248
249    // IgnoreEffectCards
250    if st_ab.ignore_effect_cards.contains(&blocker.id) {
251        return false;
252    }
253
254    true
255}
256
257// ── cantBlockBy ─────────────────────────────────────────────────────────────
258
259/// Check if a specific attacker can't be blocked by a specific blocker.
260/// Mirrors Java's `StaticAbilityCantAttackBlock.cantBlockBy()`.
261pub fn cant_block_by(
262    game: &GameState,
263    cards: &[Card],
264    attacker: &Card,
265    blocker: Option<&Card>,
266) -> bool {
267    // Java builds list from STATIC_ABILITIES_SOURCE_ZONES + attacker + blocker (for LKI)
268    for source in cards.iter().filter(|c| {
269        c.zone.is_static_ability_source()
270            || c.id == attacker.id
271            || blocker.is_some_and(|b| c.id == b.id)
272    }) {
273        for st_ab in source
274            .static_abilities
275            .iter()
276            .filter(|sa| sa.check_conditions_full(&StaticMode::CantBlockBy, source, game))
277        {
278            if apply_cant_block_by_ability(game, st_ab, attacker, blocker, source, cards) {
279                return true;
280            }
281        }
282    }
283    false
284}
285
286/// Returns true if attacker can't be blocked by blocker.
287/// Mirrors Java's `StaticAbilityCantAttackBlock.applyCantBlockByAbility()`.
288pub fn apply_cant_block_by_ability(
289    game: &GameState,
290    st_ab: &StaticAbility,
291    attacker: &Card,
292    blocker: Option<&Card>,
293    source: &Card,
294    cards: &[Card],
295) -> bool {
296    if !valid_filter::matches_valid_card_selector_opt_in_game(
297        st_ab.ir.valid_attacker.as_ref(),
298        attacker,
299        source,
300        game,
301    ) {
302        return false;
303    }
304
305    // ValidBlocker — complex logic matching Java's comma-split + withoutReach check
306    if let Some(valid_blocker_param) = st_ab.ir.valid_blocker.as_ref() {
307        let mut still_block = true;
308        for alternative in &valid_blocker_param.alternatives {
309            if let Some(b) = blocker {
310                let matches_blocker =
311                    crate::parsing::CompiledSelector::from_alternatives(vec![alternative.clone()]);
312                if valid_filter::matches_valid_card_selector_in_game(
313                    &matches_blocker,
314                    b,
315                    source,
316                    game,
317                ) {
318                    still_block = false;
319                    // Dragon Hunter check: if the filter includes "withoutReach"
320                    // and canBlockIfReach returns true, re-set still_block.
321                    if alternative
322                        .parts
323                        .iter()
324                        .any(|part| part.value.eq_ignore_ascii_case("withoutReach"))
325                        && can_block_if_reach(game, cards, attacker, b)
326                    {
327                        still_block = true;
328                    }
329                    if !still_block {
330                        break;
331                    }
332                }
333            }
334        }
335        if still_block {
336            return false;
337        }
338    }
339
340    // ValidAttackerRelative — relative to blocker
341    if let Some(blocker_card) = blocker {
342        if !valid_filter::matches_valid_card_selector_opt_in_game(
343            st_ab.ir.valid_attacker_relative.as_ref(),
344            attacker,
345            blocker_card,
346            game,
347        ) {
348            return false;
349        }
350    } else if st_ab.ir.has_valid_attacker_relative {
351        return false;
352    }
353
354    // ValidBlockerRelative — relative to attacker
355    if let Some(blocker_card) = blocker {
356        if !valid_filter::matches_valid_card_selector_opt_in_game(
357            st_ab.ir.valid_blocker_relative.as_ref(),
358            blocker_card,
359            attacker,
360            game,
361        ) {
362            return false;
363        }
364    } else if st_ab.ir.has_valid_blocker_relative {
365        return false;
366    }
367
368    // ValidDefender — checks blocker's controller
369    if let Some(blocker_card) = blocker {
370        if !valid_filter::matches_valid_player_selector_opt(
371            st_ab.ir.valid_defender.as_ref(),
372            blocker_card.controller,
373            source.controller,
374        ) {
375            return false;
376        }
377    } else {
378        // blocker is null => doesn't match ValidDefender
379        return false;
380    }
381
382    // Landwalk check
383    if let Some(kw_val) = st_ab.ir.kw_text.as_deref() {
384        if kw_val.contains("Landwalk") || kw_val.contains("landwalk") {
385            if let Some(blocker_card) = blocker {
386                if crate::staticability::static_ability_ignore_landwalk::ignore_land_walk(
387                    cards,
388                    attacker,
389                    blocker_card,
390                    kw_val,
391                ) {
392                    return false;
393                }
394            }
395        }
396    }
397
398    true
399}
400
401// ── canBlockIfReach ─────────────────────────────────────────────────────────
402
403/// Check if reach allows blocking despite a restriction.
404/// Mirrors Java's `StaticAbilityCantAttackBlock.canBlockIfReach()`.
405pub fn can_block_if_reach(
406    game: &GameState,
407    cards: &[Card],
408    attacker: &Card,
409    blocker: &Card,
410) -> bool {
411    for source in cards.iter().filter(|c| c.zone.is_static_ability_source()) {
412        for st_ab in source
413            .static_abilities
414            .iter()
415            .filter(|sa| sa.check_conditions_full(&StaticMode::CanBlockIfReach, source, game))
416        {
417            if apply_can_block_if_reach_ability(game, st_ab, attacker, blocker, source) {
418                return true;
419            }
420        }
421    }
422    false
423}
424
425/// Mirrors Java's `StaticAbilityCantAttackBlock.applyCanBlockIfReachAbility()`.
426pub fn apply_can_block_if_reach_ability(
427    game: &GameState,
428    st_ab: &StaticAbility,
429    attacker: &Card,
430    blocker: &Card,
431    source: &Card,
432) -> bool {
433    if !valid_filter::matches_valid_card_selector_opt_in_game(
434        st_ab.ir.valid_attacker.as_ref(),
435        attacker,
436        source,
437        game,
438    ) {
439        return false;
440    }
441    if !valid_filter::matches_valid_card_selector_opt_in_game(
442        st_ab.ir.valid_blocker.as_ref(),
443        blocker,
444        source,
445        game,
446    ) {
447        return false;
448    }
449    true
450}
451
452// ── canBlockTapped ──────────────────────────────────────────────────────────
453
454/// Check if tapped creatures can block.
455/// Mirrors Java's `StaticAbilityCantAttackBlock.canBlockTapped()`.
456pub fn can_block_tapped(game: &GameState, cards: &[Card], card: &Card) -> bool {
457    for source in cards.iter().filter(|c| c.zone.is_static_ability_source()) {
458        for st_ab in source
459            .static_abilities
460            .iter()
461            .filter(|sa| sa.check_conditions_full(&StaticMode::BlockTapped, source, game))
462        {
463            if apply_block_tapped(game, st_ab, card, source) {
464                return true;
465            }
466        }
467    }
468    false
469}
470
471/// Mirrors Java's `StaticAbilityCantAttackBlock.applyBlockTapped()`.
472fn apply_block_tapped(game: &GameState, st_ab: &StaticAbility, card: &Card, source: &Card) -> bool {
473    if !valid_filter::matches_valid_card_selector_opt_in_game(
474        st_ab.ir.valid_card.as_ref(),
475        card,
476        source,
477        game,
478    ) {
479        return false;
480    }
481    true
482}
483
484// ── canAttackHaste ──────────────────────────────────────────────────────────
485
486/// Check if a creature can attack despite summoning sickness (as if it had haste).
487/// Mirrors Java's `StaticAbilityCantAttackBlock.canAttackHaste()`.
488pub fn can_attack_haste(
489    game: &GameState,
490    cards: &[Card],
491    attacker: &Card,
492    _defender: PlayerId,
493) -> bool {
494    // If the creature is not summoning sick, it can always attack (no need to check statics)
495    if !attacker.summoning_sick {
496        return true;
497    }
498
499    for source in cards.iter().filter(|c| c.zone.is_static_ability_source()) {
500        for st_ab in source
501            .static_abilities
502            .iter()
503            .filter(|sa| sa.check_conditions_full(&StaticMode::CanAttackIfHaste, source, game))
504        {
505            if apply_can_attack_haste_ability(game, st_ab, attacker, _defender, source) {
506                return true;
507            }
508        }
509    }
510    false
511}
512
513/// Mirrors Java's `StaticAbilityCantAttackBlock.applyCanAttackHasteAbility()`.
514pub fn apply_can_attack_haste_ability(
515    game: &GameState,
516    st_ab: &StaticAbility,
517    card: &Card,
518    defender: PlayerId,
519    source: &Card,
520) -> bool {
521    if !valid_filter::matches_valid_card_selector_opt_in_game(
522        st_ab.ir.valid_card.as_ref(),
523        card,
524        source,
525        game,
526    ) {
527        return false;
528    }
529
530    // ValidTarget — in Java this validates the target entity (defender).
531    if !valid_filter::matches_valid_player_selector_opt(
532        st_ab.ir.valid_target.as_ref(),
533        defender,
534        source.controller,
535    ) {
536        return false;
537    }
538
539    true
540}
541
542// ── getMinMaxBlocker ────────────────────────────────────────────────────────
543
544/// Get the minimum and maximum number of creatures that must/can block an attacker.
545/// Returns (min, max). Mirrors Java's `StaticAbilityCantAttackBlock.getMinMaxBlocker()`.
546pub fn get_min_max_blocker(
547    game: &GameState,
548    cards: &[Card],
549    attacker: &Card,
550    _defender: PlayerId,
551) -> (i32, i32) {
552    let mut min: i32 = 1;
553    let mut max: i32 = i32::MAX;
554
555    // Menace baseline: requires at least 2 blockers
556    if attacker.has_menace() {
557        min = 2;
558    }
559
560    for source in cards.iter().filter(|c| c.zone.is_static_ability_source()) {
561        for st_ab in source
562            .static_abilities
563            .iter()
564            .filter(|sa| sa.check_conditions_full(&StaticMode::MinMaxBlocker, source, game))
565        {
566            apply_min_max_blocker_ability(
567                game, st_ab, attacker, source, _defender, cards, &mut min, &mut max,
568            );
569        }
570    }
571
572    (min, max)
573}
574
575/// Mirrors Java's `StaticAbilityCantAttackBlock.applyMinMaxBlockerAbility()`.
576pub fn apply_min_max_blocker_ability(
577    game: &GameState,
578    st_ab: &StaticAbility,
579    attacker: &Card,
580    source: &Card,
581    defender: PlayerId,
582    cards: &[Card],
583    min: &mut i32,
584    max: &mut i32,
585) {
586    if !valid_filter::matches_valid_card_selector_opt_in_game(
587        st_ab.ir.valid_card.as_ref(),
588        attacker,
589        source,
590        game,
591    ) {
592        return;
593    }
594
595    if let Some(min_val) = st_ab.ir.min_text.as_deref() {
596        if min_val == "All" {
597            // In Java: defender.getCreaturesInPlay().size()
598            // Count creatures controlled by the defending player
599            let creature_count = cards
600                .iter()
601                .filter(|c| {
602                    c.controller == defender && c.zone == ZoneType::Battlefield && c.is_creature()
603                })
604                .count() as i32;
605            *min = creature_count;
606        } else if let Some(val) = resolve_amount_expr(None, source, min_val) {
607            *min = val;
608        }
609    }
610
611    if let Some(max_val) = st_ab.ir.max_text.as_deref() {
612        if let Some(val) = resolve_amount_expr(None, source, max_val) {
613            *max = val;
614        }
615    }
616}
617
618// ── attackVigilance ─────────────────────────────────────────────────────────
619
620/// Check if attacker has vigilance from a static ability (doesn't tap when attacking).
621/// Mirrors Java's `StaticAbilityCantAttackBlock.attackVigilance()`.
622pub fn attack_vigilance(game: &GameState, cards: &[Card], card: &Card) -> bool {
623    for source in cards.iter().filter(|c| c.zone.is_static_ability_source()) {
624        for st_ab in source
625            .static_abilities
626            .iter()
627            .filter(|sa| sa.check_conditions_full(&StaticMode::AttackVigilance, source, game))
628        {
629            if apply_attack_vigilance_ability(game, st_ab, card, source) {
630                return true;
631            }
632        }
633    }
634    false
635}
636
637/// Mirrors Java's `StaticAbilityCantAttackBlock.applyAttackVigilanceAbility()`.
638pub fn apply_attack_vigilance_ability(
639    game: &GameState,
640    st_ab: &StaticAbility,
641    card: &Card,
642    source: &Card,
643) -> bool {
644    if !valid_filter::matches_valid_card_selector_opt_in_game(
645        st_ab.ir.valid_card.as_ref(),
646        card,
647        source,
648        game,
649    ) {
650        return false;
651    }
652    true
653}
654
655// ── getAttackCost ───────────────────────────────────────────────────────────
656
657/// Get the cost required to attack with a creature.
658/// Returns the cost string if applicable, or None.
659/// Mirrors Java's `StaticAbilityCantAttackBlock.getAttackCost()`.
660pub fn get_attack_cost(
661    st_ab: &StaticAbility,
662    attacker: &Card,
663    target: PlayerId,
664    source: &Card,
665) -> Option<String> {
666    if !valid_filter::matches_valid_card_selector_opt(
667        st_ab.ir.valid_card.as_ref(),
668        attacker,
669        source,
670    ) {
671        return None;
672    }
673
674    if !valid_filter::matches_valid_player_opt(
675        st_ab.ir.target_text.as_deref(),
676        target,
677        source.controller,
678    ) {
679        return None;
680    }
681
682    let mut cost_string = st_ab.ir.cost.clone()?;
683    if let Some(svar_expr) = source.svars.get(&cost_string) {
684        let add_x = cost_string.starts_with('X');
685        let amount = crate::svar::evaluate_svar(
686            svar_expr,
687            &crate::spellability::SpellAbility::new_empty(Some(source.id), source.controller),
688        );
689        cost_string = amount.to_string();
690        if add_x {
691            cost_string.push_str(" X");
692        }
693    }
694
695    if st_ab.ir.trigger {
696        // TODO: cost.getCostParts().get(0).setTrigger(stAb.getPayingTrigSA())
697        // Trigger-based cost parts not yet modelled.
698    }
699
700    Some(cost_string)
701}
702
703// ── getBlockCost ────────────────────────────────────────────────────────────
704
705/// Get the cost required to block with a creature.
706/// Returns the cost string if applicable, or None.
707/// Mirrors Java's `StaticAbilityCantAttackBlock.getBlockCost()`.
708pub fn get_block_cost(
709    st_ab: &StaticAbility,
710    blocker: &Card,
711    attacker_player: PlayerId,
712    source: &Card,
713) -> Option<String> {
714    if !valid_filter::matches_valid_card_selector_opt(st_ab.ir.valid_card.as_ref(), blocker, source)
715    {
716        return None;
717    }
718
719    // Attacker validation — in Java this is matchesValidParam("Attacker", attacker)
720    // where attacker is a GameEntity. We validate as a player for now.
721    if !valid_filter::matches_valid_player_opt(
722        st_ab.ir.attacker_text.as_deref(),
723        attacker_player,
724        source.controller,
725    ) {
726        return None;
727    }
728
729    let mut cost_string = st_ab.ir.cost.clone()?;
730    if let Some(svar_expr) = source.svars.get(&cost_string) {
731        let add_x = cost_string.starts_with('X');
732        let amount = crate::svar::evaluate_svar(
733            svar_expr,
734            &crate::spellability::SpellAbility::new_empty(Some(source.id), source.controller),
735        );
736        cost_string = amount.to_string();
737        if add_x {
738            cost_string.push_str(" X");
739        }
740    }
741
742    Some(cost_string)
743}
744
745fn resolve_amount_expr(game: Option<&GameState>, source: &Card, expr: &str) -> Option<i32> {
746    if let Ok(v) = expr.parse::<i32>() {
747        return Some(v);
748    }
749    let svar_expr = source.svars.get(expr)?;
750    if let Some(g) = game {
751        if svar_expr.starts_with("Count$") {
752            return Some(crate::svar::resolve_count_svar(
753                svar_expr,
754                g,
755                source.id,
756                source.controller,
757            ));
758        }
759    }
760    Some(crate::svar::evaluate_svar(
761        svar_expr,
762        &crate::spellability::SpellAbility::new_empty(Some(source.id), source.controller),
763    ))
764}