Skip to main content

manabrew_engine/
card_trait_base.rs

1//! Port of Java `forge.game.CardTraitBase`.
2//!
3//! Base data for Triggers, ReplacementEffects and StaticAbilities.
4//!
5//! Notes on Java → Rust divergence:
6//! * `sVars` uses `HashMap` (not `TreeMap`) because the `HasSVars` trait
7//!   is keyed on `HashMap`.
8//! * Java uses runtime `Object` dispatch in `matchesValid`. Rust replicates
9//!   the argument-type dispatch via the `MatchValidTarget` enum, and the
10//!   `this instanceof Trigger` self-type dispatch via the `CardTrait` trait
11//!   with a `resolve_source_player` hook that subclasses override.
12//! * `Card` does not implement `ITranslatable`; `get_host_name` returns a
13//!   `HostName` enum. The `Card`-branch is pending that impl.
14
15use std::collections::{BTreeMap, HashMap};
16
17use serde::{Deserialize, Serialize};
18
19use forge_foundation::CardStateName;
20
21use crate::ability::ability_utils::apply_text_change_effects;
22use crate::card::card_state::CardState;
23use crate::card::valid_filter::{self, CardTraitRequirementsIr};
24use crate::card::Card;
25use crate::core::{HasSVars, Identifiable};
26use crate::event::AbilityValue;
27use crate::game::GameState;
28use crate::game_object::GameObject;
29use crate::ids::{CardId, PlayerId};
30use crate::keyword::keyword_instance::Keyword;
31use crate::keyword::keyword_interface::KeywordInterface;
32use crate::parsing::CompiledSelector;
33use crate::player::GameLossReason;
34
35// Keys of descriptive (text) parameters.
36const DESCRIPTIVE_KEYS: &[&str] = &[
37    "Description",
38    "SpellDescription",
39    "StackDescription",
40    "TriggerDescription",
41    "ChangeTypeDesc",
42    "ValidTgtsDesc",
43];
44
45// Keys that should not be changed.
46const NO_CHANGE_KEYS: &[&str] = &[
47    "TokenScript",
48    "NewName",
49    "DefinedName",
50    "ChooseFromList",
51    "AddAbility",
52];
53
54/// Target of `matches_valid`. Mirrors Java's `Object` dispatch.
55pub enum MatchValidTarget<'a> {
56    Card(&'a Card),
57    Player(PlayerId),
58    GameObj(&'a dyn GameObject),
59    Iter(&'a [MatchValidTarget<'a>]),
60    Str(&'a str),
61    LossReason(GameLossReason),
62    // TODO(port): PlanarDice — planechase not implemented.
63    PlanarDice,
64}
65
66/// Result of `get_host_name`. Mirrors Java's `ITranslatable` return type.
67///
68/// The `Card` branch is pending `impl ITranslatable for Card`.
69pub enum HostName<'a> {
70    State(CardStateName),
71    Card(&'a Card),
72}
73
74#[derive(Debug, Default, Serialize, Deserialize)]
75pub struct CardTraitBase {
76    id: i32,
77    #[serde(skip)]
78    host_card_id: Option<CardId>,
79    #[serde(skip)]
80    card_state_name: Option<CardStateName>,
81    #[serde(skip)]
82    keyword: Option<KeywordInterface>,
83
84    original_map_params: HashMap<String, String>,
85    map_params: HashMap<String, String>,
86
87    intrinsic: bool,
88    suppressed: bool,
89
90    svars: HashMap<String, String>,
91
92    intrinsic_changed_text_colors: HashMap<String, String>,
93    intrinsic_changed_text_types: HashMap<String, String>,
94    changed_text_colors: HashMap<String, String>,
95    changed_text_types: HashMap<String, String>,
96}
97
98impl Clone for CardTraitBase {
99    fn clone(&self) -> Self {
100        Self {
101            id: self.id,
102            host_card_id: self.host_card_id,
103            card_state_name: self.card_state_name,
104            keyword: None,
105            original_map_params: self.original_map_params.clone(),
106            map_params: self.map_params.clone(),
107            intrinsic: self.intrinsic,
108            suppressed: self.suppressed,
109            svars: self.svars.clone(),
110            intrinsic_changed_text_colors: self.intrinsic_changed_text_colors.clone(),
111            intrinsic_changed_text_types: self.intrinsic_changed_text_types.clone(),
112            changed_text_colors: self.changed_text_colors.clone(),
113            changed_text_types: self.changed_text_types.clone(),
114        }
115    }
116}
117
118impl CardTraitBase {
119    pub fn set_id(&mut self, id: i32) {
120        self.id = id;
121    }
122
123    // ── mapParams accessors ─────────────────────────────────────────
124
125    pub fn get_map_params(&self) -> &HashMap<String, String> {
126        &self.map_params
127    }
128
129    pub fn get_param_or_default<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
130        self.map_params
131            .get(key)
132            .map(String::as_str)
133            .unwrap_or(default)
134    }
135
136    pub fn get_param(&self, key: &str) -> Option<&str> {
137        self.map_params.get(key).map(String::as_str)
138    }
139
140    pub fn get_original_param(&self, key: &str) -> Option<&str> {
141        self.original_map_params.get(key).map(String::as_str)
142    }
143
144    pub fn has_param(&self, key: &str) -> bool {
145        self.map_params.contains_key(key)
146    }
147
148    pub fn put_param(&mut self, key: String, value: String) -> Option<String> {
149        self.map_params.insert(key, value)
150    }
151
152    pub fn remove_param(&mut self, key: &str) {
153        self.map_params.remove(key);
154    }
155
156    pub fn get_original_map_params(&self) -> &HashMap<String, String> {
157        &self.original_map_params
158    }
159
160    /// Initialize `map_params` and `original_map_params` from a source map.
161    /// Java sets these directly in each subclass constructor; Rust exposes a setter.
162    pub fn set_map_params(&mut self, params: HashMap<String, String>) {
163        self.original_map_params = params.clone();
164        self.map_params = params;
165    }
166
167    // ── intrinsic ───────────────────────────────────────────────────
168
169    pub fn is_intrinsic(&self) -> bool {
170        self.intrinsic
171    }
172
173    pub fn set_intrinsic(&mut self, i: bool) {
174        self.intrinsic = i;
175    }
176
177    // ── host card ───────────────────────────────────────────────────
178
179    pub fn host_card_id(&self) -> CardId {
180        self.host_card_id
181            .expect("CardTraitBase host_card_id must be bound before use")
182    }
183
184    pub fn set_host_card_id(&mut self, id: CardId) {
185        self.host_card_id = Some(id);
186    }
187
188    pub fn host_card<'a>(&self, game: &'a GameState) -> &'a Card {
189        game.card(self.host_card_id())
190    }
191
192    pub fn host_controller(&self, game: &GameState) -> PlayerId {
193        self.host_card(game).controller
194    }
195
196    // ── keyword ─────────────────────────────────────────────────────
197
198    pub fn is_keyword(&self, kw: Keyword) -> bool {
199        self.keyword
200            .as_ref()
201            .map(|current| current.get_keyword() == kw)
202            .unwrap_or(false)
203    }
204
205    pub fn get_keyword(&self) -> Option<&KeywordInterface> {
206        self.keyword.as_ref()
207    }
208
209    pub fn set_keyword(&mut self, kw: KeywordInterface) {
210        self.keyword = Some(kw);
211    }
212
213    pub fn is_embalm(&self) -> bool {
214        self.is_keyword(Keyword::Embalm)
215    }
216
217    pub fn is_eternalize(&self) -> bool {
218        self.is_keyword(Keyword::Eternalize)
219    }
220
221    // ── structural classifiers ──────────────────────────────────────
222
223    pub fn is_secondary(&self) -> bool {
224        self.get_param_or_default("Secondary", "False") == "True"
225    }
226
227    pub fn is_class_ability(&self) -> bool {
228        self.has_param("ClassLevel")
229    }
230
231    pub fn is_class_level_n_ability(&self, level: i32) -> bool {
232        let raw = self.get_param_or_default("ClassLevel", "0");
233        let numeric = if raw.chars().all(|c| c.is_ascii_digit()) {
234            raw
235        } else {
236            // Java does substring(2); used for ranges like "2-3" or "2+".
237            &raw[2..]
238        };
239        numeric.parse::<i32>().map(|n| n == level).unwrap_or(false)
240    }
241
242    /// Overridden by `SpellAbility`. Base returns false.
243    pub fn is_mana_ability(&self) -> bool {
244        false
245    }
246
247    // ── matches_valid ───────────────────────────────────────────────
248
249    /// Resolution worker for `Valid$` expressions with an explicit source
250    /// player. All `CardTrait` dispatch funnels through here; the trait
251    /// methods only decide *which* player to pass (see
252    /// `CardTrait::resolve_source_player`).
253    pub fn matches_valid_with_player(
254        &self,
255        target: &MatchValidTarget<'_>,
256        valids: &[&str],
257        src_card: &Card,
258        src_player: PlayerId,
259    ) -> bool {
260        match target {
261            MatchValidTarget::Card(card) => {
262                let selector = crate::parsing::cached_compiled_selector(&valids.join(","));
263                valid_filter::matches_valid_card_selector(&selector, card, src_card)
264            }
265            MatchValidTarget::Player(player) => {
266                valid_filter::matches_valid_player(&valids.join(","), *player, src_player)
267            }
268            MatchValidTarget::GameObj(obj) => {
269                let owned: Vec<String> = valids.iter().map(|s| s.to_string()).collect();
270                obj.is_valid(&owned, src_player, src_card, self)
271            }
272            MatchValidTarget::Iter(items) => items
273                .iter()
274                .any(|item| self.matches_valid_with_player(item, valids, src_card, src_player)),
275            MatchValidTarget::Str(s) => valids.contains(s),
276            MatchValidTarget::LossReason(reason) => valids.iter().any(|v| {
277                GameLossReason::smart_value_of(v)
278                    .map(|parsed| parsed == *reason)
279                    .unwrap_or(false)
280            }),
281            MatchValidTarget::PlanarDice => {
282                unimplemented!("port: PlanarDice — planechase not implemented")
283            }
284        }
285    }
286
287    pub fn matches_compiled_valid_with_player(
288        &self,
289        target: &MatchValidTarget<'_>,
290        selector: &CompiledSelector,
291        src_card: &Card,
292        src_player: PlayerId,
293    ) -> bool {
294        match target {
295            MatchValidTarget::Card(card) => {
296                valid_filter::matches_valid_card_selector(selector, card, src_card)
297            }
298            MatchValidTarget::Player(player) => {
299                valid_filter::matches_valid_player_selector(selector, *player, src_player)
300            }
301            MatchValidTarget::GameObj(obj) => {
302                let owned: Vec<String> = selector
303                    .alternatives
304                    .iter()
305                    .map(|alternative| alternative.raw.clone())
306                    .collect();
307                obj.is_valid(&owned, src_player, src_card, self)
308            }
309            MatchValidTarget::Iter(items) => items.iter().any(|item| {
310                self.matches_compiled_valid_with_player(item, selector, src_card, src_player)
311            }),
312            MatchValidTarget::Str(s) => selector
313                .alternatives
314                .iter()
315                .any(|alternative| alternative.raw == *s),
316            MatchValidTarget::LossReason(reason) => {
317                selector.alternatives.iter().any(|alternative| {
318                    GameLossReason::smart_value_of(&alternative.raw)
319                        .map(|parsed| parsed == *reason)
320                        .unwrap_or(false)
321                })
322            }
323            MatchValidTarget::PlanarDice => {
324                unimplemented!("port: PlanarDice — planechase not implemented")
325            }
326        }
327    }
328
329    // ── suppressed ──────────────────────────────────────────────────
330
331    pub fn set_suppressed(&mut self, supp: bool) {
332        self.suppressed = supp;
333    }
334
335    pub fn is_suppressed(&self) -> bool {
336        self.suppressed
337    }
338
339    // ── CardView ────────────────────────────────────────────────────
340
341    /// TODO(port): `CardView` / `IHasCardView` not ported.
342    pub fn get_card_view(&self) -> ! {
343        unimplemented!("port: CardView — UI layer, not in Rust engine")
344    }
345
346    // ── SVar fallback / lookup ──────────────────────────────────────
347
348    /// Ordered SVar fallback chain: keyword-static.
349    /// Mirrors Java's chained `getSVar` walk in `CardTraitBase`.
350    fn get_svar_fallback(&self, name: Option<&str>) -> Vec<&dyn HasSVars> {
351        let mut result: Vec<&dyn HasSVars> = Vec::new();
352
353        if let Some(kw) = self.keyword.as_ref() {
354            if let Some(st) = kw.get_static() {
355                // Only add when the keyword has part of the SVar in its original string.
356                let include = match name {
357                    None => true,
358                    Some(n) => kw.get_original().contains(n),
359                };
360                if include {
361                    result.push(st);
362                }
363            }
364        }
365        result
366    }
367
368    fn find_svar(&self, name: &str) -> Option<&dyn HasSVars> {
369        self.get_svar_fallback(Some(name))
370            .into_iter()
371            .find(|src| HasSVars::has_svar(*src, name))
372    }
373
374    pub fn get_svar_int(&self, name: &str) -> Option<i32> {
375        let value = HasSVars::get_svar(self, name)?;
376        value.parse::<i32>().ok()
377    }
378
379    /// Merged SVar map across keyword-static → self.
380    /// Local `svars` override fallbacks, matching Java `getSVars()` at line 613.
381    pub fn get_all_svars(&self) -> HashMap<String, String> {
382        let mut res: HashMap<String, String> = HashMap::new();
383        for src in self.get_svar_fallback(None) {
384            for (k, v) in HasSVars::get_svars(src) {
385                res.insert(k.clone(), v.clone());
386            }
387        }
388        for (k, v) in &self.svars {
389            res.insert(k.clone(), v.clone());
390        }
391        res
392    }
393
394    // ── card state / host name ─────────────────────────────────────
395
396    pub fn set_card_state(&mut self, state: &CardState) {
397        self.card_state_name = Some(state.get_state_name());
398        for (key, value) in HasSVars::get_svars(state) {
399            self.svars
400                .entry(key.clone())
401                .or_insert_with(|| value.clone());
402        }
403    }
404
405    pub fn get_card_state_name(&self) -> Option<CardStateName> {
406        self.card_state_name
407    }
408
409    /// Mirrors `getHostName(CardTraitBase node)`.
410    ///
411    /// Returns the alternate card-state view when the node is intrinsic and
412    /// its state differs from the host's current state; otherwise the host.
413    pub fn get_host_name<'a>(
414        &'a self,
415        game: &'a GameState,
416        node: &'a CardTraitBase,
417    ) -> HostName<'a> {
418        if node.is_intrinsic() {
419            // TODO(port): needs `Card::get_current_state_name()` for the
420            // comparison. For now assume the state differs when present
421            // and the host has no way to report its current state.
422            if let Some(_state_name) = node.card_state_name {
423                unimplemented!(
424                    "port: Card::get_current_state_name — required by CardTraitBase::get_host_name"
425                );
426            }
427        }
428        HostName::Card(node.host_card(game))
429    }
430
431    pub fn is_copied_trait(&self) -> bool {
432        false
433    }
434
435    // ── changed text ────────────────────────────────────────────────
436
437    pub fn get_changed_text_colors(&self) -> HashMap<String, String> {
438        combine_changed_map(
439            &self.intrinsic_changed_text_colors,
440            &self.changed_text_colors,
441        )
442    }
443
444    pub fn get_changed_text_types(&self) -> HashMap<String, String> {
445        combine_changed_map(&self.intrinsic_changed_text_types, &self.changed_text_types)
446    }
447
448    /// Rust-only helper: flatten changed-text color + type maps into
449    /// `(from, to)` pairs. Consumed by `SpellAbility::apply_text_changes` to
450    /// push the same changes down into the trait's overriding ability.
451    pub fn changed_text_pairs(&self) -> Vec<(String, String)> {
452        self.changed_text_colors
453            .iter()
454            .chain(self.changed_text_types.iter())
455            .map(|(from, to)| (from.clone(), to.clone()))
456            .collect()
457    }
458
459    pub fn change_text_intrinsic(
460        &mut self,
461        color_map: HashMap<String, String>,
462        type_map: HashMap<String, String>,
463    ) {
464        self.intrinsic_changed_text_colors = color_map.clone();
465        self.intrinsic_changed_text_types = type_map.clone();
466
467        let color_tree: BTreeMap<String, String> = color_map.into_iter().collect();
468        let type_tree: BTreeMap<String, String> = type_map.into_iter().collect();
469
470        let keys: Vec<String> = self.map_params.keys().cloned().collect();
471        for key in keys {
472            let Some(value) = self.original_map_params.get(&key).cloned() else {
473                continue;
474            };
475            let new_value = if NO_CHANGE_KEYS.contains(&key.as_str()) {
476                continue;
477            } else if DESCRIPTIVE_KEYS.contains(&key.as_str()) {
478                Some(apply_text_change_effects(
479                    &value,
480                    true,
481                    &color_tree,
482                    &type_tree,
483                ))
484            } else if self.svars.contains_key(&value) {
485                // Don't change literal SVar names.
486                continue;
487            } else {
488                Some(apply_text_change_effects(
489                    &value,
490                    false,
491                    &color_tree,
492                    &type_tree,
493                ))
494            };
495
496            if let Some(nv) = new_value {
497                self.map_params.insert(key, nv);
498            }
499        }
500        // Overwrite originalMapParams — mirrors Java line 708.
501        self.original_map_params = self.map_params.clone();
502    }
503
504    pub fn change_text(&mut self) {
505        // TODO(port): needs `Card::get_changed_text_color_words()` and
506        // `Card::get_changed_text_type_words()`. The engine currently stores
507        // text changes as SVars on the card (see
508        // `ability_utils::extract_text_change_maps`), which differs from
509        // Java's model. Resolve when Card exposes these accessors.
510        unimplemented!(
511            "port: Card::get_changed_text_color_words / _type_words — \
512             required by CardTraitBase::change_text"
513        );
514    }
515
516    // ── copy ────────────────────────────────────────────────────────
517
518    pub fn copy_helper(&self, copy: &mut CardTraitBase, host: Card) {
519        self.copy_helper_with_text(copy, host, false);
520    }
521
522    pub fn copy_helper_with_text(
523        &self,
524        copy: &mut CardTraitBase,
525        host: Card,
526        keep_text_changes: bool,
527    ) {
528        copy.original_map_params = self.original_map_params.clone();
529        copy.map_params = if keep_text_changes {
530            self.map_params.clone()
531        } else {
532            self.original_map_params.clone()
533        };
534        copy.set_svars(self.svars.clone());
535        copy.card_state_name = self.card_state_name;
536        // Mirrors Java copyHelper: assign host directly instead of using set_host_card.
537        copy.host_card_id = Some(host.id);
538        copy.keyword = self.keyword.clone();
539    }
540
541    // ── trigger remembered ──────────────────────────────────────────
542
543    /// Java dispatches on `this instanceof SpellAbility` / `Trigger`. In Rust
544    /// `CardTraitBase` is the concrete base; subclasses expose their own
545    /// `get_trigger_remembered` and should be called directly. Base returns
546    /// empty, matching Java's final `return ImmutableList.of()`.
547    pub fn get_trigger_remembered(&self) -> Vec<AbilityValue> {
548        Vec::new()
549    }
550}
551
552/// Combine an intrinsic change map with a non-intrinsic one. Mirrors
553/// Java's private `_combineChangedMap`.
554fn combine_changed_map(
555    input: &HashMap<String, String>,
556    output: &HashMap<String, String>,
557) -> HashMap<String, String> {
558    if input.is_empty() {
559        return output.clone();
560    }
561    if output.is_empty() {
562        return input.clone();
563    }
564    let mut result = output.clone();
565    for (k, v) in input {
566        let replacement = output.get(v).cloned().unwrap_or_else(|| v.clone());
567        result.insert(k.clone(), replacement);
568    }
569    result
570}
571
572impl Identifiable for CardTraitBase {
573    fn id(&self) -> i32 {
574        self.id
575    }
576}
577
578impl HasSVars for CardTraitBase {
579    fn get_svar(&self, name: &str) -> Option<&str> {
580        if let Some(v) = self.svars.get(name) {
581            return Some(v.as_str());
582        }
583        // Java returns "" when fallback also misses; Rust returns None to keep
584        // the Option type signature. Callers that need Java parity can
585        // `.unwrap_or("")`.
586        //
587        // The fallback must return an `&str` tied to `self`; we re-walk
588        // the chain inline (rather than reusing `get_svar_fallback`) so the
589        // borrow scope survives the outer `Option<&str>` return.
590        if let Some(kw) = self.keyword.as_ref() {
591            if let Some(st) = kw.get_static() {
592                if kw.get_original().contains(name) {
593                    if let Some(v) = HasSVars::get_svar(st, name) {
594                        return Some(v);
595                    }
596                }
597            }
598        }
599        None
600    }
601
602    fn has_svar(&self, name: &str) -> bool {
603        self.svars.contains_key(name) || self.find_svar(name).is_some()
604    }
605
606    fn set_svar(&mut self, name: String, value: String) {
607        self.svars.insert(name, value);
608    }
609
610    fn set_svars(&mut self, new_svars: HashMap<String, String>) {
611        self.svars = new_svars;
612    }
613
614    fn get_svars(&self) -> &HashMap<String, String> {
615        &self.svars
616    }
617
618    fn remove_svar(&mut self, var: &str) {
619        self.svars.remove(var);
620    }
621}
622
623impl GameObject for CardTraitBase {}
624
625/// Polymorphic facade over `CardTraitBase` — the Rust stand-in for Java's
626/// inheritance chain where `Trigger`, `ReplacementEffect`, and `StaticAbility`
627/// extend `CardTraitBase`. Because Rust structs have no virtual methods, the
628/// `this instanceof Trigger` self-type dispatch in Java's `matchesValid` is
629/// expressed here as the `resolve_source_player` hook: the default returns
630/// `src_card.controller`, and `Trigger` overrides it to consult its spawning
631/// ability's activating player.
632pub trait CardTrait {
633    /// Borrow the underlying `CardTraitBase`. Implementors own a
634    /// `CardTraitBase` (directly or transitively) and return a reference.
635    fn base(&self) -> &CardTraitBase;
636
637    /// Resolves the player whose perspective is used for `Valid$` expressions.
638    /// Default matches Java's base behavior (source card's controller).
639    /// `Trigger` overrides — mirrors `this instanceof Trigger` in Java
640    /// `CardTraitBase.matchesValid(Object, String[], Card)` at line 214.
641    fn resolve_source_player(&self, src_card: &Card) -> PlayerId {
642        src_card.controller
643    }
644
645    /// Mirrors `matchesValid(Object, String[], Card)`.
646    fn matches_valid(
647        &self,
648        target: &MatchValidTarget<'_>,
649        valids: &[&str],
650        src_card: Option<&Card>,
651    ) -> bool {
652        let Some(src) = src_card else {
653            return false;
654        };
655        let player = self.resolve_source_player(src);
656        self.base()
657            .matches_valid_with_player(target, valids, src, player)
658    }
659
660    fn matches_compiled_valid(
661        &self,
662        target: &MatchValidTarget<'_>,
663        selector: &CompiledSelector,
664        src_card: Option<&Card>,
665    ) -> bool {
666        let Some(src) = src_card else {
667            return false;
668        };
669        let player = self.resolve_source_player(src);
670        self.base()
671            .matches_compiled_valid_with_player(target, selector, src, player)
672    }
673
674    fn matches_valid_param(
675        &self,
676        param: &str,
677        target: &MatchValidTarget<'_>,
678        src_card: Option<&Card>,
679    ) -> bool {
680        let b = self.base();
681        let invert_key = format!("Invert{}", param);
682        let invert = b.has_param(&invert_key);
683        if b.has_param(param) {
684            let raw = b.get_param(param).unwrap_or("");
685            let parts: Vec<&str> = raw.split(',').collect();
686            if !self.matches_valid(target, &parts, src_card) {
687                return invert;
688            }
689        }
690        !invert
691    }
692
693    /// Ergonomic comma-separated-expression wrapper over `matches_valid` for
694    /// card targets. Mirrors Java's `matchesValid(Object, String[], Card)`
695    /// call pattern where `valids` is often a single comma-separated string
696    /// (e.g. `"Creature.YouCtrl,Artifact"`).
697    fn matches_valid_card(&self, expr: &str, card: &Card, source: &Card) -> bool {
698        let parts: Vec<&str> = expr.split(',').collect();
699        self.matches_valid(&MatchValidTarget::Card(card), &parts, Some(source))
700    }
701
702    fn matches_compiled_valid_card(
703        &self,
704        selector: &CompiledSelector,
705        card: &Card,
706        source: &Card,
707    ) -> bool {
708        self.matches_compiled_valid(&MatchValidTarget::Card(card), selector, Some(source))
709    }
710
711    /// Ergonomic comma-separated-expression wrapper over `matches_valid` for
712    /// player targets.
713    fn matches_valid_player(&self, expr: &str, player: PlayerId, source: &Card) -> bool {
714        let parts: Vec<&str> = expr.split(',').collect();
715        self.matches_valid(&MatchValidTarget::Player(player), &parts, Some(source))
716    }
717
718    fn matches_compiled_valid_player(
719        &self,
720        selector: &CompiledSelector,
721        player: PlayerId,
722        source: &Card,
723    ) -> bool {
724        self.matches_compiled_valid(&MatchValidTarget::Player(player), selector, Some(source))
725    }
726}
727
728impl CardTrait for CardTraitBase {
729    fn base(&self) -> &CardTraitBase {
730        self
731    }
732}
733
734/// Runtime objects that have a lowered card-trait IR.
735///
736/// This is the Rust equivalent of Java subclasses inheriting common
737/// `CardTraitBase` behavior while carrying their own concrete data. It is not
738/// limited to `CardTrait` implementors because `SpellAbility` also owns a
739/// lowered IR in Rust.
740///
741/// The associated type keeps each owner tied to its specific IR (`TriggerIr`,
742/// `StaticAbilityIr`, `ReplacementEffectIr`, `SpellAbilityIr`, ...). The
743/// requirement view is exposed directly here so shared checks only need this
744/// one trait.
745pub trait CardTraitIrOwner {
746    type Ir;
747
748    fn ir(&self) -> &Self::Ir;
749
750    fn card_trait_requirements(&self) -> &CardTraitRequirementsIr;
751
752    fn meets_card_trait_requirements(
753        &self,
754        game: &GameState,
755        source: &Card,
756        svar_source: &dyn HasSVars,
757    ) -> bool {
758        self.card_trait_requirements()
759            .meets(game, source, svar_source)
760    }
761}