Skip to main content

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