Skip to main content

manabrew_engine/ability/effects/
surveil_effect.rs

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