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 {key} diverged from string params"
594        );
595        result
596    }
597
598    /// Parse a parameter as i32, returning None if absent or non-numeric.
599    pub fn as_i32<K: AsRef<str>>(&self, key: K) -> Option<i32> {
600        crate::perf::increment_params_lookup();
601        let key = key.as_ref();
602        let result = self.0.get(key).and_then(|value| semantic_i32(key, value));
603        #[cfg(debug_assertions)]
604        debug_assert_eq!(
605            result,
606            self.0.get(key).and_then(|value| value.trim().parse().ok()),
607            "semantic i32 param {key} diverged from string params"
608        );
609        result
610    }
611
612    /// Parse a parameter as usize, returning None if absent or non-numeric.
613    pub fn as_usize<K: AsRef<str>>(&self, key: K) -> Option<usize> {
614        crate::perf::increment_params_lookup();
615        let key = key.as_ref();
616        let result = self
617            .0
618            .get(key)
619            .and_then(|value| semantic_i32(key, value))
620            .and_then(|value| usize::try_from(value).ok());
621        #[cfg(debug_assertions)]
622        debug_assert_eq!(
623            result,
624            self.0.get(key).and_then(|value| value.trim().parse().ok()),
625            "semantic usize param {key} diverged from string params"
626        );
627        result
628    }
629
630    /// Parse a parameter as a single zone type.
631    pub fn zone_type<K: AsRef<str>>(&self, key: K) -> Option<ZoneType> {
632        crate::perf::increment_params_lookup();
633        let key = key.as_ref();
634        let result = self
635            .0
636            .get(key)
637            .and_then(|value| semantic_zone_type(key, value));
638        #[cfg(debug_assertions)]
639        debug_assert_eq!(
640            result,
641            self.0.get(key).and_then(|value| legacy_zone_type(value)),
642            "semantic zone param {key} diverged from string params"
643        );
644        result
645    }
646
647    /// Parse a parameter as a comma-separated zone list.
648    pub fn zone_types<K: AsRef<str>>(&self, key: K) -> Vec<ZoneType> {
649        crate::perf::increment_params_lookup();
650        let key = key.as_ref();
651        let result = self
652            .0
653            .get(key)
654            .map(|value| semantic_zone_types(key, value))
655            .unwrap_or_default();
656        #[cfg(debug_assertions)]
657        debug_assert_eq!(
658            result,
659            self.0
660                .get(key)
661                .map(|value| legacy_zone_types(value))
662                .unwrap_or_default(),
663            "semantic zone-list param {key} diverged from string params"
664        );
665        result
666    }
667
668    /// Get a selector-like parameter, asserting that semantic classification
669    /// agrees with selector/reference usage while preserving the legacy raw
670    /// string consumed by current matchers.
671    pub fn selector_value<K: AsRef<str>>(&self, key: K) -> Option<&str> {
672        crate::perf::increment_params_lookup();
673        let key = key.as_ref();
674        let value = self.0.get(key).map(String::as_str)?;
675        #[cfg(debug_assertions)]
676        debug_assert!(
677            matches_selector_semantics(key, value),
678            "semantic selector param {} classified unexpectedly: {:?}",
679            key,
680            parse_semantic_param_value(key, value)
681        );
682        Some(value)
683    }
684
685    /// Get a compiled selector-like parameter.
686    pub fn selector<K: AsRef<str>>(&self, key: K) -> Option<&CompiledSelector> {
687        crate::perf::increment_params_lookup();
688        let key = key.as_ref();
689        self.selector_untracked(key)
690    }
691
692    pub fn selector_untracked<K: AsRef<str>>(&self, key: K) -> Option<&CompiledSelector> {
693        let key = key.as_ref();
694        match self.1.get(key) {
695            Some(CompiledParamValue::Selector(selector))
696            | Some(CompiledParamValue::Reference(selector)) => Some(selector),
697            None => None,
698        }
699    }
700
701    /// Get an owned compiled selector-like parameter.
702    ///
703    /// This is used when trigger/effect structs cache a filter for repeated
704    /// execution. It prefers the parser-produced IR and falls back to compiling
705    /// the raw value as a selector for compatibility with less-specific
706    /// historical keys like `ValidToken`.
707    pub fn selector_cloned<K: AsRef<str>>(&self, key: K) -> Option<CompiledSelector> {
708        crate::perf::increment_params_lookup();
709        let key = key.as_ref();
710        match self.1.get(key) {
711            Some(CompiledParamValue::Selector(selector))
712            | Some(CompiledParamValue::Reference(selector)) => Some(selector.clone()),
713            None => self.0.get(key).map(|value| cached_compiled_selector(value)),
714        }
715    }
716
717    pub fn selector_cloned_any(&self, keys: &[&str]) -> Option<CompiledSelector> {
718        keys.iter().find_map(|key| self.selector_cloned(*key))
719    }
720
721    /// Get a reference-like parameter, asserting that semantic classification
722    /// agrees with reference usage while preserving the legacy raw string.
723    pub fn reference_value<K: AsRef<str>>(&self, key: K) -> Option<&str> {
724        crate::perf::increment_params_lookup();
725        let key = key.as_ref();
726        let value = self.0.get(key).map(String::as_str)?;
727        #[cfg(debug_assertions)]
728        debug_assert!(
729            matches_reference_semantics(key, value),
730            "semantic reference param {} classified unexpectedly: {:?}",
731            key,
732            parse_semantic_param_value(key, value)
733        );
734        Some(value)
735    }
736
737    /// Get a compiled reference-like parameter.
738    pub fn reference<K: AsRef<str>>(&self, key: K) -> Option<&CompiledSelector> {
739        crate::perf::increment_params_lookup();
740        let key = key.as_ref();
741        match self.1.get(key) {
742            Some(CompiledParamValue::Reference(selector))
743            | Some(CompiledParamValue::Selector(selector)) => Some(selector),
744            None => None,
745        }
746    }
747
748    /// Get a parameter value, cloning it into an owned String.
749    pub fn get_cloned(&self, key: &str) -> Option<String> {
750        self.0.get(key).cloned()
751    }
752
753    /// Split a delimited parameter value into trimmed, non-empty owned parts.
754    pub fn split_param_list<K: AsRef<str>>(&self, key: K, delimiter: &str) -> Vec<String> {
755        split_param_list_value(self.get(key), delimiter)
756    }
757
758    /// Get a parameter value lowered through the semantic Forge DSL classifier.
759    ///
760    /// This borrows the stored value and falls back to `SemanticParamValue::Raw`
761    /// for keys that are intentionally not classified yet.
762    pub fn semantic_value<K: AsRef<str>>(&self, key: K) -> Option<SemanticParamValue<'_>> {
763        crate::perf::increment_params_lookup();
764        let key = key.as_ref();
765        self.0
766            .get(key)
767            .map(|value| parse_semantic_param_value(key, value))
768    }
769
770    // ── Diagnostics ────────────────────────────────────────────────────
771
772    /// Get a required parameter, logging a warning if missing.
773    ///
774    /// Use this instead of `.get()` when the parameter is expected to exist.
775    /// Missing parameters are logged with context for debugging card scripts.
776    pub fn require(&self, key: &str, context: &str) -> Option<&str> {
777        match self.get(key) {
778            Some(v) => Some(v),
779            None => {
780                eprintln!("[parse] missing required param '{key}' in {context}");
781                None
782            }
783        }
784    }
785
786    /// Get a required parameter as an owned String, logging if missing.
787    pub fn require_cloned(&self, key: &str, context: &str) -> Option<String> {
788        self.require(key, context).map(|s| s.to_string())
789    }
790
791    // ── Iteration ───────────────────────────────────────────────────────
792
793    /// Iterate over all key-value pairs.
794    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
795        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
796    }
797}
798
799pub fn split_param_list_value(value: Option<&str>, delimiter: &str) -> Vec<String> {
800    value
801        .map(|value| {
802            value
803                .split(delimiter)
804                .map(str::trim)
805                .filter(|part| !part.is_empty())
806                .map(str::to_owned)
807                .collect()
808        })
809        .unwrap_or_default()
810}
811
812fn semantic_i32(key: &str, value: &str) -> Option<i32> {
813    match parse_semantic_param_value(key, value) {
814        SemanticParamValue::Integer(value) => Some(value),
815        SemanticParamValue::Amount(SemanticAmount::Literal(value)) => Some(value),
816        _ => value.trim().parse().ok(),
817    }
818}
819
820fn semantic_zone_type(key: &str, value: &str) -> Option<ZoneType> {
821    match parse_semantic_param_value(key, value) {
822        SemanticParamValue::ZoneList(zones) if zones.len() == 1 => {
823            zones.first().and_then(|zone| legacy_zone_type(zone))
824        }
825        SemanticParamValue::ZoneList(_) => None,
826        _ => legacy_zone_type(value),
827    }
828}
829
830fn semantic_zone_types(key: &str, value: &str) -> Vec<ZoneType> {
831    match parse_semantic_param_value(key, value) {
832        SemanticParamValue::ZoneList(zones) => {
833            zones.into_iter().filter_map(legacy_zone_type).collect()
834        }
835        _ => legacy_zone_types(value),
836    }
837}
838
839fn legacy_zone_type(value: &str) -> Option<ZoneType> {
840    let value = value.trim();
841    if value.eq_ignore_ascii_case("Deck") {
842        Some(ZoneType::Library)
843    } else {
844        ZoneType::from_str_compat(value)
845    }
846}
847
848fn legacy_zone_types(value: &str) -> Vec<ZoneType> {
849    value
850        .split([',', ' '])
851        .map(str::trim)
852        .filter(|part| !part.is_empty())
853        .filter_map(legacy_zone_type)
854        .collect()
855}
856
857fn compile_param_map(map: &BTreeMap<String, String>) -> BTreeMap<String, CompiledParamValue> {
858    map.iter()
859        .filter_map(|(key, value)| {
860            compile_semantic_param_value(&parse_semantic_param_value(key, value))
861                .map(|compiled| (key.clone(), compiled))
862        })
863        .collect()
864}
865
866fn update_compiled_param(
867    compiled: &mut BTreeMap<String, CompiledParamValue>,
868    key: &str,
869    value: &str,
870) {
871    match compile_semantic_param_value(&parse_semantic_param_value(key, value)) {
872        Some(value) => {
873            compiled.insert(key.to_string(), value);
874        }
875        None => {
876            compiled.remove(key);
877        }
878    }
879}
880
881fn compile_semantic_param_value(value: &SemanticParamValue<'_>) -> Option<CompiledParamValue> {
882    match value {
883        SemanticParamValue::Selector(selector) => Some(CompiledParamValue::Selector(
884            compile_semantic_selector(selector),
885        )),
886        SemanticParamValue::Reference(selector) => Some(CompiledParamValue::Reference(
887            compile_semantic_selector(selector),
888        )),
889        _ => None,
890    }
891}
892
893fn compile_semantic_selector(selector: &SemanticSelector<'_>) -> CompiledSelector {
894    let alternatives = selector
895        .alternatives
896        .iter()
897        .map(|alternative| CompiledSelectorAlternative {
898            raw: alternative.raw.to_string(),
899            parts: alternative
900                .parts
901                .iter()
902                .map(|part| CompiledSelectorPart {
903                    separator: part.separator,
904                    value: part.value.to_string(),
905                })
906                .collect(),
907        })
908        .collect::<Vec<_>>();
909    let ir = lower_compiled_selector(&alternatives);
910    CompiledSelector { alternatives, ir }
911}
912
913fn lower_compiled_selector(alternatives: &[CompiledSelectorAlternative]) -> Selector {
914    Selector {
915        alternatives: alternatives
916            .iter()
917            .map(|alternative| {
918                let mut predicates: Vec<_> = alternative
919                    .parts
920                    .iter()
921                    .enumerate()
922                    .map(|(idx, part)| lower_selector_part(&part.value, idx == 0))
923                    .collect();
924                predicates.sort_by_key(selector_predicate_order);
925                SelectorAlt { predicates }
926            })
927            .collect(),
928    }
929}
930
931fn selector_predicate_order(predicate: &SelectorPredicate) -> u8 {
932    match predicate {
933        SelectorPredicate::Any
934        | SelectorPredicate::CardType(_)
935        | SelectorPredicate::CardSupertype(_)
936        | SelectorPredicate::Zone(_)
937        | SelectorPredicate::CardController(_)
938        | SelectorPredicate::CardOwner(_)
939        | SelectorPredicate::Tapped(_)
940        | SelectorPredicate::StartedTurnTapped(_)
941        | SelectorPredicate::CameUnderControlSinceLastUpkeep
942        | SelectorPredicate::Token(_)
943        | SelectorPredicate::Color(_)
944        | SelectorPredicate::Multicolor
945        | SelectorPredicate::Colorless
946        | SelectorPredicate::Commander
947        | SelectorPredicate::Legendary
948        | SelectorPredicate::Kicked => 0,
949        SelectorPredicate::CardIdentity(_) | SelectorPredicate::PlayerController(_) => 1,
950        SelectorPredicate::NumericComparison { .. }
951        | SelectorPredicate::NumericParity { .. }
952        | SelectorPredicate::CounterComparison { .. }
953        | SelectorPredicate::Keyword { .. }
954        | SelectorPredicate::CardState(_)
955        | SelectorPredicate::ChosenType
956        | SelectorPredicate::WasCast { .. }
957        | SelectorPredicate::SourceColor(_)
958        | SelectorPredicate::SourceColorless
959        | SelectorPredicate::ChosenColorSource => 2,
960        SelectorPredicate::Context(_) => 3,
961        SelectorPredicate::Relation(_) => 4,
962        SelectorPredicate::RememberedCard
963        | SelectorPredicate::TriggerRememberedCard
964        | SelectorPredicate::EffectSource
965        | SelectorPredicate::DamagedBy
966        | SelectorPredicate::AttachedBy => 5,
967        SelectorPredicate::Not(inner) => selector_predicate_order(inner).saturating_add(1),
968        SelectorPredicate::Player => 6,
969        SelectorPredicate::Raw(_) => 7,
970    }
971}
972
973fn lower_selector_part(value: &str, is_first_part: bool) -> SelectorPredicate {
974    let normalized = value.trim();
975    if normalized.is_empty() {
976        return SelectorPredicate::Raw(String::new());
977    }
978    let lower = normalized.to_ascii_lowercase();
979    if is_first_part {
980        return match lower.as_str() {
981            "any" => SelectorPredicate::Any,
982            "card" => SelectorPredicate::CardType(CardSelectorType::Card),
983            "creature" => SelectorPredicate::CardType(CardSelectorType::Creature),
984            "land" => SelectorPredicate::CardType(CardSelectorType::Land),
985            "instant" => SelectorPredicate::CardType(CardSelectorType::Instant),
986            "sorcery" => SelectorPredicate::CardType(CardSelectorType::Sorcery),
987            "artifact" => SelectorPredicate::CardType(CardSelectorType::Artifact),
988            "enchantment" => SelectorPredicate::CardType(CardSelectorType::Enchantment),
989            "planeswalker" => SelectorPredicate::CardType(CardSelectorType::Planeswalker),
990            "permanent" => SelectorPredicate::CardType(CardSelectorType::Permanent),
991            "spell" => SelectorPredicate::CardType(CardSelectorType::Spell),
992            "nonland" => SelectorPredicate::CardType(CardSelectorType::NonLand),
993            "noncreature" => SelectorPredicate::CardType(CardSelectorType::NonCreature),
994            "player" | "each" | "player.ingame" => SelectorPredicate::Player,
995            "you" | "youctrl" => SelectorPredicate::PlayerController(ControllerSelector::You),
996            "opponent" | "oppctrl" | "opponentctrl" => {
997                SelectorPredicate::PlayerController(ControllerSelector::Opponent)
998            }
999            named if named.starts_with("named") => SelectorPredicate::CardType(
1000                CardSelectorType::Named(normalized[5..].trim().to_string()),
1001            ),
1002            _ => SelectorPredicate::CardType(CardSelectorType::Subtype(normalized.to_string())),
1003        };
1004    }
1005
1006    if let Some(stripped) = normalized.strip_prefix('!') {
1007        let predicate = lower_selector_part(stripped, false);
1008        return SelectorPredicate::Not(Box::new(predicate));
1009    }
1010
1011    match lower.as_str() {
1012        "self" | "strictlyself" => SelectorPredicate::CardIdentity(CardIdentitySelector::Self_),
1013        "other" | "strictlyother" => SelectorPredicate::CardIdentity(CardIdentitySelector::Other),
1014        "you" | "youctrl" | "youcontrol" => {
1015            SelectorPredicate::CardController(ControllerSelector::You)
1016        }
1017        "opponent" | "oppctrl" | "opponentctrl" => {
1018            SelectorPredicate::CardController(ControllerSelector::Opponent)
1019        }
1020        "chosenctrl" => SelectorPredicate::Raw(normalized.to_string()),
1021        "youown" => SelectorPredicate::CardOwner(ControllerSelector::You),
1022        "oppown" | "opponentown" => SelectorPredicate::CardOwner(ControllerSelector::Opponent),
1023        // Raw — runtime-resolved against the SA's target list in valid_filter.
1024        // (`targetedplayerctrl` already lowers to a Context predicate below.)
1025        "targetedplayerown" | "targetedown" | "targetedowner" => {
1026            SelectorPredicate::Raw(normalized.to_string())
1027        }
1028        "youdontctrl" => SelectorPredicate::Not(Box::new(SelectorPredicate::CardController(
1029            ControllerSelector::You,
1030        ))),
1031        "youdontown" => SelectorPredicate::Not(Box::new(SelectorPredicate::CardOwner(
1032            ControllerSelector::You,
1033        ))),
1034        "tapped" => SelectorPredicate::Tapped(true),
1035        "untapped" => SelectorPredicate::Tapped(false),
1036        "startedtheturntapped" => SelectorPredicate::StartedTurnTapped(true),
1037        "startedtheturnuntapped" => SelectorPredicate::StartedTurnTapped(false),
1038        "cameundercontrolsincelastupkeep" => SelectorPredicate::CameUnderControlSinceLastUpkeep,
1039        "inzonebattlefield" => SelectorPredicate::Zone(ZoneType::Battlefield),
1040        "inzonegraveyard" => SelectorPredicate::Zone(ZoneType::Graveyard),
1041        "inzonehand" => SelectorPredicate::Zone(ZoneType::Hand),
1042        "inzoneexile" => SelectorPredicate::Zone(ZoneType::Exile),
1043        "inzonestack" => SelectorPredicate::Zone(ZoneType::Stack),
1044        "isremembered" => SelectorPredicate::RememberedCard,
1045        "istriggerremembered" => SelectorPredicate::TriggerRememberedCard,
1046        "effectsource" => SelectorPredicate::EffectSource,
1047        "iscommander" => SelectorPredicate::Commander,
1048        "legendary" => SelectorPredicate::Legendary,
1049        "basic" => SelectorPredicate::CardSupertype(CardSupertypeSelector::Basic),
1050        "snow" => SelectorPredicate::CardSupertype(CardSupertypeSelector::Snow),
1051        "kicked" => SelectorPredicate::Kicked,
1052        "token" => SelectorPredicate::Token(true),
1053        "nontoken" => SelectorPredicate::Token(false),
1054        "creature" => SelectorPredicate::CardType(CardSelectorType::Creature),
1055        "land" => SelectorPredicate::CardType(CardSelectorType::Land),
1056        "instant" => SelectorPredicate::CardType(CardSelectorType::Instant),
1057        "sorcery" => SelectorPredicate::CardType(CardSelectorType::Sorcery),
1058        "artifact" => SelectorPredicate::CardType(CardSelectorType::Artifact),
1059        "enchantment" => SelectorPredicate::CardType(CardSelectorType::Enchantment),
1060        "planeswalker" => SelectorPredicate::CardType(CardSelectorType::Planeswalker),
1061        "permanent" => SelectorPredicate::CardType(CardSelectorType::Permanent),
1062        "noncreature" => SelectorPredicate::CardType(CardSelectorType::NonCreature),
1063        "nonland" => SelectorPredicate::CardType(CardSelectorType::NonLand),
1064        "time lord" => {
1065            SelectorPredicate::CardType(CardSelectorType::Subtype("Time Lord".to_string()))
1066        }
1067        "white" => SelectorPredicate::Color(CardColorSelector::White),
1068        "blue" => SelectorPredicate::Color(CardColorSelector::Blue),
1069        "black" => SelectorPredicate::Color(CardColorSelector::Black),
1070        "red" => SelectorPredicate::Color(CardColorSelector::Red),
1071        "green" => SelectorPredicate::Color(CardColorSelector::Green),
1072        "multicolor" => SelectorPredicate::Multicolor,
1073        "colorless" => SelectorPredicate::Colorless,
1074        "whitesource" => SelectorPredicate::SourceColor(CardColorSelector::White),
1075        "bluesource" => SelectorPredicate::SourceColor(CardColorSelector::Blue),
1076        "blacksource" => SelectorPredicate::SourceColor(CardColorSelector::Black),
1077        "redsource" => SelectorPredicate::SourceColor(CardColorSelector::Red),
1078        "greensource" => SelectorPredicate::SourceColor(CardColorSelector::Green),
1079        "colorlesssource" => SelectorPredicate::SourceColorless,
1080        "chosencolorsource" => SelectorPredicate::ChosenColorSource,
1081        "attacking" => SelectorPredicate::Context(ContextPredicate::Attacking(None)),
1082        "attackingyou" => {
1083            SelectorPredicate::Context(ContextPredicate::Attacking(Some(TargetRef::Source)))
1084        }
1085        "blocking" => SelectorPredicate::Context(ContextPredicate::Blocking(None)),
1086        "blocked" => SelectorPredicate::Context(ContextPredicate::Blocked),
1087        "attackedthisturn" => SelectorPredicate::Context(ContextPredicate::AttackedThisTurn),
1088        "blockingsource" => SelectorPredicate::Context(ContextPredicate::BlockingSource),
1089        "blockedbysource" => SelectorPredicate::Context(ContextPredicate::BlockedBySource),
1090        "samename" => {
1091            SelectorPredicate::Relation(RelationPredicate::SharesNameWith(TargetRef::Source))
1092        }
1093        "damagedby" => SelectorPredicate::DamagedBy,
1094        "equippedby" | "enchantedby" | "attachedby" => SelectorPredicate::AttachedBy,
1095        "facedown" => SelectorPredicate::CardState(CardStateSelector::FaceDown),
1096        "paired" => SelectorPredicate::CardState(CardStateSelector::Paired),
1097        "pairedwith" => SelectorPredicate::CardState(CardStateSelector::PairedWithSource),
1098        "attached" => SelectorPredicate::CardState(CardStateSelector::Attached),
1099        "equipped" => SelectorPredicate::CardState(CardStateSelector::Equipped),
1100        "enchanted" => SelectorPredicate::CardState(CardStateSelector::Enchanted),
1101        "hascounters" => SelectorPredicate::CardState(CardStateSelector::HasCounters),
1102        "isimprinted" => SelectorPredicate::CardState(CardStateSelector::IsImprinted),
1103        "chosen" => SelectorPredicate::CardState(CardStateSelector::Chosen),
1104        "chosencard" | "chosencardstrict" => {
1105            SelectorPredicate::CardState(CardStateSelector::ChosenCard)
1106        }
1107        "namedcard" => SelectorPredicate::CardState(CardStateSelector::NamedCard),
1108        "chosencolor" => SelectorPredicate::CardState(CardStateSelector::ChosenColor),
1109        "thisturnentered" => SelectorPredicate::CardState(CardStateSelector::EnteredThisTurn),
1110        "wasdealtdamagethisturn" => {
1111            SelectorPredicate::CardState(CardStateSelector::WasDealtDamageThisTurn)
1112        }
1113        "historic" => SelectorPredicate::CardState(CardStateSelector::Historic),
1114        "modified" => SelectorPredicate::CardState(CardStateSelector::Modified),
1115        "issaddled" => SelectorPredicate::CardState(CardStateSelector::Saddled),
1116        "mayplaysource" => SelectorPredicate::CardState(CardStateSelector::MayPlaySource),
1117        "exiledwithsource" => SelectorPredicate::Context(ContextPredicate::ExiledWithSource),
1118        "toplibrary" => SelectorPredicate::Context(ContextPredicate::TopLibrary),
1119        "suspended" => SelectorPredicate::CardState(CardStateSelector::Suspended),
1120        "singletarget" => SelectorPredicate::CardState(CardStateSelector::SingleTarget),
1121        "promisedgift" => SelectorPredicate::CardState(CardStateSelector::PromisedGift),
1122        "isringbearer" => SelectorPredicate::CardState(CardStateSelector::RingBearer),
1123        "wascast" => SelectorPredicate::WasCast { by_you: false },
1124        "wascastbyyou" => SelectorPredicate::WasCast { by_you: true },
1125        named if named.starts_with("named") => {
1126            SelectorPredicate::CardType(CardSelectorType::Named(normalized[5..].trim().to_string()))
1127        }
1128        cast_origin if cast_origin.starts_with("wascastfrom") => {
1129            lower_cast_origin_predicate(normalized)
1130                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1131        }
1132        "chosentype" => SelectorPredicate::ChosenType,
1133        "rememberedplayerctrl" => {
1134            SelectorPredicate::Context(ContextPredicate::RememberedPlayerCtrl)
1135        }
1136        "targetedplayerctrl" => SelectorPredicate::Context(ContextPredicate::TargetedPlayerCtrl),
1137        "activeplayerctrl" => SelectorPredicate::Context(ContextPredicate::ActivePlayerCtrl),
1138        "defenderctrl" => SelectorPredicate::Context(ContextPredicate::DefenderCtrl),
1139        "enchantedcontroller" => SelectorPredicate::Context(ContextPredicate::EnchantedController),
1140        "notdefinedtargeted" => SelectorPredicate::Context(ContextPredicate::NotDefinedTargeted),
1141        controlled if controlled.starts_with("controlledby ") => SelectorPredicate::Context(
1142            ContextPredicate::ControlledBy(normalized["ControlledBy ".len()..].trim().to_string()),
1143        ),
1144        shares if shares.starts_with("sharesnamewith") => {
1145            lower_relation_target_ref(normalized["sharesNameWith".len()..].trim())
1146                .map(|target| {
1147                    SelectorPredicate::Relation(RelationPredicate::SharesNameWith(target))
1148                })
1149                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1150        }
1151        shares if shares.starts_with("doesnotsharenamewith") => {
1152            lower_relation_target_ref(normalized["doesNotShareNameWith".len()..].trim())
1153                .map(|target| {
1154                    SelectorPredicate::Relation(RelationPredicate::DoesNotShareNameWith(target))
1155                })
1156                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1157        }
1158        shares if shares.starts_with("sharescardtypewith") => {
1159            lower_relation_target_ref(normalized["sharesCardTypeWith".len()..].trim())
1160                .map(|target| {
1161                    SelectorPredicate::Relation(RelationPredicate::SharesCardTypeWith(target))
1162                })
1163                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1164        }
1165        shares if shares.starts_with("sharescolorwith") => {
1166            lower_relation_target_ref(normalized["SharesColorWith".len()..].trim())
1167                .map(|target| {
1168                    SelectorPredicate::Relation(RelationPredicate::SharesColorWith(target))
1169                })
1170                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1171        }
1172        shares if shares.starts_with("sharescmcwith") => {
1173            lower_relation_target_ref(normalized["SharesCMCWith".len()..].trim())
1174                .map(|target| {
1175                    SelectorPredicate::Relation(RelationPredicate::SharesManaValueWith(target))
1176                })
1177                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1178        }
1179        shares if shares.starts_with("sharescreaturetypewith") => {
1180            lower_relation_target_ref(normalized["sharesCreatureTypeWith".len()..].trim())
1181                .map(|target| {
1182                    SelectorPredicate::Relation(RelationPredicate::SharesCreatureTypeWith(target))
1183                })
1184                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1185        }
1186        attacking if attacking.starts_with("attacking ") => {
1187            lower_relation_target_ref(normalized["attacking ".len()..].trim())
1188                .map(|target| SelectorPredicate::Context(ContextPredicate::Attacking(Some(target))))
1189                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1190        }
1191        blocked_by if blocked_by.starts_with("blockedbyvalidthisturn ") => {
1192            lower_blocked_by_valid_this_turn(normalized["blockedByValidThisTurn ".len()..].trim())
1193                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1194        }
1195        blocked if blocked.starts_with("blockedvalidthisturn ") => {
1196            lower_card_selector_type(normalized["blockedValidThisTurn ".len()..].trim())
1197                .map(|card_type| {
1198                    SelectorPredicate::Context(ContextPredicate::BlockedValidThisTurn(card_type))
1199                })
1200                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1201        }
1202        blocking_valid if blocking_valid.starts_with("blockingvalid ") => {
1203            lower_card_selector_type(normalized["blockingValid ".len()..].trim())
1204                .map(|card_type| {
1205                    SelectorPredicate::Context(ContextPredicate::BlockingValid(card_type))
1206                })
1207                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1208        }
1209        blocking if blocking.starts_with("blocking ") => {
1210            lower_relation_target_ref(normalized["blocking ".len()..].trim())
1211                .map(|target| SelectorPredicate::Context(ContextPredicate::Blocking(Some(target))))
1212                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1213        }
1214        attached if attached.starts_with("attachedto ") => {
1215            lower_attached_to_relation(normalized["AttachedTo ".len()..].trim())
1216                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1217        }
1218        owned if owned.starts_with("ownedby ") => {
1219            lower_relation_target_ref(normalized["OwnedBy ".len()..].trim())
1220                .map(|target| SelectorPredicate::Relation(RelationPredicate::OwnedBy(target)))
1221                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1222        }
1223        opponent if opponent.starts_with("opponentof ") => {
1224            lower_relation_target_ref(normalized["OpponentOf ".len()..].trim())
1225                .map(|target| SelectorPredicate::Relation(RelationPredicate::OpponentOf(target)))
1226                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1227        }
1228        targeting if targeting.starts_with("istargeting ") => {
1229            lower_relation_target_ref(normalized["IsTargeting ".len()..].trim())
1230                .map(|target| SelectorPredicate::Relation(RelationPredicate::IsTargeting(target)))
1231                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1232        }
1233        entered if entered.starts_with("thisturnenteredfrom_") => {
1234            lower_entered_from_zone(normalized["ThisTurnEnteredFrom_".len()..].trim())
1235                .map(|zone| SelectorPredicate::Context(ContextPredicate::EnteredThisTurnFrom(zone)))
1236                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1237        }
1238        entered if entered.starts_with("enteredunder ") => {
1239            lower_relation_target_ref(normalized["EnteredUnder ".len()..].trim())
1240                .map(|target| SelectorPredicate::Context(ContextPredicate::EnteredUnder(target)))
1241                .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1242        }
1243        _ => lower_non_predicate(normalized).unwrap_or_else(|| {
1244            lower_keyword_predicate(normalized).unwrap_or_else(|| {
1245                lower_selector_comparison(normalized).unwrap_or_else(|| {
1246                    lower_counter_comparison(normalized).unwrap_or_else(|| {
1247                        lower_subtype_predicate(normalized)
1248                            .unwrap_or_else(|| SelectorPredicate::Raw(normalized.to_string()))
1249                    })
1250                })
1251            })
1252        }),
1253    }
1254}
1255
1256fn lower_relation_target_ref(value: &str) -> Option<TargetRef> {
1257    if value.is_empty()
1258        || value.eq_ignore_ascii_case("Self")
1259        || value.eq_ignore_ascii_case("Source")
1260        || value.eq_ignore_ascii_case("You")
1261        || value.eq_ignore_ascii_case("YouCtrl")
1262    {
1263        Some(TargetRef::Source)
1264    } else if value.eq_ignore_ascii_case("Remembered") {
1265        Some(TargetRef::Remembered)
1266    } else if value.eq_ignore_ascii_case("RememberedLKI") {
1267        Some(TargetRef::RememberedLki)
1268    } else if value.eq_ignore_ascii_case("Imprinted") {
1269        Some(TargetRef::Imprinted)
1270    } else if value.eq_ignore_ascii_case("ChosenCard") {
1271        Some(TargetRef::ChosenCard)
1272    } else if value.eq_ignore_ascii_case("ChosenPlayer") {
1273        Some(TargetRef::ChosenPlayer)
1274    } else if value.eq_ignore_ascii_case("Targeted")
1275        || value.eq_ignore_ascii_case("TargetedPlayer")
1276        || value.eq_ignore_ascii_case("TargetedController")
1277    {
1278        Some(TargetRef::Targeted)
1279    } else if value.eq_ignore_ascii_case("Player") {
1280        Some(TargetRef::Player)
1281    } else if value.eq_ignore_ascii_case("Opponent") {
1282        Some(TargetRef::Opponent)
1283    } else if value.eq_ignore_ascii_case("Battlefield") {
1284        Some(TargetRef::Battlefield)
1285    } else if value.eq_ignore_ascii_case("OtherYourBattlefield") {
1286        Some(TargetRef::OtherYourBattlefield)
1287    } else if value.eq_ignore_ascii_case("YourGraveyard") {
1288        Some(TargetRef::YourGraveyard)
1289    } else if value.eq_ignore_ascii_case("TriggeredTarget") {
1290        Some(TargetRef::TriggeredTarget)
1291    } else if value.eq_ignore_ascii_case("TriggeredPlayer") {
1292        Some(TargetRef::TriggeredPlayer)
1293    } else if value.eq_ignore_ascii_case("TriggeredCard") {
1294        Some(TargetRef::TriggeredCard)
1295    } else if value.eq_ignore_ascii_case("TriggeredCardController") {
1296        Some(TargetRef::TriggeredCardController)
1297    } else if value.eq_ignore_ascii_case("TriggeredDefendingPlayer") {
1298        Some(TargetRef::TriggeredDefendingPlayer)
1299    } else if value.eq_ignore_ascii_case("TriggeredAttackedTarget") {
1300        Some(TargetRef::TriggeredAttackedTarget)
1301    } else if value.eq_ignore_ascii_case("Commander") {
1302        Some(TargetRef::Commander)
1303    } else {
1304        None
1305    }
1306}
1307
1308fn lower_blocked_by_valid_this_turn(value: &str) -> Option<SelectorPredicate> {
1309    if let Some(target) = lower_relation_target_ref(value) {
1310        return Some(SelectorPredicate::Context(
1311            ContextPredicate::BlockedByValidThisTurn(target),
1312        ));
1313    }
1314    lower_card_selector_type(value).map(|card_type| {
1315        SelectorPredicate::Context(ContextPredicate::BlockedByValidThisTurnType(card_type))
1316    })
1317}
1318
1319fn lower_card_selector_type(value: &str) -> Option<CardSelectorType> {
1320    match value.to_ascii_lowercase().as_str() {
1321        "card" => Some(CardSelectorType::Card),
1322        "creature" => Some(CardSelectorType::Creature),
1323        "land" => Some(CardSelectorType::Land),
1324        "artifact" => Some(CardSelectorType::Artifact),
1325        "enchantment" => Some(CardSelectorType::Enchantment),
1326        "planeswalker" => Some(CardSelectorType::Planeswalker),
1327        "permanent" => Some(CardSelectorType::Permanent),
1328        "nonland" => Some(CardSelectorType::NonLand),
1329        "noncreature" => Some(CardSelectorType::NonCreature),
1330        _ if value
1331            .chars()
1332            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '\'') =>
1333        {
1334            Some(CardSelectorType::Subtype(value.to_string()))
1335        }
1336        _ => None,
1337    }
1338}
1339
1340fn lower_entered_from_zone(value: &str) -> Option<ZoneType> {
1341    match value.to_ascii_lowercase().as_str() {
1342        "battlefield" => Some(ZoneType::Battlefield),
1343        "graveyard" => Some(ZoneType::Graveyard),
1344        "library" => Some(ZoneType::Library),
1345        "hand" => Some(ZoneType::Hand),
1346        "exile" => Some(ZoneType::Exile),
1347        _ => None,
1348    }
1349}
1350
1351fn lower_attached_to_relation(value: &str) -> Option<SelectorPredicate> {
1352    if let Some(target) = lower_relation_target_ref(value) {
1353        return Some(SelectorPredicate::Relation(RelationPredicate::AttachedTo(
1354            target,
1355        )));
1356    }
1357    let card_type = match value.to_ascii_lowercase().as_str() {
1358        "card" => CardSelectorType::Card,
1359        "creature" => CardSelectorType::Creature,
1360        "land" => CardSelectorType::Land,
1361        "artifact" => CardSelectorType::Artifact,
1362        "enchantment" => CardSelectorType::Enchantment,
1363        "permanent" => CardSelectorType::Permanent,
1364        _ => return None,
1365    };
1366    Some(SelectorPredicate::Relation(
1367        RelationPredicate::AttachedToType(card_type),
1368    ))
1369}
1370
1371fn lower_cast_origin_predicate(value: &str) -> Option<SelectorPredicate> {
1372    let lower = value.to_ascii_lowercase();
1373    let origin = match lower.as_str() {
1374        "wascastfromhand" => CastOrigin::Hand,
1375        "wascastfromyourhand" => CastOrigin::YourHand,
1376        "wascastfromyourhandbyyou" => CastOrigin::YourHandByYou,
1377        "wascastfromtheirhand" => CastOrigin::TheirHand,
1378        "wascastfromexile" => CastOrigin::Exile,
1379        "wascastfromgraveyard" => CastOrigin::Graveyard,
1380        "wascastfromyourgraveyard" => CastOrigin::YourGraveyard,
1381        "wascastfromyourgraveyardbyyou" => CastOrigin::YourGraveyardByYou,
1382        "wascastfromyourlibrary" => CastOrigin::YourLibrary,
1383        _ => return None,
1384    };
1385    Some(SelectorPredicate::Context(ContextPredicate::WasCastFrom(
1386        origin,
1387    )))
1388}
1389
1390fn lower_non_predicate(value: &str) -> Option<SelectorPredicate> {
1391    let lower = value.to_ascii_lowercase();
1392    let rest = lower.strip_prefix("non")?;
1393    if rest.is_empty() {
1394        return None;
1395    }
1396    let positive = match rest {
1397        "white" => SelectorPredicate::Color(CardColorSelector::White),
1398        "blue" => SelectorPredicate::Color(CardColorSelector::Blue),
1399        "black" => SelectorPredicate::Color(CardColorSelector::Black),
1400        "red" => SelectorPredicate::Color(CardColorSelector::Red),
1401        "green" => SelectorPredicate::Color(CardColorSelector::Green),
1402        "colorless" => SelectorPredicate::Colorless,
1403        "creature" => SelectorPredicate::CardType(CardSelectorType::Creature),
1404        "land" => SelectorPredicate::CardType(CardSelectorType::Land),
1405        "artifact" => SelectorPredicate::CardType(CardSelectorType::Artifact),
1406        "enchantment" => SelectorPredicate::CardType(CardSelectorType::Enchantment),
1407        "legendary" => SelectorPredicate::Legendary,
1408        "basic" => SelectorPredicate::CardSupertype(CardSupertypeSelector::Basic),
1409        "snow" => SelectorPredicate::CardSupertype(CardSupertypeSelector::Snow),
1410        "token" => SelectorPredicate::Token(true),
1411        _ => SelectorPredicate::CardType(CardSelectorType::Subtype(value[3..].to_string())),
1412    };
1413    Some(SelectorPredicate::Not(Box::new(positive)))
1414}
1415
1416fn lower_keyword_predicate(value: &str) -> Option<SelectorPredicate> {
1417    let lower = value.to_ascii_lowercase();
1418    if lower.starts_with("without") && value.len() > 7 {
1419        return Some(SelectorPredicate::Keyword {
1420            name: value[7..].to_string(),
1421            present: false,
1422        });
1423    }
1424    if lower.starts_with("with") && value.len() > 4 {
1425        return Some(SelectorPredicate::Keyword {
1426            name: value[4..].to_string(),
1427            present: true,
1428        });
1429    }
1430    None
1431}
1432
1433fn lower_subtype_predicate(value: &str) -> Option<SelectorPredicate> {
1434    let lower = value.to_ascii_lowercase();
1435    let structured = lower.starts_with("cmc")
1436        || lower.starts_with("power")
1437        || lower.starts_with("toughness")
1438        || lower.starts_with("counters_")
1439        || lower.starts_with("wascastfrom")
1440        || lower.starts_with("shares")
1441        || lower.starts_with("attached")
1442        || lower.starts_with("controlledby ")
1443        || lower.ends_with("source")
1444        || value.contains(' ')
1445        || value.contains('_')
1446        || value.contains('/');
1447    if structured
1448        || value.is_empty()
1449        || !value
1450            .chars()
1451            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '\'')
1452    {
1453        return None;
1454    }
1455    Some(SelectorPredicate::CardType(CardSelectorType::Subtype(
1456        value.to_string(),
1457    )))
1458}
1459
1460fn lower_selector_comparison(value: &str) -> Option<SelectorPredicate> {
1461    let lower = value.to_ascii_lowercase();
1462    let (property, rest) = if lower.starts_with("cmc") {
1463        (NumericSelectorProperty::ManaValue, &value[3..])
1464    } else if lower.starts_with("power") {
1465        (NumericSelectorProperty::Power, &value[5..])
1466    } else if lower.starts_with("toughness") {
1467        (NumericSelectorProperty::Toughness, &value[9..])
1468    } else if lower.starts_with("numtargets ") {
1469        (NumericSelectorProperty::TargetCount, value[11..].trim())
1470    } else if lower.starts_with("manaspent ") {
1471        (NumericSelectorProperty::ManaSpent, value[10..].trim())
1472    } else {
1473        return None;
1474    };
1475    if rest.eq_ignore_ascii_case("even") {
1476        return Some(SelectorPredicate::NumericParity {
1477            property,
1478            even: true,
1479        });
1480    }
1481    if rest.eq_ignore_ascii_case("odd") {
1482        return Some(SelectorPredicate::NumericParity {
1483            property,
1484            even: false,
1485        });
1486    }
1487    let (operator, value) = parse_selector_comparison(rest)?;
1488    Some(SelectorPredicate::NumericComparison {
1489        property,
1490        operator,
1491        value,
1492    })
1493}
1494
1495fn lower_counter_comparison(value: &str) -> Option<SelectorPredicate> {
1496    let rest = value.strip_prefix("counters_")?;
1497    let operator = parse_selector_operator(rest.get(..2)?)?;
1498    let after_op = rest.get(2..)?;
1499    let split = after_op.find('_')?;
1500    let value = parse_selector_operand(&after_op[..split])?;
1501    let counter_type = after_op[split + 1..].to_string();
1502    Some(SelectorPredicate::CounterComparison {
1503        operator,
1504        value,
1505        counter_type,
1506    })
1507}
1508
1509fn parse_selector_comparison(
1510    rest: &str,
1511) -> Option<(SelectorCompareOperator, SelectorNumericOperand)> {
1512    let operator = parse_selector_operator(rest.get(..2)?)?;
1513    let value = parse_selector_operand(rest.get(2..)?)?;
1514    Some((operator, value))
1515}
1516
1517fn parse_selector_operand(value: &str) -> Option<SelectorNumericOperand> {
1518    if value.is_empty() {
1519        return None;
1520    }
1521    if let Ok(value) = value.parse::<i32>() {
1522        Some(SelectorNumericOperand::Literal(value))
1523    } else if value
1524        .chars()
1525        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1526    {
1527        Some(SelectorNumericOperand::Symbol(value.to_string()))
1528    } else {
1529        None
1530    }
1531}
1532
1533fn parse_selector_operator(operator: &str) -> Option<SelectorCompareOperator> {
1534    match operator.to_ascii_lowercase().as_str() {
1535        "eq" => Some(SelectorCompareOperator::Eq),
1536        "ne" => Some(SelectorCompareOperator::Ne),
1537        "lt" => Some(SelectorCompareOperator::Lt),
1538        "le" => Some(SelectorCompareOperator::Le),
1539        "gt" => Some(SelectorCompareOperator::Gt),
1540        "ge" => Some(SelectorCompareOperator::Ge),
1541        _ => None,
1542    }
1543}
1544
1545#[cfg(debug_assertions)]
1546fn matches_selector_semantics(key: &str, value: &str) -> bool {
1547    matches!(
1548        parse_semantic_param_value(key, value),
1549        SemanticParamValue::Selector(_)
1550            | SemanticParamValue::Reference(_)
1551            | SemanticParamValue::SVarReference(_)
1552            | SemanticParamValue::DelimitedList(_)
1553            | SemanticParamValue::Raw(_)
1554    )
1555}
1556
1557#[cfg(debug_assertions)]
1558fn matches_reference_semantics(key: &str, value: &str) -> bool {
1559    matches!(
1560        parse_semantic_param_value(key, value),
1561        SemanticParamValue::Reference(_)
1562            | SemanticParamValue::Selector(_)
1563            | SemanticParamValue::SVarReference(_)
1564            | SemanticParamValue::Raw(_)
1565    )
1566}
1567
1568#[cfg(debug_assertions)]
1569fn legacy_parse_params(raw: &str) -> BTreeMap<String, String> {
1570    let mut map = BTreeMap::new();
1571    for part in raw.split('|') {
1572        let part = part.trim();
1573        if let Some(idx) = part.find("$ ") {
1574            let key = part[..idx].trim().to_string();
1575            let value = part[idx + 2..].trim().to_string();
1576            map.insert(key, value);
1577        } else if let Some(idx) = part.find('$') {
1578            let key = part[..idx].trim().to_string();
1579            let value = part[idx + 1..].trim().to_string();
1580            map.insert(key, value);
1581        }
1582    }
1583    map
1584}
1585
1586/// Adapter: convert a parse result to Option, logging failures only when
1587/// the raw input looks like it was *intended* to be the given kind.
1588///
1589/// Card scripts pass all ability lines to all parsers — most `None` results
1590/// are intentional (e.g., an `AB$` line passed to `parse_static_ability`).
1591/// This function only warns when the line's prefix matches the expected kind.
1592///
1593/// ```ignore
1594/// .filter_map(|raw| parse_or_warn(parse_static_ability(raw), "StaticAbility", raw))
1595/// ```
1596/// Like [`parse_or_warn`], but for lines the card parser already classified
1597/// (`T:`/`S:`/`R:` records, stored prefix-stripped on the face): a `None`
1598/// here silently drops a known rule, so it always warns.
1599pub fn parse_classified_or_warn<T>(result: Option<T>, kind: &str, raw: &str) -> Option<T> {
1600    if result.is_none() {
1601        let preview: String = raw.trim().chars().take(100).collect();
1602        eprintln!("[parse] failed to parse {kind} from: {preview}");
1603    }
1604    result
1605}
1606
1607pub fn parse_or_warn<T>(result: Option<T>, kind: &str, raw: &str) -> Option<T> {
1608    if result.is_none() {
1609        let trimmed = raw.trim();
1610        let should_warn = match kind {
1611            "StaticAbility" => trimmed.starts_with("S$") || trimmed.starts_with("S:"),
1612            "ReplacementEffect" => trimmed.starts_with("R$") || trimmed.starts_with("R:"),
1613            "Trigger" => trimmed.starts_with("T$") || trimmed.starts_with("T:"),
1614            "ActivatedAbility" => {
1615                // Only AB$ lines are activated abilities. SP$ (spell) and DB$
1616                // (sub-ability) lines are resolved via build_spell_ability, not
1617                // parse_activated_ability — their None result is intentional.
1618                trimmed.starts_with("AB$") || trimmed.starts_with("AB:")
1619            }
1620            _ => false,
1621        };
1622        if should_warn {
1623            let preview: String = trimmed.chars().take(100).collect();
1624            eprintln!("[parse] failed to parse {kind} from: {preview}");
1625        }
1626    }
1627    result
1628}
1629
1630// ── Conversions for incremental migration ───────────────────────────────────
1631
1632impl From<BTreeMap<String, String>> for Params {
1633    fn from(map: BTreeMap<String, String>) -> Self {
1634        Params::from_map(map)
1635    }
1636}
1637
1638impl From<Params> for BTreeMap<String, String> {
1639    fn from(params: Params) -> Self {
1640        params.0
1641    }
1642}
1643
1644// ── Shared DSL parsing utilities ─────────────────────────────────────────────
1645
1646/// Strip a `/Times.N` multiplier suffix from a filter string.
1647/// Returns (filter_without_suffix, multiplier).
1648///
1649/// Used by SVar `Count$Valid` expressions and cost parsing.
1650/// Example: `"Enchantment.Other/Times.2"` → `("Enchantment.Other", 2)`
1651pub fn strip_times_multiplier(s: &str) -> (&str, i32) {
1652    if let Some(idx) = s.find("/Times.") {
1653        let mult_str = &s[idx + 7..];
1654        let mult = mult_str.parse::<i32>().unwrap_or(1);
1655        (&s[..idx], mult)
1656    } else {
1657        (s, 1)
1658    }
1659}
1660
1661/// Map an "Enchant <type>" keyword value to a ValidTgts$ filter string.
1662/// Used by aura targeting (ability_factory) and aura SBA legality (action.rs).
1663///
1664/// Example: `"creature"` → `"Creature"`, `"land"` → `"Land"`
1665fn normalize_enchant_type(enchant_type: &str) -> &str {
1666    enchant_type
1667        .split_once(':')
1668        .map(|(kind, _)| kind)
1669        .unwrap_or(enchant_type)
1670        .trim()
1671}
1672
1673pub fn enchant_type_to_valid_tgts(enchant_type: &str) -> String {
1674    let normalized = normalize_enchant_type(enchant_type).trim();
1675    match normalized.to_lowercase().as_str() {
1676        "creature" => "Creature".to_string(),
1677        "land" => "Land".to_string(),
1678        "artifact" => "Artifact".to_string(),
1679        "enchantment" => "Enchantment".to_string(),
1680        "planeswalker" => "Planeswalker".to_string(),
1681        "permanent" | "" => "Permanent".to_string(),
1682        "player" => "Player".to_string(),
1683        "creature or player" => "Creature,Player".to_string(),
1684        // Compound filters like "Creature.Legendary" pass through verbatim so
1685        // the targeting layer applies the full restriction rather than
1686        // collapsing to "Permanent" (which would let any permanent qualify).
1687        _ => normalized.to_string(),
1688    }
1689}
1690
1691/// Build a minimal targeting params string from an Enchant keyword payload.
1692/// Handles special cases like `Creature.inZoneGraveyard` used by Animate Dead.
1693pub fn enchant_type_to_target_params(enchant_type: &str) -> String {
1694    let normalized = normalize_enchant_type(enchant_type);
1695    let lower = normalized.to_lowercase();
1696    if lower == "creature.inzonegraveyard" {
1697        return "Origin$ Graveyard | ValidTgts$ Creature".to_string();
1698    }
1699    format!("ValidTgts$ {}", enchant_type_to_valid_tgts(normalized))
1700}
1701
1702/// Check if a card type matches an "Enchant <type>" keyword value.
1703/// Used by aura SBA to verify the enchant restriction is still met.
1704///
1705/// Example: `enchant_type_matches_card("creature", card)` → true if card is a creature
1706pub fn enchant_type_matches_card(
1707    enchant_type: &str,
1708    card: &crate::card::CardInstance,
1709    aura_source: Option<&crate::card::CardInstance>,
1710) -> bool {
1711    let normalized = normalize_enchant_type(enchant_type).trim();
1712    match normalized.to_lowercase().as_str() {
1713        "creature" => card.zone == ZoneType::Battlefield && card.is_creature(),
1714        "creature.inzonegraveyard" => card.zone == ZoneType::Graveyard && card.is_creature(),
1715        "land" => card.zone == ZoneType::Battlefield && card.is_land(),
1716        "artifact" => card.zone == ZoneType::Battlefield && card.type_line.is_artifact(),
1717        "enchantment" => card.zone == ZoneType::Battlefield && card.type_line.is_enchantment(),
1718        "planeswalker" => card.zone == ZoneType::Battlefield && card.type_line.is_planeswalker(),
1719        "permanent" | "" => card.zone == ZoneType::Battlefield,
1720        _ => {
1721            // Compound filters like "Creature.Legendary" or
1722            // "Creature.IsRemembered" — defer to the full valid-card matcher
1723            // using the aura as source so qualifiers that consult source
1724            // state (Remembered, YouCtrl, …) resolve correctly. Without an
1725            // aura source, fall back to using the host as its own source,
1726            // which is correct for source-independent filters like
1727            // "Creature.Legendary".
1728            if card.zone != ZoneType::Battlefield {
1729                return false;
1730            }
1731            let source = aura_source.unwrap_or(card);
1732            crate::card::valid_filter::matches_valid_card(normalized, card, source)
1733        }
1734    }
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739    use super::*;
1740
1741    #[test]
1742    fn parse_basic_pipe_params() {
1743        let params = Params::from_raw("Mode$ ChangesZone | Origin$ Any | Destination$ Battlefield");
1744        assert_eq!(params.get("Mode"), Some("ChangesZone"));
1745        assert_eq!(params.get("Origin"), Some("Any"));
1746        assert_eq!(params.get("Destination"), Some("Battlefield"));
1747    }
1748
1749    #[test]
1750    fn parse_bare_dollar_no_space() {
1751        // This was a bug in the duplicated parsers — they only handled "$ "
1752        let params = Params::from_raw("Key$Value | Other$ Spaced");
1753        assert_eq!(params.get("Key"), Some("Value"));
1754        assert_eq!(params.get("Other"), Some("Spaced"));
1755    }
1756
1757    #[test]
1758    fn is_true_case_insensitive() {
1759        let params = Params::from_raw("Hidden$ True | Mandatory$ true | Other$ false");
1760        assert!(params.is_true("Hidden"));
1761        assert!(params.is_true("Mandatory"));
1762        assert!(!params.is_true("Other"));
1763        assert!(!params.is_true("Missing"));
1764    }
1765
1766    #[test]
1767    fn numeric_accessors() {
1768        let params = Params::from_raw("Amount$ 3 | Bad$ notanumber");
1769        assert_eq!(params.as_i32("Amount"), Some(3));
1770        assert_eq!(params.as_usize("Amount"), Some(3));
1771        assert_eq!(params.as_i32("Bad"), None);
1772        assert_eq!(params.as_i32("Missing"), None);
1773    }
1774
1775    #[test]
1776    fn get_or_default_works() {
1777        let params = Params::from_raw("Mode$ Continuous");
1778        assert_eq!(params.get_or_default("Mode", "None"), "Continuous");
1779        assert_eq!(params.get_or_default("Missing", "fallback"), "fallback");
1780    }
1781
1782    #[test]
1783    fn serde_roundtrip() {
1784        let params = Params::from_raw("Mode$ Test | Amount$ 5");
1785        let json = serde_json::to_string(&params).unwrap();
1786        let deserialized: Params = serde_json::from_str(&json).unwrap();
1787        assert_eq!(deserialized.get("Mode"), Some("Test"));
1788        assert_eq!(deserialized.get("Amount"), Some("5"));
1789    }
1790}