Skip to main content

manabrew_engine/staticability/
static_ability_must_target.rs

1use forge_foundation::ZoneType;
2
3use crate::card::card_util;
4use crate::game::GameState;
5use crate::ids::CardId;
6use crate::spellability::target_restrictions::TargetKind;
7use crate::spellability::SpellAbility;
8use crate::staticability::StaticMode;
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11struct MustTargetRestriction {
12    valid_target: String,
13    zone: ZoneType,
14}
15
16pub fn filter_must_target_cards(
17    game: &GameState,
18    sa: &SpellAbility,
19    targets: Vec<CardId>,
20) -> Vec<CardId> {
21    if targets.is_empty() {
22        return targets;
23    }
24    let restrictions = get_restrictions(game, sa);
25    if restrictions.is_empty() {
26        return targets;
27    }
28
29    // Keep only unresolved restrictions where at least one current choice can satisfy it.
30    let unresolved: Vec<MustTargetRestriction> = restrictions
31        .into_iter()
32        .filter(|r| {
33            targets
34                .iter()
35                .any(|&cid| card_matches_restriction(game, cid, r))
36        })
37        .collect();
38
39    if unresolved.is_empty() {
40        return targets;
41    }
42
43    targets
44        .into_iter()
45        .filter(|&cid| {
46            unresolved
47                .iter()
48                .any(|r| card_matches_restriction(game, cid, r))
49        })
50        .collect()
51}
52
53pub fn must_target_cards_required(game: &GameState, sa: &SpellAbility, targets: &[CardId]) -> bool {
54    let restrictions = get_restrictions(game, sa);
55    if restrictions.is_empty() {
56        return false;
57    }
58    restrictions.iter().any(|r| {
59        targets
60            .iter()
61            .any(|&cid| card_matches_restriction(game, cid, r))
62    })
63}
64
65/// Mirrors Java's `StaticAbilityMustTarget.meetsMustTargetRestriction(spellAbility)`.
66/// This is checked *after* target choices are made and can invalidate the cast
67/// if a required MustTarget card was targetable but not chosen.
68pub fn meets_must_target_restriction(game: &GameState, sa: &SpellAbility) -> bool {
69    if sa.is_copy {
70        return true;
71    }
72
73    let mut restrictions = get_restrictions(game, sa);
74    if restrictions.is_empty() {
75        return true;
76    }
77
78    let mut current = Some(sa);
79    let mut uses_targeting = false;
80    while let Some(node) = current {
81        if node.uses_targeting() && !node.ir.targeting_player {
82            uses_targeting = true;
83            let choices = get_targetable_card_choices(game, node);
84            is_restrictions_met(game, &mut restrictions, &choices, node);
85        }
86        current = node.sub_ability.as_deref();
87    }
88
89    !uses_targeting || restrictions.is_empty()
90}
91
92fn get_restrictions(game: &GameState, sa: &SpellAbility) -> Vec<MustTargetRestriction> {
93    if sa.is_copy {
94        return Vec::new();
95    }
96    let Some(src_id) = sa.source else {
97        return Vec::new();
98    };
99    // Java applies card-target filtering only when caster controls the spell.
100    if sa.activating_player != game.card(src_id).controller {
101        return Vec::new();
102    }
103
104    let mut out = Vec::new();
105    for source in game
106        .cards
107        .iter()
108        .filter(|c| c.zone == ZoneType::Battlefield)
109    {
110        for st_ab in source
111            .static_abilities
112            .iter()
113            .filter(|sa| sa.check_mode(&StaticMode::MustTarget))
114        {
115            if let Some(valid_sa) = st_ab.ir.valid_sa.as_deref() {
116                if !spell_ability_matches(valid_sa, sa, sa.activating_player, source.controller) {
117                    continue;
118                }
119            }
120            let Some(valid_target) = st_ab.ir.valid_target_text.clone() else {
121                continue;
122            };
123            let zone = st_ab
124                .ir
125                .valid_zone
126                .first()
127                .copied()
128                .unwrap_or(ZoneType::Battlefield);
129            let r = MustTargetRestriction { valid_target, zone };
130            if !out.contains(&r) {
131                out.push(r);
132            }
133        }
134    }
135    out
136}
137
138fn is_restrictions_met(
139    game: &GameState,
140    restrictions: &mut Vec<MustTargetRestriction>,
141    choices: &[CardId],
142    sa: &SpellAbility,
143) {
144    let mut i = restrictions.len();
145    while i > 0 {
146        i -= 1;
147        let restriction = &restrictions[i];
148
149        let already_targeted = sa
150            .target_chosen
151            .target_card
152            .map(|cid| card_matches_restriction(game, cid, restriction))
153            .unwrap_or(false);
154        if already_targeted {
155            restrictions.remove(i);
156            continue;
157        }
158
159        let can_target_matching = choices
160            .iter()
161            .any(|&cid| card_matches_restriction(game, cid, restriction));
162        if !can_target_matching {
163            restrictions.remove(i);
164        }
165    }
166}
167
168fn get_targetable_card_choices(game: &GameState, sa: &SpellAbility) -> Vec<CardId> {
169    let Some(tr) = sa.target_restrictions.as_ref() else {
170        return Vec::new();
171    };
172
173    match &tr.target_kind {
174        TargetKind::Any
175        | TargetKind::Creature(_)
176        | TargetKind::Permanent(_)
177        | TargetKind::CardInZone { .. } => card_util::get_valid_cards_to_target(game, sa),
178        TargetKind::Player | TargetKind::Spell | TargetKind::None => Vec::new(),
179    }
180}
181
182fn card_matches_restriction(game: &GameState, cid: CardId, r: &MustTargetRestriction) -> bool {
183    let card = game.card(cid);
184    if card.zone != r.zone {
185        return false;
186    }
187    let t = r.valid_target.as_str();
188    if t.eq_ignore_ascii_case("Card") || t.eq_ignore_ascii_case("Permanent") {
189        return true;
190    }
191    if t.eq_ignore_ascii_case("Creature") {
192        return card.is_creature();
193    }
194    if t.eq_ignore_ascii_case("Land") {
195        return card.is_land();
196    }
197    if t.eq_ignore_ascii_case("Artifact") {
198        return card.type_line.is_artifact();
199    }
200    if t.eq_ignore_ascii_case("Enchantment") {
201        return card.type_line.is_enchantment();
202    }
203    if t.eq_ignore_ascii_case("Planeswalker") {
204        return card.type_line.is_planeswalker();
205    }
206    if t.eq_ignore_ascii_case("Instant") {
207        return card.type_line.is_instant();
208    }
209    if t.eq_ignore_ascii_case("Sorcery") {
210        return card.type_line.is_sorcery();
211    }
212    card.type_line.has_subtype(t)
213}
214
215fn spell_ability_matches(
216    valid_sa: &str,
217    sa: &SpellAbility,
218    activating_player: crate::ids::PlayerId,
219    source_controller: crate::ids::PlayerId,
220) -> bool {
221    let tokens: Vec<&str> = valid_sa
222        .split(',')
223        .map(|s| s.trim())
224        .filter(|s| !s.is_empty())
225        .collect();
226    if tokens.is_empty() {
227        return true;
228    }
229    tokens.iter().any(|tok| {
230        let lower = tok.to_ascii_lowercase();
231        let parts: Vec<&str> = lower.split('.').collect();
232        let base = parts.first().copied().unwrap_or("");
233        let ctrl_ok = if parts.len() > 1 {
234            match parts[1] {
235                "oppctrl" | "opponentctrl" => activating_player != source_controller,
236                "youctrl" | "youcontrol" => activating_player == source_controller,
237                _ => true,
238            }
239        } else {
240            true
241        };
242        if !ctrl_ok {
243            return false;
244        }
245        match base {
246            "spell" => sa.is_spell,
247            "activated" => sa.is_activated,
248            "istargeting" => sa.target_restrictions.is_some(),
249            "xcost" => sa.cost_has_x(),
250            _ => false,
251        }
252    })
253}