Skip to main content

manabrew_engine/replacement/
replacement_effect.rs

1//! Replacement effect parsing and types.
2//!
3//! Mirrors the Java Forge `forge/game/replacement/` package, specifically
4//! `ReplacementEffect.java`.
5//!
6//! Card scripts encode replacement effects as `R$`-prefixed lines, e.g.:
7//! ```text
8//! R$ Event$ DamageDone | ActiveZones$ Battlefield | ValidCard$ Card.Self | Prevent$ True | Description$ Prevent all damage dealt to ~.
9//! R$ Event$ Draw | ValidPlayer$ You | Description$ Skip your draw step.
10//! R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self | Description$ If ~ would die, exile it instead.
11//! R$ Event$ Destroy | ValidCard$ Card.Self | Description$ ~ is indestructible.
12//! ```
13
14use forge_foundation::{PhaseType, ZoneType};
15use serde::{Deserialize, Serialize};
16
17use crate::ability::ability_factory::build_spell_ability;
18use crate::ability::AbilityKey;
19use crate::card::valid_filter::CardTraitRequirementsIr;
20use crate::card::Card;
21use crate::card_trait_base::{CardTrait, CardTraitBase, CardTraitIrOwner};
22use crate::core::HasSVars;
23use crate::game::GameState;
24use crate::game_loop::trigger_replacement_base::TriggerReplacementBase;
25use crate::ids::{CardId, PlayerId};
26use crate::parsing::{keys, CompiledSelector, Params};
27pub use crate::player::GameLossReason;
28
29use super::replacement_handler::ReplacementEvent;
30use crate::spellability::SpellAbility;
31
32// Re-export so existing `use crate::replacement::replacement_effect::{ReplacementType, ReplacementLayer}`
33// paths keep working.
34pub use super::replacement_layer::ReplacementLayer;
35pub use super::replacement_type::ReplacementType;
36
37// ── ReplacementEffect ─────────────────────────────────────────────────────────
38
39/// A parsed replacement effect from an `R$` line in a card script.
40///
41/// Reference: Java `ReplacementEffect.java` in `forge/game/replacement/`.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ReplacementEffect {
44    /// Shared trait base (host card, sVars, text-changes, map params).
45    /// Mirrors Java `ReplacementEffect extends TriggerReplacementBase extends CardTraitBase`.
46    /// Currently default-initialized by the parser; card factory population is
47    /// a follow-up parity task so that `matches_valid_param` picks up
48    /// `Invert*` entries from `map_params`.
49    ///
50    /// Boxed because `CardState` holds five inline `Option<ReplacementEffect>`
51    /// fields (`loyalty_rep`, `defense_rep`, `saga_rep`, `adventure_rep`,
52    /// `omen_rep`) and `TriggerReplacementBase → CardTraitBase` contains an
53    /// `Option<CardState>`, which would otherwise form an infinite-sized
54    /// type. `Trigger` does not need this because `CardState` only owns
55    /// triggers via `Vec` (heap indirection already).
56    #[serde(skip, default)]
57    pub base: Box<TriggerReplacementBase>,
58    /// The event type this effect intercepts.
59    pub event: ReplacementType,
60    /// The CR 616 layer this effect belongs to.
61    pub layer: ReplacementLayer,
62    /// Typed runtime view of lowered replacement semantics.
63    pub ir: ReplacementEffectIr,
64    /// Zones where this effect is active. Empty = active everywhere.
65    /// Parsed from `ActiveZones$` parameter.
66    /// TODO(java-parity): collapse into `base.valid_host_zones`.
67    pub active_zones: Vec<ZoneType>,
68    /// Temporary suppression flag used by effects like commander replacement.
69    #[serde(default)]
70    pub suppressed: bool,
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct ReplacementEffectIr {
75    #[serde(skip, default)]
76    pub card_trait_requirements: CardTraitRequirementsIr,
77    pub replace_with: Option<String>,
78    pub description_text: Option<String>,
79    pub player_turn_text: Option<String>,
80    pub new_destination_text: Option<String>,
81    pub new_destination_zone: Option<ZoneType>,
82    pub origin_text: Option<String>,
83    pub origin_zone: Option<ZoneType>,
84    pub destination_text: Option<String>,
85    pub destination_zone: Option<ZoneType>,
86    pub exclude_destination_text: Option<String>,
87    pub valid_card_text: Option<String>,
88    pub valid_card_selector: Option<CompiledSelector>,
89    pub valid_player_text: Option<String>,
90    pub valid_player_selector: Option<CompiledSelector>,
91    pub valid_object_selector: Option<CompiledSelector>,
92    pub valid_cause_text: Option<String>,
93    pub valid_target_selector: Option<CompiledSelector>,
94    pub valid_source_selector: Option<CompiledSelector>,
95    pub valid_activator_text: Option<String>,
96    pub valid_step_turn_to_controller_text: Option<String>,
97    pub valid_explorer_text: Option<String>,
98    pub valid_counter_type_text: Option<String>,
99    pub valid_lose_reason_text: Option<String>,
100    pub valid_sides_text: Option<String>,
101    pub active_phases: Vec<PhaseType>,
102    pub amount_text: Option<String>,
103    pub damage_amount_text: Option<String>,
104    pub result_text: Option<String>,
105    pub number_text: Option<String>,
106    pub dredge_amount: Option<usize>,
107    pub skip: bool,
108    pub prevent: bool,
109    pub optional: bool,
110    pub effect_only: bool,
111    pub discard: Option<bool>,
112    pub flashback_cast: Option<bool>,
113    pub harmonize_cast: Option<bool>,
114    pub not_first_card_in_draw_step: bool,
115    pub exiled_with_effect_source: bool,
116    pub is_combat: Option<bool>,
117    pub is_damage: Option<bool>,
118    pub max_speed: Option<bool>,
119    pub replacement_result: Option<String>,
120    pub optional_decider_text: Option<String>,
121    pub replace_mana_text: Option<String>,
122    pub replace_type_text: Option<String>,
123    pub replace_color_text: Option<String>,
124    pub replace_only_text: Option<String>,
125    pub replace_amount_text: Option<String>,
126    pub valid_lki_text: Option<String>,
127    pub counter_map: bool,
128    pub replace_with_chain: Option<ReplacementChainIr>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum ReplacementChainIr {
133    ReplaceToken {
134        token_type: Option<String>,
135        amount_expr: Option<String>,
136    },
137    ReplaceCounter {
138        amount_expr: String,
139        choose_counter: bool,
140        after_replacement_static_abilities: Vec<String>,
141    },
142    ReplaceEffect {
143        var_name: Option<String>,
144        var_type: Option<String>,
145        var_key: Option<String>,
146        var_value: Option<String>,
147        sub_ability: Option<Box<ReplacementChainIr>>,
148    },
149}
150
151impl CardTrait for ReplacementEffect {
152    fn base(&self) -> &CardTraitBase {
153        &self.base.card_trait_base
154    }
155}
156
157impl CardTraitIrOwner for ReplacementEffect {
158    type Ir = ReplacementEffectIr;
159
160    fn ir(&self) -> &Self::Ir {
161        &self.ir
162    }
163
164    fn card_trait_requirements(&self) -> &CardTraitRequirementsIr {
165        &self.ir.card_trait_requirements
166    }
167}
168
169impl ReplacementEffect {
170    /// Attach the host card id on the embedded trait base. Mirrors Java's
171    /// inherited `CardTraitBase.setHostCard(host)` — called from the
172    /// `ReplacementEffect` constructor (`ReplacementEffect.java:107`) so
173    /// a freshly-constructed effect is always host-bound.
174    ///
175    /// In Rust the parser builds an unbound effect first (parser doesn't
176    /// have a `Card` handle) and every insertion site
177    /// (`card_state::add_replacement_effect`, keyword grants, factory
178    /// helpers) routes through this method to bind the host. After this
179    /// call, `CardTrait` machinery can stop threading explicit `host: &Card`
180    /// args. `TriggerReplacementBase::set_host_card` also propagates the
181    /// host into any cached overriding ability.
182    pub fn set_host_card(&mut self, host: &Card) {
183        self.ir.replace_with_chain = self
184            .replace_with()
185            .and_then(|svar_name| host.get_svar(svar_name))
186            .and_then(|raw| parse_replacement_chain(raw, host));
187        self.base.set_host_card_id(host.id);
188    }
189}
190
191impl ReplacementEffect {
192    pub fn new(
193        event: ReplacementType,
194        layer: ReplacementLayer,
195        params: Params,
196        active_zones: Vec<ZoneType>,
197    ) -> Self {
198        let mut effect = Self {
199            base: Box::new(TriggerReplacementBase::default()),
200            event,
201            layer,
202            ir: ReplacementEffectIr::from_params(&params),
203            active_zones,
204            suppressed: false,
205        };
206        effect.sync_trait_base_params(&params);
207        effect
208    }
209
210    pub fn replace_with(&self) -> Option<&str> {
211        self.ir.replace_with.as_deref()
212    }
213
214    pub fn has_skip(&self) -> bool {
215        self.ir.skip
216    }
217
218    pub fn prevents(&self) -> bool {
219        self.ir.prevent
220    }
221
222    pub fn matches_phase(&self, phase: PhaseType) -> bool {
223        self.ir.active_phases.is_empty() || self.ir.active_phases.contains(&phase)
224    }
225
226    fn sync_trait_base_params(&mut self, params: &Params) {
227        let map = params
228            .iter()
229            .map(|(k, v)| (k.to_string(), v.to_string()))
230            .collect();
231        self.base.card_trait_base.set_map_params(map);
232    }
233
234    /// Returns `true` if this effect is active while the source card is in `zone`.
235    ///
236    /// An empty `active_zones` list means the effect is always active (mirrors
237    /// Java `zonesCheck()` returning `true` when `activeZones` is empty).
238    pub fn active_in_zone(&self, zone: ZoneType) -> bool {
239        !self.suppressed && (self.active_zones.is_empty() || self.active_zones.contains(&zone))
240    }
241
242    /// Human-readable description. Mirrors Java `ReplacementEffect.getDescription()`.
243    ///
244    /// - Suppressed or missing `Description$` → empty string.
245    /// - Applies text-change effects carried on the trait (Glamerdye / Crystal
246    ///   Spray word-swaps).
247    /// - Substitutes `CARDNAME` and `NICKNAME` with the host's name.
248    /// - Substitutes `EFFECTSOURCE` with the card that created this host via
249    ///   `effect_source` (token makers, emblems, etc.).
250    /// - For `DamageDone` replacements whose overriding `SpellAbility` uses
251    ///   `AB$ ReplaceDamage` / `AB$ ReplaceSplitDamage`, appends
252    ///   `"Shields remain: N"` when the `Amount$` / `VarName$` SVar resolves
253    ///   to `Number$<n>`. Only fires when the SA is already cached on the
254    ///   base (matches Java's `getOverridingAbility()` not `ensureAbility()`).
255    ///
256    /// Multi-locale translation is UI-layer and intentionally skipped.
257    pub fn description(&self, host: &Card, game: &GameState) -> String {
258        if self.suppressed || self.base.card_trait_base.is_suppressed() {
259            return String::new();
260        }
261        let Some(raw) = self.ir.description_text.as_deref() else {
262            return String::new();
263        };
264        let mut desc =
265            crate::ability::ability_utils::apply_description_text_change_effects(raw, host);
266        desc = desc.replace("CARDNAME", &host.card_name);
267        // Nickname localization isn't ported; fall back to the card name
268        // (matches `spell_ability_effect::tokenize_description`).
269        desc = desc.replace("NICKNAME", &host.card_name);
270        if desc.contains("EFFECTSOURCE") {
271            let source_name = host
272                .effect_source
273                .map(|id| game.card(id).card_name.clone())
274                .unwrap_or_else(|| host.card_name.clone());
275            desc = desc.replace("EFFECTSOURCE", &source_name);
276        }
277
278        // DamageDone shield-remaining appendix (Java L228-253).
279        if self.event == ReplacementType::DamageDone {
280            if let Some(rep_sa) = self.base.get_overriding_ability() {
281                desc = append_shield_remaining(desc, rep_sa, host);
282            }
283        }
284
285        desc
286    }
287
288    /// Always `false`. Java's `ReplacementEffect.hasRun` is a per-effect flag
289    /// used mainly during `otherChoices` resolution (Java gap #2 here). Our
290    /// per-event chain uses `ReplacementHandler.has_run` instead, and a new
291    /// handler is constructed by `apply_replacements` per event — so stale
292    /// run-marks never leak across events. Revisit when `otherChoices` lands
293    /// (a nested choice flow is the only path that needs the per-effect flag).
294    pub fn has_run(&self) -> bool {
295        false
296    }
297
298    /// Check requirements for this replacement effect against the current game state.
299    pub fn requirements_check(&self, game: &GameState, source: &Card) -> bool {
300        if self.suppressed || self.base.card_trait_base.is_suppressed() {
301            return false;
302        }
303
304        if let Some(pt) = self.ir.player_turn_text.as_deref() {
305            if pt == "True" {
306                if game.active_player() != source.controller {
307                    return false;
308                }
309            } else {
310                let players = crate::ability::ability_utils::resolve_defined_players(
311                    pt,
312                    source.controller,
313                    game,
314                );
315                if !players.contains(&game.active_player()) {
316                    return false;
317                }
318            }
319        }
320
321        // ActivePhases$ — current phase must be in the listed phases.
322        if !self.matches_phase(game.turn.phase) {
323            return false;
324        }
325
326        self.meets_card_trait_requirements(game, source, source)
327    }
328
329    /// Clone this replacement effect. Since `ReplacementEffect` derives `Clone`,
330    /// this delegates to `self.clone()`.
331    ///
332    /// Mirrors Java `ReplacementEffect.copy()`.
333    pub fn copy(&self) -> Self {
334        self.clone()
335    }
336
337    /// Mirrors Java `ReplacementEffect.ensureAbility()`:
338    ///
339    /// 1. If an overriding `SpellAbility` is already cached on the base, return
340    ///    a clone of it.
341    /// 2. Otherwise, if `ReplaceWith$` is set, look up the named SVar on the
342    ///    host card, parse it via `AbilityFactory.getAbility()` (the Rust
343    ///    equivalent is `build_spell_ability`), and return the built ability.
344    ///
345    /// This variant does NOT cache the built ability (const receiver). Use
346    /// `ensure_ability_mut` to lazily cache on the base, matching Java's
347    /// `setOverridingAbility(sa)` call inside `ensureAbility`.
348    pub fn ensure_ability(
349        &self,
350        game: &GameState,
351        host_card: CardId,
352        activating_player: PlayerId,
353    ) -> Option<SpellAbility> {
354        if let Some(overriding) = self.base.get_overriding_ability() {
355            return Some(overriding.clone());
356        }
357        let svar_name = self.replace_with()?;
358        let host = game.card(host_card);
359        let script = host.get_svar(svar_name)?;
360        Some(build_spell_ability(
361            game,
362            host_card,
363            script,
364            activating_player,
365        ))
366    }
367
368    /// Mirrors Java `ReplacementEffect.ensureAbility()` including the cache
369    /// write (Java calls `setOverridingAbility(sa)` on first build). Returns
370    /// a mutable reference to the cached ability so callers can mutate
371    /// trigger payloads before resolution.
372    pub fn ensure_ability_mut(
373        &mut self,
374        game: &GameState,
375        host_card: CardId,
376        activating_player: PlayerId,
377    ) -> Option<&mut SpellAbility> {
378        if self.base.get_overriding_ability().is_none() {
379            let ability = self.ensure_ability(game, host_card, activating_player)?;
380            // Store directly. The built ability is already host-bound by
381            // `build_spell_ability`.
382            self.base.overriding_ability = Some(ability);
383        }
384        self.base.overriding_ability.as_mut()
385    }
386
387    /// Filter for ETB replacement events. Mirrors Java
388    /// `ReplacementEffect.canReplaceETB(runParams)` (L321-345).
389    ///
390    /// Returns `false` (skip) when the effect targets things OTHER than itself
391    /// (`ValidCard$` is not `Card.Self`-prefixed) AND the affected card IS
392    /// the host card — i.e. the effect would be replacing its own ETB.
393    /// Otherwise returns `true`.
394    ///
395    /// Not yet ported: Java's second guard reads `AbilityKey.LastStateBattlefield`
396    /// to also skip when the host wasn't on the battlefield before this
397    /// Moved event. Rust doesn't snapshot the previous battlefield state for
398    /// replacement resolution, so that branch is omitted. Effects whose host
399    /// just entered may still slip through in narrow nested ETB scenarios.
400    pub fn can_replace_etb(&self, source: &Card, affected: &Card) -> bool {
401        let targets_self_only = self
402            .ir
403            .valid_card_text
404            .as_deref()
405            .map(|v| v.starts_with("Card.Self"))
406            .unwrap_or(false);
407        if !targets_self_only && source.id == affected.id {
408            return false;
409        }
410        true
411    }
412
413    /// Mirrors Java `ReplacementEffect.setReplacingObjects(runParams, sa)`.
414    /// Java's base method is an empty default overridden by each concrete
415    /// subclass (`ReplaceMoved`, `ReplaceDamage`, `ReplaceAddCounter`, …).
416    /// Rust has no subclasses; the match on `self.event` fills the same role
417    /// by dispatching per event type inline.
418    ///
419    /// The sub-ability walk is a Rust-ism — Java's resolver inherits the
420    /// triggering/replacing maps from the parent SA automatically, while the
421    /// Rust `SpellAbility` resolver reads directly from each node. Writing
422    /// to every node keeps `Defined$ ReplacedCard`-style lookups working
423    /// inside `SubAbility$` chains (Rust stores these under
424    /// `sa.trigger_objects`; Java keeps `replacingObjects` separate).
425    ///
426    /// Scope note: today this only runs on event paths that actually build a
427    /// `SpellAbility` and resolve it through the SA resolver — currently just
428    /// `replace_moved::execute`. Other handlers mutate events inline via
429    /// `execute_replace_effect_chain` and bypass this hook. Migrate those
430    /// paths first before adding their cases here.
431    pub fn set_replacing_objects(
432        &self,
433        event: &ReplacementEvent,
434        sa: &mut crate::spellability::SpellAbility,
435    ) {
436        let mut current = Some(sa);
437        while let Some(node) = current {
438            match event {
439                ReplacementEvent::Moved {
440                    card,
441                    origin,
442                    destination,
443                    ..
444                } => {
445                    let card_csv = card.0.to_string();
446                    // Java `ReplaceMoved.setReplacingObjects`: Card + (NewCard,
447                    // CardLKI, Cause, LastStateBattlefield, LastStateGraveyard,
448                    // CounterTable, CounterMap). Rust's `ReplacementEvent::Moved`
449                    // only carries card + zones; the other keys aren't tracked
450                    // on the event today.
451                    node.set_triggering_object(AbilityKey::Card, card_csv.as_str());
452                    node.set_triggering_object(AbilityKey::ReplacedCard, card_csv.as_str());
453                    node.set_triggering_object(AbilityKey::Affected, card_csv.as_str());
454                    node.set_triggering_object(AbilityKey::Origin, format!("{origin:?}").as_str());
455                    node.set_triggering_object(
456                        AbilityKey::Destination,
457                        format!("{destination:?}").as_str(),
458                    );
459                }
460                ReplacementEvent::DamageToCard {
461                    target,
462                    amount,
463                    source,
464                    ..
465                } => {
466                    // Java `ReplaceDamage.setReplacingObjects`: DamageAmount,
467                    // Target (from Affected), Source (from DamageSource).
468                    let target_csv = target.0.to_string();
469                    node.set_triggering_object(AbilityKey::Target, target_csv.as_str());
470                    node.set_triggering_object(AbilityKey::Affected, target_csv.as_str());
471                    node.set_triggering_object(
472                        AbilityKey::DamageAmount,
473                        amount.to_string().as_str(),
474                    );
475                    if let Some(src) = source {
476                        let src_csv = src.0.to_string();
477                        node.set_triggering_object(AbilityKey::Source, src_csv.as_str());
478                        node.set_triggering_object(AbilityKey::DamageSource, src_csv.as_str());
479                    }
480                }
481                ReplacementEvent::DamageToPlayer {
482                    target,
483                    amount,
484                    source,
485                    ..
486                } => {
487                    node.set_triggering_object(
488                        AbilityKey::DamageAmount,
489                        amount.to_string().as_str(),
490                    );
491                    if let Some(src) = source {
492                        let src_csv = src.0.to_string();
493                        node.set_triggering_object(AbilityKey::Source, src_csv.as_str());
494                        node.set_triggering_object(AbilityKey::DamageSource, src_csv.as_str());
495                    }
496                    node.set_triggering_object(
497                        AbilityKey::TriggeredPlayer,
498                        target.index().to_string().as_str(),
499                    );
500                }
501                ReplacementEvent::Destroy { target } => {
502                    // Java `ReplaceDestroy.setReplacingObjects`: Card, Cause.
503                    let target_csv = target.0.to_string();
504                    node.set_triggering_object(AbilityKey::Card, target_csv.as_str());
505                    node.set_triggering_object(AbilityKey::Affected, target_csv.as_str());
506                }
507                ReplacementEvent::AddCounter {
508                    target,
509                    counter_type,
510                    count,
511                    ..
512                } => {
513                    // Java `ReplaceAddCounter.setReplacingObjects`: CounterMap,
514                    // Card/Player (polymorphic on Affected), Object.
515                    let target_csv = match target {
516                        crate::agent::GameEntity::Card(card) => card.0.to_string(),
517                        crate::agent::GameEntity::Player(player) => player.0.to_string(),
518                    };
519                    match target {
520                        crate::agent::GameEntity::Card(_) => {
521                            node.set_triggering_object(AbilityKey::Card, target_csv.as_str());
522                        }
523                        crate::agent::GameEntity::Player(_) => {
524                            node.set_triggering_object(AbilityKey::Player, target_csv.as_str());
525                        }
526                    }
527                    node.set_triggering_object(AbilityKey::Affected, target_csv.as_str());
528                    node.set_triggering_object(AbilityKey::Object, target_csv.as_str());
529                    node.set_triggering_object(
530                        AbilityKey::CounterMap,
531                        format!("{counter_type:?}:{count}").as_str(),
532                    );
533                }
534                ReplacementEvent::Draw {
535                    player,
536                    extra_draws,
537                    ..
538                } => {
539                    // Java `ReplaceDraw.setReplacingObjects`: Player (from
540                    // Affected) + Cause + Source (from cause.getHostCard()).
541                    let pid = player.index().to_string();
542                    node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
543                    node.set_triggering_object(AbilityKey::Affected, pid.as_str());
544                    node.set_triggering_object(AbilityKey::Num, extra_draws.to_string().as_str());
545                }
546                ReplacementEvent::DrawCards { player, count } => {
547                    // Java `ReplaceDrawCards.setReplacingObjects`: Player + Num.
548                    let pid = player.index().to_string();
549                    node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
550                    node.set_triggering_object(AbilityKey::Affected, pid.as_str());
551                    node.set_triggering_object(AbilityKey::Num, count.to_string().as_str());
552                }
553                ReplacementEvent::CreateToken { player, count, .. } => {
554                    // Java `ReplaceToken.setReplacingObjects`: TokenNum, Token,
555                    // Cause, Player.
556                    let pid = player.index().to_string();
557                    node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
558                    node.set_triggering_object(AbilityKey::Affected, pid.as_str());
559                    node.set_triggering_object(AbilityKey::TokenNum, count.to_string().as_str());
560                }
561                ReplacementEvent::GainLife { player, amount }
562                | ReplacementEvent::PayLife { player, amount }
563                | ReplacementEvent::LifeReduced { player, amount, .. } => {
564                    let pid = player.index().to_string();
565                    node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
566                    node.set_triggering_object(AbilityKey::Affected, pid.as_str());
567                    node.set_triggering_object(AbilityKey::LifeAmount, amount.to_string().as_str());
568                }
569                ReplacementEvent::Mill { player, count }
570                | ReplacementEvent::Scry { player, count }
571                | ReplacementEvent::Proliferate { player, count }
572                | ReplacementEvent::CopySpell { player, count } => {
573                    let pid = player.index().to_string();
574                    node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
575                    node.set_triggering_object(AbilityKey::Affected, pid.as_str());
576                    node.set_triggering_object(AbilityKey::Num, count.to_string().as_str());
577                }
578                ReplacementEvent::BeginTurn { player }
579                | ReplacementEvent::BeginPhase { player, .. }
580                | ReplacementEvent::DeclareBlocker { player }
581                | ReplacementEvent::RollPlanarDice { player }
582                | ReplacementEvent::PlanarDiceResult { player }
583                | ReplacementEvent::LoseMana { player }
584                | ReplacementEvent::GameLoss { player, .. }
585                | ReplacementEvent::GameWin { player }
586                | ReplacementEvent::Cascade { player }
587                | ReplacementEvent::Learn { player }
588                | ReplacementEvent::Planeswalk { player }
589                | ReplacementEvent::SetInMotion { player }
590                | ReplacementEvent::AssembleContraption { player } => {
591                    let pid = player.index().to_string();
592                    node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
593                    node.set_triggering_object(AbilityKey::Affected, pid.as_str());
594                }
595                ReplacementEvent::Counter { card }
596                | ReplacementEvent::Tap { card }
597                | ReplacementEvent::Untap { card, .. }
598                | ReplacementEvent::Explore { card }
599                | ReplacementEvent::Transform { card }
600                | ReplacementEvent::TurnFaceUp { card }
601                | ReplacementEvent::AssignDealDamage { card } => {
602                    let csv = card.0.to_string();
603                    node.set_triggering_object(AbilityKey::Card, csv.as_str());
604                    node.set_triggering_object(AbilityKey::Affected, csv.as_str());
605                }
606                ReplacementEvent::DealtDamage {
607                    target,
608                    amount,
609                    source,
610                } => {
611                    let target_csv = target.0.to_string();
612                    node.set_triggering_object(AbilityKey::Target, target_csv.as_str());
613                    node.set_triggering_object(AbilityKey::Affected, target_csv.as_str());
614                    node.set_triggering_object(
615                        AbilityKey::DamageAmount,
616                        amount.to_string().as_str(),
617                    );
618                    if let Some(src) = source {
619                        node.set_triggering_object(
620                            AbilityKey::DamageSource,
621                            src.0.to_string().as_str(),
622                        );
623                    }
624                }
625                ReplacementEvent::RemoveCounter {
626                    target,
627                    counter_type,
628                    count,
629                } => {
630                    let target_csv = target.0.to_string();
631                    node.set_triggering_object(AbilityKey::Card, target_csv.as_str());
632                    node.set_triggering_object(
633                        AbilityKey::CounterMap,
634                        format!("{counter_type:?}:{count}").as_str(),
635                    );
636                }
637                ReplacementEvent::Attached { card, target } => {
638                    node.set_triggering_object(AbilityKey::Card, card.0.to_string().as_str());
639                    node.set_triggering_object(AbilityKey::Target, target.0.to_string().as_str());
640                    node.set_triggering_object(AbilityKey::Affected, target.0.to_string().as_str());
641                }
642                ReplacementEvent::ProduceMana {
643                    source,
644                    activator,
645                    mana,
646                } => {
647                    node.set_triggering_object(AbilityKey::Source, source.0.to_string().as_str());
648                    node.set_triggering_object(AbilityKey::Card, source.0.to_string().as_str());
649                    node.set_triggering_object(
650                        AbilityKey::TriggeredPlayer,
651                        activator.index().to_string().as_str(),
652                    );
653                    node.set_triggering_object(AbilityKey::Produced, mana.as_str());
654                }
655                ReplacementEvent::RollDice {
656                    player,
657                    sides,
658                    number,
659                    ..
660                } => {
661                    node.set_triggering_object(
662                        AbilityKey::TriggeredPlayer,
663                        player.index().to_string().as_str(),
664                    );
665                    node.set_triggering_object(AbilityKey::Num, number.to_string().as_str());
666                    node.set_triggering_object(AbilityKey::Sides, sides.to_string().as_str());
667                }
668            }
669            current = node.get_sub_ability_mut();
670        }
671    }
672
673    /// Check if this effect's event type matches the given event.
674    ///
675    /// For `AddCounter`, also matches `Moved` events when the effect handles
676    /// counter-on-move (i.e. has a `CounterMap` interaction).
677    ///
678    /// Mirrors Java `ReplacementEffect.modeCheck()`.
679    pub fn mode_check(&self, event: &ReplacementType) -> bool {
680        if self.event == *event {
681            return true;
682        }
683        // AddCounter effects can also intercept Moved events when they
684        // involve a counter map (e.g. moving counters with the card).
685        if self.event == ReplacementType::AddCounter && *event == ReplacementType::Moved {
686            return self.ir.counter_map;
687        }
688        false
689    }
690}
691
692impl ReplacementEffectIr {
693    fn from_params(params: &Params) -> Self {
694        Self {
695            card_trait_requirements: CardTraitRequirementsIr::from_key_values(
696                params.iter(),
697                params.selector_untracked(keys::IS_PRESENT).cloned(),
698                params.selector_untracked("IsPresent2").cloned(),
699            ),
700            replace_with: params.get(keys::REPLACE_WITH).map(str::to_string),
701            description_text: params.get(keys::DESCRIPTION).map(str::to_string),
702            player_turn_text: params.get(keys::PLAYER_TURN).map(str::to_string),
703            new_destination_text: params.get(keys::NEW_DESTINATION).map(str::to_string),
704            new_destination_zone: parsed_zone_type(params.get(keys::NEW_DESTINATION)),
705            origin_text: params.get(keys::ORIGIN).map(str::to_string),
706            origin_zone: parsed_zone_type(params.get(keys::ORIGIN)),
707            destination_text: params.get(keys::DESTINATION).map(str::to_string),
708            destination_zone: parsed_zone_type(params.get(keys::DESTINATION)),
709            exclude_destination_text: params.get("ExcludeDestination").map(str::to_string),
710            valid_card_text: params.get(keys::VALID_CARD).map(str::to_string),
711            valid_card_selector: params.selector_cloned(keys::VALID_CARD),
712            valid_player_text: params.get(keys::VALID_PLAYER).map(str::to_string),
713            valid_player_selector: params.selector_cloned(keys::VALID_PLAYER),
714            valid_object_selector: params.selector_cloned("ValidObject"),
715            valid_cause_text: params.get(keys::VALID_CAUSE).map(str::to_string),
716            valid_target_selector: params.selector_cloned(keys::VALID_TARGET),
717            valid_source_selector: params.selector_cloned(keys::VALID_SOURCE),
718            valid_activator_text: params.get(keys::VALID_ACTIVATOR).map(str::to_string),
719            valid_step_turn_to_controller_text: params
720                .get("ValidStepTurnToController")
721                .map(str::to_string),
722            valid_explorer_text: params.get(keys::VALID_EXPLORER).map(str::to_string),
723            valid_counter_type_text: params.get(keys::VALID_COUNTER_TYPE).map(str::to_string),
724            valid_lose_reason_text: params.get(keys::VALID_LOSE_REASON).map(str::to_string),
725            valid_sides_text: params.get(keys::VALID_SIDES).map(str::to_string),
726            active_phases: parsed_phase_types(
727                params
728                    .get(keys::PHASE)
729                    .or_else(|| params.get(keys::ACTIVE_PHASES)),
730            ),
731            amount_text: params.get(keys::AMOUNT).map(str::to_string),
732            damage_amount_text: params.get(keys::DAMAGE_AMOUNT).map(str::to_string),
733            result_text: params.get(keys::RESULT).map(str::to_string),
734            number_text: params.get(keys::NUMBER).map(str::to_string),
735            dredge_amount: params.get("DredgeAmount").and_then(|s| s.parse().ok()),
736            skip: params.has(keys::SKIP),
737            prevent: parsed_true(params.get(keys::PREVENT)),
738            optional: params.has(keys::OPTIONAL),
739            effect_only: params.has("EffectOnly"),
740            discard: parsed_bool(params.get("Discard")),
741            flashback_cast: parsed_bool(params.get("FlashbackCast")),
742            harmonize_cast: parsed_bool(params.get("HarmonizeCast")),
743            not_first_card_in_draw_step: parsed_true(params.get("NotFirstCardInDrawStep")),
744            exiled_with_effect_source: params.has("ExiledWithEffectSource"),
745            is_combat: parsed_bool(params.get(keys::IS_COMBAT)),
746            is_damage: parsed_bool(params.get(keys::IS_DAMAGE)),
747            max_speed: parsed_bool(params.get("MaxSpeed")),
748            replacement_result: params.get("ReplacementResult").map(str::to_string),
749            optional_decider_text: params.get(keys::OPTIONAL_DECIDER).map(str::to_string),
750            replace_mana_text: params.get(keys::REPLACE_MANA).map(str::to_string),
751            replace_type_text: params.get(keys::REPLACE_TYPE).map(str::to_string),
752            replace_color_text: params.get(keys::REPLACE_COLOR).map(str::to_string),
753            replace_only_text: params.get(keys::REPLACE_ONLY).map(str::to_string),
754            replace_amount_text: params.get(keys::REPLACE_AMOUNT).map(str::to_string),
755            valid_lki_text: params.get("ValidLKI").map(str::to_string),
756            counter_map: params.get("CounterMap").is_some(),
757            replace_with_chain: None,
758        }
759    }
760}
761
762/// Append "Shields remain: N" when a `ReplaceDamage` / `ReplaceSplitDamage`
763/// ability's `Amount$` / `VarName$` resolves to a `Number$<n>` SVar.
764///
765/// Mirrors Java `ReplacementEffect.getDescription()` lines 228-253. Returns
766/// the description unchanged when the ability api / params / SVar shape
767/// doesn't match (Java silently skips).
768fn append_shield_remaining(mut desc: String, rep_sa: &SpellAbility, host: &Card) -> String {
769    use crate::ability::api_type::ApiType;
770
771    let api = match rep_sa.api {
772        Some(a) => a,
773        None => return desc,
774    };
775
776    let (param_value, default_one) = match api {
777        ApiType::ReplaceDamage => match rep_sa.ir.amount.as_deref() {
778            Some(v) => (v.to_string(), false),
779            None => return desc,
780        },
781        ApiType::ReplaceSplitDamage => (
782            rep_sa
783                .ir
784                .var_name_text
785                .clone()
786                .unwrap_or_else(|| "1".to_string()),
787            true,
788        ),
789        _ => return desc,
790    };
791
792    // Java: if numeric, skip (the raw number already appears in text elsewhere).
793    // ReplaceSplitDamage with the default "1" renders as "Shields remain: 1".
794    if param_value.chars().all(|c| c.is_ascii_digit()) {
795        if default_one && param_value == "1" {
796            desc.push_str(" \nShields remain: 1");
797        }
798        return desc;
799    }
800
801    // Non-numeric → resolve as SVar on host, expect "Number$<value>".
802    if let Some(resolved) = host.get_svar(&param_value) {
803        if let Some(rest) = resolved.strip_prefix("Number$") {
804            desc.push_str(" \nShields remain: ");
805            desc.push_str(rest);
806        }
807    }
808    desc
809}
810
811// ── Helper filter functions ───────────────────────────────────────────────────
812//
813// `matches_valid_card` / `matches_valid_player` used to live here as free
814// functions. They are now default methods on `CardTrait` (see
815// `card_trait_base.rs`) so every subclass — `Trigger`, `ReplacementEffect`,
816// and future `StaticAbility`/`SpellAbility` — gets the same API without
817// per-module wrappers.
818
819/// Check if a zone name string matches `zone`.
820pub fn zone_matches(expr: &str, zone: ZoneType) -> bool {
821    expr.split(',').any(|part| match part.trim() {
822        "Battlefield" => zone == ZoneType::Battlefield,
823        "Graveyard" => zone == ZoneType::Graveyard,
824        "Hand" => zone == ZoneType::Hand,
825        "Library" => zone == ZoneType::Library,
826        "Exile" => zone == ZoneType::Exile,
827        "Command" => zone == ZoneType::Command,
828        "Stack" => zone == ZoneType::Stack,
829        _ => false,
830    })
831}
832
833// ── Parser ────────────────────────────────────────────────────────────────────
834
835/// Parse an `R$` (or `R:`) replacement-effect line from a card script.
836///
837/// Returns `None` if the line does not start with the `R$` / `R:` prefix or
838/// has no recognisable `Event$` param.
839///
840/// # Format
841///
842/// ```text
843/// R$ Event$ DamageDone | ActiveZones$ Battlefield | ValidCard$ Card.Self | Prevent$ True | Description$ Prevent all damage.
844/// R$ Event$ Draw | ValidPlayer$ You | Description$ Skip your draw step.
845/// R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self
846/// ```
847///
848/// Reference: Java `ReplacementEffect.java` in `forge/game/replacement/`.
849pub fn parse_replacement_effect(raw: &str) -> Option<ReplacementEffect> {
850    let trimmed = raw.trim();
851    // Accept "R$ ..." / "R: ..." discriminator prefixes (used in card files),
852    // AND bare "Event$ … | …" bodies (used by `EffectEffect`'s
853    // `ReplacementEffects$` SVar lookups, which Java parses without a
854    // discriminator via `ReplacementHandler.parseReplacement(svarText, …)`).
855    let body = if let Some(rest) = trimmed.strip_prefix("R$ ") {
856        rest
857    } else if let Some(rest) = trimmed.strip_prefix("R:") {
858        rest.trim_start()
859    } else {
860        trimmed
861    };
862
863    // Parse "|"-separated "Key$ Value" pairs.
864    let params = Params::from_raw(body);
865
866    let event = match params.get(keys::EVENT) {
867        Some(s) => ReplacementType::smart_value_of(s),
868        None => return None,
869    };
870
871    // Parse the layer (defaults to Other if not specified).
872    let layer = params
873        .get(keys::LAYER)
874        .and_then(ReplacementLayer::smart_value_of)
875        .unwrap_or(ReplacementLayer::Other);
876
877    // Parse ActiveZones$ (comma- or space-separated list of zone names).
878    let active_zones = params
879        .get(keys::ACTIVE_ZONES)
880        .map(parse_zone_list)
881        .unwrap_or_default();
882
883    Some(ReplacementEffect::new(event, layer, params, active_zones))
884}
885
886/// Parse a comma- or space-separated zone list string into `ZoneType` values.
887pub(super) fn parse_zone_list(s: &str) -> Vec<ZoneType> {
888    s.split([',', ' '])
889        .filter_map(|tok| match tok.trim() {
890            "Battlefield" => Some(ZoneType::Battlefield),
891            "Graveyard" => Some(ZoneType::Graveyard),
892            "Hand" => Some(ZoneType::Hand),
893            "Library" => Some(ZoneType::Library),
894            "Exile" => Some(ZoneType::Exile),
895            "Command" => Some(ZoneType::Command),
896            _ => None,
897        })
898        .collect()
899}
900
901fn parsed_zone_type(value: Option<&str>) -> Option<ZoneType> {
902    match value? {
903        "Battlefield" => Some(ZoneType::Battlefield),
904        "Graveyard" => Some(ZoneType::Graveyard),
905        "Hand" => Some(ZoneType::Hand),
906        "Library" => Some(ZoneType::Library),
907        "Exile" => Some(ZoneType::Exile),
908        "Command" => Some(ZoneType::Command),
909        "Stack" => Some(ZoneType::Stack),
910        _ => None,
911    }
912}
913
914pub(crate) fn parse_replacement_chain(
915    raw: &str,
916    svars: &impl HasSVars,
917) -> Option<ReplacementChainIr> {
918    let params = Params::from_raw(raw);
919    let db = params.get(keys::DB)?;
920    match db {
921        "ReplaceToken" => Some(ReplacementChainIr::ReplaceToken {
922            token_type: params.get("Type").map(str::to_string),
923            amount_expr: params.get("Amount").map(str::to_string),
924        }),
925        "ReplaceCounter" => Some(ReplacementChainIr::ReplaceCounter {
926            amount_expr: params.get("Amount")?.to_string(),
927            choose_counter: params.has("ChooseCounter"),
928            after_replacement_static_abilities: params
929                .get(keys::SUB_ABILITY)
930                .and_then(|name| svars.get_svar(name))
931                .map(Params::from_raw)
932                .filter(|sub| {
933                    sub.get(keys::DB) == Some("ImmediateTrigger") && sub.has("AfterReplacement")
934                })
935                .and_then(|sub| sub.get(keys::EXECUTE).and_then(|name| svars.get_svar(name)))
936                .map(Params::from_raw)
937                .filter(|execute| execute.get(keys::DB) == Some("Effect"))
938                .and_then(|execute| execute.get(keys::STATIC_ABILITIES).map(str::to_string))
939                .map(|names| {
940                    names
941                        .split(',')
942                        .filter_map(|name| svars.get_svar(name.trim()).map(str::to_string))
943                        .collect()
944                })
945                .unwrap_or_default(),
946        }),
947        "ReplaceEffect" => {
948            let sub_ability = params
949                .get(keys::SUB_ABILITY)
950                .and_then(|name| svars.get_svar(name))
951                .and_then(|sub_raw| parse_replacement_chain(sub_raw, svars))
952                .map(Box::new);
953            Some(ReplacementChainIr::ReplaceEffect {
954                var_name: params.get("VarName").map(str::to_string),
955                var_type: params.get("VarType").map(str::to_string),
956                var_key: params.get("VarKey").map(str::to_string),
957                var_value: params.get("VarValue").map(str::to_string),
958                sub_ability,
959            })
960        }
961        _ => None,
962    }
963}
964
965pub(crate) fn resolve_replace_with_chain(
966    effect: &ReplacementEffect,
967    source_card: &Card,
968) -> Option<ReplacementChainIr> {
969    effect.ir.replace_with_chain.clone().or_else(|| {
970        effect
971            .replace_with()
972            .and_then(|svar_name| source_card.get_svar(svar_name))
973            .and_then(|raw| parse_replacement_chain(raw, source_card))
974    })
975}
976
977fn parsed_phase_types(value: Option<&str>) -> Vec<PhaseType> {
978    value
979        .into_iter()
980        .flat_map(|raw| raw.split(','))
981        .filter_map(|phase| PhaseType::from_script_name(phase.trim()))
982        .collect()
983}
984
985fn parsed_true(value: Option<&str>) -> bool {
986    matches!(value, Some("True") | Some("true"))
987}
988
989fn parsed_bool(value: Option<&str>) -> Option<bool> {
990    value.map(|raw| matches!(raw, "True" | "true"))
991}
992
993// ── Tests ─────────────────────────────────────────────────────────────────────
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998
999    // ── Parser tests ──────────────────────────────────────────────────────
1000
1001    #[test]
1002    fn parse_damage_prevention() {
1003        let raw = "R$ Event$ DamageDone | ActiveZones$ Battlefield | ValidCard$ Card.Self | Prevent$ True | Description$ Prevent all damage dealt to ~.";
1004        let re = parse_replacement_effect(raw).expect("should parse");
1005        assert_eq!(re.event, ReplacementType::DamageDone);
1006        assert_eq!(re.layer, ReplacementLayer::Other);
1007        assert!(re.ir.prevent);
1008        assert_eq!(re.active_zones, vec![ZoneType::Battlefield]);
1009    }
1010
1011    #[test]
1012    fn parse_draw_skip() {
1013        let raw = "R$ Event$ Draw | ValidPlayer$ You | Description$ Skip your draw step.";
1014        let re = parse_replacement_effect(raw).expect("should parse");
1015        assert_eq!(re.event, ReplacementType::Draw);
1016        assert_eq!(re.ir.valid_player_text.as_deref(), Some("You"));
1017        assert!(re.active_zones.is_empty());
1018    }
1019
1020    #[test]
1021    fn parse_destroy_replacement() {
1022        let raw = "R$ Event$ Destroy | ValidCard$ Card.Self | Description$ ~ is indestructible.";
1023        let re = parse_replacement_effect(raw).expect("should parse");
1024        assert_eq!(re.event, ReplacementType::Destroy);
1025        assert_eq!(re.ir.valid_card_text.as_deref(), Some("Card.Self"));
1026    }
1027
1028    #[test]
1029    fn parse_moved_exile_instead() {
1030        let raw = "R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self | NewDestination$ Exile | Description$ If ~ would die, exile it instead.";
1031        let re = parse_replacement_effect(raw).expect("should parse");
1032        assert_eq!(re.event, ReplacementType::Moved);
1033        assert_eq!(re.ir.destination_text.as_deref(), Some("Graveyard"));
1034        assert_eq!(re.ir.origin_text.as_deref(), Some("Battlefield"));
1035        assert_eq!(re.ir.new_destination_text.as_deref(), Some("Exile"));
1036    }
1037
1038    #[test]
1039    fn parse_cant_happen_layer() {
1040        let raw = "R$ Event$ Destroy | Layer$ CantHappen | ValidCard$ Card.Self";
1041        let re = parse_replacement_effect(raw).expect("should parse");
1042        assert_eq!(re.layer, ReplacementLayer::CantHappen);
1043    }
1044
1045    #[test]
1046    fn parse_r_colon_prefix() {
1047        let raw = "R: Event$ Draw | ValidPlayer$ You";
1048        let re = parse_replacement_effect(raw).expect("should parse R: prefix");
1049        assert_eq!(re.event, ReplacementType::Draw);
1050    }
1051
1052    #[test]
1053    fn non_replacement_line_returns_none() {
1054        assert!(parse_replacement_effect("AB$ Mana | Cost$ T | Produced$ G").is_none());
1055        assert!(
1056            parse_replacement_effect("S$ Mode$ Continuous | Affected$ Creature.YouControl")
1057                .is_none()
1058        );
1059        assert!(parse_replacement_effect("").is_none());
1060    }
1061
1062    // ── active_in_zone tests ──────────────────────────────────────────────
1063
1064    #[test]
1065    fn active_in_zone_empty_means_always() {
1066        let raw = "R$ Event$ Draw | ValidPlayer$ You";
1067        let re = parse_replacement_effect(raw).unwrap();
1068        // Empty active_zones → active in all zones.
1069        assert!(re.active_in_zone(ZoneType::Battlefield));
1070        assert!(re.active_in_zone(ZoneType::Hand));
1071        assert!(re.active_in_zone(ZoneType::Graveyard));
1072    }
1073
1074    #[test]
1075    fn active_in_zone_respects_active_zones() {
1076        let raw = "R$ Event$ DamageDone | ActiveZones$ Battlefield | Prevent$ True";
1077        let re = parse_replacement_effect(raw).unwrap();
1078        assert!(re.active_in_zone(ZoneType::Battlefield));
1079        assert!(!re.active_in_zone(ZoneType::Graveyard));
1080        assert!(!re.active_in_zone(ZoneType::Hand));
1081    }
1082
1083    // ── New ReplacementType variant parsing tests ─────────────────────────
1084
1085    #[test]
1086    fn parse_all_new_event_types() {
1087        for (event_str, expected) in [
1088            ("Tap", ReplacementType::Tap),
1089            ("Untap", ReplacementType::Untap),
1090            ("Mill", ReplacementType::Mill),
1091            ("Scry", ReplacementType::Scry),
1092            ("Explore", ReplacementType::Explore),
1093            ("Cascade", ReplacementType::Cascade),
1094            ("Learn", ReplacementType::Learn),
1095            ("Proliferate", ReplacementType::Proliferate),
1096            ("Transform", ReplacementType::Transform),
1097            ("TurnFaceUp", ReplacementType::TurnFaceUp),
1098            ("RollDice", ReplacementType::RollDice),
1099        ] {
1100            let raw = format!("R$ Event$ {event_str} | Description$ test");
1101            let re = parse_replacement_effect(&raw).expect(&format!("should parse {event_str}"));
1102            assert_eq!(re.event, expected, "failed for {event_str}");
1103        }
1104    }
1105}