Skip to main content

manabrew_engine/ability/effects/
dig_multiple_effect.rs

1use forge_foundation::ZoneType;
2
3use super::{
4    emit_zone_trigger, matches_change_type, resolve_defined_player, resolve_numeric_svar,
5    EffectContext,
6};
7use crate::parsing::keys;
8
9/// Mirrors Java's `DigEffect.java`.
10///
11/// `SP$ Dig | DigNum$ N | ChangeNum$ K | DestinationZone$ Hand | DestinationZone2$ Library`
12///
13/// Looks at the top N cards of the target player's library.
14/// The activating player chooses up to K of them and moves them to DestinationZone (default Hand).
15/// The rest go to DestinationZone2 (default Library bottom).
16/// Struct form of this effect so it can participate in the
17/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
18/// `DigMultipleEffect` class extending `SpellAbilityEffect`.
19#[manabrew_engine_macros::spell_effect(DigMultipleEffect)]
20fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
21    let dig_num = resolve_numeric_svar(ctx.game, sa, "DigNum", 1).max(0) as usize;
22    let optional = sa.ir.optional;
23    let change_all = sa
24        .ir
25        .change_num_text
26        .as_deref()
27        .map(|s| s.eq_ignore_ascii_case("All"))
28        .unwrap_or(false);
29    let any_number = sa
30        .ir
31        .change_num_text
32        .as_deref()
33        .map(|s| s.eq_ignore_ascii_case("Any"))
34        .unwrap_or(false);
35    let change_num = if change_all || any_number {
36        dig_num
37    } else {
38        resolve_numeric_svar(ctx.game, sa, keys::CHANGE_NUM, 1).max(0) as usize
39    };
40
41    let dest_zone1 = sa.destination_zone().unwrap_or(ZoneType::Hand);
42    let lib_position1: i32 = sa
43        .library_position()
44        .and_then(|s| s.parse().ok())
45        .unwrap_or(-1);
46    let dest_zone2 = sa.ir.destination_zone_2.unwrap_or(ZoneType::Library);
47
48    // Library position for zone2 placement: -1 = bottom, 0 = top
49    let lib_position2: i32 = sa
50        .library_position_2()
51        .and_then(|s| s.parse().ok())
52        .unwrap_or(-1);
53
54    let change_valid = sa
55        .ir
56        .change_valid
57        .as_deref()
58        .map(|s| s.to_string())
59        .unwrap_or_default();
60
61    // Determine the player whose library we dig through.
62    let dig_player = sa
63        .target_chosen
64        .target_player
65        .or_else(|| {
66            sa.defined()
67                .and_then(|d| resolve_defined_player(d, sa.activating_player, ctx.game))
68        })
69        .unwrap_or(sa.activating_player);
70
71    let lib_len = ctx.game.cards_in_zone(ZoneType::Library, dig_player).len();
72    if lib_len == 0 {
73        return;
74    }
75
76    let count = dig_num.min(lib_len);
77
78    // Take top N cards off the library.
79    let mut top_n = ctx
80        .game
81        .take_top_cards_from_zone(ZoneType::Library, dig_player, count);
82    // Java DigEffect iterates top cards in top-first order.
83    // Our library uses index 0 = bottom, so split_off returns deepest->top.
84    // Reverse to expose the same chooser order Java uses.
85    top_n.reverse();
86
87    // Filter valid choices by ChangeValid$ (e.g. "Creature").
88    let valid: Vec<_> = if change_valid.is_empty() {
89        top_n.clone()
90    } else {
91        top_n
92            .iter()
93            .copied()
94            .filter(|&id| matches_change_type(ctx.game.card(id), &change_valid, &[]))
95            .collect()
96    };
97
98    // Java DigEffect only prompts for optional skip when PromptToSkipOptionalAbility is set.
99    // Otherwise Optional$ True is modeled by allowing 0 selected cards in choose_dig.
100    let may_be_skipped = sa.ir.prompt_to_skip_optional_ability;
101    if optional && may_be_skipped && !valid.is_empty() {
102        let source_name = sa.source.map(|cid| ctx.game.card(cid).card_name.clone());
103        let prompt = sa
104            .ir
105            .optional_ability_prompt
106            .as_deref()
107            .unwrap_or("Would you like to proceed with this optional ability?");
108        let accepted = ctx.agents[dig_player.index()].confirm_action(
109            dig_player,
110            None,
111            prompt,
112            &[],
113            sa.source,
114            Some(crate::ability::api_type::ApiType::Dig),
115        );
116        if !accepted {
117            // Put cards back into library — reverse to restore original deepest→top order.
118            top_n.reverse();
119            for card_id in top_n {
120                ctx.game
121                    .add_card_to_zone(ZoneType::Library, dig_player, card_id);
122            }
123            return;
124        }
125    }
126
127    // Ask the chooser (activating player) which cards to take.
128    let max_take = change_num.min(valid.len());
129    let chosen = ctx.agents[sa.activating_player.index()].choose_dig(
130        ctx.game,
131        sa.activating_player,
132        &valid,
133        max_take,
134        optional || any_number,
135    );
136
137    let chosen: Vec<_> = chosen
138        .into_iter()
139        .filter(|id| valid.contains(id))
140        .take(max_take)
141        .collect();
142
143    let rest: Vec<_> = top_n
144        .iter()
145        .copied()
146        .filter(|id| !chosen.contains(id))
147        .collect();
148
149    // Move chosen cards to dest_zone1.
150    for &id in &chosen {
151        let owner = ctx.game.card(id).owner;
152        let dest_owner = if dest_zone1 == ZoneType::Battlefield {
153            sa.activating_player
154        } else {
155            owner
156        };
157        ctx.move_card(id, dest_zone1, dest_owner);
158        if dest_zone1 == ZoneType::Library {
159            match lib_position1 {
160                pos if pos < 0 => {
161                    ctx.game
162                        .reorder_card_in_zone(ZoneType::Library, dest_owner, id, 0)
163                }
164                0 => {}
165                pos => {
166                    let len = ctx.game.cards_in_zone(ZoneType::Library, dest_owner).len();
167                    let from_top = pos as usize;
168                    let index = len.saturating_sub(from_top + 1);
169                    ctx.game
170                        .reorder_card_in_zone(ZoneType::Library, dest_owner, id, index);
171                }
172            }
173        }
174        emit_zone_trigger(ctx.trigger_handler, id, ZoneType::Library, dest_zone1);
175    }
176
177    // Move rest to dest_zone2.
178    for &id in &rest {
179        let owner = ctx.game.card(id).owner;
180        if dest_zone2 == ZoneType::Library {
181            // Put back into the library at the specified position.
182            // lib_position2 == -1 means bottom (index 0), 0 means top.
183            if lib_position2 == 0 {
184                // top of library
185                ctx.game.add_card_to_zone(ZoneType::Library, owner, id);
186                ctx.game.card_mut(id).set_zone(ZoneType::Library);
187            } else {
188                // bottom of library
189                ctx.game
190                    .add_card_to_zone_bottom(ZoneType::Library, owner, id);
191                ctx.game.card_mut(id).set_zone(ZoneType::Library);
192            }
193        } else {
194            let dest_owner = if dest_zone2 == ZoneType::Battlefield {
195                sa.activating_player
196            } else {
197                owner
198            };
199            ctx.move_card(id, dest_zone2, dest_owner);
200            emit_zone_trigger(ctx.trigger_handler, id, ZoneType::Library, dest_zone2);
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use crate::ability::spell_ability_effect::SpellAbilityEffect;
208    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
209
210    use crate::ability::effects::EffectContext;
211    use crate::agent::{PassAgent, PlayerAgent};
212    use crate::card::Card;
213    use crate::combat::DefenderId;
214    use crate::game::GameState;
215    use crate::ids::{CardId, PlayerId};
216    use crate::mana::ManaPool;
217    use crate::spellability::SpellAbility;
218    use crate::trigger::handler::TriggerHandler;
219    use std::collections::HashMap;
220
221    fn make_land(game: &mut GameState, owner: PlayerId) -> CardId {
222        let c = Card::new(
223            CardId(0),
224            "Island".into(),
225            owner,
226            CardTypeLine::parse("Basic Land Island"),
227            ManaCost::parse(""),
228            ColorSet::COLORLESS,
229            None,
230            None,
231            vec![],
232            vec![],
233        );
234        game.create_card(c)
235    }
236
237    /// Agent that always picks the first card offered during dig.
238    struct TakeFirstAgent;
239    impl PlayerAgent for TakeFirstAgent {
240        fn mulligan_decision(&mut self, _: PlayerId, _: &[CardId], _: u32) -> bool {
241            true
242        }
243        fn choose_action(
244            &mut self,
245            player: PlayerId,
246            action_space: Option<&crate::agent::PriorityActionSpace>,
247            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
248        ) -> crate::player::actions::PlayerAction {
249            crate::player::actions::PlayerAction::PassPriority
250        }
251        fn choose_attackers(
252            &mut self,
253            _: PlayerId,
254            _: &[CardId],
255            _: &[DefenderId],
256        ) -> Vec<(CardId, DefenderId)> {
257            vec![]
258        }
259        fn choose_blockers(
260            &mut self,
261            _: PlayerId,
262            _: &[CardId],
263            _: &[CardId],
264            _: Option<usize>,
265        ) -> Vec<(CardId, CardId)> {
266            vec![]
267        }
268        fn choose_target_player(
269            &mut self,
270            _: PlayerId,
271            v: &[PlayerId],
272            _sa: Option<&crate::spellability::SpellAbility>,
273        ) -> Option<PlayerId> {
274            v.first().copied()
275        }
276        fn choose_target_card(
277            &mut self,
278            _: PlayerId,
279            v: &[CardId],
280            _sa: Option<&crate::spellability::SpellAbility>,
281        ) -> Option<CardId> {
282            v.first().copied()
283        }
284        fn choose_target_any(
285            &mut self,
286            _: PlayerId,
287            vp: &[PlayerId],
288            vc: &[CardId],
289            _sa: Option<&crate::spellability::SpellAbility>,
290        ) -> crate::agent::TargetChoice {
291            vp.first()
292                .copied()
293                .map(crate::agent::TargetChoice::Player)
294                .or_else(|| vc.first().copied().map(crate::agent::TargetChoice::Card))
295                .unwrap_or(crate::agent::TargetChoice::None)
296        }
297        fn choose_land_or_spell(&mut self, _: PlayerId) -> Option<bool> {
298            None
299        }
300        fn choose_dig(
301            &mut self,
302            _game: &GameState,
303            _player: PlayerId,
304            cards: &[CardId],
305            max: usize,
306            _optional: bool,
307        ) -> Vec<CardId> {
308            cards.iter().copied().take(max).collect()
309        }
310        fn choose_targets_for(
311            &mut self,
312            _sa: &mut SpellAbility,
313            _game: &GameState,
314            _mana_pools: &[ManaPool],
315        ) -> bool {
316            false
317        }
318    }
319
320    #[test]
321    fn dig_moves_chosen_to_hand() {
322        let mut game = GameState::new(&["Alice", "Bob"], 20);
323        let p0 = PlayerId(0);
324
325        let a = make_land(&mut game, p0);
326        let b = make_land(&mut game, p0);
327        let c = make_land(&mut game, p0);
328        // Library (bottom→top): a, b, c  → c is on top
329        game.replace_zone_cards(ZoneType::Library, p0, vec![a, b, c]);
330
331        // Dig 3, take 1 to hand, rest go to graveyard.
332        let sa = SpellAbility::new_simple(
333            None,
334            p0,
335            "SP$ Dig | DigNum$ 3 | ChangeNum$ 1 | DestinationZone2$ Graveyard | NoReveal$ True",
336        );
337        let mut trigger_handler = TriggerHandler::new();
338        let mut agents: Vec<Box<dyn PlayerAgent>> =
339            vec![Box::new(TakeFirstAgent), Box::new(PassAgent)];
340        let mut mana_pools = vec![ManaPool::default(), ManaPool::default()];
341        let token_templates = HashMap::new();
342        let templates_variants: HashMap<(String, String), usize> = HashMap::new();
343        let token_fallback: HashMap<String, String> = HashMap::new();
344        let edition_dates: HashMap<String, String> = HashMap::new();
345        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
346        let mut ctx = EffectContext {
347            game: &mut game,
348            combat: None,
349            agents: &mut agents,
350            trigger_handler: &mut trigger_handler,
351            token_templates: &token_templates,
352            token_art_variants: &templates_variants,
353            token_fallback: &token_fallback,
354            edition_dates: &edition_dates,
355            mana_pools: &mut mana_pools,
356            parent_target_card: None,
357            rng: &mut rng_adapter,
358        };
359
360        super::DigMultipleEffect::resolve(&mut ctx, &sa);
361
362        // 1 card goes to hand, 2 go to graveyard.
363        assert_eq!(ctx.game.cards_in_zone(ZoneType::Hand, p0).len(), 1);
364        assert_eq!(ctx.game.cards_in_zone(ZoneType::Graveyard, p0).len(), 2);
365        assert_eq!(ctx.game.cards_in_zone(ZoneType::Library, p0).len(), 0);
366    }
367}