Skip to main content

manabrew_engine/replacement/
replacement_handler.rs

1//! Replacement effect dispatcher.
2//!
3//! Mirrors the Java Forge `ReplacementHandler.java` in
4//! `forge/game/replacement/`.
5//!
6//! The entry point is [`apply_replacements`], which accepts a mutable
7//! [`ReplacementEvent`] and iterates through the five CR 616 layers
8//! (CantHappen → Control → Copy → Transform → Other), applying the first
9//! matching effect in each layer.
10
11use std::collections::{HashMap, HashSet};
12
13use forge_foundation::{PhaseType, ZoneType};
14
15use crate::agent::GameEntity;
16use crate::card::Card;
17use crate::card::CounterType;
18use crate::card_trait_base::CardTrait;
19use crate::game::GameState;
20use crate::game_rng::GameRng;
21use crate::ids::{CardId, PlayerId};
22use crate::mana::ManaPool;
23use crate::replacement::replacement_effect::{
24    resolve_replace_with_chain, GameLossReason, ReplacementChainIr, ReplacementEffect,
25};
26use crate::replacement::replacement_layer::ReplacementLayer;
27use crate::replacement::replacement_result::ReplacementResult;
28use crate::replacement::replacement_type::ReplacementType;
29use crate::trigger::TriggerHandler;
30
31#[derive(Debug, Clone)]
32pub struct CounterMapValue {
33    pub source: Option<PlayerId>,
34    pub counters: std::collections::BTreeMap<CounterType, i32>,
35}
36
37// ── ReplacementEvent ──────────────────────────────────────────────────────────
38
39/// A game event that may be subject to one or more replacement effects.
40///
41/// Each variant carries the mutable parameters of the event so that a
42/// replacement effect can modify them (e.g. reduce damage to 0, redirect a
43/// zone move, etc.).
44///
45/// Mirrors the `runParams` map passed to `ReplacementHandler.run()` in Java,
46/// but typed for safety.
47#[derive(Debug, Clone)]
48pub enum ReplacementEvent {
49    /// A card is being drawn by a player.
50    /// `extra_draws` is incremented by replacements (e.g. Alhammarret's Archive DrawTwo).
51    /// `is_first_in_draw_step` is true for the first draw in the draw step (not extra draws).
52    Draw {
53        player: PlayerId,
54        extra_draws: i32,
55        is_first_in_draw_step: bool,
56    },
57
58    /// Damage is being dealt to a permanent.
59    DamageToCard {
60        target: CardId,
61        amount: i32,
62        /// The card dealing the damage, if known.
63        source: Option<CardId>,
64        /// Whether this is combat damage.
65        is_combat: bool,
66    },
67
68    /// Damage is being dealt to a player.
69    DamageToPlayer {
70        target: PlayerId,
71        amount: i32,
72        /// The card dealing the damage, if known.
73        source: Option<CardId>,
74        /// Whether this is combat damage.
75        is_combat: bool,
76    },
77
78    /// A permanent is being destroyed (lethal damage or destroy effect).
79    Destroy { target: CardId },
80
81    /// A card is moving between zones.
82    Moved {
83        card: CardId,
84        origin: ZoneType,
85        destination: ZoneType,
86        /// True when this move originates from a discard action.
87        /// Mirrors Java's `AbilityKey.Discard` in replacement params.
88        is_discard: bool,
89        counter_map: Option<Vec<CounterMapValue>>,
90        counter_cause: Option<Box<crate::spellability::SpellAbility>>,
91        counter_is_effect: bool,
92        after_replacement_static_abilities: Vec<(CardId, Vec<String>)>,
93    },
94
95    /// A player is gaining life.
96    GainLife { player: PlayerId, amount: i32 },
97
98    /// Token(s) are being created.
99    /// `is_effect` is `true` when created by a spell/ability effect, `false` for game rules.
100    CreateToken {
101        player: PlayerId,
102        count: i32,
103        is_effect: bool,
104    },
105
106    /// Counter(s) are being added to a permanent.
107    /// `is_effect` is `true` when placed by a spell/ability, `false` for ETB keywords/game rules.
108    AddCounter {
109        target: GameEntity,
110        source: Option<PlayerId>,
111        counter_type: CounterType,
112        count: i32,
113        counter_map: Vec<CounterMapValue>,
114        after_replacement_static_abilities: Vec<(CardId, Vec<String>)>,
115        cause: Option<Box<crate::spellability::SpellAbility>>,
116        is_effect: bool,
117    },
118
119    /// A player is losing the game.
120    GameLoss {
121        player: PlayerId,
122        reason: GameLossReason,
123    },
124
125    /// A player is winning the game.
126    GameWin { player: PlayerId },
127
128    /// A spell is being countered.
129    Counter { card: CardId },
130
131    /// Mana is being produced (for doublers like Mirari's Wake, Nyxbloom Ancient).
132    /// `mana` is the produced mana string (e.g. "G" or "U U") that may be modified.
133    ProduceMana {
134        source: CardId,
135        activator: PlayerId,
136        mana: String,
137    },
138
139    /// A permanent is being tapped.
140    Tap { card: CardId },
141
142    /// A permanent is being untapped.
143    Untap {
144        card: CardId,
145        player: Option<PlayerId>,
146    },
147
148    /// Life is being reduced (distinct from damage).
149    LifeReduced {
150        player: PlayerId,
151        amount: i32,
152        is_damage: bool,
153    },
154
155    /// Counter(s) are being removed from a permanent.
156    RemoveCounter {
157        target: CardId,
158        counter_type: CounterType,
159        count: i32,
160    },
161
162    /// Damage has been dealt (post-damage).
163    DealtDamage {
164        target: CardId,
165        amount: i32,
166        source: Option<CardId>,
167    },
168
169    /// Multiple cards are being drawn.
170    DrawCards { player: PlayerId, count: i32 },
171
172    /// Cards are being milled.
173    Mill { player: PlayerId, count: i32 },
174
175    /// Life is being paid as a cost.
176    PayLife { player: PlayerId, amount: i32 },
177
178    /// A player is scrying.
179    Scry { player: PlayerId, count: i32 },
180
181    /// An aura/equipment is being attached.
182    Attached { card: CardId, target: CardId },
183
184    /// A phase is beginning.
185    BeginPhase {
186        player: PlayerId,
187        phase: forge_foundation::PhaseType,
188    },
189
190    /// A turn is beginning.
191    BeginTurn { player: PlayerId },
192
193    /// A creature is exploring.
194    Explore { card: CardId },
195
196    /// Blockers are being declared.
197    DeclareBlocker { player: PlayerId },
198
199    /// Damage is being assigned before dealing.
200    AssignDealDamage { card: CardId },
201
202    /// A DFC is transforming.
203    Transform { card: CardId },
204
205    /// A face-down card is turning face up.
206    TurnFaceUp { card: CardId },
207
208    /// A spell is being copied.
209    CopySpell { player: PlayerId, count: i32 },
210
211    /// A player is proliferating.
212    Proliferate { player: PlayerId, count: i32 },
213
214    /// Cascade is triggering.
215    Cascade { player: PlayerId },
216
217    /// A player is learning.
218    Learn { player: PlayerId },
219
220    /// Mana is being lost.
221    LoseMana { player: PlayerId },
222
223    /// Dice are being rolled.
224    RollDice {
225        player: PlayerId,
226        sides: i32,
227        number: i32,
228        ignore: i32,
229        ignore_chosen: HashMap<PlayerId, i32>,
230        dice_pt_exchanges: HashSet<CardId>,
231    },
232
233    /// The planar die is being rolled.
234    RollPlanarDice { player: PlayerId },
235
236    /// A planar dice result is being applied.
237    PlanarDiceResult { player: PlayerId },
238
239    /// A player is planeswalking.
240    Planeswalk { player: PlayerId },
241
242    /// A scheme is being set in motion.
243    SetInMotion { player: PlayerId },
244
245    /// A contraption is being assembled.
246    AssembleContraption { player: PlayerId },
247}
248
249// ── ReplacementHandler struct ─────────────────────────────────────────────────
250
251use crate::agent::PlayerAgent;
252
253pub struct ReplacementRuntime<'a> {
254    pub trigger_handler: &'a mut TriggerHandler,
255    pub token_templates: &'a HashMap<String, Card>,
256    pub token_art_variants: &'a HashMap<(String, String), usize>,
257    pub token_fallback: &'a HashMap<String, String>,
258    pub edition_dates: &'a HashMap<String, String>,
259    pub mana_pools: &'a mut Vec<ManaPool>,
260    pub rng: &'a mut dyn GameRng,
261}
262
263/// Struct-based replacement handler with loop prevention and optional agent access.
264///
265/// Mirrors Java `ReplacementHandler` class. The `has_run` set prevents infinite
266/// loops when effects re-trigger themselves.
267///
268/// Usage:
269/// - `ReplacementHandler::new().run(game, Some(agents), event)` — with agent choice
270/// - `apply_replacements(game, event)` — free function wrapper (no agent, auto-selects first)
271pub struct ReplacementHandler {
272    /// Tracks (card_id, layer, effect_index) tuples that have already been applied
273    /// during this handler invocation, to prevent infinite re-application.
274    has_run: HashSet<(CardId, ReplacementLayer, usize)>,
275}
276
277impl Default for ReplacementHandler {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283impl ReplacementHandler {
284    pub fn new() -> Self {
285        Self {
286            has_run: HashSet::new(),
287        }
288    }
289
290    /// Apply all applicable replacement effects to a game event.
291    ///
292    /// Processes effects in CR 616 layer order:
293    /// CantHappen → Control → Copy → Transform → Other.
294    ///
295    /// When `agents` is `Some` and multiple effects match in the same layer,
296    /// the affected player's agent is asked to choose via
297    /// `choose_single_replacement_effect()`.
298    ///
299    /// Mirrors Java `ReplacementHandler.run(ReplacementType, Map<AbilityKey,Object>)`.
300    pub fn run(
301        &mut self,
302        game: &mut GameState,
303        mut agents: Option<&mut [Box<dyn PlayerAgent>]>,
304        mut runtime: Option<&mut ReplacementRuntime<'_>>,
305        event: &mut ReplacementEvent,
306    ) -> ReplacementResult {
307        for layer in [
308            ReplacementLayer::CantHappen,
309            ReplacementLayer::Control,
310            ReplacementLayer::Copy,
311            ReplacementLayer::Transform,
312            ReplacementLayer::Other,
313        ] {
314            let result = self.run_layer(
315                game,
316                agents.as_deref_mut(),
317                runtime.as_deref_mut(),
318                event,
319                layer,
320            );
321            match result {
322                ReplacementResult::NotReplaced => continue,
323                ReplacementResult::Updated => {
324                    // Java preserves Updated unless a later replacement fully
325                    // replaces the event during the re-run.
326                    return match self.run(game, agents, runtime, event) {
327                        ReplacementResult::NotReplaced | ReplacementResult::Updated => {
328                            ReplacementResult::Updated
329                        }
330                        other => other,
331                    };
332                }
333                other => return other,
334            }
335        }
336        ReplacementResult::NotReplaced
337    }
338
339    /// Apply one CR 616 layer of replacement effects.
340    fn run_layer(
341        &mut self,
342        game: &mut GameState,
343        mut agents: Option<&mut [Box<dyn PlayerAgent>]>,
344        mut runtime: Option<&mut ReplacementRuntime<'_>>,
345        event: &mut ReplacementEvent,
346        layer: ReplacementLayer,
347    ) -> ReplacementResult {
348        let effects = collect_effects(game, event, layer);
349        let mut declined_effects: HashSet<(CardId, usize)> = HashSet::new();
350
351        if effects.is_empty() {
352            return ReplacementResult::NotReplaced;
353        }
354
355        loop {
356            let eligible: Vec<_> = effects
357                .iter()
358                .filter(|(card_id, _re, effect_idx)| {
359                    !self.has_run.contains(&(*card_id, layer, *effect_idx))
360                        && !declined_effects.contains(&(*card_id, *effect_idx))
361                })
362                .cloned()
363                .collect();
364
365            if eligible.is_empty() {
366                return ReplacementResult::NotReplaced;
367            }
368
369            let chosen_idx = if !eligible.is_empty() && layer != ReplacementLayer::CantHappen {
370                if let Some(agents) = agents.as_deref_mut() {
371                    let descriptions: Vec<String> = eligible
372                        .iter()
373                        .map(|(card_id, re, _)| {
374                            let host = game.card(*card_id);
375                            let card_name = &host.card_name;
376                            let desc = re.description(host, game);
377                            format!("{card_name}: {desc}")
378                        })
379                        .collect();
380
381                    let affected_player = affected_player_for_event(event, game);
382                    let agent = &mut agents[affected_player.index()];
383                    agent
384                        .choose_single_replacement_effect(affected_player, &descriptions)
385                        .min(eligible.len() - 1)
386                } else {
387                    0
388                }
389            } else {
390                0
391            };
392
393            let (source_card_id, ref effect, effect_idx) = eligible[chosen_idx];
394            self.has_run.insert((source_card_id, layer, effect_idx));
395            let result = execute_effect(
396                game,
397                source_card_id,
398                effect,
399                event,
400                agents.as_deref_mut(),
401                runtime.as_deref_mut(),
402            );
403            if result == ReplacementResult::NotReplaced {
404                self.has_run.remove(&(source_card_id, layer, effect_idx));
405                declined_effects.insert((source_card_id, effect_idx));
406                continue;
407            }
408            return result;
409        }
410    }
411}
412
413/// Determine which player is "affected" by the event (for choosing among effects).
414fn affected_player_for_event(event: &ReplacementEvent, game: &GameState) -> PlayerId {
415    match event {
416        ReplacementEvent::Draw { player, .. } => *player,
417        ReplacementEvent::DamageToCard { target, .. } => game.cards[target.index()].controller,
418        ReplacementEvent::DamageToPlayer { target, .. } => *target,
419        ReplacementEvent::Destroy { target } => game.cards[target.index()].controller,
420        ReplacementEvent::Moved { card, .. } => game.cards[card.index()].controller,
421        ReplacementEvent::GainLife { player, .. } => *player,
422        ReplacementEvent::CreateToken { player, .. } => *player,
423        ReplacementEvent::AddCounter { target, .. } => match target {
424            GameEntity::Card(target) => game.cards[target.index()].controller,
425            GameEntity::Player(target) => *target,
426        },
427        ReplacementEvent::GameLoss { player, .. } => *player,
428        ReplacementEvent::GameWin { player } => *player,
429        ReplacementEvent::Counter { card } => game.cards[card.index()].controller,
430        ReplacementEvent::ProduceMana { activator, .. } => *activator,
431        ReplacementEvent::Tap { card } => game.cards[card.index()].controller,
432        ReplacementEvent::Untap { card, .. } => game.cards[card.index()].controller,
433        ReplacementEvent::LifeReduced { player, .. } => *player,
434        ReplacementEvent::RemoveCounter { target, .. } => game.cards[target.index()].controller,
435        ReplacementEvent::DealtDamage { target, .. } => game.cards[target.index()].controller,
436        ReplacementEvent::DrawCards { player, .. } => *player,
437        ReplacementEvent::Mill { player, .. } => *player,
438        ReplacementEvent::PayLife { player, .. } => *player,
439        ReplacementEvent::Scry { player, .. } => *player,
440        ReplacementEvent::Attached { card, .. } => game.cards[card.index()].controller,
441        ReplacementEvent::BeginPhase { player, .. } => *player,
442        ReplacementEvent::BeginTurn { player } => *player,
443        ReplacementEvent::Explore { card } => game.cards[card.index()].controller,
444        ReplacementEvent::DeclareBlocker { player } => *player,
445        ReplacementEvent::AssignDealDamage { card } => game.cards[card.index()].controller,
446        ReplacementEvent::Transform { card } => game.cards[card.index()].controller,
447        ReplacementEvent::TurnFaceUp { card } => game.cards[card.index()].controller,
448        ReplacementEvent::CopySpell { player, .. } => *player,
449        ReplacementEvent::Proliferate { player, .. } => *player,
450        ReplacementEvent::Cascade { player } => *player,
451        ReplacementEvent::Learn { player } => *player,
452        ReplacementEvent::LoseMana { player } => *player,
453        ReplacementEvent::RollDice { player, .. } => *player,
454        ReplacementEvent::RollPlanarDice { player } => *player,
455        ReplacementEvent::PlanarDiceResult { player } => *player,
456        ReplacementEvent::Planeswalk { player } => *player,
457        ReplacementEvent::SetInMotion { player } => *player,
458        ReplacementEvent::AssembleContraption { player } => *player,
459    }
460}
461
462// ── Public free-function API (backward compatibility) ─────────────────────────
463
464/// Apply all applicable replacement effects to a game event.
465///
466/// Free function wrapper — auto-selects first effect (no agent prompt).
467/// Used by callers that don't have access to agents (e.g. `action.rs`).
468///
469/// Mirrors Java `ReplacementHandler.run(ReplacementType, Map<AbilityKey,Object>)`.
470pub fn apply_replacements(game: &mut GameState, event: &mut ReplacementEvent) -> ReplacementResult {
471    let mut handler = ReplacementHandler::new();
472    handler.run(game, None, None, event)
473}
474
475/// Apply Moved replacement effects (Rest in Peace, Leyline of the Void) for a card
476/// being moved to the Graveyard. Uses agents for proper RNG consumption when
477/// multiple effects apply. Returns the final destination zone.
478///
479/// If `dest` is not `Graveyard`, returns `dest` unchanged (no replacement needed).
480pub fn apply_moved_replacement(
481    game: &mut GameState,
482    card_id: CardId,
483    dest: ZoneType,
484    agents: Option<&mut [Box<dyn crate::agent::PlayerAgent>]>,
485) -> ZoneType {
486    if dest != ZoneType::Graveyard {
487        return dest;
488    }
489    let src_zone = game.cards[card_id.index()].zone;
490    let mut event = ReplacementEvent::Moved {
491        card: card_id,
492        origin: src_zone,
493        destination: ZoneType::Graveyard,
494        is_discard: false,
495        counter_map: None,
496        counter_cause: None,
497        counter_is_effect: false,
498        after_replacement_static_abilities: Vec::new(),
499    };
500    let mut handler = ReplacementHandler::new();
501    handler.run(game, agents, None, &mut event);
502    if let ReplacementEvent::Moved { destination, .. } = event {
503        destination
504    } else {
505        ZoneType::Graveyard
506    }
507}
508
509pub fn apply_replacements_with_agents(
510    game: &mut GameState,
511    agents: &mut [Box<dyn PlayerAgent>],
512    event: &mut ReplacementEvent,
513) -> ReplacementResult {
514    let mut handler = ReplacementHandler::new();
515    handler.run(game, Some(agents), None, event)
516}
517
518pub fn apply_replacements_with_agents_and_runtime(
519    game: &mut GameState,
520    agents: &mut [Box<dyn PlayerAgent>],
521    runtime: &mut ReplacementRuntime<'_>,
522    event: &mut ReplacementEvent,
523) -> ReplacementResult {
524    let mut handler = ReplacementHandler::new();
525    handler.run(game, Some(agents), Some(runtime), event)
526}
527
528pub(crate) fn replacement_event_amount(event: &ReplacementEvent) -> Option<i32> {
529    match event {
530        ReplacementEvent::DamageToCard { amount, .. } => Some(*amount),
531        ReplacementEvent::DamageToPlayer { amount, .. } => Some(*amount),
532        ReplacementEvent::GainLife { amount, .. } => Some(*amount),
533        ReplacementEvent::LifeReduced { amount, .. } => Some(*amount),
534        ReplacementEvent::CreateToken { count, .. } => Some(*count),
535        ReplacementEvent::AddCounter { count, .. } => Some(*count),
536        ReplacementEvent::DrawCards { count, .. } => Some(*count),
537        ReplacementEvent::Mill { count, .. } => Some(*count),
538        ReplacementEvent::PayLife { amount, .. } => Some(*amount),
539        ReplacementEvent::Scry { count, .. } => Some(*count),
540        ReplacementEvent::CopySpell { count, .. } => Some(*count),
541        ReplacementEvent::Proliferate { count, .. } => Some(*count),
542        ReplacementEvent::RollDice { number, .. } => Some(*number),
543        _ => None,
544    }
545}
546
547pub(crate) fn set_replacement_event_amount(event: &mut ReplacementEvent, value: i32) -> bool {
548    match event {
549        ReplacementEvent::DamageToCard { amount, .. } => *amount = value.max(0),
550        ReplacementEvent::DamageToPlayer { amount, .. } => *amount = value.max(0),
551        ReplacementEvent::GainLife { amount, .. } => *amount = value.max(0),
552        ReplacementEvent::LifeReduced { amount, .. } => *amount = value.max(0),
553        ReplacementEvent::CreateToken { count, .. } => *count = value.max(0),
554        ReplacementEvent::AddCounter { count, .. } => *count = value.max(0),
555        ReplacementEvent::DrawCards { count, .. } => *count = value.max(0),
556        ReplacementEvent::Mill { count, .. } => *count = value.max(0),
557        ReplacementEvent::PayLife { amount, .. } => *amount = value.max(0),
558        ReplacementEvent::Scry { count, .. } => *count = value.max(0),
559        ReplacementEvent::CopySpell { count, .. } => *count = value.max(0),
560        ReplacementEvent::Proliferate { count, .. } => *count = value.max(0),
561        ReplacementEvent::RollDice { number, .. } => *number = value.max(0),
562        _ => return false,
563    }
564    true
565}
566
567fn amount_after_math(mut amount: i32, ops: &str) -> i32 {
568    if ops.is_empty() {
569        return amount;
570    }
571    let parts: Vec<&str> = ops.split('.').collect();
572    let op = parts.first().copied().unwrap_or("");
573    let rhs = parts
574        .get(1)
575        .and_then(|value| value.parse::<i32>().ok())
576        .unwrap_or(0);
577    if op.contains("Plus") {
578        amount += rhs;
579    } else if op.contains("Minus") {
580        amount -= rhs;
581    } else if op.contains("Twice") {
582        amount *= 2;
583    } else if op.contains("Thrice") {
584        amount *= 3;
585    } else if op.contains("HalfUp") {
586        amount = ((amount as f64) / 2.0).ceil() as i32;
587    } else if op.contains("HalfDown") {
588        amount = ((amount as f64) / 2.0).floor() as i32;
589    }
590    amount
591}
592
593pub(crate) fn resolve_replace_value(
594    expr: &str,
595    game: &GameState,
596    source_card_id: CardId,
597    event: &ReplacementEvent,
598) -> Option<i32> {
599    let expr = expr.trim();
600    if let Ok(value) = expr.parse::<i32>() {
601        return Some(value);
602    }
603    if let Some(svar) = game.card(source_card_id).svars.get(expr) {
604        return resolve_replace_value(svar, game, source_card_id, event);
605    }
606    let rest = expr.strip_prefix("ReplaceCount$")?;
607    let (field, ops) = rest.split_once('/').unwrap_or((rest, ""));
608    let base = match field {
609        "DamageAmount" | "LifeGained" | "Amount" | "Number" | "TokenNum" | "CounterNum" | "Num" => {
610            replacement_event_amount(event)?
611        }
612        "Ignore" => match event {
613            ReplacementEvent::RollDice { ignore, .. } => *ignore,
614            _ => return None,
615        },
616        _ => return None,
617    };
618    Some(amount_after_math(base, ops))
619}
620
621pub(crate) fn execute_replace_with_numeric_update(
622    effect: &ReplacementEffect,
623    event: &mut ReplacementEvent,
624    game: &GameState,
625    source_card_id: CardId,
626    var_name: &str,
627) -> Option<ReplacementResult> {
628    let chain = resolve_replace_with_chain(effect, game.card(source_card_id))?;
629    execute_replace_effect_ir(&chain, event, game, source_card_id, Some(var_name))
630}
631
632pub(crate) fn execute_replace_effect_ir(
633    chain: &ReplacementChainIr,
634    event: &mut ReplacementEvent,
635    game: &GameState,
636    source_card_id: CardId,
637    required_var_name: Option<&str>,
638) -> Option<ReplacementResult> {
639    match chain {
640        ReplacementChainIr::ReplaceToken {
641            token_type,
642            amount_expr,
643        } => execute_replace_token_chain(
644            token_type.as_deref(),
645            amount_expr.as_deref(),
646            event,
647            game,
648            source_card_id,
649        ),
650        ReplacementChainIr::ReplaceCounter { amount_expr, .. } => {
651            execute_replace_counter_chain(amount_expr, event, game, source_card_id)
652        }
653        ReplacementChainIr::ReplaceEffect {
654            var_name,
655            var_type,
656            var_key,
657            var_value,
658            sub_ability,
659        } => {
660            let mut updated = false;
661            if let Some(var_name) = var_name.as_deref() {
662                if required_var_name.is_none_or(|required| required == var_name) {
663                    match var_name {
664                        "DicePTExchanges" => {
665                            if var_type.as_deref() == Some("CardSet") {
666                                if let Some(var_value) = var_value.as_deref() {
667                                    if let Some(card_id) =
668                                        resolve_replace_card_key(var_value, source_card_id)
669                                    {
670                                        if let ReplacementEvent::RollDice {
671                                            dice_pt_exchanges,
672                                            ..
673                                        } = event
674                                        {
675                                            dice_pt_exchanges.insert(card_id);
676                                            updated = true;
677                                        }
678                                    }
679                                }
680                            }
681                        }
682                        _ => {
683                            if let Some(var_value) = var_value.as_deref() {
684                                if let Some(value) =
685                                    resolve_replace_value(var_value, game, source_card_id, event)
686                                {
687                                    match var_name {
688                                        "Ignore" => {
689                                            if let ReplacementEvent::RollDice { ignore, .. } = event
690                                            {
691                                                *ignore = value.max(0);
692                                                updated = true;
693                                            }
694                                        }
695                                        "IgnoreChosen" => {
696                                            if var_type.as_deref() == Some("Map") {
697                                                if let Some(var_key) = var_key.as_deref() {
698                                                    if let Some(player) = resolve_replace_player_key(
699                                                        var_key,
700                                                        game,
701                                                        source_card_id,
702                                                        event,
703                                                    ) {
704                                                        if let ReplacementEvent::RollDice {
705                                                            ignore_chosen,
706                                                            ..
707                                                        } = event
708                                                        {
709                                                            ignore_chosen
710                                                                .insert(player, value.max(0));
711                                                            updated = true;
712                                                        }
713                                                    }
714                                                }
715                                            }
716                                        }
717                                        _ => {
718                                            if set_replacement_event_amount(event, value) {
719                                                updated = true;
720                                            }
721                                        }
722                                    }
723                                }
724                            }
725                        }
726                    }
727                }
728            }
729
730            if let Some(sub_ability) = sub_ability.as_deref() {
731                updated |=
732                    execute_replace_effect_ir(sub_ability, event, game, source_card_id, None)
733                        .is_some();
734            }
735
736            updated.then_some(ReplacementResult::Updated)
737        }
738    }
739}
740
741/// Handle `DB$ ReplaceToken` SVars.
742///
743/// Mirrors Java `ReplaceTokenEffect.resolve()`.
744/// - `Type$ Amount`: multiplies token count using `Amount$` param (default `Twice`).
745fn execute_replace_token_chain(
746    token_type: Option<&str>,
747    amount_expr: Option<&str>,
748    event: &mut ReplacementEvent,
749    game: &GameState,
750    source_card_id: CardId,
751) -> Option<ReplacementResult> {
752    let token_type = token_type.unwrap_or("Amount");
753    match token_type {
754        "Amount" => {
755            // Get current count from event
756            let current = replacement_event_amount(event)?;
757            // Amount$ param, defaults to "Twice" per Java
758            let amount_expr = amount_expr.unwrap_or("Twice");
759            let new_value = do_x_math(current, amount_expr, game, source_card_id, event);
760            set_replacement_event_amount(event, new_value);
761            Some(ReplacementResult::Updated)
762        }
763        _ => {
764            // Other types (AddToken, ReplaceToken, ReplaceController) not yet needed
765            None
766        }
767    }
768}
769
770/// Handle `DB$ ReplaceCounter` SVars.
771///
772/// Mirrors Java `ReplaceCounterEffect.resolve()`.
773/// Applies `Amount$` expression to the counter count.
774fn execute_replace_counter_chain(
775    amount_expr: &str,
776    event: &mut ReplacementEvent,
777    game: &GameState,
778    source_card_id: CardId,
779) -> Option<ReplacementResult> {
780    // Ensure event has a valid amount before resolving
781    let _current = replacement_event_amount(event)?;
782    let value = resolve_replace_value(amount_expr, game, source_card_id, event)?;
783    if value <= 0 {
784        // Java removes the counter entry when value <= 0
785        set_replacement_event_amount(event, 0);
786    } else {
787        set_replacement_event_amount(event, value);
788    }
789    Some(ReplacementResult::Updated)
790}
791
792/// Simple math evaluation mirroring Java `AbilityUtils.doXMath()`.
793///
794/// Handles common expressions: "Twice", "Thrice", "Half", integer literals,
795/// and `Plus.N` / `Minus.N` style ops.
796fn do_x_math(
797    base: i32,
798    expr: &str,
799    game: &GameState,
800    source_card_id: CardId,
801    event: &ReplacementEvent,
802) -> i32 {
803    match expr {
804        "Twice" => base * 2,
805        "Thrice" => base * 3,
806        "Half" => ((base as f64) / 2.0).ceil() as i32,
807        _ => {
808            // Try resolving as an SVar or ReplaceCount$ expression
809            if let Some(val) = resolve_replace_value(expr, game, source_card_id, event) {
810                val
811            } else {
812                base
813            }
814        }
815    }
816}
817
818fn resolve_replace_player_key(
819    expr: &str,
820    game: &GameState,
821    source_card_id: CardId,
822    event: &ReplacementEvent,
823) -> Option<PlayerId> {
824    match expr.trim() {
825        "You" => Some(game.card(source_card_id).controller),
826        "Affected" => match event {
827            ReplacementEvent::RollDice { player, .. } => Some(*player),
828            _ => None,
829        },
830        "Opponent" => Some(game.opponent_of(game.card(source_card_id).controller)),
831        _ => None,
832    }
833}
834
835fn resolve_replace_card_key(expr: &str, source_card_id: CardId) -> Option<CardId> {
836    match expr.trim() {
837        "Self" => Some(source_card_id),
838        _ => None,
839    }
840}
841
842// ── Scan-parity helper functions ──────────────────────────────────────────────
843
844/// Check if any `CantHappen`-layer replacement effect would prevent this event.
845///
846/// Walks all battlefield cards' replacement effects, filtering by the
847/// `CantHappen` layer. Returns `true` if at least one effect matches.
848///
849/// Mirrors Java `ReplacementHandler.cantHappenCheck()`.
850pub fn cant_happen_check(game: &GameState, event: &ReplacementEvent) -> bool {
851    let effects = collect_effects(game, event, ReplacementLayer::CantHappen);
852    !effects.is_empty()
853}
854
855/// Apply replacement effects specifically for damage events.
856///
857/// Delegates to `apply_replacements`. This is a convenience entry point
858/// matching Java's batch-damage processing path in `ReplacementHandler`.
859///
860/// Mirrors Java `ReplacementHandler.runReplaceDamage()`.
861pub fn run_replace_damage(game: &mut GameState, event: &mut ReplacementEvent) -> ReplacementResult {
862    apply_replacements(game, event)
863}
864
865/// Parse a raw `R$` replacement-effect line. Re-export of
866/// `replacement_effect::parse_replacement_effect` for scan parity.
867///
868/// Mirrors Java `ReplacementHandler.parseReplacement()`.
869pub fn parse_replacement(raw: &str) -> Option<ReplacementEffect> {
870    super::replacement_effect::parse_replacement_effect(raw)
871}
872
873/// Check if any `BeginPhase` replacement effect in the `Control` layer would
874/// skip the given phase for the given player.
875///
876/// Walks all battlefield cards looking for matching `BeginPhase` effects
877/// with `Layer$ Control` (or `CantHappen`) whose `ActivePhases$` includes
878/// the target phase and whose `ValidPlayer$` matches the player.
879///
880/// Mirrors Java `ReplacementHandler.wouldPhaseBeSkipped()`.
881pub fn would_phase_be_skipped(game: &GameState, player: PlayerId, phase: PhaseType) -> bool {
882    for card in game.cards.iter() {
883        if card.zone != ZoneType::Battlefield {
884            continue;
885        }
886        for re in &card.replacement_effects {
887            if re.event != ReplacementType::BeginPhase {
888                continue;
889            }
890            // Must be Control or CantHappen layer to skip a phase
891            if re.layer != ReplacementLayer::Control && re.layer != ReplacementLayer::CantHappen {
892                continue;
893            }
894            // Check ActivePhases$ matches the target phase
895            if !re.matches_phase(phase) {
896                continue;
897            }
898            // Check ValidPlayer$ matches the player
899            if let Some(vp) = re.ir.valid_player_selector.as_ref() {
900                if !re.matches_compiled_valid_player(vp, player, card) {
901                    continue;
902                }
903            }
904            return true;
905        }
906    }
907    false
908}
909
910/// Check if any `BeginTurn` replacement effect in the `Other` layer would
911/// skip an extra turn for the given player.
912///
913/// Mirrors Java `ReplacementHandler.wouldExtraTurnBeSkipped()`.
914pub fn would_extra_turn_be_skipped(game: &GameState, player: PlayerId) -> bool {
915    for card in game.cards.iter() {
916        if card.zone != ZoneType::Battlefield {
917            continue;
918        }
919        for re in &card.replacement_effects {
920            if re.event != ReplacementType::BeginTurn {
921                continue;
922            }
923            if re.layer != ReplacementLayer::Other && re.layer != ReplacementLayer::CantHappen {
924                continue;
925            }
926            // Check ValidPlayer$ matches the player
927            if let Some(vp) = re.ir.valid_player_selector.as_ref() {
928                if !re.matches_compiled_valid_player(vp, player, card) {
929                    continue;
930                }
931            }
932            return true;
933        }
934    }
935    false
936}
937
938/// Walk all battlefield cards and collect `(card_id, effect_index)` pairs for
939/// replacement effects matching the given event type and optional layer.
940///
941/// This is the scan-parity equivalent of Java's `ReplacementHandler.visit()`.
942pub fn visit(
943    game: &GameState,
944    event: &ReplacementType,
945    layer: Option<ReplacementLayer>,
946) -> Vec<(CardId, usize)> {
947    let mut result = Vec::new();
948    let mut global_idx = 0usize;
949
950    for (i, card) in game.cards.iter().enumerate() {
951        let card_id = CardId(i as u32);
952        for re in &card.replacement_effects {
953            let current_idx = global_idx;
954            global_idx += 1;
955
956            // Layer filter
957            if let Some(target_layer) = layer {
958                if re.layer != target_layer {
959                    continue;
960                }
961            }
962            // Zone filter — effect must be active in the card's current zone
963            if !re.active_in_zone(card.zone) {
964                continue;
965            }
966            // Mode check — effect's event must match the requested event
967            if !re.mode_check(event) {
968                continue;
969            }
970            // has_run check (always false in our architecture)
971            if re.has_run() {
972                continue;
973            }
974            // Requirements check
975            if !re.requirements_check(game, card) {
976                continue;
977            }
978
979            result.push((card_id, current_idx));
980        }
981    }
982
983    result
984}
985
986// ── Internal helpers ──────────────────────────────────────────────────────────
987
988/// Collect all `(source_card_id, effect, effect_index)` triples that apply to `event` in `layer`.
989///
990/// Iterates over every card in the game and checks each of its replacement
991/// effects.  Only effects whose source card is in an active zone are included.
992///
993/// The `effect_index` is a global unique index used for `has_run` tracking.
994///
995/// Mirrors `ReplacementHandler.getReplacementList()`.
996fn collect_effects(
997    game: &GameState,
998    event: &ReplacementEvent,
999    layer: ReplacementLayer,
1000) -> Vec<(CardId, ReplacementEffect, usize)> {
1001    let _perf_scope =
1002        crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Replacement);
1003    use super::{
1004        replace_add_counter,
1005        // Format/mechanic-specific replacements
1006        replace_assemble_contraption,
1007        replace_assign_deal_damage,
1008        replace_attached,
1009        replace_begin_phase,
1010        replace_begin_turn,
1011        replace_cascade,
1012        replace_copy_spell,
1013        replace_counter,
1014        replace_damage,
1015        replace_dealt_damage,
1016        replace_declare_blocker,
1017        replace_destroy,
1018        replace_draw,
1019        replace_draw_cards,
1020        replace_explore,
1021        replace_gain_life,
1022        replace_game_loss,
1023        replace_game_win,
1024        replace_learn,
1025        replace_life_reduced,
1026        replace_lose_mana,
1027        replace_mill,
1028        replace_moved,
1029        replace_pay_life,
1030        replace_planar_dice_result,
1031        replace_planeswalk,
1032        replace_produce_mana,
1033        replace_proliferate,
1034        replace_remove_counter,
1035        replace_roll_dice,
1036        replace_roll_planar_dice,
1037        replace_scry,
1038        replace_set_in_motion,
1039        replace_tap,
1040        replace_token,
1041        replace_transform,
1042        replace_turn_face_up,
1043        replace_untap,
1044    };
1045
1046    let mut result = Vec::new();
1047    for (i, card) in game.cards.iter().enumerate() {
1048        let card_id = CardId(i as u32);
1049
1050        for (effect_idx_in_card, re) in card.replacement_effects.iter().enumerate() {
1051            let current_idx = effect_idx_in_card;
1052            // Layer filter.
1053            if re.layer != layer {
1054                continue;
1055            }
1056            // Zone filter — effect is only active when host is in a valid zone.
1057            let entering_self_counter = matches!(
1058                event,
1059                ReplacementEvent::Moved {
1060                    card: moved,
1061                    destination: ZoneType::Battlefield,
1062                    counter_map: Some(_),
1063                    ..
1064                } if *moved == card_id
1065                    && re.event == ReplacementType::AddCounter
1066                    && re.ir.valid_card_text.as_deref().is_some_and(|valid| valid.starts_with("Card.Self"))
1067            );
1068            if !re.active_in_zone(card.zone) && !entering_self_counter {
1069                continue;
1070            }
1071            if !re.requirements_check(game, card) {
1072                continue;
1073            }
1074            // Dispatch to per-type module for applicability check.
1075            let applies = match re.event {
1076                ReplacementType::DamageDone => replace_damage::can_replace(re, event, game, card),
1077                ReplacementType::Draw => replace_draw::can_replace(re, event, game, card),
1078                ReplacementType::Destroy => replace_destroy::can_replace(re, event, game, card),
1079                ReplacementType::Moved => replace_moved::can_replace(re, event, game, card),
1080                ReplacementType::GainLife => replace_gain_life::can_replace(re, event, game, card),
1081                ReplacementType::CreateToken => replace_token::can_replace(re, event, game, card),
1082                ReplacementType::AddCounter => {
1083                    replace_add_counter::can_replace(re, event, game, card)
1084                }
1085                ReplacementType::GameLoss => replace_game_loss::can_replace(re, event, game, card),
1086                ReplacementType::GameWin => replace_game_win::can_replace(re, event, game, card),
1087                ReplacementType::Counter => replace_counter::can_replace(re, event, game, card),
1088                ReplacementType::ProduceMana => {
1089                    replace_produce_mana::can_replace(re, event, game, card)
1090                }
1091                // Format/mechanic-specific replacements
1092                ReplacementType::DrawCards => {
1093                    replace_draw_cards::can_replace(re, event, game, card)
1094                }
1095                ReplacementType::AssembleContraption => {
1096                    replace_assemble_contraption::can_replace(re, event, game, card)
1097                }
1098                ReplacementType::AssignDealDamage => {
1099                    replace_assign_deal_damage::can_replace(re, event, game, card)
1100                }
1101                ReplacementType::Attached => replace_attached::can_replace(re, event, game, card),
1102                ReplacementType::BeginPhase => {
1103                    replace_begin_phase::can_replace(re, event, game, card)
1104                }
1105                ReplacementType::BeginTurn => {
1106                    replace_begin_turn::can_replace(re, event, game, card)
1107                }
1108                ReplacementType::Cascade => replace_cascade::can_replace(re, event, game, card),
1109                ReplacementType::CopySpell => {
1110                    replace_copy_spell::can_replace(re, event, game, card)
1111                }
1112                ReplacementType::DealtDamage => {
1113                    replace_dealt_damage::can_replace(re, event, game, card)
1114                }
1115                ReplacementType::DeclareBlocker => {
1116                    replace_declare_blocker::can_replace(re, event, game, card)
1117                }
1118                ReplacementType::Explore => replace_explore::can_replace(re, event, game, card),
1119                ReplacementType::Learn => replace_learn::can_replace(re, event, game, card),
1120                ReplacementType::LifeReduced => {
1121                    replace_life_reduced::can_replace(re, event, game, card)
1122                }
1123                ReplacementType::LoseMana => replace_lose_mana::can_replace(re, event, game, card),
1124                ReplacementType::Mill => replace_mill::can_replace(re, event, game, card),
1125                ReplacementType::PayLife => replace_pay_life::can_replace(re, event, game, card),
1126                ReplacementType::PlanarDiceResult => {
1127                    replace_planar_dice_result::can_replace(re, event, game, card)
1128                }
1129                ReplacementType::Planeswalk => {
1130                    replace_planeswalk::can_replace(re, event, game, card)
1131                }
1132                ReplacementType::Proliferate => {
1133                    replace_proliferate::can_replace(re, event, game, card)
1134                }
1135                ReplacementType::RemoveCounter => {
1136                    replace_remove_counter::can_replace(re, event, game, card)
1137                }
1138                ReplacementType::RollDice => replace_roll_dice::can_replace(re, event, game, card),
1139                ReplacementType::RollPlanarDice => {
1140                    replace_roll_planar_dice::can_replace(re, event, game, card)
1141                }
1142                ReplacementType::Scry => replace_scry::can_replace(re, event, game, card),
1143                ReplacementType::SetInMotion => {
1144                    replace_set_in_motion::can_replace(re, event, game, card)
1145                }
1146                ReplacementType::Tap => replace_tap::can_replace(re, event, game, card),
1147                ReplacementType::Transform => replace_transform::can_replace(re, event, game, card),
1148                ReplacementType::TurnFaceUp => {
1149                    replace_turn_face_up::can_replace(re, event, game, card)
1150                }
1151                ReplacementType::Untap => replace_untap::can_replace(re, event, game, card),
1152                ReplacementType::Other(_) => false,
1153            };
1154
1155            if applies {
1156                result.push((card_id, re.clone(), current_idx));
1157            }
1158        }
1159    }
1160
1161    result
1162}
1163
1164/// Execute a single replacement effect, mutating the event parameters.
1165///
1166/// Dispatches to the per-type module's `execute()` function.
1167///
1168/// Mirrors `ReplacementHandler.executeReplacement()`.
1169fn execute_effect(
1170    game: &mut GameState,
1171    card_id: CardId,
1172    effect: &ReplacementEffect,
1173    event: &mut ReplacementEvent,
1174    mut agents: Option<&mut [Box<dyn PlayerAgent>]>,
1175    runtime: Option<&mut ReplacementRuntime<'_>>,
1176) -> ReplacementResult {
1177    use super::{
1178        replace_add_counter,
1179        // Format/mechanic-specific replacements
1180        replace_assemble_contraption,
1181        replace_assign_deal_damage,
1182        replace_attached,
1183        replace_begin_phase,
1184        replace_begin_turn,
1185        replace_cascade,
1186        replace_copy_spell,
1187        replace_counter,
1188        replace_damage,
1189        replace_dealt_damage,
1190        replace_declare_blocker,
1191        replace_destroy,
1192        replace_draw,
1193        replace_draw_cards,
1194        replace_explore,
1195        replace_gain_life,
1196        replace_game_loss,
1197        replace_game_win,
1198        replace_learn,
1199        replace_life_reduced,
1200        replace_lose_mana,
1201        replace_mill,
1202        replace_moved,
1203        replace_pay_life,
1204        replace_planar_dice_result,
1205        replace_planeswalk,
1206        replace_produce_mana,
1207        replace_proliferate,
1208        replace_remove_counter,
1209        replace_roll_dice,
1210        replace_roll_planar_dice,
1211        replace_scry,
1212        replace_set_in_motion,
1213        replace_tap,
1214        replace_token,
1215        replace_transform,
1216        replace_turn_face_up,
1217        replace_untap,
1218    };
1219
1220    if effect.ir.optional {
1221        let decider = optional_decider_for_effect(effect, game, card_id, event)
1222            .unwrap_or_else(|| affected_player_for_event(event, game));
1223        let host = game.card(card_id);
1224        let question = replacement_question(effect, host, game, event);
1225        let confirmed = if let Some(agents) = agents.as_deref_mut() {
1226            agents[decider.index()].confirm_replacement_effect(
1227                decider,
1228                &question,
1229                &effect.description(host, game),
1230                Some(card_id),
1231            )
1232        } else {
1233            true
1234        };
1235        if !confirmed {
1236            return ReplacementResult::NotReplaced;
1237        }
1238    }
1239
1240    match effect.event {
1241        ReplacementType::DamageDone => replace_damage::execute(effect, event, game, card_id),
1242        ReplacementType::Draw => replace_draw::execute(effect, event, game, card_id),
1243        ReplacementType::Destroy => replace_destroy::execute(effect, event, game, card_id),
1244        ReplacementType::Moved => {
1245            replace_moved::execute(effect, event, game, card_id, agents, runtime)
1246        }
1247        ReplacementType::GainLife => replace_gain_life::execute(effect, event, game, card_id),
1248        ReplacementType::CreateToken => replace_token::execute(effect, event, game, card_id),
1249        ReplacementType::AddCounter => {
1250            replace_add_counter::execute(effect, event, game, card_id, agents)
1251        }
1252        ReplacementType::GameLoss => replace_game_loss::execute(effect, event, game, card_id),
1253        ReplacementType::GameWin => replace_game_win::execute(effect, event, game, card_id),
1254        ReplacementType::Counter => replace_counter::execute(effect, event, game, card_id),
1255        ReplacementType::ProduceMana => replace_produce_mana::execute(effect, event, game, card_id),
1256        // Format/mechanic-specific replacements
1257        ReplacementType::DrawCards => replace_draw_cards::execute(effect, event, game, card_id),
1258        ReplacementType::AssembleContraption => {
1259            replace_assemble_contraption::execute(effect, event, game, card_id)
1260        }
1261        ReplacementType::AssignDealDamage => {
1262            replace_assign_deal_damage::execute(effect, event, game, card_id)
1263        }
1264        ReplacementType::Attached => replace_attached::execute(effect, event, game, card_id),
1265        ReplacementType::BeginPhase => replace_begin_phase::execute(effect, event, game, card_id),
1266        ReplacementType::BeginTurn => replace_begin_turn::execute(effect, event, game, card_id),
1267        ReplacementType::Cascade => replace_cascade::execute(effect, event, game, card_id),
1268        ReplacementType::CopySpell => replace_copy_spell::execute(effect, event, game, card_id),
1269        ReplacementType::DealtDamage => replace_dealt_damage::execute(effect, event, game, card_id),
1270        ReplacementType::DeclareBlocker => {
1271            replace_declare_blocker::execute(effect, event, game, card_id)
1272        }
1273        ReplacementType::Explore => replace_explore::execute(effect, event, game, card_id),
1274        ReplacementType::Learn => replace_learn::execute(effect, event, game, card_id),
1275        ReplacementType::LifeReduced => replace_life_reduced::execute(effect, event, game, card_id),
1276        ReplacementType::LoseMana => replace_lose_mana::execute(effect, event, game, card_id),
1277        ReplacementType::Mill => replace_mill::execute(effect, event, game, card_id),
1278        ReplacementType::PayLife => replace_pay_life::execute(effect, event, game, card_id),
1279        ReplacementType::PlanarDiceResult => {
1280            replace_planar_dice_result::execute(effect, event, game, card_id)
1281        }
1282        ReplacementType::Planeswalk => replace_planeswalk::execute(effect, event, game, card_id),
1283        ReplacementType::Proliferate => replace_proliferate::execute(effect, event, game, card_id),
1284        ReplacementType::RemoveCounter => {
1285            replace_remove_counter::execute(effect, event, game, card_id)
1286        }
1287        ReplacementType::RollDice => replace_roll_dice::execute(effect, event, game, card_id),
1288        ReplacementType::RollPlanarDice => {
1289            replace_roll_planar_dice::execute(effect, event, game, card_id)
1290        }
1291        ReplacementType::Scry => replace_scry::execute(effect, event, game, card_id),
1292        ReplacementType::SetInMotion => {
1293            replace_set_in_motion::execute(effect, event, game, card_id)
1294        }
1295        ReplacementType::Tap => replace_tap::execute(effect, event, game, card_id),
1296        ReplacementType::Transform => replace_transform::execute(effect, event, game, card_id),
1297        ReplacementType::TurnFaceUp => replace_turn_face_up::execute(effect, event, game, card_id),
1298        ReplacementType::Untap => replace_untap::execute(effect, event, game, card_id),
1299        ReplacementType::Other(_) => ReplacementResult::NotReplaced,
1300    }
1301}
1302
1303fn optional_decider_for_effect(
1304    effect: &ReplacementEffect,
1305    game: &GameState,
1306    source_card_id: CardId,
1307    event: &ReplacementEvent,
1308) -> Option<PlayerId> {
1309    let expr = effect.ir.optional_decider_text.as_deref()?.trim();
1310    match expr {
1311        "You" | "Controller" => Some(game.card(source_card_id).controller),
1312        "Owner" => Some(game.card(source_card_id).owner),
1313        "Affected" | "AffectedController" => Some(affected_player_for_event(event, game)),
1314        "Opponent" => Some(game.opponent_of(game.card(source_card_id).controller)),
1315        _ => None,
1316    }
1317}
1318
1319fn replacement_question(
1320    effect: &ReplacementEffect,
1321    host: &crate::card::Card,
1322    game: &GameState,
1323    event: &ReplacementEvent,
1324) -> String {
1325    // `description(host, game)` already handles CARDNAME/NICKNAME + text changes.
1326    let desc = effect.description(host, game);
1327    match event {
1328        ReplacementEvent::Moved { card, .. } => format!(
1329            "Apply {} to {}?\n{}",
1330            host.card_name,
1331            game.card(*card).card_name,
1332            desc
1333        ),
1334        _ => format!("Apply {}?\n{}", host.card_name, desc),
1335    }
1336}
1337
1338// ── Tests ─────────────────────────────────────────────────────────────────────
1339
1340#[cfg(test)]
1341mod tests {
1342    use super::*;
1343    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
1344
1345    use crate::agent::PlayerAgent;
1346    use crate::card::Card;
1347    use crate::ids::{CardId, PlayerId};
1348
1349    // ── Test helpers ──────────────────────────────────────────────────────
1350
1351    fn make_game() -> GameState {
1352        GameState::new(&["Alice", "Bob"], 20)
1353    }
1354
1355    fn add_creature_with_abilities(
1356        game: &mut GameState,
1357        owner: PlayerId,
1358        name: &str,
1359        abilities: Vec<String>,
1360    ) -> CardId {
1361        let card = Card::new(
1362            CardId(0), // placeholder; create_card assigns real ID
1363            name.to_string(),
1364            owner,
1365            CardTypeLine::parse("Creature - Bear"),
1366            ManaCost::parse("1 G"),
1367            ColorSet::GREEN,
1368            Some(2),
1369            Some(2),
1370            vec![],
1371            abilities,
1372        );
1373        game.create_card(card)
1374    }
1375
1376    fn put_on_battlefield(game: &mut GameState, card_id: CardId, owner: PlayerId) {
1377        game.move_card(card_id, ZoneType::Battlefield, owner);
1378    }
1379
1380    struct ConfirmReplacementAgent {
1381        confirm: bool,
1382    }
1383
1384    impl PlayerAgent for ConfirmReplacementAgent {
1385        fn mulligan_decision(
1386            &mut self,
1387            _player: PlayerId,
1388            _hand: &[CardId],
1389            _mulligan_count: u32,
1390        ) -> bool {
1391            true
1392        }
1393
1394        fn choose_action(
1395            &mut self,
1396            player: PlayerId,
1397            action_space: Option<&crate::agent::PriorityActionSpace>,
1398            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
1399        ) -> crate::player::actions::PlayerAction {
1400            crate::player::actions::PlayerAction::PassPriority
1401        }
1402
1403        fn choose_attackers(
1404            &mut self,
1405            _player: PlayerId,
1406            _available: &[CardId],
1407            _possible_defenders: &[crate::combat::DefenderId],
1408        ) -> Vec<(CardId, crate::combat::DefenderId)> {
1409            vec![]
1410        }
1411
1412        fn choose_blockers(
1413            &mut self,
1414            _player: PlayerId,
1415            _attackers: &[CardId],
1416            _available_blockers: &[CardId],
1417            _max_blockers: Option<usize>,
1418        ) -> Vec<(CardId, CardId)> {
1419            vec![]
1420        }
1421
1422        fn choose_target_player(
1423            &mut self,
1424            _player: PlayerId,
1425            valid: &[PlayerId],
1426            _sa: Option<&crate::spellability::SpellAbility>,
1427        ) -> Option<PlayerId> {
1428            valid.first().copied()
1429        }
1430
1431        fn choose_target_card(
1432            &mut self,
1433            _player: PlayerId,
1434            valid: &[CardId],
1435            _sa: Option<&crate::spellability::SpellAbility>,
1436        ) -> Option<CardId> {
1437            valid.first().copied()
1438        }
1439
1440        fn choose_target_any(
1441            &mut self,
1442            _player: PlayerId,
1443            valid_players: &[PlayerId],
1444            valid_cards: &[CardId],
1445            _sa: Option<&crate::spellability::SpellAbility>,
1446        ) -> crate::agent::TargetChoice {
1447            if let Some(player) = valid_players.first() {
1448                crate::agent::TargetChoice::Player(*player)
1449            } else if let Some(card) = valid_cards.first() {
1450                crate::agent::TargetChoice::Card(*card)
1451            } else {
1452                crate::agent::TargetChoice::None
1453            }
1454        }
1455
1456        fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
1457            Some(true)
1458        }
1459
1460        fn confirm_replacement_effect(
1461            &mut self,
1462            _player: PlayerId,
1463            _question: &str,
1464            _effect_description: &str,
1465            _source: Option<crate::ids::CardId>,
1466        ) -> bool {
1467            self.confirm
1468        }
1469
1470        fn choose_targets_for(
1471            &mut self,
1472            _sa: &mut crate::spellability::SpellAbility,
1473            _game: &GameState,
1474            _mana_pools: &[ManaPool],
1475        ) -> bool {
1476            false
1477        }
1478    }
1479
1480    // ── Draw replacement tests ────────────────────────────────────────────
1481
1482    #[test]
1483    fn draw_skip_for_controller() {
1484        let mut game = make_game();
1485        let alice = PlayerId(0);
1486        // Card with "skip your draw step" effect.
1487        let cid = add_creature_with_abilities(
1488            &mut game,
1489            alice,
1490            "SkipDraw",
1491            vec!["R$ Event$ Draw | ValidPlayer$ You | Prevent$ True".to_string()],
1492        );
1493        put_on_battlefield(&mut game, cid, alice);
1494
1495        let mut event = ReplacementEvent::Draw {
1496            player: alice,
1497            extra_draws: 0,
1498            is_first_in_draw_step: false,
1499        };
1500        let result = apply_replacements(&mut game, &mut event);
1501        assert_eq!(result, ReplacementResult::Skipped);
1502    }
1503
1504    #[test]
1505    fn draw_not_skipped_for_opponent() {
1506        let mut game = make_game();
1507        let alice = PlayerId(0);
1508        let bob = PlayerId(1);
1509        let cid = add_creature_with_abilities(
1510            &mut game,
1511            alice,
1512            "SkipDraw",
1513            vec!["R$ Event$ Draw | ValidPlayer$ You | Prevent$ True".to_string()],
1514        );
1515        put_on_battlefield(&mut game, cid, alice);
1516
1517        // Bob's draw is not affected by Alice's effect.
1518        let mut event = ReplacementEvent::Draw {
1519            player: bob,
1520            extra_draws: 0,
1521            is_first_in_draw_step: false,
1522        };
1523        let result = apply_replacements(&mut game, &mut event);
1524        assert_eq!(result, ReplacementResult::NotReplaced);
1525    }
1526
1527    #[test]
1528    fn draw_not_applied_when_card_not_on_battlefield() {
1529        let mut game = make_game();
1530        let alice = PlayerId(0);
1531        let cid = add_creature_with_abilities(
1532            &mut game,
1533            alice,
1534            "SkipDraw",
1535            vec![
1536                "R$ Event$ Draw | ActiveZones$ Battlefield | ValidPlayer$ You | Prevent$ True"
1537                    .to_string(),
1538            ],
1539        );
1540        // Card stays in hand, not battlefield.
1541        game.move_card(cid, ZoneType::Hand, alice);
1542
1543        let mut event = ReplacementEvent::Draw {
1544            player: alice,
1545            extra_draws: 0,
1546            is_first_in_draw_step: false,
1547        };
1548        let result = apply_replacements(&mut game, &mut event);
1549        assert_eq!(result, ReplacementResult::NotReplaced);
1550    }
1551
1552    // ── Damage prevention tests ───────────────────────────────────────────
1553
1554    #[test]
1555    fn damage_to_player_prevented() {
1556        let mut game = make_game();
1557        let alice = PlayerId(0);
1558        // "Prevent all damage dealt to you."
1559        let cid = add_creature_with_abilities(
1560            &mut game,
1561            alice,
1562            "Shield",
1563            vec!["R$ Event$ DamageDone | ValidTarget$ Player | Prevent$ True".to_string()],
1564        );
1565        put_on_battlefield(&mut game, cid, alice);
1566
1567        let mut event = ReplacementEvent::DamageToPlayer {
1568            target: alice,
1569            amount: 5,
1570            source: None,
1571            is_combat: false,
1572        };
1573        let result = apply_replacements(&mut game, &mut event);
1574        assert_eq!(result, ReplacementResult::Prevented);
1575        // Amount should be zeroed out.
1576        if let ReplacementEvent::DamageToPlayer { amount, .. } = event {
1577            assert_eq!(amount, 0);
1578        } else {
1579            panic!("unexpected event type");
1580        }
1581    }
1582
1583    #[test]
1584    fn damage_zero_not_replaced() {
1585        let mut game = make_game();
1586        let alice = PlayerId(0);
1587        let cid = add_creature_with_abilities(
1588            &mut game,
1589            alice,
1590            "Shield",
1591            vec!["R$ Event$ DamageDone | ValidTarget$ Player | Prevent$ True".to_string()],
1592        );
1593        put_on_battlefield(&mut game, cid, alice);
1594
1595        // Zero damage — effect should not apply.
1596        let mut event = ReplacementEvent::DamageToPlayer {
1597            target: alice,
1598            amount: 0,
1599            source: None,
1600            is_combat: false,
1601        };
1602        let result = apply_replacements(&mut game, &mut event);
1603        assert_eq!(result, ReplacementResult::NotReplaced);
1604    }
1605
1606    #[test]
1607    fn damage_to_card_prevented() {
1608        let mut game = make_game();
1609        let alice = PlayerId(0);
1610        let shield = add_creature_with_abilities(
1611            &mut game,
1612            alice,
1613            "Shield",
1614            vec!["R$ Event$ DamageDone | ValidTarget$ Card | Prevent$ True".to_string()],
1615        );
1616        put_on_battlefield(&mut game, shield, alice);
1617
1618        let target_creature = add_creature_with_abilities(&mut game, alice, "Target", vec![]);
1619        put_on_battlefield(&mut game, target_creature, alice);
1620
1621        let mut event = ReplacementEvent::DamageToCard {
1622            target: target_creature,
1623            amount: 3,
1624            source: None,
1625            is_combat: false,
1626        };
1627        let result = apply_replacements(&mut game, &mut event);
1628        assert_eq!(result, ReplacementResult::Prevented);
1629        if let ReplacementEvent::DamageToCard { amount, .. } = event {
1630            assert_eq!(amount, 0);
1631        } else {
1632            panic!("unexpected event type");
1633        }
1634    }
1635
1636    #[test]
1637    fn damage_replace_with_can_increase_amount() {
1638        let mut game = make_game();
1639        let alice = PlayerId(0);
1640        let bob = PlayerId(1);
1641
1642        let replacer = add_creature_with_abilities(
1643            &mut game,
1644            alice,
1645            "Boss",
1646            vec![
1647                "R$ Event$ DamageDone | ActiveZones$ Battlefield | ValidSource$ Card.YouCtrl | ValidTarget$ Player.Opponent | ReplaceWith$ DmgPlus".to_string(),
1648            ],
1649        );
1650        game.card_mut(replacer).set_s_var(
1651            "DmgPlus",
1652            "DB$ ReplaceEffect | VarName$ DamageAmount | VarValue$ ReplaceCount$DamageAmount/Plus.1",
1653        );
1654        put_on_battlefield(&mut game, replacer, alice);
1655
1656        let source = add_creature_with_abilities(&mut game, alice, "Source", vec![]);
1657        put_on_battlefield(&mut game, source, alice);
1658
1659        let mut event = ReplacementEvent::DamageToPlayer {
1660            target: bob,
1661            amount: 2,
1662            source: Some(source),
1663            is_combat: false,
1664        };
1665        let _ = apply_replacements(&mut game, &mut event);
1666        if let ReplacementEvent::DamageToPlayer { amount, .. } = event {
1667            assert_eq!(amount, 3);
1668        } else {
1669            panic!("unexpected event type");
1670        }
1671    }
1672
1673    #[test]
1674    fn damage_replace_with_respects_max_speed() {
1675        let mut game = make_game();
1676        let alice = PlayerId(0);
1677        let bob = PlayerId(1);
1678        game.player_mut(alice).speed = 4;
1679
1680        let replacer = add_creature_with_abilities(
1681            &mut game,
1682            alice,
1683            "Far Fortune",
1684            vec![
1685                "R$ Event$ DamageDone | MaxSpeed$ True | ActiveZones$ Battlefield | ValidSource$ Card.YouCtrl | ValidTarget$ Player.Opponent | ReplaceWith$ DmgPlus".to_string(),
1686            ],
1687        );
1688        game.card_mut(replacer).set_s_var(
1689            "DmgPlus",
1690            "DB$ ReplaceEffect | VarName$ DamageAmount | VarValue$ ReplaceCount$DamageAmount/Plus.1",
1691        );
1692        put_on_battlefield(&mut game, replacer, alice);
1693
1694        let source = add_creature_with_abilities(&mut game, alice, "Source", vec![]);
1695        put_on_battlefield(&mut game, source, alice);
1696
1697        let mut event = ReplacementEvent::DamageToPlayer {
1698            target: bob,
1699            amount: 1,
1700            source: Some(source),
1701            is_combat: false,
1702        };
1703        let _ = apply_replacements(&mut game, &mut event);
1704        if let ReplacementEvent::DamageToPlayer { amount, .. } = event {
1705            assert_eq!(amount, 2);
1706        } else {
1707            panic!("unexpected event type");
1708        }
1709    }
1710
1711    #[test]
1712    fn roll_dice_replace_with_updates_number_and_ignore() {
1713        let mut game = make_game();
1714        let alice = PlayerId(0);
1715
1716        let replacer = add_creature_with_abilities(
1717            &mut game,
1718            alice,
1719            "Barbarian Class",
1720            vec![
1721                "R$ Event$ RollDice | ActiveZones$ Battlefield | ValidPlayer$ You | ReplaceWith$ PlusRoll".to_string(),
1722            ],
1723        );
1724        game.card_mut(replacer).set_s_var(
1725            "PlusRoll",
1726            "DB$ ReplaceEffect | VarName$ Number | VarValue$ ReplaceCount$Number/Plus.1 | SubAbility$ IgnoreLowest",
1727        );
1728        game.card_mut(replacer).set_s_var(
1729            "IgnoreLowest",
1730            "DB$ ReplaceEffect | VarName$ Ignore | VarValue$ ReplaceCount$Ignore/Plus.1",
1731        );
1732        put_on_battlefield(&mut game, replacer, alice);
1733
1734        let mut event = ReplacementEvent::RollDice {
1735            player: alice,
1736            sides: 20,
1737            number: 1,
1738            ignore: 0,
1739            ignore_chosen: HashMap::new(),
1740            dice_pt_exchanges: HashSet::new(),
1741        };
1742        let result = apply_replacements(&mut game, &mut event);
1743        assert_eq!(result, ReplacementResult::Updated);
1744        if let ReplacementEvent::RollDice { number, ignore, .. } = event {
1745            assert_eq!(number, 2);
1746            assert_eq!(ignore, 1);
1747        } else {
1748            panic!("unexpected event type");
1749        }
1750    }
1751
1752    #[test]
1753    fn roll_dice_replace_with_updates_ignore_chosen_map() {
1754        let mut game = make_game();
1755        let alice = PlayerId(0);
1756
1757        let replacer = add_creature_with_abilities(
1758            &mut game,
1759            alice,
1760            "Bamboozling Beeble",
1761            vec![
1762                "R$ Event$ RollDice | ActiveZones$ Battlefield | ValidPlayer$ You | ReplaceWith$ RigRoll".to_string(),
1763            ],
1764        );
1765        game.card_mut(replacer).set_s_var(
1766            "RigRoll",
1767            "DB$ ReplaceEffect | VarName$ IgnoreChosen | VarType$ Map | VarKey$ You | VarValue$ 1",
1768        );
1769        put_on_battlefield(&mut game, replacer, alice);
1770
1771        let mut event = ReplacementEvent::RollDice {
1772            player: alice,
1773            sides: 6,
1774            number: 2,
1775            ignore: 0,
1776            ignore_chosen: HashMap::new(),
1777            dice_pt_exchanges: HashSet::new(),
1778        };
1779        let result = apply_replacements(&mut game, &mut event);
1780        assert_eq!(result, ReplacementResult::Updated);
1781        if let ReplacementEvent::RollDice { ignore_chosen, .. } = event {
1782            assert_eq!(ignore_chosen.get(&alice), Some(&1));
1783        } else {
1784            panic!("unexpected event type");
1785        }
1786    }
1787
1788    #[test]
1789    fn roll_dice_replace_with_updates_dice_pt_exchange_set() {
1790        let mut game = make_game();
1791        let alice = PlayerId(0);
1792
1793        let replacer = add_creature_with_abilities(
1794            &mut game,
1795            alice,
1796            "Vedalken Squirrel-Whacker",
1797            vec![
1798                "R$ Event$ RollDice | ActiveZones$ Battlefield | ValidPlayer$ You | ValidSides$ 6 | ReplaceWith$ SwapRoll".to_string(),
1799            ],
1800        );
1801        game.card_mut(replacer).set_s_var(
1802            "SwapRoll",
1803            "DB$ ReplaceEffect | VarName$ DicePTExchanges | VarType$ CardSet | VarValue$ Self",
1804        );
1805        put_on_battlefield(&mut game, replacer, alice);
1806
1807        let mut event = ReplacementEvent::RollDice {
1808            player: alice,
1809            sides: 6,
1810            number: 2,
1811            ignore: 0,
1812            ignore_chosen: HashMap::new(),
1813            dice_pt_exchanges: HashSet::new(),
1814        };
1815        let result = apply_replacements(&mut game, &mut event);
1816        assert_eq!(result, ReplacementResult::Updated);
1817        if let ReplacementEvent::RollDice {
1818            dice_pt_exchanges, ..
1819        } = event
1820        {
1821            assert!(dice_pt_exchanges.contains(&replacer));
1822        } else {
1823            panic!("unexpected event type");
1824        }
1825    }
1826
1827    // ── Destroy replacement tests ─────────────────────────────────────────
1828
1829    #[test]
1830    fn indestructible_destroy_replaced() {
1831        let mut game = make_game();
1832        let alice = PlayerId(0);
1833        // Indestructible: "If ~ would be destroyed, instead it isn't."
1834        let cid = add_creature_with_abilities(
1835            &mut game,
1836            alice,
1837            "Indestructible Bear",
1838            vec!["R$ Event$ Destroy | ValidCard$ Card.Self".to_string()],
1839        );
1840        put_on_battlefield(&mut game, cid, alice);
1841
1842        let mut event = ReplacementEvent::Destroy { target: cid };
1843        let result = apply_replacements(&mut game, &mut event);
1844        assert_eq!(result, ReplacementResult::Replaced);
1845    }
1846
1847    #[test]
1848    fn destroy_not_replaced_for_other_card() {
1849        let mut game = make_game();
1850        let alice = PlayerId(0);
1851        // Indestructible creature — protects only itself.
1852        let indestructible = add_creature_with_abilities(
1853            &mut game,
1854            alice,
1855            "Indestructible Bear",
1856            vec!["R$ Event$ Destroy | ValidCard$ Card.Self".to_string()],
1857        );
1858        let other = add_creature_with_abilities(&mut game, alice, "Mortal Bear", vec![]);
1859        put_on_battlefield(&mut game, indestructible, alice);
1860        put_on_battlefield(&mut game, other, alice);
1861
1862        let mut event = ReplacementEvent::Destroy { target: other };
1863        let result = apply_replacements(&mut game, &mut event);
1864        assert_eq!(result, ReplacementResult::NotReplaced);
1865    }
1866
1867    // ── Moved replacement tests ───────────────────────────────────────────
1868
1869    #[test]
1870    fn moved_destination_updated_to_exile() {
1871        let mut game = make_game();
1872        let alice = PlayerId(0);
1873        // "If ~ would be put into a graveyard from the battlefield, exile it instead."
1874        let cid = add_creature_with_abilities(
1875            &mut game,
1876            alice,
1877            "Exile Bear",
1878            vec!["R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self | NewDestination$ Exile".to_string()],
1879        );
1880        put_on_battlefield(&mut game, cid, alice);
1881
1882        let mut event = ReplacementEvent::Moved {
1883            card: cid,
1884            origin: ZoneType::Battlefield,
1885            destination: ZoneType::Graveyard,
1886            is_discard: false,
1887            counter_map: None,
1888            counter_cause: None,
1889            counter_is_effect: false,
1890            after_replacement_static_abilities: Vec::new(),
1891        };
1892        let result = apply_replacements(&mut game, &mut event);
1893        assert_eq!(result, ReplacementResult::Updated);
1894        if let ReplacementEvent::Moved { destination, .. } = event {
1895            assert_eq!(destination, ZoneType::Exile);
1896        } else {
1897            panic!("unexpected event type");
1898        }
1899    }
1900
1901    #[test]
1902    fn moved_not_replaced_wrong_origin() {
1903        let mut game = make_game();
1904        let alice = PlayerId(0);
1905        let cid = add_creature_with_abilities(
1906            &mut game,
1907            alice,
1908            "Exile Bear",
1909            vec!["R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self | NewDestination$ Exile".to_string()],
1910        );
1911        put_on_battlefield(&mut game, cid, alice);
1912
1913        // Card moving from Hand → Graveyard: Origin doesn't match Battlefield.
1914        let mut event = ReplacementEvent::Moved {
1915            card: cid,
1916            origin: ZoneType::Hand,
1917            destination: ZoneType::Graveyard,
1918            is_discard: false,
1919            counter_map: None,
1920            counter_cause: None,
1921            counter_is_effect: false,
1922            after_replacement_static_abilities: Vec::new(),
1923        };
1924        let result = apply_replacements(&mut game, &mut event);
1925        assert_eq!(result, ReplacementResult::NotReplaced);
1926    }
1927
1928    #[test]
1929    fn declined_optional_replacement_falls_through_to_next_candidate() {
1930        let mut game = make_game();
1931        let alice = PlayerId(0);
1932        let cid = add_creature_with_abilities(
1933            &mut game,
1934            alice,
1935            "Optional Bear",
1936            vec![
1937                "R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self | Optional$ True | OptionalDecider$ You | NewDestination$ Command".to_string(),
1938                "R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self | NewDestination$ Exile".to_string(),
1939            ],
1940        );
1941        put_on_battlefield(&mut game, cid, alice);
1942
1943        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1944            Box::new(ConfirmReplacementAgent { confirm: false }),
1945            Box::new(ConfirmReplacementAgent { confirm: false }),
1946        ];
1947        let mut event = ReplacementEvent::Moved {
1948            card: cid,
1949            origin: ZoneType::Battlefield,
1950            destination: ZoneType::Graveyard,
1951            is_discard: false,
1952            counter_map: None,
1953            counter_cause: None,
1954            counter_is_effect: false,
1955            after_replacement_static_abilities: Vec::new(),
1956        };
1957        let result = apply_replacements_with_agents(&mut game, agents.as_mut_slice(), &mut event);
1958        assert_eq!(result, ReplacementResult::Updated);
1959        if let ReplacementEvent::Moved { destination, .. } = event {
1960            assert_eq!(destination, ZoneType::Exile);
1961        } else {
1962            panic!("unexpected event type");
1963        }
1964    }
1965
1966    #[test]
1967    fn accepted_optional_replacement_updates_destination() {
1968        let mut game = make_game();
1969        let alice = PlayerId(0);
1970        let cid = add_creature_with_abilities(
1971            &mut game,
1972            alice,
1973            "Optional Bear",
1974            vec![
1975                "R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self | Optional$ True | OptionalDecider$ You | NewDestination$ Command".to_string(),
1976            ],
1977        );
1978        put_on_battlefield(&mut game, cid, alice);
1979
1980        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1981            Box::new(ConfirmReplacementAgent { confirm: true }),
1982            Box::new(ConfirmReplacementAgent { confirm: true }),
1983        ];
1984        let mut event = ReplacementEvent::Moved {
1985            card: cid,
1986            origin: ZoneType::Battlefield,
1987            destination: ZoneType::Graveyard,
1988            is_discard: false,
1989            counter_map: None,
1990            counter_cause: None,
1991            counter_is_effect: false,
1992            after_replacement_static_abilities: Vec::new(),
1993        };
1994        let result = apply_replacements_with_agents(&mut game, agents.as_mut_slice(), &mut event);
1995        assert_eq!(result, ReplacementResult::Updated);
1996        if let ReplacementEvent::Moved { destination, .. } = event {
1997            assert_eq!(destination, ZoneType::Command);
1998        } else {
1999            panic!("unexpected event type");
2000        }
2001    }
2002
2003    // ── No effects test ───────────────────────────────────────────────────
2004
2005    #[test]
2006    fn no_effects_returns_not_replaced() {
2007        let mut game = make_game();
2008        let mut event = ReplacementEvent::Draw {
2009            player: PlayerId(0),
2010            extra_draws: 0,
2011            is_first_in_draw_step: false,
2012        };
2013        let result = apply_replacements(&mut game, &mut event);
2014        assert_eq!(result, ReplacementResult::NotReplaced);
2015    }
2016}