1use forge_foundation::{CoreType, ZoneType};
8use serde::{Deserialize, Serialize};
9
10use crate::card::{card_property, valid_filter};
11use crate::game::GameState;
12use crate::ids::{CardId, PlayerId};
13use crate::parsing::{cached_compiled_selector, keys, CompiledSelector, Params, ParsedParams};
14use crate::spellability::SpellAbility;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub enum TargetKind {
20 Player,
22 Any,
24 Creature(Option<String>),
26 Permanent(Option<String>),
29 CardInZone {
31 zone: ZoneType,
32 filter: Option<String>,
33 },
34 Spell,
36 None,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TargetRestrictions {
45 pub valid_tgts: Vec<String>,
47 #[serde(default)]
49 pub valid_tgts_selector: CompiledSelector,
50 pub target_kind: TargetKind,
52 pub target_type_filter: Option<String>,
54 pub min_targets: String,
57 pub max_targets: String,
60 pub tgt_zone: Vec<ZoneType>,
62}
63
64impl TargetRestrictions {
65 pub fn new_from_parsed(parsed: &ParsedParams<'_>, params: &Params) -> Option<Self> {
66 let valid_tgts_str = parsed.get(keys::VALID_TGTS)?;
67 let valid_tgts: Vec<String> = valid_tgts_str
68 .split(',')
69 .map(|s| s.trim().to_string())
70 .collect();
71 let target_zone = parsed_zone_type(parsed.get(keys::TGT_ZONE));
76 let origin_zone = parsed_zone_type(parsed.get(keys::ORIGIN));
77 let effective_zone = target_zone.or(origin_zone);
78 let enhanced_input = if effective_zone.is_some_and(|z| z != ZoneType::Battlefield) {
79 valid_tgts_str
80 } else {
81 &valid_tgts[0]
82 };
83 let mut target_kind = parse_target_kind_enhanced(enhanced_input, effective_zone);
84 if effective_zone.is_none_or(|z| z == ZoneType::Battlefield) && valid_tgts.len() > 1 {
89 target_kind = TargetKind::Any;
90 }
91 let min_targets = parsed.get(keys::TARGET_MIN).unwrap_or("1").to_string();
92 let max_targets = parsed.get(keys::TARGET_MAX).unwrap_or("1").to_string();
93 let target_type_filter = parsed.get(keys::TARGET_TYPE).map(str::to_string);
94
95 if target_type_filter.is_some() {
100 target_kind = TargetKind::Spell;
101 }
102
103 Some(TargetRestrictions {
104 valid_tgts,
105 valid_tgts_selector: params
106 .selector_untracked(keys::VALID_TGTS)
107 .cloned()
108 .unwrap_or_else(|| cached_compiled_selector(valid_tgts_str)),
109 target_kind,
110 target_type_filter,
111 min_targets,
112 max_targets,
113 tgt_zone: effective_zone
114 .map(|zone| vec![zone])
115 .unwrap_or_else(|| vec![ZoneType::Battlefield]),
116 })
117 }
118
119 pub fn new(params: &Params) -> Option<Self> {
122 let valid_tgts_str = params.selector_value(keys::VALID_TGTS)?;
123 let valid_tgts: Vec<String> = valid_tgts_str
124 .split(',')
125 .map(|s| s.trim().to_string())
126 .collect();
127 let target_zone = params.zone_type(keys::TGT_ZONE);
132 let origin_zone = params.zone_type(keys::ORIGIN);
133 let effective_zone = target_zone.or(origin_zone);
134 let enhanced_input = if effective_zone.is_some_and(|z| z != ZoneType::Battlefield) {
139 valid_tgts_str
140 } else {
141 &valid_tgts[0]
142 };
143 let mut target_kind = parse_target_kind_enhanced(enhanced_input, effective_zone);
144 if effective_zone.is_none_or(|z| z == ZoneType::Battlefield) && valid_tgts.len() > 1 {
149 target_kind = TargetKind::Any;
150 }
151 let min_targets = params
152 .get_cloned(keys::TARGET_MIN)
153 .unwrap_or_else(|| "1".to_string());
154 let max_targets = params
155 .get_cloned(keys::TARGET_MAX)
156 .unwrap_or_else(|| "1".to_string());
157
158 let target_type_filter = params.get_cloned(keys::TARGET_TYPE);
160
161 if target_type_filter.is_some() {
169 target_kind = TargetKind::Spell;
170 }
171
172 Some(TargetRestrictions {
173 valid_tgts,
174 valid_tgts_selector: params
175 .selector(keys::VALID_TGTS)
176 .cloned()
177 .unwrap_or_else(|| cached_compiled_selector(valid_tgts_str)),
178 target_kind,
179 target_type_filter,
180 min_targets,
181 max_targets,
182 tgt_zone: effective_zone
183 .map(|zone| vec![zone])
184 .unwrap_or_else(|| vec![ZoneType::Battlefield]),
185 })
186 }
187
188 pub fn has_candidates(
192 &self,
193 game: &GameState,
194 player: PlayerId,
195 source_card: Option<CardId>,
196 ) -> bool {
197 let _perf_scope =
198 crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Target);
199 match &self.target_kind {
200 TargetKind::None => true,
201 TargetKind::Player => !game.alive_players().is_empty(),
203 TargetKind::Any => {
205 if any_target_allows_players(&self.valid_tgts) && !game.alive_players().is_empty() {
206 return true;
207 }
208 get_all_candidates_any_filtered_for_restrictions(game, self, player, source_card)
209 .into_iter()
210 .any(|cid| can_be_targeted_by(game, cid, player, source_card))
211 }
212 TargetKind::Creature(ref filter) => {
213 get_all_candidates_creature_filtered_for_restrictions(
214 game,
215 self,
216 filter.as_deref(),
217 player,
218 source_card,
219 )
220 .into_iter()
221 .filter(|&cid| !is_other_filter_self_hit(filter.as_deref(), source_card, cid))
222 .any(|cid| can_be_targeted_by(game, cid, player, source_card))
223 }
224 TargetKind::Permanent(ref filter) => {
225 get_all_battlefield_permanents_filtered_for_restrictions(
226 game,
227 self,
228 filter.as_deref(),
229 player,
230 source_card,
231 )
232 .into_iter()
233 .filter(|&cid| !is_other_filter_self_hit(filter.as_deref(), source_card, cid))
234 .any(|cid| can_be_targeted_by(game, cid, player, source_card))
235 }
236 TargetKind::CardInZone { zone, filter } => !get_valid_cards_in_zone_for_restrictions(
237 game,
238 self,
239 *zone,
240 player,
241 filter.as_deref(),
242 source_card,
243 )
244 .is_empty(),
245 TargetKind::Spell => {
246 !filter_spells_for_target_restrictions(game, &get_all_candidates_spells(game), self)
247 .is_empty()
248 }
249 }
250 }
251
252 pub fn get_min_targets(&self, game: &GameState, sa: &SpellAbility) -> i32 {
254 resolve_target_count_expr(&self.min_targets, game, sa)
255 }
256
257 pub fn get_max_targets(&self, game: &GameState, sa: &SpellAbility) -> i32 {
259 resolve_target_count_expr(&self.max_targets, game, sa)
260 }
261
262 pub fn can_only_tgt_opponent(&self) -> bool {
265 self.valid_tgts
266 .iter()
267 .all(|v| v.eq_ignore_ascii_case("Opponent"))
268 }
269
270 pub fn can_tgt_player(&self) -> bool {
273 matches!(self.target_kind, TargetKind::Player | TargetKind::Any)
274 }
275
276 pub fn can_tgt_permanent(&self) -> bool {
279 matches!(
280 self.target_kind,
281 TargetKind::Permanent(_) | TargetKind::Creature(_) | TargetKind::Any
282 )
283 }
284
285 pub fn can_tgt_creature(&self) -> bool {
288 matches!(self.target_kind, TargetKind::Creature(_) | TargetKind::Any)
289 }
290
291 pub fn can_tgt_planeswalker(&self) -> bool {
294 matches!(self.target_kind, TargetKind::Permanent(_) | TargetKind::Any)
295 }
296
297 pub fn can_tgt_creature_and_player(&self) -> bool {
300 matches!(self.target_kind, TargetKind::Any)
301 }
302
303 pub fn copy(&self) -> Self {
306 self.clone()
307 }
308
309 pub fn apply_target_text_changes(&mut self, changes: &[(&str, &str)]) {
312 for tgt in &mut self.valid_tgts {
313 for &(old, new) in changes {
314 if tgt.contains(old) {
315 *tgt = tgt.replace(old, new);
316 }
317 }
318 }
319 if let Some(first) = self.valid_tgts.first() {
321 self.target_kind = parse_target_kind_legacy(first);
322 }
323 self.valid_tgts_selector = cached_compiled_selector(&self.valid_tgts.join(","));
324 }
325}
326
327fn parsed_zone_type(value: Option<&str>) -> Option<ZoneType> {
328 let value = value?.trim();
329 if value.eq_ignore_ascii_case("Deck") {
330 Some(ZoneType::Library)
331 } else {
332 ZoneType::from_str_compat(value)
333 }
334}
335
336impl TargetRestrictions {
337 fn compiled_valid_tgts(&self) -> CompiledSelector {
338 if self.valid_tgts_selector.alternatives.is_empty() {
339 cached_compiled_selector(&self.valid_tgts.join(","))
340 } else {
341 self.valid_tgts_selector.clone()
342 }
343 }
344}
345
346fn has_other_qualifier(filter: &str) -> bool {
347 filter.split(['.', '+']).any(|part| {
348 part.eq_ignore_ascii_case("Other") || part.eq_ignore_ascii_case("StrictlyOther")
349 })
350}
351
352fn is_other_filter_self_hit(
353 filter: Option<&str>,
354 source_card: Option<CardId>,
355 candidate: CardId,
356) -> bool {
357 match (filter, source_card) {
358 (Some(f), Some(src)) if src == candidate => has_other_qualifier(f),
359 _ => false,
360 }
361}
362
363pub fn apply_other_source_filter(
366 candidates: Vec<CardId>,
367 filter: Option<&str>,
368 source_card: Option<CardId>,
369) -> Vec<CardId> {
370 candidates
371 .into_iter()
372 .filter(|&cid| !is_other_filter_self_hit(filter, source_card, cid))
373 .collect()
374}
375
376fn resolve_target_count_expr(expr: &str, game: &GameState, sa: &SpellAbility) -> i32 {
380 if let Some(n) = parse_literal_target_count(expr) {
381 return n;
382 }
383
384 crate::svar::resolve_numeric_value(game, sa, expr, 1)
385}
386
387fn parse_literal_target_count(expr: &str) -> Option<i32> {
388 if let Ok(n) = expr.trim().parse::<i32>() {
389 return Some(n);
390 }
391 expr.trim().strip_prefix('+')?.parse::<i32>().ok()
392}
393
394pub fn has_valid_spell_with_filter(game: &GameState, filter: &str) -> bool {
396 !filter_spells_by_type(game, &get_all_candidates_spells(game), filter).is_empty()
397}
398
399pub fn filter_spells_for_target_restrictions(
401 game: &GameState,
402 candidates: &[u32],
403 restrictions: &TargetRestrictions,
404) -> Vec<u32> {
405 let mut filtered = candidates.to_vec();
406 if let Some(ref filter) = restrictions.target_type_filter {
407 filtered = filter_spells_by_type(game, &filtered, filter);
408 }
409 if !restrictions.valid_tgts.is_empty()
410 && !restrictions
411 .valid_tgts
412 .iter()
413 .all(|clause| clause.eq_ignore_ascii_case("Card"))
414 {
415 let valid_filter = restrictions.valid_tgts.join(",");
416 filtered = filter_spells_by_type(game, &filtered, &valid_filter);
417 }
418 filtered
419}
420
421pub fn filter_spells_by_type(game: &GameState, candidates: &[u32], filter: &str) -> Vec<u32> {
427 candidates
428 .iter()
429 .filter(|&&id| {
430 let Some(entry) = game.stack.iter().find(|entry| entry.id == id) else {
431 return false;
432 };
433 stack_entry_matches_filter(game, &entry.spell_ability, filter)
434 })
435 .copied()
436 .collect()
437}
438
439fn stack_entry_matches_filter(
440 game: &GameState,
441 sa: &crate::spellability::SpellAbility,
442 filter: &str,
443) -> bool {
444 if filter.trim().is_empty() {
445 return sa.is_spell;
446 }
447 let Some(source_id) = sa.source else {
448 return false;
449 };
450 let source = game.card(source_id);
451
452 if crate::spellability::matches_valid_sa(filter, sa, source, Some(source)) {
453 return true;
454 }
455
456 filter
457 .split(',')
458 .map(str::trim)
459 .filter(|s| !s.is_empty())
460 .any(|clause| {
461 if clause == crate::card::filter_constants::CARD {
464 return true;
465 }
466 card_property::card_has_property(source, clause, sa.activating_player)
467 })
468}
469
470#[allow(dead_code)]
473fn parse_target_kind(val: &str) -> TargetKind {
474 let val = val.trim();
475 if val.eq_ignore_ascii_case("Any") {
476 return TargetKind::Any;
477 }
478 if val.eq_ignore_ascii_case("Player") || val.eq_ignore_ascii_case("Opponent") {
479 return TargetKind::Player;
480 }
481 if val.eq_ignore_ascii_case("Spell") {
482 return TargetKind::Spell;
483 }
484 if val.starts_with("Creature") {
485 let filter = val.strip_prefix("Creature").unwrap_or("");
487 if filter.is_empty() {
488 return TargetKind::Creature(None);
489 }
490 let filter = filter.strip_prefix('.').unwrap_or(filter);
491 return TargetKind::Creature(Some(filter.to_string()));
492 }
493 if val.starts_with("Permanent") {
494 let filter = val.strip_prefix("Permanent").unwrap_or("");
496 if filter.is_empty() {
497 return TargetKind::Permanent(None);
498 }
499 let filter = filter.strip_prefix('.').unwrap_or(filter);
500 return TargetKind::Permanent(Some(filter.to_string()));
501 }
502 if val.starts_with("Land") {
503 let filter = val.strip_prefix("Land").unwrap_or("");
506 if filter.is_empty() {
507 return TargetKind::Permanent(Some("Land".to_string()));
508 }
509 let filter = filter.strip_prefix('.').unwrap_or(filter);
510 return TargetKind::Permanent(Some(format!("Land.{filter}")));
511 }
512 TargetKind::Any
514}
515
516pub fn parse_valid_targets(ability: &str) -> TargetKind {
520 let params = Params::from_raw(ability);
521 let origin_zone = params.zone_type(keys::ORIGIN);
522 match params.selector_value(keys::VALID_TGTS) {
523 Some(val) => parse_target_kind_enhanced(val, origin_zone),
524 None => TargetKind::None,
525 }
526}
527
528pub fn has_candidates(
531 game: &GameState,
532 player: PlayerId,
533 ability: &str,
534 source: Option<CardId>,
535) -> bool {
536 let _perf_scope =
537 crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Target);
538 let params = Params::from_raw(ability);
539 match TargetRestrictions::new(¶ms) {
540 Some(tr) => tr.has_candidates(game, player, source),
541 None => true, }
543}
544
545pub fn has_candidates_in_chain(
549 game: &GameState,
550 player: PlayerId,
551 ability: &str,
552 source: Option<CardId>,
553) -> bool {
554 let _perf_scope =
555 crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Target);
556 let params = Params::from_raw(ability);
557 if let Some(tr) = TargetRestrictions::new(¶ms) {
558 let min_targets = parse_literal_target_count(&tr.min_targets).unwrap_or_else(|| {
559 if let Some(card_id) = source {
560 let sa = crate::spellability::build_spell_ability(game, card_id, ability, player);
561 tr.get_min_targets(game, &sa)
562 } else {
563 let sa = SpellAbility::new_simple(None, player, ability);
564 tr.get_min_targets(game, &sa)
565 }
566 });
567 if min_targets > 0 && !tr.has_candidates(game, player, source) {
568 return false;
569 }
570 }
571
572 if let Some(sub_svar_name) = params.get(keys::SUB_ABILITY) {
573 if let Some(card_id) = source {
574 if let Some(sub_text) = game.card(card_id).get_s_var(sub_svar_name) {
575 let sub_text = sub_text.to_string();
576 return has_candidates_in_chain(game, player, &sub_text, source);
577 }
578 }
579 }
580
581 true
582}
583
584pub fn has_candidates_in_spell_ability_chain(
589 game: &GameState,
590 player: PlayerId,
591 sa: &SpellAbility,
592) -> bool {
593 let _perf_scope =
594 crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Target);
595 let mut current = Some(sa);
596 while let Some(node) = current {
597 if let Some(tr) = node.target_restrictions.as_ref() {
598 let min_targets = tr.get_min_targets(game, node);
599 if min_targets > 0 && !tr.has_candidates(game, player, node.source) {
600 return false;
601 }
602 }
603 current = node.sub_ability.as_deref();
604 }
605 true
606}
607
608pub fn can_be_targeted_by(
612 game: &GameState,
613 target_id: CardId,
614 source_controller: PlayerId,
615 source_card: Option<CardId>,
616) -> bool {
617 can_be_targeted_by_internal(game, target_id, source_controller, source_card, None)
618}
619
620pub fn can_be_targeted_by_sa(
621 game: &GameState,
622 target_id: CardId,
623 source_controller: PlayerId,
624 source_sa: &SpellAbility,
625) -> bool {
626 let _perf_scope =
627 crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Target);
628 can_be_targeted_by_internal(
629 game,
630 target_id,
631 source_controller,
632 source_sa.source,
633 Some(source_sa),
634 )
635}
636
637fn can_be_targeted_by_internal(
638 game: &GameState,
639 target_id: CardId,
640 source_controller: PlayerId,
641 source_card: Option<CardId>,
642 source_sa: Option<&SpellAbility>,
643) -> bool {
644 let target = game.card(target_id);
645 let source_card_ref = source_card.map(|id| game.card(id));
646 if crate::staticability::static_ability_cant_target::cant_target(
647 &game.cards,
648 target,
649 source_controller,
650 source_card_ref,
651 source_sa,
652 ) {
653 return false;
654 }
655 if target.zone != ZoneType::Battlefield {
662 return true;
663 }
664 let ignore_shroud = crate::staticability::static_ability_ignore_hexproof_shroud::ignore_shroud(
666 &game.cards,
667 target,
668 source_controller,
669 );
670 if target.has_shroud() && !ignore_shroud {
671 return false;
672 }
673 let ignore_hexproof =
675 crate::staticability::static_ability_ignore_hexproof_shroud::ignore_hexproof(
676 &game.cards,
677 target,
678 source_controller,
679 );
680 if target.has_hexproof() && target.controller != source_controller && !ignore_hexproof {
681 return false;
682 }
683 if let Some(src_id) = source_card {
684 let src = game.card(src_id);
685 if target.controller != source_controller {
687 for color in &["white", "blue", "black", "red", "green"] {
688 if target.has_hexproof_from(color) {
689 let has_color = crate::staticability::static_ability_colorless_damage_source::source_has_color(
690 &game.cards,
691 src,
692 color,
693 );
694 if has_color {
695 return false;
696 }
697 }
698 }
699 }
700 if crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
702 &game.cards, target, src,
703 ) {
704 return false;
705 }
706 }
707 true
708}
709
710pub fn get_all_candidates_creatures(game: &GameState) -> Vec<CardId> {
713 let mut creatures = Vec::new();
714 for &pid in &game.player_order {
715 for &cid in game.cards_in_zone(ZoneType::Battlefield, pid) {
716 let card = game.card(cid);
717 if card.phased_out {
721 continue;
722 }
723 if card.is_creature() {
724 creatures.push(cid);
725 }
726 }
727 }
728 creatures
729}
730
731pub fn get_all_candidates_creature_filtered(
734 game: &GameState,
735 filter: Option<&str>,
736 source_controller: PlayerId,
737) -> Vec<CardId> {
738 let all = get_all_candidates_creatures(game);
739 match filter {
740 None => all,
741 Some(f) => all
742 .into_iter()
743 .filter(|&cid| card_property::card_has_property(game.card(cid), f, source_controller))
744 .collect(),
745 }
746}
747
748pub fn get_all_candidates_creature_filtered_for_restrictions(
749 game: &GameState,
750 restrictions: &TargetRestrictions,
751 filter: Option<&str>,
752 source_controller: PlayerId,
753 source_card: Option<CardId>,
754) -> Vec<CardId> {
755 let all = get_all_candidates_creatures(game);
756 filter_card_candidates_for_restrictions(
757 game,
758 all,
759 restrictions,
760 filter,
761 source_controller,
762 source_card,
763 )
764}
765
766pub fn get_all_battlefield_permanents(game: &GameState) -> Vec<CardId> {
768 let mut permanents = Vec::new();
769 for &pid in &game.player_order {
770 for &cid in game.cards_in_zone(ZoneType::Battlefield, pid) {
771 if game.card(cid).phased_out {
774 continue;
775 }
776 permanents.push(cid);
777 }
778 }
779 permanents
780}
781
782pub fn get_all_battlefield_permanents_filtered(
785 game: &GameState,
786 filter: Option<&str>,
787 source_controller: PlayerId,
788) -> Vec<CardId> {
789 let all = get_all_battlefield_permanents(game);
790 match filter {
791 None => all,
792 Some(f) => all
793 .into_iter()
794 .filter(|&cid| card_property::card_has_property(game.card(cid), f, source_controller))
795 .collect(),
796 }
797}
798
799pub fn get_all_battlefield_permanents_filtered_for_restrictions(
800 game: &GameState,
801 restrictions: &TargetRestrictions,
802 filter: Option<&str>,
803 source_controller: PlayerId,
804 source_card: Option<CardId>,
805) -> Vec<CardId> {
806 let all = get_all_battlefield_permanents(game);
807 filter_card_candidates_for_restrictions(
808 game,
809 all,
810 restrictions,
811 filter,
812 source_controller,
813 source_card,
814 )
815}
816
817fn filter_card_candidates_for_restrictions(
818 game: &GameState,
819 candidates: Vec<CardId>,
820 restrictions: &TargetRestrictions,
821 filter: Option<&str>,
822 source_controller: PlayerId,
823 source_card: Option<CardId>,
824) -> Vec<CardId> {
825 let Some(source_id) = source_card else {
826 return match filter {
827 None => candidates,
828 Some(f) => candidates
829 .into_iter()
830 .filter(|&cid| {
831 card_property::card_has_property(game.card(cid), f, source_controller)
832 })
833 .collect(),
834 };
835 };
836 let selector = restrictions.compiled_valid_tgts();
837 let source = game.card(source_id);
838 candidates
839 .into_iter()
840 .filter(|&cid| !is_other_filter_self_hit(filter, source_card, cid))
841 .filter(|&cid| {
842 valid_filter::matches_valid_card_selector_in_game(
843 &selector,
844 game.card(cid),
845 source,
846 game,
847 )
848 })
849 .collect()
850}
851
852fn parse_target_kind_enhanced(val: &str, origin_zone: Option<ZoneType>) -> TargetKind {
858 let val = val.trim();
859
860 if val.eq_ignore_ascii_case("Player") || val.eq_ignore_ascii_case("Opponent") {
864 return TargetKind::Player;
865 }
866
867 if let Some(zone) = origin_zone {
869 if zone != ZoneType::Battlefield {
870 let filter = if val.is_empty() {
874 None
875 } else {
876 Some(val.to_string())
877 };
878 return TargetKind::CardInZone { zone, filter };
879 }
880 }
881
882 parse_target_kind_legacy(val)
884}
885
886fn parse_target_kind_legacy(val: &str) -> TargetKind {
888 let val = val.trim();
889 if val.eq_ignore_ascii_case("Any") {
890 return TargetKind::Any;
891 }
892 if val.eq_ignore_ascii_case("Player") || val.eq_ignore_ascii_case("Opponent") {
893 return TargetKind::Player;
894 }
895 if val.eq_ignore_ascii_case("Spell") {
896 return TargetKind::Spell;
897 }
898 if val.starts_with("Creature") {
899 let filter = val.strip_prefix("Creature").unwrap_or("");
901 if filter.is_empty() {
902 return TargetKind::Creature(None);
903 }
904 let filter = filter.strip_prefix('.').unwrap_or(filter);
905 return TargetKind::Creature(Some(filter.to_string()));
906 }
907 if val.starts_with("Permanent") {
908 let filter = val.strip_prefix("Permanent").unwrap_or("");
910 if filter.is_empty() {
911 return TargetKind::Permanent(None);
912 }
913 let filter = filter.strip_prefix('.').unwrap_or(filter);
914 return TargetKind::Permanent(Some(filter.to_string()));
915 }
916 if val.starts_with("Land") {
917 let filter = val.strip_prefix("Land").unwrap_or("");
920 if filter.is_empty() {
921 return TargetKind::Permanent(Some("Land".to_string()));
922 }
923 let filter = filter.strip_prefix('.').unwrap_or(filter);
924 return TargetKind::Permanent(Some(format!("Land.{filter}")));
925 }
926 TargetKind::Any
928}
929
930pub fn get_valid_cards_in_zone(
932 game: &GameState,
933 zone: ZoneType,
934 player: PlayerId,
935 filter: Option<&str>,
936 source_card: Option<CardId>,
937) -> Vec<CardId> {
938 let restrict_to_player = filter
942 .map(|f| {
943 f.contains("YouCtrl")
944 || f.contains("YouOwn")
945 || f.contains("YouControl")
946 || f.contains("EnchantedBy")
947 })
948 .unwrap_or(false);
949
950 let zone_cards: Vec<CardId> = if restrict_to_player {
951 game.cards_in_zone(zone, player).to_vec()
952 } else {
953 game.player_order
954 .iter()
955 .flat_map(|&pid| game.cards_in_zone(zone, pid).to_vec())
956 .collect()
957 };
958
959 match filter {
960 None => zone_cards,
961 Some(f) => {
962 let clauses: Vec<&str> = f.split(',').map(str::trim).collect();
965 zone_cards
966 .into_iter()
967 .filter(|&cid| !is_other_filter_self_hit(Some(f), source_card, cid))
968 .filter(|&cid| {
969 clauses.iter().any(|clause| {
970 card_property::card_has_property(game.card(cid), clause, player)
971 })
972 })
973 .collect()
974 }
975 }
976}
977
978pub fn get_valid_cards_in_zone_for_sa(
981 game: &GameState,
982 zone: ZoneType,
983 player: PlayerId,
984 filter: Option<&str>,
985 ability: &SpellAbility,
986) -> Vec<CardId> {
987 let zone_cards = candidate_zone_cards(game, zone, player, filter);
988 let Some(source_id) = ability.source else {
989 return get_valid_cards_in_zone(game, zone, player, filter, None);
990 };
991 let Some(restrictions) = ability.target_restrictions.as_ref() else {
992 return zone_cards;
993 };
994 let selector = restrictions.compiled_valid_tgts();
995 let source = game.card(source_id);
996 let triggering_card = ability.get_triggering_card(crate::ability::AbilityKey::Card);
997 let triggering_player = ability
998 .get_triggering_value(crate::ability::AbilityKey::Player)
999 .and_then(|value| match value {
1000 crate::event::AbilityValue::Player(player) => Some(*player),
1001 _ => None,
1002 });
1003
1004 zone_cards
1005 .into_iter()
1006 .filter(|&cid| !is_other_filter_self_hit(filter, ability.source, cid))
1007 .filter(|&cid| {
1008 valid_filter::matches_valid_card_selector_with_context(
1009 &selector,
1010 game.card(cid),
1011 valid_filter::MatchContext::from_source(source)
1012 .with_game(game)
1013 .with_triggering(triggering_card, triggering_player),
1014 )
1015 })
1016 .collect()
1017}
1018
1019fn get_valid_cards_in_zone_for_restrictions(
1020 game: &GameState,
1021 restrictions: &TargetRestrictions,
1022 zone: ZoneType,
1023 player: PlayerId,
1024 filter: Option<&str>,
1025 source_card: Option<CardId>,
1026) -> Vec<CardId> {
1027 let zone_cards = candidate_zone_cards(game, zone, player, filter);
1028 let Some(source_id) = source_card else {
1029 return match filter {
1030 None => zone_cards,
1031 Some(f) => {
1032 let clauses: Vec<&str> = f.split(',').map(str::trim).collect();
1033 zone_cards
1034 .into_iter()
1035 .filter(|&cid| !is_other_filter_self_hit(Some(f), source_card, cid))
1036 .filter(|&cid| {
1037 clauses.iter().any(|clause| {
1038 card_property::card_has_property(game.card(cid), clause, player)
1039 })
1040 })
1041 .collect()
1042 }
1043 };
1044 };
1045 let selector = restrictions.compiled_valid_tgts();
1046 let source = game.card(source_id);
1047 zone_cards
1048 .into_iter()
1049 .filter(|&cid| !is_other_filter_self_hit(filter, source_card, cid))
1050 .filter(|&cid| {
1051 valid_filter::matches_valid_card_selector_in_game(
1052 &selector,
1053 game.card(cid),
1054 source,
1055 game,
1056 )
1057 })
1058 .collect()
1059}
1060
1061fn candidate_zone_cards(
1062 game: &GameState,
1063 zone: ZoneType,
1064 player: PlayerId,
1065 filter: Option<&str>,
1066) -> Vec<CardId> {
1067 let restrict_to_player = filter
1068 .map(|f| {
1069 f.contains("YouCtrl")
1070 || f.contains("YouOwn")
1071 || f.contains("YouControl")
1072 || f.contains("EnchantedBy")
1073 })
1074 .unwrap_or(false);
1075
1076 if restrict_to_player {
1077 game.cards_in_zone(zone, player).to_vec()
1078 } else {
1079 game.player_order
1080 .iter()
1081 .flat_map(|&pid| game.cards_in_zone(zone, pid).to_vec())
1082 .collect()
1083 }
1084}
1085
1086pub fn get_all_candidates_spells(game: &GameState) -> Vec<u32> {
1093 let mut ids: Vec<u32> = game
1094 .stack
1095 .iter()
1096 .filter(|entry| !entry.is_pending_cast)
1097 .map(|entry| entry.id)
1098 .collect();
1099 ids.reverse();
1100 ids
1101}
1102
1103fn token_allows_player_targets(token: &str) -> bool {
1104 let t = token.trim().to_ascii_lowercase();
1105 t == "any" || t.contains("player") || t == "you" || t == "opponent"
1106}
1107
1108pub fn any_target_allows_players(valid_tgts: &[String]) -> bool {
1110 valid_tgts.iter().any(|t| token_allows_player_targets(t))
1111}
1112
1113pub fn get_all_candidates_any_filtered(
1115 game: &GameState,
1116 valid_tgts: &[String],
1117 source_controller: PlayerId,
1118) -> Vec<CardId> {
1119 if valid_tgts
1120 .iter()
1121 .any(|t| t.trim().eq_ignore_ascii_case("Any"))
1122 {
1123 return get_all_candidates_any_target_cards(game);
1124 }
1125
1126 let mut candidates = Vec::new();
1127 for &pid in &game.player_order {
1128 for &cid in game.cards_in_zone(ZoneType::Battlefield, pid) {
1129 if valid_tgts.iter().any(|raw| {
1130 let token = raw.trim();
1131 if token_allows_player_targets(token) {
1132 return false;
1133 }
1134 card_property::card_has_property(game.card(cid), token, source_controller)
1135 }) {
1136 candidates.push(cid);
1137 }
1138 }
1139 }
1140 candidates
1141}
1142
1143pub fn get_all_candidates_any_filtered_for_restrictions(
1144 game: &GameState,
1145 restrictions: &TargetRestrictions,
1146 source_controller: PlayerId,
1147 source_card: Option<CardId>,
1148) -> Vec<CardId> {
1149 if restrictions
1150 .valid_tgts
1151 .iter()
1152 .any(|t| t.trim().eq_ignore_ascii_case("Any"))
1153 {
1154 return get_all_candidates_any_target_cards(game);
1155 }
1156 let candidates = get_all_battlefield_permanents(game);
1157 let Some(source_id) = source_card else {
1158 return candidates
1159 .into_iter()
1160 .filter(|&cid| {
1161 restrictions.valid_tgts.iter().any(|raw| {
1162 let token = raw.trim();
1163 if token_allows_player_targets(token) {
1164 return false;
1165 }
1166 card_property::card_has_property(game.card(cid), token, source_controller)
1167 })
1168 })
1169 .collect();
1170 };
1171 let selector = restrictions.compiled_valid_tgts();
1172 let source = game.card(source_id);
1173 candidates
1174 .into_iter()
1175 .filter(|&cid| {
1176 valid_filter::matches_valid_card_selector_in_game(
1177 &selector,
1178 game.card(cid),
1179 source,
1180 game,
1181 )
1182 })
1183 .collect()
1184}
1185
1186fn get_all_candidates_any_target_cards(game: &GameState) -> Vec<CardId> {
1187 let mut cards = Vec::new();
1188 for &pid in &game.player_order {
1189 for &cid in game.cards_in_zone(ZoneType::Battlefield, pid) {
1190 let card = game.card(cid);
1191 if card.phased_out {
1192 continue;
1193 }
1194 if card.is_creature()
1195 || card.type_line.is_planeswalker()
1196 || card.type_line.core_types.contains(&CoreType::Battle)
1197 {
1198 cards.push(cid);
1199 }
1200 }
1201 }
1202 cards
1203}
1204
1205pub fn has_valid_target_in_zone(
1207 game: &GameState,
1208 player: PlayerId,
1209 zone: ZoneType,
1210 filter: Option<&str>,
1211 source_card: Option<CardId>,
1212) -> bool {
1213 !get_valid_cards_in_zone(game, zone, player, filter, source_card).is_empty()
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219
1220 #[test]
1221 fn parse_valid_targets_any() {
1222 assert_eq!(
1223 parse_valid_targets("SP$ DealDamage | ValidTgts$ Any | NumDmg$ 3"),
1224 TargetKind::Any
1225 );
1226 }
1227
1228 #[test]
1229 fn parse_valid_targets_creature_filter() {
1230 assert_eq!(
1231 parse_valid_targets("SP$ Destroy | ValidTgts$ Creature.nonBlack"),
1232 TargetKind::Creature(Some("nonBlack".to_string()))
1233 );
1234 }
1235
1236 #[test]
1237 fn parse_valid_targets_creature_no_filter() {
1238 assert_eq!(
1239 parse_valid_targets("SP$ Destroy | ValidTgts$ Creature"),
1240 TargetKind::Creature(None)
1241 );
1242 }
1243
1244 #[test]
1245 fn parse_valid_targets_player() {
1246 assert_eq!(
1247 parse_valid_targets("SP$ Draw | ValidTgts$ Player"),
1248 TargetKind::Player
1249 );
1250 }
1251
1252 #[test]
1253 fn parse_valid_targets_graveyard_creature() {
1254 let ability =
1256 "SP$ ChangeZone | Origin$ Graveyard | Destination$ Hand | ValidTgts$ Creature.YouCtrl";
1257 let target_kind = parse_valid_targets(ability);
1258 assert!(matches!(
1259 target_kind,
1260 TargetKind::CardInZone {
1261 zone: ZoneType::Graveyard,
1262 ..
1263 }
1264 ));
1265 }
1266
1267 #[test]
1268 fn parse_valid_targets_land() {
1269 assert_eq!(
1270 parse_valid_targets("SP$ Destroy | ValidTgts$ Land"),
1271 TargetKind::Permanent(Some("Land".to_string()))
1272 );
1273 }
1274
1275 #[test]
1276 fn parse_valid_targets_land_filter() {
1277 assert_eq!(
1278 parse_valid_targets("SP$ Destroy | ValidTgts$ Land.nonBasic"),
1279 TargetKind::Permanent(Some("Land.nonBasic".to_string()))
1280 );
1281 }
1282
1283 #[test]
1284 fn target_restrictions_from_params() {
1285 let params = Params::from_raw("ValidTgts$ Creature.OppCtrl");
1286 let tr = TargetRestrictions::new(¶ms).unwrap();
1287 assert_eq!(tr.target_kind, TargetKind::Creature(Some("OppCtrl".into())));
1288 assert_eq!(tr.min_targets, "1");
1289 assert_eq!(tr.max_targets, "1");
1290 }
1291
1292 #[test]
1293 fn target_restrictions_from_params_graveyard_origin() {
1294 let params = Params::from_raw("Origin$ Graveyard | ValidTgts$ Creature.YouCtrl");
1295 let tr = TargetRestrictions::new(¶ms).unwrap();
1296 assert_eq!(
1297 tr.target_kind,
1298 TargetKind::CardInZone {
1299 zone: ZoneType::Graveyard,
1300 filter: Some("Creature.YouCtrl".into()),
1301 }
1302 );
1303 }
1304
1305 #[test]
1306 fn no_valid_tgts_returns_none() {
1307 let params = Params::from_raw("");
1308 assert!(TargetRestrictions::new(¶ms).is_none());
1309 }
1310
1311 #[test]
1312 fn has_candidates_in_chain_allows_zero_target_subability() {
1313 use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
1314
1315 let mut game = GameState::new(&["Alice", "Bob"], 20);
1316 let p0 = PlayerId(0);
1317 let mut card = crate::card::Card::new(
1318 CardId(0),
1319 "Valley Rally".to_string(),
1320 p0,
1321 CardTypeLine::parse("Instant"),
1322 ManaCost::parse("2 R"),
1323 ColorSet::RED,
1324 None,
1325 None,
1326 vec![],
1327 vec![
1328 "SP$ PumpAll | ValidCards$ Creature.YouCtrl | NumAtt$ +2 | SubAbility$ DBPump"
1329 .to_string(),
1330 ],
1331 );
1332 card.svars.insert(
1333 "DBPump".to_string(),
1334 "DB$ Pump | ValidTgts$ Creature.YouCtrl | TargetMin$ X | TargetMax$ X | KW$ First Strike"
1335 .to_string(),
1336 );
1337 card.svars
1338 .insert("X".to_string(), "Count$PromisedGift.1.0".to_string());
1339 let card_id = game.create_card(card);
1340 game.move_card(card_id, ZoneType::Hand, p0);
1341
1342 let ability = game.card(card_id).abilities[0].clone();
1343 assert!(has_candidates_in_chain(&game, p0, &ability, Some(card_id)));
1344 }
1345}