manabrew_engine/ability/effects/
change_zone_all_effect.rs1use 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 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 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
104pub fn build_spell_ability(sa: &mut crate::spellability::SpellAbility) {
108 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#[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 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 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 let effective_target = sa.target_chosen.target_card.or(ctx.parent_target_card);
155
156 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 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; }
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 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 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}