manabrew_engine/staticability/
static_ability_must_target.rs1use 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 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
65pub 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 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}