Skip to main content

manabrew_engine/ability/effects/
scry_effect.rs

1use forge_foundation::ZoneType;
2
3use super::{resolve_defined_player, resolve_numeric_svar, EffectContext};
4use crate::event::RunParams;
5use crate::replacement::replacement_handler::{apply_replacements, ReplacementEvent};
6use crate::replacement::ReplacementResult;
7use crate::trigger::TriggerType;
8
9/// Mirrors Java's `ScryEffect.java`.
10///
11/// `SP$ Scry | ScryNum$ N`
12/// Lets the activating player look at the top N cards of their library,
13/// then put any number of them on the bottom in any order; the rest stay on top.
14/// Struct form of this effect so it can participate in the
15/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
16/// `ScryEffect` class extending `SpellAbilityEffect`.
17#[manabrew_engine_macros::spell_effect(ScryEffect)]
18fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
19    let num = resolve_numeric_svar(ctx.game, sa, "ScryNum", 1).max(0) as usize;
20
21    let target = sa
22        .defined()
23        .and_then(|d| resolve_defined_player(d, sa.activating_player, ctx.game))
24        .unwrap_or(sa.activating_player);
25
26    // Run Scry replacement effects before scrying.
27    let mut event = ReplacementEvent::Scry {
28        player: target,
29        count: num as i32,
30    };
31    let result = apply_replacements(ctx.game, &mut event);
32    if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
33        return;
34    }
35    let num = if let ReplacementEvent::Scry { count, .. } = event {
36        count.max(0) as usize
37    } else {
38        num
39    };
40
41    if sa.ir.optional {
42        let _source_name = sa.source.map(|cid| ctx.game.card(cid).card_name.as_str());
43        let accepted = ctx.agents[target.index()].confirm_action(
44            target,
45            None,
46            "Do you want to scry?",
47            &[],
48            sa.source,
49            Some(crate::ability::api_type::ApiType::Scry),
50        );
51        if !accepted {
52            return;
53        }
54    }
55
56    let lib_len = ctx.game.cards_in_zone(ZoneType::Library, target).len();
57    if lib_len == 0 || num == 0 {
58        return;
59    }
60
61    let count = num.min(lib_len);
62
63    // Take top N cards off the library (index 0 = bottom, last = top).
64    let mut top_n = ctx
65        .game
66        .take_top_cards_from_zone(ZoneType::Library, target, count);
67    // Reverse to match Java's iteration order (top-to-bottom).
68    // Java's `getCardsIn(Library, n)` returns cards starting from index 0 (top)
69    // downward, so the deterministic agent must consume RNG in the same order.
70    top_n.reverse();
71
72    // Ask the agent to distribute the cards: piles[0] = top, piles[1] = bottom.
73    let piles = ctx.agents[target.index()].choose_scry(ctx.game, target, sa.source, &top_n);
74    let (top, bottom) = super::split_scry_piles(&top_n, &piles);
75
76    // Bottom cards go under the library (preserve their order).
77    for &id in &bottom {
78        ctx.game
79            .add_card_to_zone_bottom(ZoneType::Library, target, id);
80    }
81    // Top pile is ordered top-to-bottom (first = top of library); iterate in
82    // reverse so the last append leaves the intended card on top.
83    for &id in top.iter().rev() {
84        ctx.game.add_card_to_zone(ZoneType::Library, target, id);
85    }
86
87    // Fire Scry trigger
88    ctx.trigger_handler.run_trigger(
89        TriggerType::Scry,
90        RunParams {
91            player: Some(target),
92            ..Default::default()
93        },
94        false,
95    );
96}
97
98#[cfg(test)]
99mod tests {
100    use crate::ability::spell_ability_effect::SpellAbilityEffect;
101    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
102
103    use crate::ability::effects::EffectContext;
104    use crate::agent::{PassAgent, PlayerAgent};
105    use crate::card::Card;
106    use crate::combat::DefenderId;
107    use crate::game::GameState;
108    use crate::ids::{CardId, PlayerId};
109    use crate::mana::ManaPool;
110    use crate::spellability::SpellAbility;
111    use crate::trigger::handler::TriggerHandler;
112    use std::collections::HashMap;
113
114    fn make_land(game: &mut GameState, owner: PlayerId) -> CardId {
115        let c = Card::new(
116            CardId(0),
117            "Island".into(),
118            owner,
119            CardTypeLine::parse("Basic Land Island"),
120            ManaCost::parse(""),
121            ColorSet::COLORLESS,
122            None,
123            None,
124            vec![],
125            vec![],
126        );
127        game.create_card(c)
128    }
129
130    /// Agent that always puts all cards on the bottom.
131    struct BottomAllAgent;
132    impl PlayerAgent for BottomAllAgent {
133        fn mulligan_decision(&mut self, _: PlayerId, _: &[CardId], _: u32) -> bool {
134            true
135        }
136        fn choose_action(
137            &mut self,
138            player: PlayerId,
139            action_space: Option<&crate::agent::PriorityActionSpace>,
140            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
141        ) -> crate::player::actions::PlayerAction {
142            crate::player::actions::PlayerAction::PassPriority
143        }
144        fn choose_attackers(
145            &mut self,
146            _: PlayerId,
147            _: &[CardId],
148            _: &[DefenderId],
149        ) -> Vec<(CardId, DefenderId)> {
150            vec![]
151        }
152        fn choose_blockers(
153            &mut self,
154            _: PlayerId,
155            _: &[CardId],
156            _: &[CardId],
157            _: Option<usize>,
158        ) -> Vec<(CardId, CardId)> {
159            vec![]
160        }
161        fn choose_target_player(
162            &mut self,
163            _: PlayerId,
164            v: &[PlayerId],
165            _sa: Option<&crate::spellability::SpellAbility>,
166        ) -> Option<PlayerId> {
167            v.first().copied()
168        }
169        fn choose_target_card(
170            &mut self,
171            _: PlayerId,
172            v: &[CardId],
173            _sa: Option<&crate::spellability::SpellAbility>,
174        ) -> Option<CardId> {
175            v.first().copied()
176        }
177        fn choose_target_any(
178            &mut self,
179            _: PlayerId,
180            vp: &[PlayerId],
181            vc: &[CardId],
182            _sa: Option<&crate::spellability::SpellAbility>,
183        ) -> crate::agent::TargetChoice {
184            vp.first()
185                .copied()
186                .map(crate::agent::TargetChoice::Player)
187                .or_else(|| vc.first().copied().map(crate::agent::TargetChoice::Card))
188                .unwrap_or(crate::agent::TargetChoice::None)
189        }
190        fn choose_land_or_spell(&mut self, _: PlayerId) -> Option<bool> {
191            None
192        }
193        fn choose_scry(
194            &mut self,
195            _game: &GameState,
196            _player: PlayerId,
197            _source: Option<CardId>,
198            cards: &[CardId],
199        ) -> Vec<Vec<CardId>> {
200            vec![vec![], cards.to_vec()] // put all on bottom
201        }
202        fn choose_targets_for(
203            &mut self,
204            _sa: &mut SpellAbility,
205            _game: &GameState,
206            _mana_pools: &[ManaPool],
207        ) -> bool {
208            false
209        }
210    }
211
212    #[test]
213    fn scry_puts_chosen_on_bottom() {
214        let mut game = GameState::new(&["Alice", "Bob"], 20);
215        let p0 = PlayerId(0);
216
217        let a = make_land(&mut game, p0);
218        let b = make_land(&mut game, p0);
219        let c = make_land(&mut game, p0);
220
221        // Library order (bottom to top): a, b, c  → c is on top
222        game.replace_zone_cards(ZoneType::Library, p0, vec![a, b, c]);
223
224        // Scry 2: sees [b, c] (top 2). BottomAllAgent puts both on bottom.
225        let sa = SpellAbility::new_simple(None, p0, "SP$ Scry | ScryNum$ 2");
226        let mut trigger_handler = TriggerHandler::new();
227        let mut agents: Vec<Box<dyn PlayerAgent>> =
228            vec![Box::new(BottomAllAgent), Box::new(PassAgent)];
229        let mut mana_pools = vec![ManaPool::default(), ManaPool::default()];
230        let token_templates = HashMap::new();
231        let templates_variants: HashMap<(String, String), usize> = HashMap::new();
232        let token_fallback: HashMap<String, String> = HashMap::new();
233        let edition_dates: HashMap<String, String> = HashMap::new();
234        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
235        let mut ctx = EffectContext {
236            game: &mut game,
237            combat: None,
238            agents: &mut agents,
239            trigger_handler: &mut trigger_handler,
240            token_templates: &token_templates,
241            token_art_variants: &templates_variants,
242            token_fallback: &token_fallback,
243            edition_dates: &edition_dates,
244            mana_pools: &mut mana_pools,
245            parent_target_card: None,
246            rng: &mut rng_adapter,
247        };
248
249        super::ScryEffect::resolve(&mut ctx, &sa);
250
251        // Library still has all 3 cards, a is now on top (b,c went to bottom).
252        let lib = ctx.game.cards_in_zone(ZoneType::Library, p0);
253        assert_eq!(lib.len(), 3);
254        // a was at index 0 (bottom originally); it should now be on top
255        assert_eq!(*lib.last().unwrap(), a);
256    }
257
258    #[test]
259    fn scry_keep_all_on_top_with_pass_agent() {
260        let mut game = GameState::new(&["Alice", "Bob"], 20);
261        let p0 = PlayerId(0);
262
263        let a = make_land(&mut game, p0);
264        let b = make_land(&mut game, p0);
265        game.replace_zone_cards(ZoneType::Library, p0, vec![a, b]);
266
267        let sa = SpellAbility::new_simple(None, p0, "SP$ Scry | ScryNum$ 2");
268        let mut trigger_handler = TriggerHandler::new();
269        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![Box::new(PassAgent), Box::new(PassAgent)];
270        let mut mana_pools = vec![ManaPool::default(), ManaPool::default()];
271        let token_templates = HashMap::new();
272        let templates_variants: HashMap<(String, String), usize> = HashMap::new();
273        let token_fallback: HashMap<String, String> = HashMap::new();
274        let edition_dates: HashMap<String, String> = HashMap::new();
275        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
276        let mut ctx = EffectContext {
277            game: &mut game,
278            combat: None,
279            agents: &mut agents,
280            trigger_handler: &mut trigger_handler,
281            token_templates: &token_templates,
282            token_art_variants: &templates_variants,
283            token_fallback: &token_fallback,
284            edition_dates: &edition_dates,
285            mana_pools: &mut mana_pools,
286            parent_target_card: None,
287            rng: &mut rng_adapter,
288        };
289
290        super::ScryEffect::resolve(&mut ctx, &sa);
291
292        // PassAgent returns empty bottom list, so all cards stay on top.
293        // Order preserved: [a, b] with b still on top.
294        let lib = ctx.game.cards_in_zone(ZoneType::Library, p0);
295        assert_eq!(lib.len(), 2);
296        assert_eq!(*lib.last().unwrap(), b);
297    }
298}