Skip to main content

manabrew_engine/ability/effects/
change_zone_all_effect.rs

1use forge_foundation::ZoneType;
2
3use super::{emit_zone_trigger, matches_change_type, EffectContext};
4use crate::ids::{CardId, PlayerId};
5
6fn matches_change_zone_all_filter(
7    cid: CardId,
8    game: &crate::game::GameState,
9    filter: &str,
10    source_chosen_colors: &[String],
11    effective_target: Option<CardId>,
12) -> bool {
13    if filter.trim().is_empty() {
14        return true;
15    }
16
17    // Forge uses comma-separated OR clauses in ChangeType$.
18    for clause in filter.split(',') {
19        let clause = clause.trim();
20        if clause.is_empty() {
21            continue;
22        }
23
24        let mut clause_ok = true;
25        let targeted = effective_target;
26
27        // "+" means AND within a clause.
28        for term in clause.split('+') {
29            let term = term.trim();
30            if term.is_empty() {
31                continue;
32            }
33
34            if term.eq_ignore_ascii_case("NotDefinedTargeted") {
35                if let Some(t) = targeted {
36                    if cid == t {
37                        clause_ok = false;
38                        break;
39                    }
40                } else {
41                    clause_ok = false;
42                    break;
43                }
44                continue;
45            }
46
47            if term.eq_ignore_ascii_case("TargetedCard.Self") {
48                if targeted != Some(cid) {
49                    clause_ok = false;
50                    break;
51                }
52                continue;
53            }
54
55            if term.starts_with("sharesNameWith") {
56                let arg = term
57                    .strip_prefix("sharesNameWith")
58                    .unwrap_or("")
59                    .trim_start();
60                if arg.eq_ignore_ascii_case("Targeted") {
61                    if let Some(t) = targeted {
62                        if game.card(cid).card_name != game.card(t).card_name {
63                            clause_ok = false;
64                            break;
65                        }
66                    } else {
67                        clause_ok = false;
68                        break;
69                    }
70                    continue;
71                }
72            }
73
74            if term.starts_with("ControlledBy") {
75                let arg = term.strip_prefix("ControlledBy").unwrap_or("").trim_start();
76                if arg.eq_ignore_ascii_case("TargetedController") {
77                    if let Some(t) = targeted {
78                        if game.card(cid).controller != game.card(t).controller {
79                            clause_ok = false;
80                            break;
81                        }
82                    } else {
83                        clause_ok = false;
84                        break;
85                    }
86                    continue;
87                }
88            }
89
90            if !matches_change_type(game.card(cid), term, source_chosen_colors) {
91                clause_ok = false;
92                break;
93            }
94        }
95
96        if clause_ok {
97            return true;
98        }
99    }
100
101    false
102}
103
104/// Configure the spell ability during construction.
105/// Mirrors Java `ChangeZoneAllEffect.buildSpellAbility` — calls
106/// `adjustChangeZoneTarget` to set the target zone to the origin zone.
107pub fn build_spell_ability(sa: &mut crate::spellability::SpellAbility) {
108    // If the SA has an Origin$ parameter and uses targeting, set the
109    // target restriction zone to the origin zone so that targeting
110    // looks in the correct zone (not just Battlefield).
111    if let Some(zone) = sa.origin_zone() {
112        if let Some(ref mut tr) = sa.target_restrictions {
113            if !tr.can_tgt_player() {
114                tr.tgt_zone = vec![zone];
115            }
116        }
117    }
118}
119
120/// Struct form of this effect so it can participate in the
121/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
122/// `ChangeZoneAllEffect` class extending `SpellAbilityEffect`.
123#[manabrew_engine_macros::spell_effect(ChangeZoneAllEffect)]
124fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
125    let Some(origin_zone) = sa
126        .origin_zone()
127        .or_else(|| sa.origin().is_none().then_some(ZoneType::Battlefield))
128    else {
129        return;
130    };
131    let Some(dest_zone) = sa
132        .destination_zone()
133        .or_else(|| sa.destination().is_none().then_some(ZoneType::Graveyard))
134    else {
135        return;
136    };
137    // Forge uses ChangeType$ as the primary filter for ChangeZoneAll; fall back to ValidCards$.
138    let valid_cards_filter = sa
139        .change_type()
140        .or(sa.ir.valid_cards_text.as_deref())
141        .map(|s| s.to_string())
142        .unwrap_or_else(|| "Card".to_string());
143    let tapped = sa.ir.tapped;
144
145    // Resolve source card's chosen_colors for ChosenColor qualifier support.
146    let source_chosen_colors: Vec<String> = sa
147        .source
148        .map(|src| ctx.game.card(src).chosen_colors.clone())
149        .unwrap_or_default();
150
151    // Propagate the parent SA's chosen target for sub-ability chains like Deputy of Detention
152    // (TrigExile Pump → DBChangeZoneAll): the ChangeZoneAll SA itself has no ValidTgts$,
153    // so we fall back to ctx.parent_target_card set by the parent Pump SA.
154    let effective_target = sa.target_chosen.target_card.or(ctx.parent_target_card);
155
156    // For Duration$ UntilHostLeavesPlay, track the source card so we can return
157    // exiled permanents when the source leaves the battlefield (e.g. Deputy of Detention).
158    let until_host_leaves = sa.ir.duration.as_ref().is_some_and(|duration| {
159        matches!(
160            duration,
161            crate::spellability::AbilityDuration::UntilHostLeavesPlay
162                | crate::spellability::AbilityDuration::UntilHostLeavesPlayOrEot
163        )
164    });
165    let exile_source = if until_host_leaves { sa.source } else { None };
166
167    {
168        // Restrict to the targeted/defined player when set; otherwise every player.
169        let player_ids: Vec<PlayerId> = if let Some(pid) = sa.target_chosen.target_player {
170            vec![pid]
171        } else if let Some(defined) = sa.ir.defined_text.as_deref() {
172            let resolved = crate::ability::ability_utils::resolve_defined_players_with_sa(
173                defined,
174                sa,
175                sa.activating_player,
176                ctx.game,
177            );
178            if resolved.is_empty() {
179                ctx.game.player_order.clone()
180            } else {
181                resolved
182            }
183        } else {
184            ctx.game.player_order.clone()
185        };
186        let mut to_move: Vec<(CardId, PlayerId)> = Vec::new();
187
188        for &pid in &player_ids {
189            let zone_cards = ctx.game.cards_in_zone(origin_zone, pid).to_vec();
190            for cid in zone_cards {
191                if matches_change_zone_all_filter(
192                    cid,
193                    ctx.game,
194                    &valid_cards_filter,
195                    &source_chosen_colors,
196                    effective_target,
197                ) {
198                    let dest_owner = if dest_zone == ZoneType::Battlefield {
199                        sa.activating_player
200                    } else {
201                        ctx.game.card(cid).owner
202                    };
203                    to_move.push((cid, dest_owner));
204                }
205            }
206        }
207
208        if dest_zone == ZoneType::Library && to_move.len() > 1 && sa.ir.random_order {
209            let mut cards = to_move.iter().map(|(cid, _)| *cid).collect::<Vec<_>>();
210            ctx.rng.shuffle_cards(&mut cards);
211            let mut ordered = Vec::with_capacity(to_move.len());
212            for cid in cards {
213                if let Some((_, owner)) = to_move.iter().find(|(card_id, _)| *card_id == cid) {
214                    ordered.push((cid, *owner));
215                }
216            }
217            to_move = ordered;
218        }
219
220        let mut moved_to_library: Vec<(CardId, PlayerId)> = Vec::new();
221        for (card_id, dest_owner) in to_move {
222            if ctx.game.card(card_id).zone != origin_zone {
223                continue; // already moved
224            }
225            let old_zone = ctx.game.card(card_id).zone;
226            ctx.move_card(card_id, dest_zone, dest_owner);
227            if dest_zone == ZoneType::Library {
228                moved_to_library.push((card_id, dest_owner));
229            }
230            // Mark cards exiled by a UntilHostLeavesPlay effect so they can return
231            // when the source leaves the battlefield.
232            if dest_zone == ZoneType::Exile {
233                if let Some(src_id) = exile_source {
234                    ctx.game.card_mut(card_id).set_exiled_by(Some(src_id));
235                }
236            }
237            if dest_zone == ZoneType::Battlefield {
238                if tapped {
239                    ctx.game.tap(card_id);
240                }
241                ctx.trigger_handler
242                    .register_active_trigger(ctx.game, card_id);
243            }
244            emit_zone_trigger(ctx.trigger_handler, card_id, old_zone, dest_zone);
245        }
246
247        if dest_zone == ZoneType::Library && sa.library_position() == Some("-1") {
248            for &pid in &player_ids {
249                let cards = moved_to_library
250                    .iter()
251                    .filter_map(|(cid, owner)| (*owner == pid).then_some(*cid))
252                    .collect::<Vec<_>>();
253                if !cards.is_empty() {
254                    ctx.game
255                        .move_cards_to_zone_bottom(ZoneType::Library, pid, &cards);
256                }
257            }
258        }
259
260        // Handle Shuffle$ (e.g. Nihil Spellbomb shuffles the target player's library).
261        if sa.ir.shuffle_raw.is_some() {
262            for &pid in &player_ids {
263                ctx.game.shuffle_zone_cards(ZoneType::Library, pid, ctx.rng);
264            }
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::matches_change_zone_all_filter;
272    use crate::card::Card;
273    use crate::game::GameState;
274    use crate::ids::{CardId, PlayerId};
275    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
276
277    #[test]
278    fn deputy_filter_only_hits_targeted_name_group() {
279        let mut game = GameState::new(&["A", "B"], 20);
280        let p0 = PlayerId(0);
281        let p1 = PlayerId(1);
282
283        let make = |name: &str, owner: PlayerId, type_line: &str| {
284            Card::new(
285                CardId(0),
286                name.to_string(),
287                owner,
288                CardTypeLine::parse(type_line),
289                ManaCost::no_cost(),
290                ColorSet::COLORLESS,
291                None,
292                None,
293                vec![],
294                vec![],
295            )
296        };
297
298        let t1 = game.create_card(make("Token Engine", p1, "Artifact"));
299        let t2 = game.create_card(make("Token Engine", p1, "Artifact"));
300        let opp_land = game.create_card(make("Island", p1, "Land Island"));
301        let my_land = game.create_card(make("Forest", p0, "Land Forest"));
302
303        game.move_card(t1, ZoneType::Battlefield, p1);
304        game.move_card(t2, ZoneType::Battlefield, p1);
305        game.move_card(opp_land, ZoneType::Battlefield, p1);
306        game.move_card(my_land, ZoneType::Battlefield, p0);
307
308        let filter = "TargetedCard.Self,Permanent.nonLand+NotDefinedTargeted+sharesNameWith Targeted+ControlledBy TargetedController";
309
310        assert!(matches_change_zone_all_filter(
311            t1,
312            &game,
313            filter,
314            &[],
315            Some(t1)
316        ));
317        assert!(matches_change_zone_all_filter(
318            t2,
319            &game,
320            filter,
321            &[],
322            Some(t1)
323        ));
324        assert!(!matches_change_zone_all_filter(
325            opp_land,
326            &game,
327            filter,
328            &[],
329            Some(t1)
330        ));
331        assert!(!matches_change_zone_all_filter(
332            my_land,
333            &game,
334            filter,
335            &[],
336            Some(t1)
337        ));
338    }
339}