Skip to main content

manabrew_engine/replacement/
replace_moved.rs

1//! Replacement logic for `Event$ Moved`.
2//!
3//! Mirrors Java `ReplaceMoved.java` in `forge/game/replacement/`.
4
5use std::collections::HashMap;
6
7use forge_foundation::ZoneType;
8
9use crate::ability::effects::{self, EffectContext};
10use crate::agent::{PassAgent, PlayerAgent};
11use crate::card::Card;
12use crate::game::GameState;
13use crate::game_rng::ThreadRngAdapter;
14use crate::ids::CardId;
15use crate::mana::ManaPool;
16use crate::spellability::build_spell_ability;
17use crate::trigger::TriggerHandler;
18
19use super::replacement_effect::{zone_matches, ReplacementEffect};
20use super::replacement_handler::{ReplacementEvent, ReplacementRuntime};
21use super::replacement_result::ReplacementResult;
22use super::replacement_type::ReplacementType;
23use crate::card_trait_base::CardTrait;
24
25/// Mirrors Java `ReplaceMoved.canReplace()`.
26pub fn can_replace(
27    effect: &ReplacementEffect,
28    event: &ReplacementEvent,
29    game: &GameState,
30    source_card: &Card,
31) -> bool {
32    if effect.event != ReplacementType::Moved {
33        return false;
34    }
35    let (moving_id, origin, destination, is_discard) = match event {
36        ReplacementEvent::Moved {
37            card,
38            origin,
39            destination,
40            is_discard,
41            ..
42        } => (*card, *origin, *destination, *is_discard),
43        _ => return false,
44    };
45    // Discard$ True — only match when the move is from a discard action.
46    // Mirrors Java ReplaceMoved.canReplace() Discard$ check.
47    if let Some(requires_discard) = effect.ir.discard {
48        if requires_discard != is_discard {
49            return false;
50        }
51    }
52    if let Some(dest) = effect.ir.destination_text.as_deref() {
53        if !zone_matches(dest, destination) {
54            return false;
55        }
56    }
57    if let Some(exclude) = effect.ir.exclude_destination_text.as_deref() {
58        if zone_matches(exclude, destination) {
59            return false;
60        }
61    }
62    if let Some(orig) = effect.ir.origin_text.as_deref() {
63        if !zone_matches(orig, origin) {
64            return false;
65        }
66    }
67    let moving_card = &game.cards[moving_id.index()];
68    if let Some(valid) = effect.ir.valid_card_selector.as_ref() {
69        if !effect.matches_compiled_valid_card(valid, moving_card, source_card) {
70            return false;
71        }
72    }
73    // FlashbackCast$ True — only match when the card was cast via Flashback.
74    if effect.ir.flashback_cast == Some(true) && !moving_card.cast_with_flashback {
75        return false;
76    }
77    // HarmonizeCast$ True — only match when the card was cast via Harmonize.
78    if effect.ir.harmonize_cast == Some(true) && !moving_card.cast_with_harmonize {
79        return false;
80    }
81    if let Some(valid_lki) = effect.ir.valid_lki_text.as_deref() {
82        if !effect.matches_valid_card(valid_lki, moving_card, source_card) {
83            return false;
84        }
85    }
86    // Mirrors Java `ReplaceMoved.canReplace()` L103: only gate ETB chains.
87    if destination == ZoneType::Battlefield && !effect.can_replace_etb(source_card, moving_card) {
88        return false;
89    }
90    true
91}
92
93/// Mirrors Java `ReplacementHandler.executeReplacement()` for Moved.
94pub fn execute(
95    effect: &ReplacementEffect,
96    event: &mut ReplacementEvent,
97    game: &mut GameState,
98    source_card_id: CardId,
99    agents: Option<&mut [Box<dyn PlayerAgent>]>,
100    runtime: Option<&mut ReplacementRuntime<'_>>,
101) -> ReplacementResult {
102    let (moving_id, _destination) = match event {
103        ReplacementEvent::Moved {
104            card, destination, ..
105        } => (*card, destination),
106        _ => return ReplacementResult::NotReplaced,
107    };
108    // Check NewDestination$ first (explicit redirect), then ReplaceWith$ (common alias).
109    // Rest in Peace uses "ReplaceWith$ Exile", while other cards use "NewDestination$ Exile".
110    let redirect = effect
111        .ir
112        .new_destination_text
113        .as_deref()
114        .or(effect.replace_with());
115
116    if let Some(new_dest) = redirect {
117        let new_zone = match new_dest.trim() {
118            "Exile" => Some(ZoneType::Exile),
119            "Graveyard" => Some(ZoneType::Graveyard),
120            "Hand" => Some(ZoneType::Hand),
121            "Library" => Some(ZoneType::Library),
122            "Battlefield" => Some(ZoneType::Battlefield),
123            "Command" => Some(ZoneType::Command),
124            _ => None,
125        };
126        if let Some(z) = new_zone {
127            if let ReplacementEvent::Moved { destination, .. } = event {
128                *destination = z;
129            }
130            if z == ZoneType::Exile && effect.ir.exiled_with_effect_source {
131                let exile_source = game
132                    .card(source_card_id)
133                    .effect_source
134                    .unwrap_or(source_card_id);
135                game.card_mut(moving_id).set_exiled_by(Some(exile_source));
136                game.card_mut(exile_source).add_remembered_card(moving_id);
137            }
138            return ReplacementResult::Updated;
139        }
140    }
141    // If the redirect value wasn't a zone name, try executing it as an SVar spell ability.
142    if let Some(replace_with_key) = effect.replace_with() {
143        let succeeded = execute_replace_with(
144            effect,
145            replace_with_key,
146            game,
147            source_card_id,
148            event,
149            agents,
150            runtime,
151        );
152        if !succeeded {
153            return ReplacementResult::NotReplaced;
154        }
155    }
156    if let Some(result) = effect.ir.replacement_result.as_deref() {
157        return match result {
158            "Updated" => ReplacementResult::Updated,
159            "Replaced" => ReplacementResult::Replaced,
160            "Skipped" => ReplacementResult::Skipped,
161            "Prevented" => ReplacementResult::Prevented,
162            _ => ReplacementResult::Replaced,
163        };
164    }
165    ReplacementResult::Replaced
166}
167
168fn execute_replace_with(
169    effect: &ReplacementEffect,
170    replace_with: &str,
171    game: &mut GameState,
172    source_card_id: CardId,
173    event: &ReplacementEvent,
174    agents: Option<&mut [Box<dyn PlayerAgent>]>,
175    mut runtime: Option<&mut ReplacementRuntime<'_>>,
176) -> bool {
177    let Some(raw) = game.card(source_card_id).svars.get(replace_with).cloned() else {
178        return false;
179    };
180    let controller = game.card(source_card_id).controller;
181    let mut sa = build_spell_ability(game, source_card_id, &raw, controller);
182    effect.set_replacing_objects(event, &mut sa);
183
184    // `local_agents_storage` keeps the fallback Vec alive when the caller
185    // didn't provide agents; we hand back a borrow into it.
186    #[allow(unused_assignments)]
187    let mut local_agents_storage: Option<Vec<Box<dyn PlayerAgent>>> = None;
188    let agents: &mut [Box<dyn PlayerAgent>] = if let Some(agents) = agents {
189        agents
190    } else {
191        local_agents_storage = Some(
192            (0..game.players.len())
193                .map(|_| Box::new(PassAgent) as Box<dyn PlayerAgent>)
194                .collect(),
195        );
196        local_agents_storage.as_mut().unwrap().as_mut_slice()
197    };
198
199    let mut local_mana_pools: Vec<ManaPool> =
200        (0..game.players.len()).map(|_| ManaPool::new()).collect();
201    let mana_pools_for_targets: &[ManaPool] = if let Some(rt) = runtime.as_ref() {
202        rt.mana_pools.as_slice()
203    } else {
204        local_mana_pools.as_slice()
205    };
206    if sa.uses_targeting() && !sa.setup_targets(game, agents, mana_pools_for_targets) {
207        return false;
208    }
209
210    let mut local_trigger_handler = TriggerHandler::new();
211    let local_token_templates: HashMap<String, Card> = HashMap::new();
212    let local_token_art_variants: HashMap<(String, String), usize> = HashMap::new();
213    let local_token_fallback: HashMap<String, String> = HashMap::new();
214    let local_edition_dates: HashMap<String, String> = HashMap::new();
215    let mut local_rng = ThreadRngAdapter;
216
217    let mut parent_target_card: Option<CardId> = None;
218    let mut parent_target_player = None;
219    let mut current_sa: Option<&crate::spellability::SpellAbility> = Some(&sa);
220    while let Some(cur) = current_sa {
221        let mut sa_with_ctx;
222        let sa_ref = if parent_target_player.is_some() && cur.target_chosen.target_player.is_none()
223        {
224            sa_with_ctx = cur.clone();
225            sa_with_ctx.target_chosen.target_player = parent_target_player;
226            &sa_with_ctx
227        } else {
228            cur
229        };
230
231        let (
232            trigger_handler_ref,
233            token_templates_ref,
234            token_art_ref,
235            token_fb_ref,
236            edition_dates_ref,
237            mana_pools_ref,
238            rng_ref,
239        ): (
240            &mut TriggerHandler,
241            &HashMap<String, Card>,
242            &HashMap<(String, String), usize>,
243            &HashMap<String, String>,
244            &HashMap<String, String>,
245            &mut Vec<ManaPool>,
246            &mut dyn crate::game_rng::GameRng,
247        ) = if let Some(rt) = runtime.as_deref_mut() {
248            (
249                rt.trigger_handler,
250                rt.token_templates,
251                rt.token_art_variants,
252                rt.token_fallback,
253                rt.edition_dates,
254                rt.mana_pools,
255                rt.rng,
256            )
257        } else {
258            (
259                &mut local_trigger_handler,
260                &local_token_templates,
261                &local_token_art_variants,
262                &local_token_fallback,
263                &local_edition_dates,
264                &mut local_mana_pools,
265                &mut local_rng,
266            )
267        };
268
269        let mut ctx = EffectContext {
270            game,
271            combat: None,
272            agents,
273            trigger_handler: trigger_handler_ref,
274            token_templates: token_templates_ref,
275            token_art_variants: token_art_ref,
276            token_fallback: token_fb_ref,
277            edition_dates: edition_dates_ref,
278            mana_pools: mana_pools_ref,
279            parent_target_card,
280            rng: rng_ref,
281        };
282        effects::resolve_effect(&mut ctx, sa_ref);
283        parent_target_card = sa_ref.target_chosen.target_card;
284        parent_target_player = sa_ref.target_chosen.target_player;
285        current_sa = cur.get_sub_ability();
286    }
287    true
288}
289
290// `set_replacing_objects_for_moved` was inlined into the cross-event
291// `ReplacementEffect::set_replacing_objects` dispatcher in `replacement_effect.rs`
292// to mirror Java's polymorphic `setReplacingObjects` hook.