Skip to main content

manabrew_engine/ability/effects/
control_gain_effect.rs

1use forge_foundation::ZoneType;
2
3use super::EffectContext;
4use crate::event::RunParams;
5use crate::trigger::TriggerType;
6
7/// Revert a scheduled control-gain. Mirrors Java `ControlGainEffect`'s
8/// `GameCommand.run()`: restore `original_controller_eot`, drop the
9/// `lose_control_condition`, and clear granted keywords. Zone guard — if the
10/// card has already left the battlefield, the scheduler caller is expected to
11/// have handled cleanup via `leaves_play_hook`.
12pub fn run(game: &mut crate::game::GameState, card_id: crate::ids::CardId) {
13    if game.card(card_id).zone != ZoneType::Battlefield {
14        return;
15    }
16    revert(game, card_id);
17}
18
19fn revert(game: &mut crate::game::GameState, card_id: crate::ids::CardId) {
20    if let Some(original) = game.card(card_id).original_controller_eot {
21        game.change_controller(card_id, original);
22        game.card_mut(card_id).set_original_controller_eot(None);
23    }
24    game.card_mut(card_id).lose_control_condition = None;
25    game.card_mut(card_id).clear_granted_keywords();
26}
27
28/// Hook invoked whenever a card untaps — reverts the steal if the card was
29/// scheduled with `LoseControlCondition::NextUntap`.
30pub fn untap_hook(game: &mut crate::game::GameState, card_id: crate::ids::CardId) {
31    if game.card(card_id).lose_control_condition
32        == Some(crate::card::LoseControlCondition::NextUntap)
33    {
34        revert(game, card_id);
35    }
36}
37
38/// Hook invoked at end of combat — reverts steals scheduled with
39/// `LoseControlCondition::EndOfCombat`.
40pub fn end_of_combat_hook(game: &mut crate::game::GameState) {
41    let targets: Vec<crate::ids::CardId> = game
42        .cards
43        .iter()
44        .filter(|c| {
45            c.lose_control_condition == Some(crate::card::LoseControlCondition::EndOfCombat)
46        })
47        .map(|c| c.id)
48        .collect();
49    for cid in targets {
50        if game.card(cid).zone == ZoneType::Battlefield {
51            revert(game, cid);
52        }
53    }
54}
55
56/// Hook invoked as a permanent leaves the battlefield — reverts scheduled
57/// `LoseControlCondition::LeavesPlay` commands. The card is technically
58/// already in limbo when this fires, so we just clear the schedule.
59pub fn leaves_play_hook(game: &mut crate::game::GameState, card_id: crate::ids::CardId) {
60    if game.card(card_id).lose_control_condition
61        == Some(crate::card::LoseControlCondition::LeavesPlay)
62    {
63        game.card_mut(card_id).lose_control_condition = None;
64        game.card_mut(card_id).set_original_controller_eot(None);
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::ability::spell_ability_effect::SpellAbilityEffect;
72    use crate::agent::{PassAgent, PlayerAgent};
73    use crate::card::Card;
74    use crate::game::GameState;
75    use crate::ids::{CardId, PlayerId};
76    use crate::mana::ManaPool;
77    use crate::spellability::SpellAbility;
78    use crate::trigger::TriggerHandler;
79    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
80    use std::collections::HashMap;
81
82    fn creature(owner: PlayerId, name: &str) -> Card {
83        Card::new(
84            CardId(0),
85            name.to_string(),
86            owner,
87            CardTypeLine::parse("Creature Goblin"),
88            ManaCost::parse("R"),
89            ColorSet::RED,
90            Some(1),
91            Some(1),
92            vec![],
93            vec![],
94        )
95    }
96
97    #[test]
98    fn repeated_eot_control_gain_restores_first_controller() {
99        let p0 = PlayerId(0);
100        let p1 = PlayerId(1);
101        let mut game = GameState::new(&["Alice", "Bob"], 20);
102        let goblin = game.create_card(creature(p0, "Raging Goblin"));
103        game.move_card(goblin, ZoneType::Battlefield, p0);
104
105        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![Box::new(PassAgent), Box::new(PassAgent)];
106        let mut trigger_handler = TriggerHandler::new();
107        let templates = HashMap::new();
108        let token_art_variants = HashMap::new();
109        let token_fallback = HashMap::new();
110        let edition_dates = HashMap::new();
111        let mut mana_pools = vec![ManaPool::new(), ManaPool::new()];
112        let mut rng = crate::game_rng::ThreadRngAdapter;
113
114        {
115            let mut ctx = EffectContext {
116                game: &mut game,
117                combat: None,
118                agents: &mut agents,
119                trigger_handler: &mut trigger_handler,
120                token_templates: &templates,
121                token_art_variants: &token_art_variants,
122                token_fallback: &token_fallback,
123                edition_dates: &edition_dates,
124                mana_pools: &mut mana_pools,
125                parent_target_card: None,
126                rng: &mut rng,
127            };
128
129            let mut steal = SpellAbility::new_simple(
130                Some(goblin),
131                p1,
132                "SP$ GainControl | ValidTgts$ Creature.OppCtrl | LoseControl$ EOT",
133            );
134            steal.target_chosen.target_card = Some(goblin);
135            ControlGainEffect::resolve(&mut ctx, &steal);
136            assert_eq!(ctx.game.card(goblin).controller, p1);
137
138            let mut steal_back = SpellAbility::new_simple(
139                Some(goblin),
140                p0,
141                "SP$ GainControl | ValidTgts$ Creature.OppCtrl | LoseControl$ EOT",
142            );
143            steal_back.target_chosen.target_card = Some(goblin);
144            ControlGainEffect::resolve(&mut ctx, &steal_back);
145            assert_eq!(ctx.game.card(goblin).controller, p0);
146        }
147
148        assert_eq!(game.card(goblin).original_controller_eot, Some(p0));
149        run(&mut game, goblin);
150        assert_eq!(game.card(goblin).controller, p0);
151        assert_eq!(game.card(goblin).original_controller_eot, None);
152    }
153}
154
155/// SP$ ControlGain — gain control of target permanent until end of turn or permanently.
156///
157/// Mirrors Java's `ControlGainEffect.resolve()`.
158/// Struct form of this effect so it can participate in the
159/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
160/// `ControlGainEffect` class extending `SpellAbilityEffect`.
161#[manabrew_engine_macros::spell_effect(ControlGainEffect)]
162fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
163    let target_card = match sa.target_chosen.target_card {
164        Some(c) => c,
165        None => return,
166    };
167
168    // Verify target is still on the battlefield
169    if ctx.game.card(target_card).zone != ZoneType::Battlefield {
170        return;
171    }
172
173    let new_controller = sa.activating_player;
174
175    // Check if the card can be controlled by the new controller
176    if !ctx
177        .game
178        .card(target_card)
179        .can_be_controlled_by(new_controller)
180    {
181        return;
182    }
183
184    // Change controller
185    let old_controller = ctx.game.card(target_card).controller;
186    ctx.game.change_controller(target_card, new_controller);
187
188    // Fire ChangesController trigger (mirrors Java GameAction.doChangeController)
189    if old_controller != new_controller {
190        ctx.trigger_handler.run_trigger(
191            TriggerType::ChangesController,
192            RunParams {
193                card: Some(target_card),
194                player: Some(new_controller),
195                original_controller: Some(old_controller),
196                ..Default::default()
197            },
198            false,
199        );
200    }
201
202    // Schedule the controller-return GameCommand based on the LoseControl$
203    // variant. Only record original_controller on the first steal so repeated
204    // steal → steal-back → EOT reverts to the pre-chain controller.
205    let already_scheduled = ctx.game.card(target_card).original_controller_eot.is_some();
206    if let Some(cond) = sa.ir.lose_control {
207        if !already_scheduled {
208            ctx.game
209                .card_mut(target_card)
210                .set_original_controller_eot(Some(old_controller));
211        }
212        ctx.game.card_mut(target_card).lose_control_condition = Some(cond);
213    }
214
215    // Handle Untap parameter
216    if sa.ir.untap_on_resolve {
217        ctx.game.untap(target_card);
218    }
219
220    // Handle AddKWs parameter (add keywords)
221    if let Some(kws_str) = sa.ir.add_kws.as_deref() {
222        let keywords: Vec<String> = kws_str.split(" & ").map(|s| s.to_string()).collect();
223        for kw in keywords {
224            ctx.game.card_mut(target_card).add_granted_keyword(&kw);
225        }
226    }
227}