Skip to main content

manabrew_engine/parsing/
mod.rs

1//! Central parsing module for the Forge DSL.
2//!
3//! Mirrors Java's `FileSection.parseToMap()` + `CardTraitBase` accessor
4//! methods. All pipe-delimited parameter parsing and typed access goes
5//! through the [`Params`] wrapper — no code should use raw
6//! `BTreeMap<String, String>` for DSL parameters.
7
8pub mod amount;
9pub mod card_script;
10pub mod compare;
11pub mod cost;
12pub mod keys;
13
14use std::collections::BTreeMap;
15use std::sync::{Mutex, OnceLock};
16
17use forge_foundation::ZoneType;
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19
20pub use amount::AmountExpr;
21pub use card_script::{
22    parse_semantic_param_value, ParamDiagnostic, ParamDiagnosticKind, ParamEntry, ParsedCardScript,
23    ParsedParams, ParsedParamsReport, ScriptAbility, ScriptAbilityRecord, ScriptDiagnostic,
24    ScriptDiagnosticKind, ScriptField, ScriptLine, ScriptLineKind, ScriptParamRecord, ScriptSVar,
25    ScriptSVarNumericExpression, ScriptSVarObjectRef, ScriptSVarValue, SemanticAmount,
26    SemanticComparison, SemanticComparisonOperator, SemanticParam, SemanticParamValue,
27    SemanticParamValueKind, SemanticProducedMana, SemanticProducedManaCombo, SemanticSelector,
28    SemanticSelectorAlternative, SemanticSelectorPart, SemanticTransform,
29};
30pub use cost::{CostToken, CostTokenKind};
31
32pub fn raw_has_key(raw: &str, key: &str) -> bool {
33    card_script::raw_has_key(raw, key)
34}
35
36pub fn raw_has_any(raw: &str, keys: &[&str]) -> bool {
37    card_script::raw_has_any(raw, keys)
38}
39
40pub fn raw_get<'a>(raw: &'a str, key: &str) -> Option<&'a str> {
41    card_script::raw_get(raw, key)
42}
43
44// ── Params wrapper ──────────────────────────────────────────────────────────
45
46/// Typed wrapper around parsed DSL parameters.
47///
48/// Replaces raw `BTreeMap<String, String>` everywhere. Mirrors Java's
49/// `CardTraitBase.mapParams` with its `getParam`/`hasParam`/`matchesValidParam`
50/// accessor methods.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct CompiledSelector {
53    pub alternatives: Vec<CompiledSelectorAlternative>,
54    pub ir: Selector,
55}
56
57impl CompiledSelector {
58    pub fn parse(raw: &str) -> Self {
59        crate::perf::increment(crate::perf::Metric::SelectorParses, 1);
60        match parse_semantic_param_value(keys::VALID, raw) {
61            SemanticParamValue::Selector(selector) | SemanticParamValue::Reference(selector) => {
62                compile_semantic_selector(&selector)
63            }
64            _ => CompiledSelector::from_alternatives(vec![CompiledSelectorAlternative {
65                raw: raw.trim().to_string(),
66                parts: vec![CompiledSelectorPart {
67                    separator: None,
68                    value: raw.trim().to_string(),
69                }],
70            }]),
71        }
72    }
73
74    pub fn from_alternatives(alternatives: Vec<CompiledSelectorAlternative>) -> Self {
75        let ir = lower_compiled_selector(&alternatives);
76        Self { alternatives, ir }
77    }
78
79    pub fn from_raw_alternative(raw: &str) -> Self {
80        Self::from_alternatives(vec![CompiledSelectorAlternative {
81            raw: raw.trim().to_string(),
82            parts: vec![CompiledSelectorPart {
83                separator: None,
84                value: raw.trim().to_string(),
85            }],
86        }])
87    }
88
89    pub fn is_any_of<const N: usize>(&self, values: [&str; N]) -> bool {
90        self.alternatives.len() == 1
91            && self.alternatives.first().is_some_and(|alternative| {
92                values
93                    .iter()
94                    .any(|value| alternative.raw.eq_ignore_ascii_case(value))
95            })
96    }
97
98    pub fn as_raw(&self) -> String {
99        self.alternatives
100            .iter()
101            .map(|alternative| alternative.raw.as_str())
102            .collect::<Vec<_>>()
103            .join(",")
104    }
105
106    pub fn raw_predicates(&self) -> impl Iterator<Item = &str> {
107        self.ir
108            .alternatives
109            .iter()
110            .flat_map(|alternative| &alternative.predicates)
111            .filter_map(|predicate| match predicate {
112                SelectorPredicate::Raw(raw) => Some(raw.as_str()),
113                _ => None,
114            })
115    }
116}
117
118pub fn cached_compiled_selector(raw: &str) -> CompiledSelector {
119    static CACHE: OnceLock<Mutex<BTreeMap<String, CompiledSelector>>> = OnceLock::new();
120    let key = raw.trim().to_string();
121    if let Some(selector) = common_compiled_selector(&key) {
122        return selector;
123    }
124    let cache = CACHE.get_or_init(|| Mutex::new(BTreeMap::new()));
125    if let Some(selector) = cache
126        .lock()
127        .expect("selector cache poisoned")
128        .get(&key)
129        .cloned()
130    {
131        return selector;
132    }
133    let selector = CompiledSelector::parse(&key);
134    cache
135        .lock()
136        .expect("selector cache poisoned")
137        .insert(key, selector.clone());
138    selector
139}
140
141fn common_compiled_selector(raw: &str) -> Option<CompiledSelector> {
142    match raw {
143        "Card.Self" => {
144            static SELECTOR: OnceLock<CompiledSelector> = OnceLock::new();
145            Some(
146                SELECTOR
147                    .get_or_init(|| selector_from_parts("Card.Self", &["Card", "Self"]))
148                    .clone(),
149            )
150        }
151        "Creature.Self" => {
152            static SELECTOR: OnceLock<CompiledSelector> = OnceLock::new();
153            Some(
154                SELECTOR
155                    .get_or_init(|| selector_from_parts("Creature.Self", &["Creature", "Self"]))
156                    .clone(),
157            )
158        }
159        "You" => {
160            static SELECTOR: OnceLock<CompiledSelector> = OnceLock::new();
161            Some(
162                SELECTOR
163                    .get_or_init(|| selector_from_parts("You", &["You"]))
164                    .clone(),
165            )
166        }
167        _ => None,
168    }
169}
170
171fn selector_from_parts(raw: &str, parts: &[&str]) -> CompiledSelector {
172    CompiledSelector::from_alternatives(vec![CompiledSelectorAlternative {
173        raw: raw.to_string(),
174        parts: parts
175            .iter()
176            .enumerate()
177            .map(|(index, value)| CompiledSelectorPart {
178                separator: (index > 0).then_some('.'),
179                value: (*value).to_string(),
180            })
181            .collect(),
182    }])
183}
184
185impl Serialize for CompiledSelector {
186    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
187        serializer.serialize_str(&self.as_raw())
188    }
189}
190
191impl<'de> Deserialize<'de> for CompiledSelector {
192    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
193        let raw = String::deserialize(deserializer)?;
194        Ok(Self::parse(&raw))
195    }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct CompiledSelectorAlternative {
200    pub raw: String,
201    pub parts: Vec<CompiledSelectorPart>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct CompiledSelectorPart {
206    pub separator: Option<char>,
207    pub value: String,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Default)]
211pub struct Selector {
212    pub alternatives: Vec<SelectorAlt>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct SelectorAlt {
217    pub predicates: Vec<SelectorPredicate>,
218}
219
220// Selector IR: these are predicates over cards, players, or contextual game
221// state. They are deliberately separate from amount/numeric expression parsing.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub enum SelectorPredicate {
224    Any,
225    Player,
226    CardType(CardSelectorType),
227    CardIdentity(CardIdentitySelector),
228    CardController(ControllerSelector),
229    CardOwner(ControllerSelector),
230    PlayerController(ControllerSelector),
231    CardSupertype(CardSupertypeSelector),
232    Tapped(bool),
233    StartedTurnTapped(bool),
234    CameUnderControlSinceLastUpkeep,
235    Zone(ZoneType),
236    RememberedCard,
237    TriggerRememberedCard,
238    EffectSource,
239    Commander,
240    Legendary,
241    Kicked,
242    Token(bool),
243    Color(CardColorSelector),
244    Multicolor,
245    Colorless,
246    SourceColor(CardColorSelector),
247    SourceColorless,
248    ChosenColorSource,
249    CardState(CardStateSelector),
250    Context(ContextPredicate),
251    Relation(RelationPredicate),
252    DamagedBy,
253    AttachedBy,
254    WasCast {
255        by_you: bool,
256    },
257    ChosenType,
258    Keyword {
259        name: String,
260        present: bool,
261    },
262    NumericComparison {
263        property: NumericSelectorProperty,
264        operator: SelectorCompareOperator,
265        value: SelectorNumericOperand,
266    },
267    NumericParity {
268        property: NumericSelectorProperty,
269        even: bool,
270    },
271    CounterComparison {
272        operator: SelectorCompareOperator,
273        value: SelectorNumericOperand,
274        counter_type: String,
275    },
276    Not(Box<SelectorPredicate>),
277    Raw(String),
278}
279
280// Intrinsic card/player selector predicates.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum CardSelectorType {
283    Card,
284    Creature,
285    Land,
286    Instant,
287    Sorcery,
288    Artifact,
289    Enchantment,
290    Planeswalker,
291    Permanent,
292    Spell,
293    NonLand,
294    NonCreature,
295    Named(String),
296    Subtype(String),
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum CardSupertypeSelector {
301    Basic,
302    Snow,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum ControllerSelector {
307    You,
308    Opponent,
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312pub enum CardIdentitySelector {
313    Self_,
314    Other,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum CardColorSelector {
319    White,
320    Blue,
321    Black,
322    Red,
323    Green,
324}
325
326// Predicates that need match-time context beyond the candidate card itself.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum ContextPredicate {
329    Attacking(Option<TargetRef>),
330    Blocking(Option<TargetRef>),
331    BlockedByValidThisTurn(TargetRef),
332    BlockedByValidThisTurnType(CardSelectorType),
333    BlockedValidThisTurn(CardSelectorType),
334    BlockingValid(CardSelectorType),
335    Blocked,
336    AttackedThisTurn,
337    BlockingSource,
338    BlockedBySource,
339    WasCastFrom(CastOrigin),
340    EnteredThisTurnFrom(ZoneType),
341    EnteredUnder(TargetRef),
342    TopLibrary,
343    ExiledWithSource,
344    RememberedPlayerCtrl,
345    TargetedPlayerCtrl,
346    ActivePlayerCtrl,
347    DefenderCtrl,
348    EnchantedController,
349    ControlledBy(String),
350    NotDefinedTargeted,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub enum RelationPredicate {
355    SharesNameWith(TargetRef),
356    DoesNotShareNameWith(TargetRef),
357    SharesCardTypeWith(TargetRef),
358    SharesCreatureTypeWith(TargetRef),
359    SharesColorWith(TargetRef),
360    SharesManaValueWith(TargetRef),
361    AttachedTo(TargetRef),
362    AttachedToType(CardSelectorType),
363    OwnedBy(TargetRef),
364    OpponentOf(TargetRef),
365    IsTargeting(TargetRef),
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub enum TargetRef {
370    Source,
371    Remembered,
372    RememberedLki,
373    Imprinted,
374    ChosenCard,
375    ChosenPlayer,
376    Targeted,
377    Player,
378    Opponent,
379    Battlefield,
380    OtherYourBattlefield,
381    YourGraveyard,
382    TriggeredTarget,
383    TriggeredPlayer,
384    TriggeredCard,
385    TriggeredCardController,
386    TriggeredDefendingPlayer,
387    TriggeredAttackedTarget,
388    /// Source controller's registered commander card(s).
389    /// Used by Path of Ancestry's `sharesCreatureTypeWith Commander` and
390    /// similar relation predicates that compare against the player's commander.
391    Commander,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum CastOrigin {
396    Hand,
397    YourHand,
398    YourHandByYou,
399    TheirHand,
400    Exile,
401    Graveyard,
402    YourGraveyard,
403    YourGraveyardByYou,
404    YourLibrary,
405}
406
407// Intrinsic card state predicates that do not need match-time game context.
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409pub enum CardStateSelector {
410    FaceDown,
411    Paired,
412    PairedWithSource,
413    Attached,
414    Equipped,
415    Enchanted,
416    HasCounters,
417    IsImprinted,
418    Chosen,
419    ChosenCard,
420    NamedCard,
421    ChosenColor,
422    EnteredThisTurn,
423    WasDealtDamageThisTurn,
424    Historic,
425    Modified,
426    Saddled,
427    MayPlaySource,
428    Suspended,
429    SingleTarget,
430    PromisedGift,
431    RingBearer,
432}
433
434// Numeric selector comparisons (`cmcGE3`, `powerLEX`, `counters_EQ1_P1P1`).
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum NumericSelectorProperty {
437    ManaValue,
438    Power,
439    Toughness,
440    TargetCount,
441    ManaSpent,
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub enum SelectorNumericOperand {
446    Literal(i32),
447    Symbol(String),
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub enum SelectorCompareOperator {
452    Eq,
453    Ne,
454    Lt,
455    Le,
456    Gt,
457    Ge,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461enum CompiledParamValue {
462    Selector(CompiledSelector),
463    Reference(CompiledSelector),
464}
465
466#[derive(Debug, Clone, Default, Serialize, Deserialize)]
467#[serde(transparent)]
468pub struct Params(
469    BTreeMap<String, String>,
470    #[serde(skip)] BTreeMap<String, CompiledParamValue>,
471);
472
473impl Params {
474    /// Parse a pipe-delimited DSL string into parameters.
475    ///
476    /// Handles both `Key$ Value` and `Key$Value` (no trailing space) formats.
477    /// Mirrors Java's `FileSection.parseToMap()`.
478    pub fn from_raw(raw: &str) -> Self {
479        crate::perf::increment_params_parse();
480        let params = Self::from_parsed(&ParsedParams::parse(raw));
481        #[cfg(debug_assertions)]
482        debug_assert_eq!(params.inner(), &legacy_parse_params(raw));
483        params
484    }
485
486    /// Create owned compatibility params from a zero-copy parsed script.
487    ///
488    /// Duplicate keys intentionally keep Java/map compatibility: later entries
489    /// overwrite earlier entries.
490    pub fn from_parsed(parsed: &ParsedParams<'_>) -> Self {
491        let mut map = BTreeMap::new();
492        let mut compiled = BTreeMap::new();
493        for entry in parsed.entries() {
494            map.insert(entry.key.to_string(), entry.value.to_string());
495            if let Some(value) = compile_semantic_param_value(&entry.semantic().value) {
496                compiled.insert(entry.key.to_string(), value);
497            }
498        }
499        Params(map, compiled)
500    }
501
502    /// Create from an existing map (for migration from raw BTreeMap).
503    pub fn from_map(map: BTreeMap<String, String>) -> Self {
504        let compiled = compile_param_map(&map);
505        Params(map, compiled)
506    }
507
508    /// Access the underlying map (escape hatch for incremental migration).
509    pub fn inner(&self) -> &BTreeMap<String, String> {
510        &self.0
511    }
512
513    /// Consume and return the underlying map.
514    pub fn into_inner(self) -> BTreeMap<String, String> {
515        self.0
516    }
517
518    // ── Core accessors (mirror Java CardTraitBase) ──────────────────────
519
520    /// Get a parameter value by key.
521    /// Mirrors Java's `CardTraitBase.getParam(String)`.
522    pub fn get<K: AsRef<str>>(&self, key: K) -> Option<&str> {
523        crate::perf::increment_params_lookup();
524        self.0.get(key.as_ref()).map(|s| s.as_str())
525    }
526
527    /// Get a parameter value or a default.
528    /// Mirrors Java's `CardTraitBase.getParamOrDefault(String, String)`.
529    pub fn get_or_default<'a, K: AsRef<str>>(&'a self, key: K, default: &'a str) -> &'a str {
530        crate::perf::increment_params_lookup();
531        self.0
532            .get(key.as_ref())
533            .map(|s| s.as_str())
534            .unwrap_or(default)
535    }
536
537    /// Check if a parameter key exists.
538    /// Mirrors Java's `CardTraitBase.hasParam(String)`.
539    pub fn has<K: AsRef<str>>(&self, key: K) -> bool {
540        crate::perf::increment_params_lookup();
541        self.0.contains_key(key.as_ref())
542    }
543
544    /// Set a parameter value.
545    /// Mirrors Java's `CardTraitBase.putParam(String, String)`.
546    pub fn put(&mut self, key: String, value: String) {
547        update_compiled_param(&mut self.1, &key, &value);
548        self.0.insert(key, value);
549    }
550
551    /// Remove a parameter and return its value.
552    /// Mirrors Java's `CardTraitBase.removeParam(String)`.
553    pub fn remove<K: AsRef<str>>(&mut self, key: K) -> Option<String> {
554        let key = key.as_ref();
555        self.1.remove(key);
556        self.0.remove(key)
557    }
558
559    /// Check if the map is empty.
560    pub fn is_empty(&self) -> bool {
561        self.0.is_empty()
562    }
563
564    /// Check whether any key exists without recording accessor instrumentation.
565    ///
566    /// Intended for coarse hot-path gates before a caller decides whether to
567    /// perform many instrumented typed lookups.
568    pub fn contains_any_key(&self, keys: &[&str]) -> bool {
569        keys.iter().any(|key| self.0.contains_key(*key))
570    }
571
572    // ── Typed accessors ─────────────────────────────────────────────────
573
574    /// Check if a boolean param is set to "True" (case-insensitive).
575    /// Mirrors the common Java pattern `"True".equals(getParam(key))`.
576    pub fn is_true<K: AsRef<str>>(&self, key: K) -> bool {
577        crate::perf::increment_params_lookup();
578        let key = key.as_ref();
579        let result = self
580            .0
581            .get(key)
582            .map(|value| match parse_semantic_param_value(key, value) {
583                SemanticParamValue::Boolean(value) => value,
584                _ => value.eq_ignore_ascii_case("True"),
585            })
586            .unwrap_or(false);
587        #[cfg(debug_assertions)]
588        debug_assert_eq!(
589            result,
590            self.0
591                .get(key)
592                .is_some_and(|value| value.eq_ignore_ascii_case("True")),
593            "semantic boolean param {} diverged from string params",
594            key
595        );
596        result
597    }
598
599    /// Parse a parameter as i32, returning None if absent or non-numeric.
600    pub fn as_i32<K: AsRef<str>>(&self, key: K) -> Option<i32> {
601        crate::perf::increment_params_lookup();
602        let key = key.as_ref();
603        let result = self.0.get(key).and_then(|value| semantic_i32(key, value));
604        #[cfg(debug_assertions)]
605        debug_assert_eq!(
606            result,
607            self.0.get(key).and_then(|value| value.trim().parse().ok()),
608            "semantic i32 param {} diverged from string params",
609            key
610        );
611        result
612    }
613
614    /// Parse a parameter as usize, returning None if absent or non-numeric.
615    pub fn as_usize<K: AsRef<str>>(&self, key: K) -> Option<usize> {
616        crate::perf::increment_params_lookup();
617        let key = key.as_ref();
618        let result = self
619            .0
620            .get(key)
621            .and_then(|value| semantic_i32(key, value))
622            .and_then(|value| usize::try_from(value).ok());
623        #[cfg(debug_assertions)]
624        debug_assert_eq!(
625            result,
626            self.0.get(key).and_then(|value| value.trim().parse().ok()),
627            "semantic usize param {} diverged from string params",
628            key
629        );
630        result
631    }
632
633    /// Parse a parameter as a single zone type.
634    pub fn zone_type<K: AsRef<str>>(&self, key: K) -> Option<ZoneType> {
635        crate::perf::increment_params_lookup();
636        let key = key.as_ref();
637        let result = self
638            .0
639            .get(key)
640            .and_then(|value| semantic_zone_type(key, value));
641        #[cfg(debug_assertions)]
642        debug_assert_eq!(
643            result,
644            self.0.get(key).and_then(|value| legacy_zone_type(value)),
645            "semantic zone param {} diverged from string params",
646            key
647        );
648        result
649    }
650
651    /// Parse a parameter as a comma-separated zone list.
652    pub fn zone_types<K: AsRef<str>>(&self, key: K) -> Vec<ZoneType> {
653        crate::perf::increment_params_lookup();
654        let key = key.as_ref();
655        let result = self
656            .0
657            .get(key)
658            .map(|value| semantic_zone_types(key, value))
659            .unwrap_or_default();
660        #[cfg(debug_assertions)]
661        debug_assert_eq!(
662            result,
663            self.0
664                .get(key)
665                .map(|value| legacy_zone_types(value))
666                .unwrap_or_default(),
667            "semantic zone-list param {} diverged from string params",
668            key
669        );
670        result
671    }
672
673    /// Get a selector-like parameter, asserting that semantic classification
674    /// agrees with selector/reference usage while preserving the legacy raw
675    /// string consumed by current matchers.
676    pub fn selector_value<K: AsRef<str>>(&self, key: K) -> Option<&str> {
677        crate::perf::increment_params_lookup();
678        let key = key.as_ref();
679        let value = self.0.get(key).map(String::as_str)?;
680        #[cfg(debug_assertions)]
681        debug_assert!(
682            matches_selector_semantics(key, value),
683            "semantic selector param {} classified unexpectedly: {:?}",
684            key,
685            parse_semantic_param_value(key, value)
686        );
687        Some(value)
688    }
689
690    /// Get a compiled selector-like parameter.
691    pub fn selector<K: AsRef<str>>(&self, key: K) -> Option<&CompiledSelector> {
692        crate::perf::increment_params_lookup();
693        let key = key.as_ref();
694        self.selector_untracked(key)
695    }
696
697    pub fn selector_untracked<K: AsRef<str>>(&self, key: K) -> Option<&CompiledSelector> {
698        let key = key.as_ref();
699        match self.1.get(key) {
700            Some(CompiledParamValue::Selector(selector))
701            | Some(CompiledParamValue::Reference(selector)) => Some(selector),
702            None => None,
703        }
704    }
705
706    /// Get an owned compiled selector-like parameter.
707    ///
708    /// This is used when trigger/effect structs cache a filter for repeated
709    /// execution. It prefers the parser-produced IR and falls back to compiling
710    /// the raw value as a selector for compatibility with less-specific
711    /// historical keys like `ValidToken`.
712    pub fn selector_cloned<K: AsRef<str>>(&self, key: K) -> Option<CompiledSelector> {
713        crate::perf::increment_params_lookup();
714        let key = key.as_ref();
715        match self.1.get(key) {
716            Some(CompiledParamValue::Selector(selector))
717            | Some(CompiledParamValue::Reference(selector)) => Some(selector.clone()),
718            None => self.0.get(key).map(|value| cached_compiled_selector(value)),
719        }
720    }
721
722    pub fn selector_cloned_any(&self, keys: &[&str]) -> Option<CompiledSelector> {
723        keys.iter().find_map(|key| self.selector_cloned(*key))
724    }
725
726    /// Get a reference-like parameter, asserting that semantic classification
727    /// agrees with reference usage while preserving the legacy raw string.
728    pub fn reference_value<K: AsRef<str>>(&self, key: K) -> Option<&str> {
729        crate::perf::increment_params_lookup();
730        let key = key.as_ref();
731        let value = self.0.get(key).map(String::as_str)?;
732        #[cfg(debug_assertions)]
733        debug_assert!(
734            matches_reference_semantics(key, value),
735            "semantic reference param {} classified unexpectedly: {:?}",
736            key,
737            parse_semantic_param_value(key, value)
738        );
739        Some(value)
740    }
741
742    /// Get a compiled reference-like parameter.
743    pub fn reference<K: AsRef<str>>(&self, key: K) -> Option<&CompiledSelector> {
744        crate::perf::increment_params_lookup();
745        let key = key.as_ref();
746        match self.1.get(key) {
747            Some(CompiledParamValue::Reference(selector))
748            | Some(CompiledParamValue::Selector(selector)) => Some(selector),
749            None => None,
750        }
751    }
752
753    /// Get a parameter value, cloning it into an owned String.
754    pub fn get_cloned(&self, key: &str) -> Option<String> {
755        self.0.get(key).cloned()
756    }
757
758    /// Split a delimited parameter value into trimmed, non-empty owned parts.
759    pub fn split_param_list<K: AsRef<str>>(&self, key: K, delimiter: &str) -> Vec<String> {
760        split_param_list_value(self.get(key), delimiter)
761    }
762
763    /// Get a parameter value lowered through the semantic Forge DSL classifier.
764    ///
765    /// This borrows the stored value and falls back to `SemanticParamValue::Raw`
766    /// for keys that are intentionally not classified yet.
767    pub fn semantic_value<K: AsRef<str>>(&self, key: K) -> Option<SemanticParamValue<'_>> {
768        crate::perf::increment_params_lookup();
769        let key = key.as_ref();
770        self.0
771            .get(key)
772            .map(|value| parse_semantic_param_value(key, value))
773    }
774
775    // ── Diagnostics ────────────────────────────────────────────────────
776
777    /// Get a required parameter, logging a warning if missing.
778    ///
779    /// Use this instead of `.get()` when the parameter is expected to exist.
780    /// Missing parameters are logged with context for debugging card scripts.
781    pub fn require(&self, key: &str, context: &str) -> Option<&str> {
782        match self.get(key) {
783            Some(v) => Some(v),
784            None => {
785                eprintln!("[parse] missing required param '{}' in {}", key, context);
786                None
787            }
788        }
789    }
790
791    /// Get a required parameter as an owned String, logging if missing.
792    pub fn require_cloned(&self, key: &str, context: &str) -> Option<String> {
793        self.require(key, context).map(|s| s.to_string())
794    }
795
796    // ── Iteration ───────────────────────────────────────────────────────
797
798    /// Iterate over all key-value pairs.
799    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
800        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
801    }
802}
803
804pub fn split_param_list_value(value: Option<&str>, delimiter: &str) -> Vec<String> {
805    value
806        .map(|value| {
807            value
808                .split(delimiter)
809                .map(str::trim)
810                .filter(|part| !part.is_empty())
811                .map(str::to_owned)
812                .collect()
813        })
814        .unwrap_or_default()
815}
816
817fn semantic_i32(key: &str, value: &str) -> Option<i32> {
818    match parse_semantic_param_value(key, value) {
819        SemanticParamValue::Integer(value) => Some(value),
820        SemanticParamValue::Amount(SemanticAmount::Literal(value)) => Some(value),
821        _ => value.trim().parse().ok(),
822    }
823}
824
825fn semantic_zone_type(key: &str, value: &str) -> Option<ZoneType> {
826    match parse_semantic_param_value(key, value) {
827        SemanticParamValue::ZoneList(zones) if zones.len() == 1 => {
828            zones.first().and_then(|zone| legacy_zone_type(zone))
829        }
830        SemanticParamValue::ZoneList(_) => None,
831        _ => legacy_zone_type(value),
832    }
833}
834
835fn semantic_zone_types(key: &str, value: &str) -> Vec<ZoneType> {
836    match parse_semantic_param_value(key, value) {
837        SemanticParamValue::ZoneList(zones) => {
838            zones.into_iter().filter_map(legacy_zone_type).collect()
839        }
840        _ => legacy_zone_types(value),
841    }
842}
843
844fn legacy_zone_type(value: &str) -> Option<ZoneType> {
845    let value = value.trim();
846    if value.eq_ignore_ascii_case("Deck") {
847        Some(ZoneType::Library)
848    } else {
849        ZoneType::from_str_compat(value)
850    }
851}
852
853fn legacy_zone_types(value: &str) -> Vec<ZoneType> {
854    value
855        .split([',', ' '])
856        .map(str::trim)
857        .filter(|part| !part.is_empty())
858        .filter_map(legacy_zone_type)
859        .collect()
860}
861
862fn compile_param_map(map: &BTreeMap<String, String>) -> BTreeMap<String, CompiledParamValue> {
863    map.iter()
864        .filter_map(|(key, value)| {
865            compile_semantic_param_value(&parse_semantic_param_value(key, value))
866                .map(|compiled| (key.clone(), compiled))
867        })
868        .collect()
869}
870
871fn update_compiled_param(
872    compiled: &mut BTreeMap<String, CompiledParamValue>,
873    key: &str,
874    value: &str,
875) {
876    match compile_semantic_param_value(&parse_semantic_param_value(key, value)) {
877        Some(value) => {
878            compiled.insert(key.to_string(), value);
879        }
880        None => {
881            compiled.remove(key);
882        }
883    }
884}
885
886fn compile_semantic_param_value(value: &SemanticParamValue<'_>) -> Option<CompiledParamValue> {
887    match value {
888        SemanticParamValue::Selector(selector) => Some(CompiledParamValue::Selector(
889            compile_semantic_selector(selector),
890        )),
891        SemanticParamValue::Reference(selector) => Some(CompiledParamValue::Reference(
892            compile_semantic_selector(selector),
893        )),
894        _ => None,
895    }
896}
897
898fn compile_semantic_selector(selector: &SemanticSelector<'_>) -> CompiledSelector {
899    let alternatives = selector
900        .alternatives
901        .iter()
902        .map(|alternative| CompiledSelectorAlternative {
903            raw: alternative.raw.to_string(),
904            parts: alternative
905                .parts
906                .iter()
907                .map(|part| CompiledSelectorPart {
908                    separator: part.separator,
909                    value: part.value.to_string(),
910                })
911                .collect(),
912        })
913        .collect::<Vec<_>>();
914    let ir = lower_compiled_selector(&alternatives);
915    CompiledSelector { alternatives, ir }
916}
917
918fn lower_compiled_selector(alternatives: &[CompiledSelectorAlternative]) -> Selector {
919    Selector {
920        alternatives: alternatives
921            .iter()
922            .map(|alternative| {
923                let mut predicates: Vec<_> = alternative
924                    .parts
925                    .iter()
926                    .enumerate()
927                    .map(|(idx, part)| lower_selector_part(&part.value, idx == 0))
928                    .collect();
929                predicates.sort_by_key(selector_predicate_order);
930                SelectorAlt { predicates }
931            })
932            .collect(),
933    }
934}
935
936fn selector_predicate_order(predicate: &SelectorPredicate) -> u8 {
937    match predicate {
938        SelectorPredicate::Any
939        | SelectorPredicate::CardType(_)
940        | SelectorPredicate::CardSupertype(_)
941        | SelectorPredicate::Zone(_)
942        | SelectorPredicate::CardController(_)
943        | SelectorPredicate::CardOwner(_)
944        | SelectorPredicate::Tapped(_)
945        | SelectorPredicate::StartedTurnTapped(_)
946        | SelectorPredicate::CameUnderControlSinceLastUpkeep
947        | SelectorPredicate::Token(_)
948        | SelectorPredicate::Color(_)
949        | SelectorPredicate::Multicolor
950        | SelectorPredicate::Colorless
951        | SelectorPredicate::Commander
952        | SelectorPredicate::Legendary
953        | SelectorPredicate::Kicked => 0,
954        SelectorPredicate::CardIdentity(_) | SelectorPredicate::PlayerController(_) => 1,
955        SelectorPredicate::NumericComparison { .. }
956        | SelectorPredicate::NumericParity { .. }
957        | SelectorPredicate::CounterComparison { .. }
958        | SelectorPredicate::Keyword { .. }
959        | SelectorPredicate::CardState(_)
960        | SelectorPredicate::ChosenType
961        | SelectorPredicate::WasCast { .. }
962        | SelectorPredicate::SourceColor(_)
963        | SelectorPredicate::SourceColorless
964        | SelectorPredicate::ChosenColorSource => 2,
965        SelectorPredicate::Context(_) => 3,
966        SelectorPredicate::Relation(_) => 4,
967        SelectorPredicate::RememberedCard
968        | SelectorPredicate::TriggerRememberedCard
969        | SelectorPredicate::EffectSource
970        | SelectorPredicate::DamagedBy
971        | SelectorPredicate::AttachedBy => 5,
972        SelectorPredicate::Not(inner) => selector_predicate_order(inner).saturating_add(1),
973        SelectorPredicate::Player => 6,
974        SelectorPredicate::Raw(_) => 7,
975    }
976}
977
978fn lower_selector_part(value: &str, is_first_part: bool) -> SelectorPredicate {
979    let normalized = value.trim();
980    if normalized.is_empty() {
981        return SelectorPredicate::Raw(String::new());
982    }
983    let lower = normalized.to_ascii_lowercase();
984    if is_first_part {
985        return match lower.as_str() {
986            "any" => SelectorPredicate::Any,
987            "card" => SelectorPredicate::CardType(CardSelectorType::Card),
988            "creature" => SelectorPredicate::CardType(CardSelectorType::Creature),
989            "land" => SelectorPredicate::CardType(CardSelectorType::Land),
990            "instant" => SelectorPredicate::CardType(CardSelectorType::Instant),
991            "sorcery" => SelectorPredicate::CardType(CardSelectorType::Sorcery),
992            "artifact" => SelectorPredicate::CardType(CardSelectorType::Artifact),
993            "enchantment" => SelectorPredicate::CardType(CardSelectorType::Enchantment),
994            "planeswalker" => SelectorPredicate::CardType(CardSelectorType::Planeswalker),
995            "permanent" => SelectorPredicate::CardType(CardSelectorType::Permanent),
996            "spell" => SelectorPredicate::CardType(CardSelectorType::Spell),
997            "nonland" => SelectorPredicate::CardType(CardSelectorType::NonLand),
998            "noncreature" => SelectorPredicate::CardType(CardSelectorType::NonCreature),
999            "player" | "each" | "player.ingame" => SelectorPredicate::Player,
1000            "you" | "youctrl" => SelectorPredicate::PlayerController(ControllerSelector::You),
1001            "opponent" | "oppctrl" | "opponentctrl" => {
1002                SelectorPredicate::PlayerController(ControllerSelector::Opponent)
1003            }
1004            named if named.starts_with("named") => SelectorPredicate::CardType(
1005                CardSelectorType::Named(normalized[5..].trim().to_string()),
1006            ),
1007            _ => SelectorPredicate::CardType(CardSelectorType::Subtype(normalized.to_string())),
1008        };
1009    }
1010
1011    if let Some(stripped) = normalized.strip_prefix('!') {
1012        let predicate = lower_selector_part(stripped, false);
1013        return SelectorPredicate::Not(Box::new(predicate));
1014    }
1015
1016    match lower.as_str() {
1017        "self" | "strictlyself" => SelectorPredicate::CardIdentity(CardIdentitySelector::Self_),
1018        "other" | "strictlyother" => SelectorPredicate::CardIdentity(CardIdentitySelector::Other),
1019        "you" | "youctrl" | "youcontrol" => {
1020            SelectorPredicate::CardController(ControllerSelector::You)
1021        }
1022        "opponent" | "oppctrl" | "opponentctrl" => {
1023            SelectorPredicate::CardController(ControllerSelector::Opponent)
1024        }
1025        "chosenctrl" => SelectorPredicate::Raw(normalized.to_string()),
1026        "youown" => SelectorPredicate::CardOwner(ControllerSelector::You),
1027        "oppown" | "opponentown" => SelectorPredicate::CardOwner(ControllerSelector::Opponent),
1028        // Raw — runtime-resolved against the SA's target list in valid_filter.
1029        // (`targetedplayerctrl` already lowers to a Context predicate below.)
1030        "targetedplayerown" | "targetedown" | "targetedowner" => {
1031            SelectorPredicate::Raw(normalized.to_string())
1032        }
1033        "youdontctrl" => SelectorPredicate::Not(Box::new(SelectorPredicate::CardController(
1034            ControllerSelector::You,
1035        ))),
1036        "youdontown" => SelectorPredicate::Not(Box::new(SelectorPredicate::CardOwner(
1037            ControllerSelector::You,
1038        ))),
1039        "tapped" => SelectorPredicate::Tapped(true),
1040        "untapped" => SelectorPredicate::Tapped(false),
1041        "startedtheturntapped" => SelectorPredicate::StartedTurnTapped(true),
1042        "startedtheturnuntapped" => SelectorPredicate::StartedTurnTapped(false),
1043        "cameundercontrolsincelastupkeep" => SelectorPredicate::CameUnderControlSinceLastUpkeep,
1044        "inzonebattlefield" => SelectorPredicate::Zone(ZoneType::Battlefield),
1045        "inzonegraveyard" => SelectorPredicate::Zone(ZoneType::Graveyard),
1046        "inzonehand" => SelectorPredicate::Zone(ZoneType::Hand),
1047        "inzoneexile" => SelectorPredicate::Zone(ZoneType::Exile),
1048        "inzonestack" => SelectorPredicate::Zone(ZoneType::Stack),
1049        "isremembered" => SelectorPredicate::RememberedCard,
1050        "istriggerremembered" => SelectorPredicate::TriggerRememberedCard,
1051        "effectsource" => SelectorPredicate::EffectSource,
1052        "iscommander" => SelectorPredicate::Commander,
1053        "legendary" => SelectorPredicate::Legendary,
1054        "basic" => SelectorPredicate::CardSupertype(CardSupertypeSelector::Basic),
1055        "snow" => SelectorPredicate::CardSupertype(CardSupertypeSelector::Snow),
1056        "kicked" => SelectorPredicate::Kicked,
1057        "token" => SelectorPredicate::Token(true),
1058        "nontoken" => SelectorPredicate::Token(false),
1059        "creature" => SelectorPredicate::CardType(CardSelectorType::Creature),
1060        "land" => SelectorPredicate::CardType(CardSelectorType::Land),
1061        "instant" => SelectorPredicate::CardType(CardSelectorType::Instant),
1062        "sorcery" => SelectorPredicate::CardType(CardSelectorType::Sorcery),
1063        "artifact" => SelectorPredicate::CardType(CardSelectorType::Artifact),
1064        "enchantment" => SelectorPredicate::CardType(CardSelectorType::Enchantment),
1065        "planeswalker" => SelectorPredicate::CardType(CardSelectorType::Planeswalker),
1066        "permanent" => SelectorPredicate::CardType(CardSelectorType::Permanent),
1067        "noncreature" => SelectorPredicate::CardType(CardSelectorType::NonCreature),
1068        "nonland" => SelectorPredicate::CardType(CardSelectorType::NonLand),
1069        "time lord" => {
1070            SelectorPredicate::CardType(CardSelectorType::Subtype("Time Lord".to_string()))
1071        }
1072        "white" => SelectorPredicate::Color(CardColorSelector::White),
1073        "blue" => SelectorPredicate::Color(CardColorSelector::Blue),
1074        "black" => SelectorPredicate::Color(CardColorSelector::Black),
1075        "red" => SelectorPredicate::Color(CardColorSelector::Red),
1076        "green" => SelectorPredicate::Color(CardColorSelector::Green),
1077        "multicolor" => SelectorPredicate::Multicolor,
1078        "colorless" => SelectorPredicate::Colorless,
1079        "whitesource" => SelectorPredicate::SourceColor(CardColorSelector::White),
1080        "bluesource" => SelectorPredicate::SourceColor(CardColorSelector::Blue),
1081        "blacksource" => SelectorPredicate::SourceColor(CardColorSelector::Black),
1082        "redsource" => SelectorPredicate::SourceColor(CardColorSelector::Red),
1083        "greensource" => SelectorPredicate::SourceColor(CardColorSelector::Green),
1084        "colorlesssource" => SelectorPredicate::SourceColorless,
1085        "chosencolorsource" => SelectorPredicate::ChosenColorSource,
1086        "attacking" => SelectorPredicate::Context(ContextPredicate::Attacking(None)),
1087        "attackingyou" => {
1088            SelectorPredicate::Context(ContextPredicate::Attacking(Some(TargetRef::Source)))
1089        }
1090        "blocking" => SelectorPredicate::Context(ContextPredicate::Blocking(None)),
1091        "blocked" => SelectorPredicate::Context(ContextPredicate::Blocked),
1092        "attackedthisturn" => SelectorPredicate::Context(ContextPredicate::AttackedThisTurn),
1093        "blockingsource" => SelectorPredicate::Context(ContextPredicate::BlockingSource),
1094        "blockedbysource" => SelectorPredicate::Context(ContextPredicate::BlockedBySource),
1095        "samename" => {
1096            SelectorPredicate::Relation(RelationPredicate::SharesNameWith(TargetRef::Source))
1097        }
1098        "damagedby" => SelectorPredicate::DamagedBy,
1099        "equippedby" | "enchantedby" | "attachedby" => SelectorPredicate::AttachedBy,
1100        "facedown" => SelectorPredicate::CardState(CardStateSelector::FaceDown),
1101        "paired" => SelectorPredicate::CardState(CardStateSelector::Paired),
1102        "pairedwith" => SelectorPredicate::CardState(CardStateSelector::PairedWithSource),
1103        "attached" => SelectorPredicate::CardState(CardStateSelector::Attached),
1104        "equipped" => SelectorPredicate::CardState(CardStateSelector::Equipped),
1105        "enchanted" => SelectorPredicate::CardState(CardStateSelector::Enchanted),
1106        "hascounters" => SelectorPredicate::CardState(CardStateSelector::HasCounters),
1107        "isimprinted" => SelectorPredicate::CardState(CardStateSelector::IsImprinted),
1108        "chosen" => SelectorPredicate::CardState(CardStateSelector::Chosen),
1109        "chosencard" | "chosencardstrict" => {
1110            SelectorPredicate::CardState(CardStateSelector::ChosenCard)
1111        }
1112        "namedcard" => SelectorPredicate::CardState(CardStateSelector::NamedCard),
1113        "chosencolor" => SelectorPredicate::CardState(CardStateSelector::ChosenColor),
1114        "thisturnentered" => SelectorPredicate::CardState(CardStateSelector::EnteredThisTurn),
1115        "wasdealtdamagethisturn" => {
1116            SelectorPredicate::CardState(CardStateSelector::WasDealtDamageThisTurn)
1117        }
1118        "historic" => SelectorPredicate::CardState(CardStateSelector::Historic),
1119        "modified" => SelectorPredicate::CardState(CardStateSelector::Modified),
1120        "issaddled" => SelectorPredicate::CardState(CardStateSelector::Saddled),
1121        "mayplaysource" => SelectorPredicate::CardState(CardStateSelector::MayPlaySource),
1122        "exiledwithsource" => SelectorPredicate::Context(ContextPredicate::ExiledWithSource),
1123        "toplibrary" => SelectorPredicate::Context(ContextPredicate::TopLibrary),
1124        "suspended" => SelectorPredicate::CardState(CardStateSelector::Suspended),
1125        "singletarget" => SelectorPredicate::CardState(CardStateSelector::SingleTarget),
1126        "promisedgift" => SelectorPredicate::CardState(CardStateSelector::PromisedGift),
1127        "isringbearer" => SelectorPredicate::CardState(CardStateSelector::RingBearer),
1128        "wascast" => SelectorPredicate::WasCast { by_you: false },
1129        "wascastbyyou" => SelectorPredicate::WasCast { by_you: true },
1130        named if named.starts_with("named") => {
1131            SelectorPredicate::CardType(CardSelectorType::Named(normalized[5..].trim().to_string()))
1132        }
1133        cast_origin if cast_origin.starts_with("wascastfrom") => {
1134            lower_cast_origin_predicate(normalized)
1135                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1136        }
1137        "chosentype" => SelectorPredicate::ChosenType,
1138        "rememberedplayerctrl" => {
1139            SelectorPredicate::Context(ContextPredicate::RememberedPlayerCtrl)
1140        }
1141        "targetedplayerctrl" => SelectorPredicate::Context(ContextPredicate::TargetedPlayerCtrl),
1142        "activeplayerctrl" => SelectorPredicate::Context(ContextPredicate::ActivePlayerCtrl),
1143        "defenderctrl" => SelectorPredicate::Context(ContextPredicate::DefenderCtrl),
1144        "enchantedcontroller" => SelectorPredicate::Context(ContextPredicate::EnchantedController),
1145        "notdefinedtargeted" => SelectorPredicate::Context(ContextPredicate::NotDefinedTargeted),
1146        controlled if controlled.starts_with("controlledby ") => SelectorPredicate::Context(
1147            ContextPredicate::ControlledBy(normalized["ControlledBy ".len()..].trim().to_string()),
1148        ),
1149        shares if shares.starts_with("sharesnamewith") => {
1150            lower_relation_target_ref(normalized["sharesNameWith".len()..].trim())
1151                .map(|target| {
1152                    SelectorPredicate::Relation(RelationPredicate::SharesNameWith(target))
1153                })
1154                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1155        }
1156        shares if shares.starts_with("doesnotsharenamewith") => {
1157            lower_relation_target_ref(normalized["doesNotShareNameWith".len()..].trim())
1158                .map(|target| {
1159                    SelectorPredicate::Relation(RelationPredicate::DoesNotShareNameWith(target))
1160                })
1161                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1162        }
1163        shares if shares.starts_with("sharescardtypewith") => {
1164            lower_relation_target_ref(normalized["sharesCardTypeWith".len()..].trim())
1165                .map(|target| {
1166                    SelectorPredicate::Relation(RelationPredicate::SharesCardTypeWith(target))
1167                })
1168                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1169        }
1170        shares if shares.starts_with("sharescolorwith") => {
1171            lower_relation_target_ref(normalized["SharesColorWith".len()..].trim())
1172                .map(|target| {
1173                    SelectorPredicate::Relation(RelationPredicate::SharesColorWith(target))
1174                })
1175                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1176        }
1177        shares if shares.starts_with("sharescmcwith") => {
1178            lower_relation_target_ref(normalized["SharesCMCWith".len()..].trim())
1179                .map(|target| {
1180                    SelectorPredicate::Relation(RelationPredicate::SharesManaValueWith(target))
1181                })
1182                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1183        }
1184        shares if shares.starts_with("sharescreaturetypewith") => {
1185            lower_relation_target_ref(normalized["sharesCreatureTypeWith".len()..].trim())
1186                .map(|target| {
1187                    SelectorPredicate::Relation(RelationPredicate::SharesCreatureTypeWith(target))
1188                })
1189                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1190        }
1191        attacking if attacking.starts_with("attacking ") => {
1192            lower_relation_target_ref(normalized["attacking ".len()..].trim())
1193                .map(|target| SelectorPredicate::Context(ContextPredicate::Attacking(Some(target))))
1194                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1195        }
1196        blocked_by if blocked_by.starts_with("blockedbyvalidthisturn ") => {
1197            lower_blocked_by_valid_this_turn(normalized["blockedByValidThisTurn ".len()..].trim())
1198                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1199        }
1200        blocked if blocked.starts_with("blockedvalidthisturn ") => {
1201            lower_card_selector_type(normalized["blockedValidThisTurn ".len()..].trim())
1202                .map(|card_type| {
1203                    SelectorPredicate::Context(ContextPredicate::BlockedValidThisTurn(card_type))
1204                })
1205                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1206        }
1207        blocking_valid if blocking_valid.starts_with("blockingvalid ") => {
1208            lower_card_selector_type(normalized["blockingValid ".len()..].trim())
1209                .map(|card_type| {
1210                    SelectorPredicate::Context(ContextPredicate::BlockingValid(card_type))
1211                })
1212                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1213        }
1214        blocking if blocking.starts_with("blocking ") => {
1215            lower_relation_target_ref(normalized["blocking ".len()..].trim())
1216                .map(|target| SelectorPredicate::Context(ContextPredicate::Blocking(Some(target))))
1217                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1218        }
1219        attached if attached.starts_with("attachedto ") => {
1220            lower_attached_to_relation(normalized["AttachedTo ".len()..].trim())
1221                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1222        }
1223        owned if owned.starts_with("ownedby ") => {
1224            lower_relation_target_ref(normalized["OwnedBy ".len()..].trim())
1225                .map(|target| SelectorPredicate::Relation(RelationPredicate::OwnedBy(target)))
1226                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1227        }
1228        opponent if opponent.starts_with("opponentof ") => {
1229            lower_relation_target_ref(normalized["OpponentOf ".len()..].trim())
1230                .map(|target| SelectorPredicate::Relation(RelationPredicate::OpponentOf(target)))
1231                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1232        }
1233        targeting if targeting.starts_with("istargeting ") => {
1234            lower_relation_target_ref(normalized["IsTargeting ".len()..].trim())
1235                .map(|target| SelectorPredicate::Relation(RelationPredicate::IsTargeting(target)))
1236                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1237        }
1238        entered if entered.starts_with("thisturnenteredfrom_") => {
1239            lower_entered_from_zone(normalized["ThisTurnEnteredFrom_".len()..].trim())
1240                .map(|zone| SelectorPredicate::Context(ContextPredicate::EnteredThisTurnFrom(zone)))
1241                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1242        }
1243        entered if entered.starts_with("enteredunder ") => {
1244            lower_relation_target_ref(normalized["EnteredUnder ".len()..].trim())
1245                .map(|target| SelectorPredicate::Context(ContextPredicate::EnteredUnder(target)))
1246                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1247        }
1248        _ => lower_non_predicate(normalized).unwrap_or_else(|| {
1249            lower_keyword_predicate(normalized).unwrap_or_else(|| {
1250                lower_selector_comparison(normalized).unwrap_or_else(|| {
1251                    lower_counter_comparison(normalized).unwrap_or_else(|| {
1252                        lower_subtype_predicate(normalized)
1253                            .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1254                    })
1255                })
1256            })
1257        }),
1258    }
1259}
1260
1261fn lower_relation_target_ref(value: &str) -> Option<TargetRef> {
1262    if value.is_empty()
1263        || value.eq_ignore_ascii_case("Self")
1264        || value.eq_ignore_ascii_case("Source")
1265        || value.eq_ignore_ascii_case("You")
1266        || value.eq_ignore_ascii_case("YouCtrl")
1267    {
1268        Some(TargetRef::Source)
1269    } else if value.eq_ignore_ascii_case("Remembered") {
1270        Some(TargetRef::Remembered)
1271    } else if value.eq_ignore_ascii_case("RememberedLKI") {
1272        Some(TargetRef::RememberedLki)
1273    } else if value.eq_ignore_ascii_case("Imprinted") {
1274        Some(TargetRef::Imprinted)
1275    } else if value.eq_ignore_ascii_case("ChosenCard") {
1276        Some(TargetRef::ChosenCard)
1277    } else if value.eq_ignore_ascii_case("ChosenPlayer") {
1278        Some(TargetRef::ChosenPlayer)
1279    } else if value.eq_ignore_ascii_case("Targeted")
1280        || value.eq_ignore_ascii_case("TargetedPlayer")
1281        || value.eq_ignore_ascii_case("TargetedController")
1282    {
1283        Some(TargetRef::Targeted)
1284    } else if value.eq_ignore_ascii_case("Player") {
1285        Some(TargetRef::Player)
1286    } else if value.eq_ignore_ascii_case("Opponent") {
1287        Some(TargetRef::Opponent)
1288    } else if value.eq_ignore_ascii_case("Battlefield") {
1289        Some(TargetRef::Battlefield)
1290    } else if value.eq_ignore_ascii_case("OtherYourBattlefield") {
1291        Some(TargetRef::OtherYourBattlefield)
1292    } else if value.eq_ignore_ascii_case("YourGraveyard") {
1293        Some(TargetRef::YourGraveyard)
1294    } else if value.eq_ignore_ascii_case("TriggeredTarget") {
1295        Some(TargetRef::TriggeredTarget)
1296    } else if value.eq_ignore_ascii_case("TriggeredPlayer") {
1297        Some(TargetRef::TriggeredPlayer)
1298    } else if value.eq_ignore_ascii_case("TriggeredCard") {
1299        Some(TargetRef::TriggeredCard)
1300    } else if value.eq_ignore_ascii_case("TriggeredCardController") {
1301        Some(TargetRef::TriggeredCardController)
1302    } else if value.eq_ignore_ascii_case("TriggeredDefendingPlayer") {
1303        Some(TargetRef::TriggeredDefendingPlayer)
1304    } else if value.eq_ignore_ascii_case("TriggeredAttackedTarget") {
1305        Some(TargetRef::TriggeredAttackedTarget)
1306    } else if value.eq_ignore_ascii_case("Commander") {
1307        Some(TargetRef::Commander)
1308    } else {
1309        None
1310    }
1311}
1312
1313fn lower_blocked_by_valid_this_turn(value: &str) -> Option<SelectorPredicate> {
1314    if let Some(target) = lower_relation_target_ref(value) {
1315        return Some(SelectorPredicate::Context(
1316            ContextPredicate::BlockedByValidThisTurn(target),
1317        ));
1318    }
1319    lower_card_selector_type(value).map(|card_type| {
1320        SelectorPredicate::Context(ContextPredicate::BlockedByValidThisTurnType(card_type))
1321    })
1322}
1323
1324fn lower_card_selector_type(value: &str) -> Option<CardSelectorType> {
1325    match value.to_ascii_lowercase().as_str() {
1326        "card" => Some(CardSelectorType::Card),
1327        "creature" => Some(CardSelectorType::Creature),
1328        "land" => Some(CardSelectorType::Land),
1329        "artifact" => Some(CardSelectorType::Artifact),
1330        "enchantment" => Some(CardSelectorType::Enchantment),
1331        "planeswalker" => Some(CardSelectorType::Planeswalker),
1332        "permanent" => Some(CardSelectorType::Permanent),
1333        "nonland" => Some(CardSelectorType::NonLand),
1334        "noncreature" => Some(CardSelectorType::NonCreature),
1335        _ if value
1336            .chars()
1337            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '\'') =>
1338        {
1339            Some(CardSelectorType::Subtype(value.to_string()))
1340        }
1341        _ => None,
1342    }
1343}
1344
1345fn lower_entered_from_zone(value: &str) -> Option<ZoneType> {
1346    match value.to_ascii_lowercase().as_str() {
1347        "battlefield" => Some(ZoneType::Battlefield),
1348        "graveyard" => Some(ZoneType::Graveyard),
1349        "library" => Some(ZoneType::Library),
1350        "hand" => Some(ZoneType::Hand),
1351        "exile" => Some(ZoneType::Exile),
1352        _ => None,
1353    }
1354}
1355
1356fn lower_attached_to_relation(value: &str) -> Option<SelectorPredicate> {
1357    if let Some(target) = lower_relation_target_ref(value) {
1358        return Some(SelectorPredicate::Relation(RelationPredicate::AttachedTo(
1359            target,
1360        )));
1361    }
1362    let card_type = match value.to_ascii_lowercase().as_str() {
1363        "card" => CardSelectorType::Card,
1364        "creature" => CardSelectorType::Creature,
1365        "land" => CardSelectorType::Land,
1366        "artifact" => CardSelectorType::Artifact,
1367        "enchantment" => CardSelectorType::Enchantment,
1368        "permanent" => CardSelectorType::Permanent,
1369        _ => return None,
1370    };
1371    Some(SelectorPredicate::Relation(
1372        RelationPredicate::AttachedToType(card_type),
1373    ))
1374}
1375
1376fn lower_cast_origin_predicate(value: &str) -> Option<SelectorPredicate> {
1377    let lower = value.to_ascii_lowercase();
1378    let origin = match lower.as_str() {
1379        "wascastfromhand" => CastOrigin::Hand,
1380        "wascastfromyourhand" => CastOrigin::YourHand,
1381        "wascastfromyourhandbyyou" => CastOrigin::YourHandByYou,
1382        "wascastfromtheirhand" => CastOrigin::TheirHand,
1383        "wascastfromexile" => CastOrigin::Exile,
1384        "wascastfromgraveyard" => CastOrigin::Graveyard,
1385        "wascastfromyourgraveyard" => CastOrigin::YourGraveyard,
1386        "wascastfromyourgraveyardbyyou" => CastOrigin::YourGraveyardByYou,
1387        "wascastfromyourlibrary" => CastOrigin::YourLibrary,
1388        _ => return None,
1389    };
1390    Some(SelectorPredicate::Context(ContextPredicate::WasCastFrom(
1391        origin,
1392    )))
1393}
1394
1395fn lower_non_predicate(value: &str) -> Option<SelectorPredicate> {
1396    let lower = value.to_ascii_lowercase();
1397    let rest = lower.strip_prefix("non")?;
1398    if rest.is_empty() {
1399        return None;
1400    }
1401    let positive = match rest {
1402        "white" => SelectorPredicate::Color(CardColorSelector::White),
1403        "blue" => SelectorPredicate::Color(CardColorSelector::Blue),
1404        "black" => SelectorPredicate::Color(CardColorSelector::Black),
1405        "red" => SelectorPredicate::Color(CardColorSelector::Red),
1406        "green" => SelectorPredicate::Color(CardColorSelector::Green),
1407        "colorless" => SelectorPredicate::Colorless,
1408        "creature" => SelectorPredicate::CardType(CardSelectorType::Creature),
1409        "land" => SelectorPredicate::CardType(CardSelectorType::Land),
1410        "artifact" => SelectorPredicate::CardType(CardSelectorType::Artifact),
1411        "enchantment" => SelectorPredicate::CardType(CardSelectorType::Enchantment),
1412        "legendary" => SelectorPredicate::Legendary,
1413        "basic" => SelectorPredicate::CardSupertype(CardSupertypeSelector::Basic),
1414        "snow" => SelectorPredicate::CardSupertype(CardSupertypeSelector::Snow),
1415        "token" => SelectorPredicate::Token(true),
1416        _ => SelectorPredicate::CardType(CardSelectorType::Subtype(value[3..].to_string())),
1417    };
1418    Some(SelectorPredicate::Not(Box::new(positive)))
1419}
1420
1421fn lower_keyword_predicate(value: &str) -> Option<SelectorPredicate> {
1422    let lower = value.to_ascii_lowercase();
1423    if lower.starts_with("without") && value.len() > 7 {
1424        return Some(SelectorPredicate::Keyword {
1425            name: value[7..].to_string(),
1426            present: false,
1427        });
1428    }
1429    if lower.starts_with("with") && value.len() > 4 {
1430        return Some(SelectorPredicate::Keyword {
1431            name: value[4..].to_string(),
1432            present: true,
1433        });
1434    }
1435    None
1436}
1437
1438fn lower_subtype_predicate(value: &str) -> Option<SelectorPredicate> {
1439    let lower = value.to_ascii_lowercase();
1440    let structured = lower.starts_with("cmc")
1441        || lower.starts_with("power")
1442        || lower.starts_with("toughness")
1443        || lower.starts_with("counters_")
1444        || lower.starts_with("wascastfrom")
1445        || lower.starts_with("shares")
1446        || lower.starts_with("attached")
1447        || lower.starts_with("controlledby ")
1448        || lower.ends_with("source")
1449        || value.contains(' ')
1450        || value.contains('_')
1451        || value.contains('/');
1452    if structured
1453        || value.is_empty()
1454        || !value
1455            .chars()
1456            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '\'')
1457    {
1458        return None;
1459    }
1460    Some(SelectorPredicate::CardType(CardSelectorType::Subtype(
1461        value.to_string(),
1462    )))
1463}
1464
1465fn lower_selector_comparison(value: &str) -> Option<SelectorPredicate> {
1466    let lower = value.to_ascii_lowercase();
1467    let (property, rest) = if lower.starts_with("cmc") {
1468        (NumericSelectorProperty::ManaValue, &value[3..])
1469    } else if lower.starts_with("power") {
1470        (NumericSelectorProperty::Power, &value[5..])
1471    } else if lower.starts_with("toughness") {
1472        (NumericSelectorProperty::Toughness, &value[9..])
1473    } else if lower.starts_with("numtargets ") {
1474        (NumericSelectorProperty::TargetCount, value[11..].trim())
1475    } else if lower.starts_with("manaspent ") {
1476        (NumericSelectorProperty::ManaSpent, value[10..].trim())
1477    } else {
1478        return None;
1479    };
1480    if rest.eq_ignore_ascii_case("even") {
1481        return Some(SelectorPredicate::NumericParity {
1482            property,
1483            even: true,
1484        });
1485    }
1486    if rest.eq_ignore_ascii_case("odd") {
1487        return Some(SelectorPredicate::NumericParity {
1488            property,
1489            even: false,
1490        });
1491    }
1492    let (operator, value) = parse_selector_comparison(rest)?;
1493    Some(SelectorPredicate::NumericComparison {
1494        property,
1495        operator,
1496        value,
1497    })
1498}
1499
1500fn lower_counter_comparison(value: &str) -> Option<SelectorPredicate> {
1501    let rest = value.strip_prefix("counters_")?;
1502    let operator = parse_selector_operator(rest.get(..2)?)?;
1503    let after_op = rest.get(2..)?;
1504    let split = after_op.find('_')?;
1505    let value = parse_selector_operand(&after_op[..split])?;
1506    let counter_type = after_op[split + 1..].to_string();
1507    Some(SelectorPredicate::CounterComparison {
1508        operator,
1509        value,
1510        counter_type,
1511    })
1512}
1513
1514fn parse_selector_comparison(
1515    rest: &str,
1516) -> Option<(SelectorCompareOperator, SelectorNumericOperand)> {
1517    let operator = parse_selector_operator(rest.get(..2)?)?;
1518    let value = parse_selector_operand(rest.get(2..)?)?;
1519    Some((operator, value))
1520}
1521
1522fn parse_selector_operand(value: &str) -> Option<SelectorNumericOperand> {
1523    if value.is_empty() {
1524        return None;
1525    }
1526    if let Ok(value) = value.parse::<i32>() {
1527        Some(SelectorNumericOperand::Literal(value))
1528    } else if value
1529        .chars()
1530        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1531    {
1532        Some(SelectorNumericOperand::Symbol(value.to_string()))
1533    } else {
1534        None
1535    }
1536}
1537
1538fn parse_selector_operator(operator: &str) -> Option<SelectorCompareOperator> {
1539    match operator.to_ascii_lowercase().as_str() {
1540        "eq" => Some(SelectorCompareOperator::Eq),
1541        "ne" => Some(SelectorCompareOperator::Ne),
1542        "lt" => Some(SelectorCompareOperator::Lt),
1543        "le" => Some(SelectorCompareOperator::Le),
1544        "gt" => Some(SelectorCompareOperator::Gt),
1545        "ge" => Some(SelectorCompareOperator::Ge),
1546        _ => None,
1547    }
1548}
1549
1550#[cfg(debug_assertions)]
1551fn matches_selector_semantics(key: &str, value: &str) -> bool {
1552    matches!(
1553        parse_semantic_param_value(key, value),
1554        SemanticParamValue::Selector(_)
1555            | SemanticParamValue::Reference(_)
1556            | SemanticParamValue::SVarReference(_)
1557            | SemanticParamValue::DelimitedList(_)
1558            | SemanticParamValue::Raw(_)
1559    )
1560}
1561
1562#[cfg(debug_assertions)]
1563fn matches_reference_semantics(key: &str, value: &str) -> bool {
1564    matches!(
1565        parse_semantic_param_value(key, value),
1566        SemanticParamValue::Reference(_)
1567            | SemanticParamValue::Selector(_)
1568            | SemanticParamValue::SVarReference(_)
1569            | SemanticParamValue::Raw(_)
1570    )
1571}
1572
1573#[cfg(debug_assertions)]
1574fn legacy_parse_params(raw: &str) -> BTreeMap<String, String> {
1575    let mut map = BTreeMap::new();
1576    for part in raw.split('|') {
1577        let part = part.trim();
1578        if let Some(idx) = part.find("$ ") {
1579            let key = part[..idx].trim().to_string();
1580            let value = part[idx + 2..].trim().to_string();
1581            map.insert(key, value);
1582        } else if let Some(idx) = part.find('$') {
1583            let key = part[..idx].trim().to_string();
1584            let value = part[idx + 1..].trim().to_string();
1585            map.insert(key, value);
1586        }
1587    }
1588    map
1589}
1590
1591/// Adapter: convert a parse result to Option, logging failures only when
1592/// the raw input looks like it was *intended* to be the given kind.
1593///
1594/// Card scripts pass all ability lines to all parsers — most `None` results
1595/// are intentional (e.g., an `AB$` line passed to `parse_static_ability`).
1596/// This function only warns when the line's prefix matches the expected kind.
1597///
1598/// ```ignore
1599/// .filter_map(|raw| parse_or_warn(parse_static_ability(raw), "StaticAbility", raw))
1600/// ```
1601/// Like [`parse_or_warn`], but for lines the card parser already classified
1602/// (`T:`/`S:`/`R:` records, stored prefix-stripped on the face): a `None`
1603/// here silently drops a known rule, so it always warns.
1604pub fn parse_classified_or_warn<T>(result: Option<T>, kind: &str, raw: &str) -> Option<T> {
1605    if result.is_none() {
1606        let preview: String = raw.trim().chars().take(100).collect();
1607        eprintln!("[parse] failed to parse {kind} from: {preview}");
1608    }
1609    result
1610}
1611
1612pub fn parse_or_warn<T>(result: Option<T>, kind: &str, raw: &str) -> Option<T> {
1613    if result.is_none() {
1614        let trimmed = raw.trim();
1615        let should_warn = match kind {
1616            "StaticAbility" => trimmed.starts_with("S$") || trimmed.starts_with("S:"),
1617            "ReplacementEffect" => trimmed.starts_with("R$") || trimmed.starts_with("R:"),
1618            "Trigger" => trimmed.starts_with("T$") || trimmed.starts_with("T:"),
1619            "ActivatedAbility" => {
1620                // Only AB$ lines are activated abilities. SP$ (spell) and DB$
1621                // (sub-ability) lines are resolved via build_spell_ability, not
1622                // parse_activated_ability — their None result is intentional.
1623                trimmed.starts_with("AB$") || trimmed.starts_with("AB:")
1624            }
1625            _ => false,
1626        };
1627        if should_warn {
1628            let preview: String = trimmed.chars().take(100).collect();
1629            eprintln!("[parse] failed to parse {} from: {}", kind, preview);
1630        }
1631    }
1632    result
1633}
1634
1635// ── Conversions for incremental migration ───────────────────────────────────
1636
1637impl From<BTreeMap<String, String>> for Params {
1638    fn from(map: BTreeMap<String, String>) -> Self {
1639        Params::from_map(map)
1640    }
1641}
1642
1643impl From<Params> for BTreeMap<String, String> {
1644    fn from(params: Params) -> Self {
1645        params.0
1646    }
1647}
1648
1649// ── Shared DSL parsing utilities ─────────────────────────────────────────────
1650
1651/// Strip a `/Times.N` multiplier suffix from a filter string.
1652/// Returns (filter_without_suffix, multiplier).
1653///
1654/// Used by SVar `Count$Valid` expressions and cost parsing.
1655/// Example: `"Enchantment.Other/Times.2"` → `("Enchantment.Other", 2)`
1656pub fn strip_times_multiplier(s: &str) -> (&str, i32) {
1657    if let Some(idx) = s.find("/Times.") {
1658        let mult_str = &s[idx + 7..];
1659        let mult = mult_str.parse::<i32>().unwrap_or(1);
1660        (&s[..idx], mult)
1661    } else {
1662        (s, 1)
1663    }
1664}
1665
1666/// Map an "Enchant <type>" keyword value to a ValidTgts$ filter string.
1667/// Used by aura targeting (ability_factory) and aura SBA legality (action.rs).
1668///
1669/// Example: `"creature"` → `"Creature"`, `"land"` → `"Land"`
1670fn normalize_enchant_type(enchant_type: &str) -> &str {
1671    enchant_type
1672        .split_once(':')
1673        .map(|(kind, _)| kind)
1674        .unwrap_or(enchant_type)
1675        .trim()
1676}
1677
1678pub fn enchant_type_to_valid_tgts(enchant_type: &str) -> String {
1679    let normalized = normalize_enchant_type(enchant_type).trim();
1680    match normalized.to_lowercase().as_str() {
1681        "creature" => "Creature".to_string(),
1682        "land" => "Land".to_string(),
1683        "artifact" => "Artifact".to_string(),
1684        "enchantment" => "Enchantment".to_string(),
1685        "planeswalker" => "Planeswalker".to_string(),
1686        "permanent" | "" => "Permanent".to_string(),
1687        "player" => "Player".to_string(),
1688        "creature or player" => "Creature,Player".to_string(),
1689        // Compound filters like "Creature.Legendary" pass through verbatim so
1690        // the targeting layer applies the full restriction rather than
1691        // collapsing to "Permanent" (which would let any permanent qualify).
1692        _ => normalized.to_string(),
1693    }
1694}
1695
1696/// Build a minimal targeting params string from an Enchant keyword payload.
1697/// Handles special cases like `Creature.inZoneGraveyard` used by Animate Dead.
1698pub fn enchant_type_to_target_params(enchant_type: &str) -> String {
1699    let normalized = normalize_enchant_type(enchant_type);
1700    let lower = normalized.to_lowercase();
1701    if lower == "creature.inzonegraveyard" {
1702        return "Origin$ Graveyard | ValidTgts$ Creature".to_string();
1703    }
1704    format!("ValidTgts$ {}", enchant_type_to_valid_tgts(normalized))
1705}
1706
1707/// Check if a card type matches an "Enchant <type>" keyword value.
1708/// Used by aura SBA to verify the enchant restriction is still met.
1709///
1710/// Example: `enchant_type_matches_card("creature", card)` → true if card is a creature
1711pub fn enchant_type_matches_card(
1712    enchant_type: &str,
1713    card: &crate::card::CardInstance,
1714    aura_source: Option<&crate::card::CardInstance>,
1715) -> bool {
1716    let normalized = normalize_enchant_type(enchant_type).trim();
1717    match normalized.to_lowercase().as_str() {
1718        "creature" => card.zone == ZoneType::Battlefield && card.is_creature(),
1719        "creature.inzonegraveyard" => card.zone == ZoneType::Graveyard && card.is_creature(),
1720        "land" => card.zone == ZoneType::Battlefield && card.is_land(),
1721        "artifact" => card.zone == ZoneType::Battlefield && card.type_line.is_artifact(),
1722        "enchantment" => card.zone == ZoneType::Battlefield && card.type_line.is_enchantment(),
1723        "planeswalker" => card.zone == ZoneType::Battlefield && card.type_line.is_planeswalker(),
1724        "permanent" | "" => card.zone == ZoneType::Battlefield,
1725        _ => {
1726            // Compound filters like "Creature.Legendary" or
1727            // "Creature.IsRemembered" — defer to the full valid-card matcher
1728            // using the aura as source so qualifiers that consult source
1729            // state (Remembered, YouCtrl, …) resolve correctly. Without an
1730            // aura source, fall back to using the host as its own source,
1731            // which is correct for source-independent filters like
1732            // "Creature.Legendary".
1733            if card.zone != ZoneType::Battlefield {
1734                return false;
1735            }
1736            let source = aura_source.unwrap_or(card);
1737            crate::card::valid_filter::matches_valid_card(normalized, card, source)
1738        }
1739    }
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744    use super::*;
1745
1746    #[test]
1747    fn parse_basic_pipe_params() {
1748        let params = Params::from_raw("Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield");
1749        assert_eq!(params.get("Mode"), Some("ChangesZone"));
1750        assert_eq!(params.get("Origin"), Some("Any"));
1751        assert_eq!(params.get("Destination"), Some("Battlefield"));
1752    }
1753
1754    #[test]
1755    fn parse_bare_dollar_no_space() {
1756        // This was a bug in the duplicated parsers — they only handled "$ "
1757        let params = Params::from_raw("Key$Value | Other$ Spaced");
1758        assert_eq!(params.get("Key"), Some("Value"));
1759        assert_eq!(params.get("Other"), Some("Spaced"));
1760    }
1761
1762    #[test]
1763    fn is_true_case_insensitive() {
1764        let params = Params::from_raw("Hidden$ True | Mandatory$ true | Other$ false");
1765        assert!(params.is_true("Hidden"));
1766        assert!(params.is_true("Mandatory"));
1767        assert!(!params.is_true("Other"));
1768        assert!(!params.is_true("Missing"));
1769    }
1770
1771    #[test]
1772    fn numeric_accessors() {
1773        let params = Params::from_raw("Amount$ 3 | Bad$ notanumber");
1774        assert_eq!(params.as_i32("Amount"), Some(3));
1775        assert_eq!(params.as_usize("Amount"), Some(3));
1776        assert_eq!(params.as_i32("Bad"), None);
1777        assert_eq!(params.as_i32("Missing"), None);
1778    }
1779
1780    #[test]
1781    fn get_or_default_works() {
1782        let params = Params::from_raw("Mode$ Continuous");
1783        assert_eq!(params.get_or_default("Mode", "None"), "Continuous");
1784        assert_eq!(params.get_or_default("Missing", "fallback"), "fallback");
1785    }
1786
1787    #[test]
1788    fn serde_roundtrip() {
1789        let params = Params::from_raw("Mode$ Test | Amount$ 5");
1790        let json = serde_json::to_string(&params).unwrap();
1791        let deserialized: Params = serde_json::from_str(&json).unwrap();
1792        assert_eq!(deserialized.get("Mode"), Some("Test"));
1793        assert_eq!(deserialized.get("Amount"), Some("5"));
1794    }
1795}