Skip to main content

manabrew_engine/spellability/
target_restrictions.rs

1//! Target restrictions for spell abilities.
2//!
3//! Mirrors Java's `spellability/TargetRestrictions.java` — defines what kinds
4//! of targets a spell can select, checks for valid candidates, and retrieves
5//! all valid target candidates.
6
7use 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/// What kinds of targets a spell can select.
17/// Mirrors Java's `TargetRestrictions.getValidTgts()` parsed target types.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub enum TargetKind {
20    /// Player only (e.g. "ValidTgts$ Player")
21    Player,
22    /// Any player or creature (e.g. "ValidTgts$ Any")
23    Any,
24    /// Creature with optional filter (e.g. "ValidTgts$ Creature.nonBlack")
25    Creature(Option<String>),
26    /// Any permanent on the battlefield with optional filter
27    /// (e.g. "ValidTgts$ Permanent.nonLand+OppCtrl")
28    Permanent(Option<String>),
29    /// Card in a specific zone with optional filter (e.g. Raise Dead from graveyard)
30    CardInZone {
31        zone: ZoneType,
32        filter: Option<String>,
33    },
34    /// Spell on the stack (for Counter effects, e.g. "ValidTgts$ Spell")
35    Spell,
36    /// No targets
37    None,
38}
39
40/// Targeting restrictions for a spell ability.
41/// Mirrors Java's `TargetRestrictions` — defines valid targets, min/max counts,
42/// and which zones to search for targets.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TargetRestrictions {
45    /// Raw valid target strings (e.g. ["Creature.OppCtrl"])
46    pub valid_tgts: Vec<String>,
47    /// Compiled ValidTgts selector used for card target filtering.
48    #[serde(default)]
49    pub valid_tgts_selector: CompiledSelector,
50    /// Parsed target kind
51    pub target_kind: TargetKind,
52    /// Additional target type filter (e.g. "Spell" from TargetType$ parameter)
53    pub target_type_filter: Option<String>,
54    /// Minimum number of targets expression (default "1").
55    /// Mirrors Java storing raw `TargetMin` and resolving dynamically.
56    pub min_targets: String,
57    /// Maximum number of targets expression (default "1").
58    /// Mirrors Java storing raw `TargetMax` and resolving dynamically.
59    pub max_targets: String,
60    /// Zones to search for targets (default [Battlefield])
61    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        // `TgtZone$` overrides the target-search zone (Java
72        // `TargetRestrictions.setupTargeting` line 152). Falls back to
73        // `Origin$` (the move-from zone used by most ChangeZone effects),
74        // and finally to Battlefield as the implicit default.
75        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        // Multi-clause `ValidTgts$` (e.g. `Creature.YouCtrl,Artifact.YouCtrl`)
85        // collapses to a single TargetKind. The first-clause-only parse drops
86        // the other clauses' candidates. Promote to `Any` so the per-clause
87        // valid_tgts filter is applied against the full battlefield.
88        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        // Any TargetType$ value targets the stack (the script convention is
96        // exclusively SA-kind tokens like Spell, Activated, Triggered, …).
97        // Route these through the stack-entry filtering path instead of the
98        // battlefield-permanent path.
99        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    /// Construct from parsed pipe params. Returns `None` if no `ValidTgts$`
120    /// parameter exists (mirrors Java: null targetRestrictions means no targeting).
121    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        // `TgtZone$` overrides the target-search zone (Java
128        // `TargetRestrictions.setupTargeting` line 152). Falls back to
129        // `Origin$` (the move-from zone used by most ChangeZone effects),
130        // and finally to Battlefield as the implicit default.
131        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        // For CardInZone targeting (non-battlefield zone), pass the full
135        // ValidTgts string so comma-separated types (e.g. "Creature,Land")
136        // are all included in the filter. For battlefield targeting, use
137        // only the first token (legacy parser handles single types).
138        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        // Multi-clause `ValidTgts$` (e.g. `Creature.YouCtrl,Artifact.YouCtrl`)
145        // collapses to a single TargetKind. The first-clause-only parse drops
146        // the other clauses' candidates. Promote to `Any` so the per-clause
147        // valid_tgts filter is applied against the full battlefield.
148        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        // Parse TargetType$ parameter if present (used by counterspells)
159        let target_type_filter = params.get_cloned(keys::TARGET_TYPE);
160
161        // If TargetType$ Spell* is specified, override to Spell targeting.
162        // This handles cases like Counterspell ("Spell") and Imp's Mischief
163        // ("Spell.singleTarget").
164        // Any TargetType$ value targets the stack (the script convention is
165        // exclusively SA-kind tokens like Spell, Activated, Triggered, …).
166        // Route these through the stack-entry filtering path instead of the
167        // battlefield-permanent path.
168        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    /// Check if there is at least one valid target candidate.
189    /// Accounts for Hexproof, Shroud, and Protection when `source_card` is provided.
190    /// Mirrors Java's `TargetRestrictions.hasCandidates()`.
191    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            // "target player" = any alive player (including the caster themselves).
202            TargetKind::Player => !game.alive_players().is_empty(),
203            // "any target" fallback: derive player/card candidates from ValidTgts.
204            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    /// Resolve Java-style `TargetMin` expression for this SA.
253    pub fn get_min_targets(&self, game: &GameState, sa: &SpellAbility) -> i32 {
254        resolve_target_count_expr(&self.min_targets, game, sa)
255    }
256
257    /// Resolve Java-style `TargetMax` expression for this SA.
258    pub fn get_max_targets(&self, game: &GameState, sa: &SpellAbility) -> i32 {
259        resolve_target_count_expr(&self.max_targets, game, sa)
260    }
261
262    /// Whether targeting is restricted to opponents only.
263    /// Mirrors Java's `TargetRestrictions.canOnlyTgtOpponent()`.
264    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    /// Whether this can target a player.
271    /// Mirrors Java's `TargetRestrictions.canTgtPlayer()`.
272    pub fn can_tgt_player(&self) -> bool {
273        matches!(self.target_kind, TargetKind::Player | TargetKind::Any)
274    }
275
276    /// Whether this can target a permanent.
277    /// Mirrors Java's `TargetRestrictions.canTgtPermanent()`.
278    pub fn can_tgt_permanent(&self) -> bool {
279        matches!(
280            self.target_kind,
281            TargetKind::Permanent(_) | TargetKind::Creature(_) | TargetKind::Any
282        )
283    }
284
285    /// Whether this can target a creature.
286    /// Mirrors Java's `TargetRestrictions.canTgtCreature()`.
287    pub fn can_tgt_creature(&self) -> bool {
288        matches!(self.target_kind, TargetKind::Creature(_) | TargetKind::Any)
289    }
290
291    /// Whether this can target a planeswalker.
292    /// Mirrors Java's `TargetRestrictions.canTgtPlaneswalker()`.
293    pub fn can_tgt_planeswalker(&self) -> bool {
294        matches!(self.target_kind, TargetKind::Permanent(_) | TargetKind::Any)
295    }
296
297    /// Whether this can target both a creature and a player.
298    /// Mirrors Java's `TargetRestrictions.canTgtCreatureAndPlayer()`.
299    pub fn can_tgt_creature_and_player(&self) -> bool {
300        matches!(self.target_kind, TargetKind::Any)
301    }
302
303    /// Clone this target restriction.
304    /// Mirrors Java's `TargetRestrictions.copy()`.
305    pub fn copy(&self) -> Self {
306        self.clone()
307    }
308
309    /// Apply text changes to the target restriction strings.
310    /// Mirrors Java's `TargetRestrictions.applyTargetTextChanges(Map)`.
311    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        // Re-parse target kind from updated valid_tgts
320        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
363/// Remove `Other`/`StrictlyOther` self-targets from candidate lists.
364/// Mirrors Java `Other` semantics in valid target filters.
365pub 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
376/// Resolve a target-count expression like `1`, `X`, or `Count$...`.
377/// Mirrors Java `TargetRestrictions.getMinTargets/getMaxTargets` via
378/// `AbilityUtils.calculateAmount(...)`.
379fn 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
394/// Check if there are valid spells on the stack matching the TargetType$ filter.
395pub 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
399/// Filter stack entries using the full `TargetRestrictions` for spell targets.
400pub 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
421/// Filter stack entries by a comma-separated TargetType$/ValidTgts$ filter.
422/// SA-kind clauses (Spell, Activated, Triggered, …) dispatch through
423/// `valid_sa::matches_valid_sa`, mirroring Java's `SpellAbility.isValid`.
424/// Per-clause fallback then checks the entry's source-card properties so
425/// ValidTgts tokens like `Card` and `OppCtrl` still match.
426pub 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            // Bare `Card` is the catch-all "any source" clause; `Card.<qual>`
462            // still has to match the qualifier on the source card.
463            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/// Parse a single ValidTgts value into a TargetKind.
471/// Forward-ported from Java for future use when enhanced targeting is needed.
472#[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        // Safe: we just checked starts_with, so strip_prefix will succeed
486        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        // Safe: we just checked starts_with, so strip_prefix will succeed
495        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        // "Land" targeting is a permanent target restricted to lands.
504        // Keep land qualifiers (e.g. "Land.nonBasic") in the filter string.
505        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    // Fallback: treat as "Any" if unrecognized
513    TargetKind::Any
514}
515
516/// Parse `ValidTgts$` from a raw ability string.
517/// Enhanced version that also considers `Origin$` for zone targeting.
518/// Convenience wrapper for code that doesn't have parsed params yet.
519pub 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
528/// Check if there is at least one valid target for the given ability string.
529/// Convenience wrapper that creates a temporary TargetRestrictions.
530pub 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(&params) {
540        Some(tr) => tr.has_candidates(game, player, source),
541        None => true, // No targeting = always valid
542    }
543}
544
545/// Check if there is at least one valid target for every ability in the
546/// SubAbility$ chain. Mirrors Java's target validation in `setupTargets()`
547/// which checks each ability in the chain has at least one legal target.
548pub 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(&params) {
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
584/// Check target availability for an already-built spell ability chain.
585///
586/// This avoids reparsing the raw ability text in hot action-space paths that
587/// already have a `SpellAbility` and its precompiled `TargetRestrictions`.
588pub 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
608/// Check if a card can be targeted by a spell/ability controlled by `source_controller`.
609/// Mirrors Java's `Card.canBeTargetedBy(SpellAbility)` which delegates to
610/// `StaticAbilityCantTarget` for Hexproof, Shroud, and Protection checks.
611pub 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    // Shroud/hexproof/protection are permanent abilities (CR 113.6b) —
656    // they only apply while the card is on the battlefield. Cards in
657    // graveyard/exile/hand/library don't carry these keyword effects, so
658    // target-gating for those zones is purely about the valid-filter
659    // (already applied upstream). Skip the battlefield-only checks when
660    // the target isn't on the battlefield.
661    if target.zone != ZoneType::Battlefield {
662        return true;
663    }
664    // Shroud: can't be targeted by anyone
665    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    // Hexproof: can't be targeted by opponents
674    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        // Check "Hexproof from <color>"
686        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        // Protection: can't be targeted by matching sources
701        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
710/// Get all creatures on the battlefield (any player).
711/// Part of `TargetRestrictions.getAllCandidates()` for creature targets.
712pub 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            // CR 702.26: phased-out permanents are treated as though they
718            // don't exist. Match Java's `Player.getCardsIn(Battlefield)`
719            // default `filterOutPhasedOut = true`.
720            if card.phased_out {
721                continue;
722            }
723            if card.is_creature() {
724                creatures.push(cid);
725            }
726        }
727    }
728    creatures
729}
730
731/// Get creatures matching an optional filter (e.g. "nonBlack", "OppCtrl").
732/// Mirrors Java's `TargetRestrictions.getAllCandidates()` with card property filtering.
733pub 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
766/// Get all permanents on the battlefield (any player).
767pub 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            // CR 702.26: phased-out permanents are treated as though they
772            // don't exist. Exclude from target candidate lists.
773            if game.card(cid).phased_out {
774                continue;
775            }
776            permanents.push(cid);
777        }
778    }
779    permanents
780}
781
782/// Get battlefield permanents matching an optional filter (e.g. "nonLand+OppCtrl").
783/// Similar to `get_all_candidates_creature_filtered` but for any permanent type.
784pub 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
852// ── Zone-aware targeting for cards like Raise Dead ───────────────────
853
854/// Enhanced parser that considers Origin$ parameter for zone targeting.
855/// This parser handles both legacy battlefield targeting and zone-aware targeting
856/// (e.g., Raise Dead with Origin$ Graveyard).
857fn parse_target_kind_enhanced(val: &str, origin_zone: Option<ZoneType>) -> TargetKind {
858    let val = val.trim();
859
860    // Player/Opponent targeting is never card-in-zone, even when Origin$ is
861    // a non-battlefield zone (e.g. Nihil Spellbomb targets a Player while
862    // Origin$ Graveyard specifies where to exile cards from).
863    if val.eq_ignore_ascii_case("Player") || val.eq_ignore_ascii_case("Opponent") {
864        return TargetKind::Player;
865    }
866
867    // Handle the special case of CardInZone targeting
868    if let Some(zone) = origin_zone {
869        if zone != ZoneType::Battlefield {
870            // If we have a non-battlefield origin, this is zone targeting.
871            // Keep the full ValidTgts token (e.g., "Creature.YouCtrl"), mirroring
872            // Java's Card.isValid(type.properties) flow.
873            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    // For battlefield targeting (or no origin specified), use traditional parsing
883    parse_target_kind_legacy(val)
884}
885
886/// Legacy parser for battlefield-targeting spells (Unsummon, Doom Blade, etc.)
887fn 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        // Safe: we just checked starts_with, so strip_prefix will succeed
900        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        // Safe: we just checked starts_with, so strip_prefix will succeed
909        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        // "Land" targeting is a permanent target restricted to lands.
918        // Keep land qualifiers (e.g. "Land.nonBasic") in the filter string.
919        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    // Fallback: treat as "Any" if unrecognized
927    TargetKind::Any
928}
929
930/// Get all cards in a zone matching the filter (for Raise Dead style targeting)
931pub 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    // Determine whether the filter restricts to a specific controller/owner.
939    // If not restricted, search ALL players' zones (e.g. "target card from a graveyard"
940    // can target any player's graveyard). Mirrors Java's TargetRestrictions.getAllCandidates().
941    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            // ValidTgts$ uses commas for OR logic (e.g. "Creature,Land" means
963            // creature OR land). Split and match any clause.
964            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
978/// Get all cards in a zone matching this ability's target restrictions while
979/// preserving trigger context for dynamic selectors such as `cmcLTX`.
980pub 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
1086/// Get all stack entry IDs for spells that can be countered.
1087/// Mirrors Java's `TargetRestrictions.getAllCandidates()` for Spell targets.
1088/// Returned in top-of-stack-first order to match Java's `LinkedBlockingDeque`
1089/// iteration (entries are pushed via `addFirst`, so `iterator()` walks the
1090/// top of the stack downward). Without this ordering, Stifle and similar
1091/// counters target the wrong stack entry under deterministic-agent picks.
1092pub 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
1108/// Whether this `TargetKind::Any` restriction may target players.
1109pub fn any_target_allows_players(valid_tgts: &[String]) -> bool {
1110    valid_tgts.iter().any(|t| token_allows_player_targets(t))
1111}
1112
1113/// Candidate battlefield cards for `TargetKind::Any`, derived from `ValidTgts`.
1114pub 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
1205/// Check if there are valid targets in a specific zone.
1206pub 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        // Test parsing for Raise Dead style: ValidTgts$ Creature with Origin$ Graveyard
1255        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(&params).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(&params).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(&params).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}