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