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