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