Skip to main content

manabrew_engine/game_loop/
phase_handler.rs

1use super::*;
2
3impl GameLoop {
4    pub(crate) fn run_turn_state_machine(
5        &mut self,
6        game: &mut GameState,
7        agents: &mut [Box<dyn PlayerAgent>],
8    ) {
9        let _perf_scope =
10            crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Phase);
11        // Run BeginTurn replacement effects before the turn begins.
12        let active = game.active_player();
13        {
14            use crate::replacement::replacement_handler::{apply_replacements, ReplacementEvent};
15            use crate::replacement::ReplacementResult;
16            let mut event = ReplacementEvent::BeginTurn { player: active };
17            let result = apply_replacements(game, &mut event);
18            if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
19                return;
20            }
21        }
22
23        // Fire TurnBegin trigger at the start of each turn.
24        let active = game.active_player();
25        crate::player::expire_until_your_next_turn_keywords(game, active);
26        self.trigger_handler
27            .handle_player_defined_del_triggers(active);
28        self.trigger_handler.run_trigger(
29            TriggerType::TurnBegin,
30            RunParams {
31                player: Some(active),
32                ..Default::default()
33            },
34            false,
35        );
36
37        let mut state = TurnMachineState::Untap;
38        while !game.game_over && state != TurnMachineState::Done {
39            if self.is_aborted() {
40                game.game_over = true;
41                break;
42            }
43            // EndTurn (issue #22): if end_turn_requested, skip directly to cleanup.
44            if game.end_turn_requested
45                && state != TurnMachineState::Cleanup
46                && state != TurnMachineState::Done
47            {
48                game.end_turn_requested = false;
49                self.combat.clear_with_cards(&mut game.cards);
50                state = TurnMachineState::Cleanup;
51                continue;
52            }
53            state = match state {
54                TurnMachineState::Untap => {
55                    // SkipPhase: skip untap if flag set
56                    let active = game.active_player();
57                    if game.player(active).skip_next_untap {
58                        game.player_clear_skip_untap(active);
59                        TurnMachineState::Upkeep
60                    } else {
61                        self.apply_turn_event(
62                            game,
63                            agents,
64                            TurnEvent::EnterPhase {
65                                phase: PhaseType::Untap,
66                                emit_phase_trigger: false,
67                            },
68                        );
69                        self.apply_turn_event(game, agents, TurnEvent::UntapStep);
70                        TurnMachineState::Upkeep
71                    }
72                }
73                TurnMachineState::Upkeep => {
74                    self.apply_turn_event(
75                        game,
76                        agents,
77                        TurnEvent::EnterPhase {
78                            phase: PhaseType::Upkeep,
79                            emit_phase_trigger: true,
80                        },
81                    );
82                    self.apply_turn_event(
83                        game,
84                        agents,
85                        TurnEvent::PriorityWindow {
86                            is_main_phase: false,
87                        },
88                    );
89                    self.clear_came_under_control_since_last_upkeep(game);
90                    // Cumulative upkeep is now handled as a Phase trigger
91                    // (generated by generate_keyword_triggers from the keyword),
92                    // so it fires alongside other upkeep triggers (e.g. Deep Spawn)
93                    // and is ordered by the player. No separate procedural call needed.
94                    TurnMachineState::Draw
95                }
96                TurnMachineState::Draw => {
97                    // SkipPhase: skip draw if flag set
98                    let active = game.active_player();
99                    if game.player(active).skip_next_draw
100                        || crate::replacement::replacement_handler::would_phase_be_skipped(
101                            game,
102                            active,
103                            PhaseType::Draw,
104                        )
105                    {
106                        game.player_clear_skip_draw(active);
107                        TurnMachineState::Main1
108                    } else {
109                        // Java parity: Java still advances into the Draw phase and fires the
110                        // phase-change event on turn 1 in a 2-player game, but then skips the
111                        // draw-step actions, phase triggers, and priority window.
112                        let skip_draw_phase = game.turn.turn_number == 1 && game.players.len() == 2;
113                        if skip_draw_phase {
114                            self.apply_turn_event(
115                                game,
116                                agents,
117                                TurnEvent::EnterPhase {
118                                    phase: PhaseType::Draw,
119                                    emit_phase_trigger: false,
120                                },
121                            );
122                            TurnMachineState::Main1
123                        } else {
124                            self.apply_turn_event(
125                                game,
126                                agents,
127                                TurnEvent::EnterPhase {
128                                    phase: PhaseType::Draw,
129                                    emit_phase_trigger: true,
130                                },
131                            );
132                            self.apply_turn_event(game, agents, TurnEvent::DrawStep);
133                            self.apply_turn_event(
134                                game,
135                                agents,
136                                TurnEvent::PriorityWindow {
137                                    is_main_phase: false,
138                                },
139                            );
140                            TurnMachineState::Main1
141                        }
142                    }
143                }
144                TurnMachineState::Main1 => {
145                    self.apply_turn_event(
146                        game,
147                        agents,
148                        TurnEvent::EnterPhase {
149                            phase: PhaseType::Main1,
150                            emit_phase_trigger: true,
151                        },
152                    );
153                    if game
154                        .cards_in_zone(ZoneType::Battlefield, game.active_player())
155                        .iter()
156                        .any(|&card_id| {
157                            game.card(card_id)
158                                .type_line
159                                .subtypes
160                                .iter()
161                                .any(|s| s.eq_ignore_ascii_case("Attraction"))
162                        })
163                    {
164                        crate::ability::effects::roll_dice_effect::roll_to_visit_attractions(
165                            game,
166                            &mut self.trigger_handler,
167                            &mut *self.game_rng,
168                            agents,
169                            &mut self.mana_pools,
170                            game.active_player(),
171                        );
172                    }
173                    self.apply_turn_event(
174                        game,
175                        agents,
176                        TurnEvent::PriorityWindow {
177                            is_main_phase: true,
178                        },
179                    );
180                    TurnMachineState::Combat
181                }
182                TurnMachineState::Combat => {
183                    // SkipPhase: skip combat if flag set
184                    let active = game.active_player();
185                    if game.player(active).skip_next_combat {
186                        game.player_clear_skip_combat(active);
187                        TurnMachineState::Main2
188                    } else {
189                        self.apply_turn_event(game, agents, TurnEvent::CombatStep);
190                        // Extra combat phases (issue #22, AddPhase effect)
191                        if game.extra_combat_phases > 0 {
192                            game.extra_combat_phases -= 1;
193                            TurnMachineState::Combat // loop back for another combat
194                        } else {
195                            TurnMachineState::Main2
196                        }
197                    }
198                }
199                TurnMachineState::Main2 => {
200                    self.apply_turn_event(
201                        game,
202                        agents,
203                        TurnEvent::EnterPhase {
204                            phase: PhaseType::Main2,
205                            emit_phase_trigger: true,
206                        },
207                    );
208                    self.apply_turn_event(
209                        game,
210                        agents,
211                        TurnEvent::PriorityWindow {
212                            is_main_phase: true,
213                        },
214                    );
215                    TurnMachineState::EndOfTurn
216                }
217                TurnMachineState::EndOfTurn => {
218                    self.apply_turn_event(
219                        game,
220                        agents,
221                        TurnEvent::EnterPhase {
222                            phase: PhaseType::EndOfTurn,
223                            emit_phase_trigger: true,
224                        },
225                    );
226                    self.apply_turn_event(
227                        game,
228                        agents,
229                        TurnEvent::PriorityWindow {
230                            is_main_phase: false,
231                        },
232                    );
233                    TurnMachineState::Cleanup
234                }
235                TurnMachineState::Cleanup => {
236                    self.apply_turn_event(
237                        game,
238                        agents,
239                        TurnEvent::EnterPhase {
240                            phase: PhaseType::Cleanup,
241                            emit_phase_trigger: false,
242                        },
243                    );
244                    self.apply_turn_event(game, agents, TurnEvent::CleanupStep);
245                    self.apply_turn_event(game, agents, TurnEvent::AdvanceTurn);
246                    TurnMachineState::Done
247                }
248                TurnMachineState::Done => TurnMachineState::Done,
249            };
250        }
251    }
252
253    fn apply_turn_event(
254        &mut self,
255        game: &mut GameState,
256        agents: &mut [Box<dyn PlayerAgent>],
257        event: TurnEvent,
258    ) {
259        if game.game_over {
260            return;
261        }
262        match event {
263            TurnEvent::EnterPhase {
264                phase,
265                emit_phase_trigger,
266            } => {
267                // Clear undo stack on phase change (can't undo across phases).
268                game.stack.clear_undo_stack();
269
270                // Run BeginPhase replacement effects before entering the phase.
271                {
272                    use crate::replacement::replacement_handler::{
273                        apply_replacements, ReplacementEvent,
274                    };
275                    use crate::replacement::ReplacementResult;
276                    let active = game.active_player();
277                    let mut event = ReplacementEvent::BeginPhase {
278                        player: active,
279                        phase,
280                    };
281                    let result = apply_replacements(game, &mut event);
282                    if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced
283                    {
284                        return;
285                    }
286                }
287
288                // LKI: Snapshot battlefield state before phase transition.
289                // Mirrors Java PhaseHandler line 1103: game.copyLastState().
290                game.copy_last_state();
291                self.set_phase(game, agents, phase);
292                if emit_phase_trigger {
293                    self.emit_phase_trigger(game, phase);
294                }
295                // Suspend: at the beginning of each upkeep, remove a time counter
296                // from each suspended card in exile. If last counter removed, cast for free.
297                if phase == PhaseType::Upkeep {
298                    self.process_suspend_upkeep(game, agents);
299                }
300            }
301            TurnEvent::PriorityWindow { is_main_phase } => {
302                self.step_with_priority(game, agents, is_main_phase);
303            }
304            TurnEvent::UntapStep => {
305                self.with_shared_state_mutation(game, agents, |this, game, agents| {
306                    this.step_untap(game, agents);
307                });
308            }
309            TurnEvent::DrawStep => {
310                self.with_shared_state_mutation(game, agents, |this, game, agents| {
311                    this.step_draw(game, agents);
312                });
313            }
314            TurnEvent::CombatStep => {
315                self.step_combat(game, agents);
316            }
317            TurnEvent::CleanupStep => {
318                self.with_shared_state_mutation(game, agents, |this, game, agents| {
319                    this.step_cleanup(game, agents);
320                });
321            }
322            TurnEvent::AdvanceTurn => {
323                self.with_shared_state_mutation(game, agents, |_this, game, _agents| {
324                    // Rotate stack's turn-tracking (this_turn_cast → last_turn_cast).
325                    game.stack.on_next_turn();
326                    // Reset zone turn tracking for all zones.
327                    game.reset_zone_turn_tracking();
328                    game.reset_card_turn_tracking();
329                    let player_order = game.player_order.clone();
330                    if let Some((player, _skip)) =
331                        game.turn.advance_turn(&mut game.extra_turns, &player_order)
332                    {
333                        game.player_set_skip_untap(player);
334                    }
335                });
336            }
337        }
338    }
339
340    pub fn step_untap(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
341        let active = game.active_player();
342        let cards_snapshot = game.cards.clone();
343
344        // Delegate phasing to the phase module.
345        crate::phase::untap::do_phasing(game, active);
346
347        // Untap permanents — uses agent interaction for "may choose not to untap".
348        let cards: Vec<crate::ids::CardId> =
349            game.cards_in_zone(ZoneType::Battlefield, active).to_vec();
350        for cid in cards {
351            if game.card(cid).exerted {
352                game.card_mut(cid).clear_exerted();
353            } else {
354                let optional_keyword =
355                    "You may choose not to untap CARDNAME during your untap step.";
356                let should_untap = if game.card(cid).has_keyword(optional_keyword) {
357                    let question = format!("Untap {}?", game.card(cid).card_name);
358                    let source_name = game.card(cid).card_name.clone();
359                    agents[active.index()].choose_binary(
360                        active,
361                        &question,
362                        crate::agent::BinaryChoiceKind::UntapOrLeaveTapped,
363                        Some(true),
364                        Some(cid),
365                        None,
366                    )
367                } else {
368                    true
369                };
370                if should_untap {
371                    game.untap_during_untap_step(cid, active);
372                }
373            }
374        }
375
376        // Java parity: untap permanents on other players' battlefields that
377        // have an UntapOtherPlayer static ability applying during the active
378        // player's untap step, e.g. Bender's Waterskin.
379        let other_battlefield_cards: Vec<_> = game
380            .player_order
381            .iter()
382            .copied()
383            .filter(|&pid| pid != active)
384            .flat_map(|pid| {
385                game.cards_in_zone(ZoneType::Battlefield, pid)
386                    .iter()
387                    .copied()
388            })
389            .collect();
390        for cid in other_battlefield_cards {
391            let card = game.card(cid);
392            if !card.tapped {
393                continue;
394            }
395            if crate::staticability::static_ability_untap_other_player::untap(
396                &cards_snapshot,
397                card,
398                active,
399            ) {
400                game.untap_during_untap_step(cid, active);
401            }
402        }
403
404        self.pool_mut(active).reset_pool();
405    }
406
407    pub fn step_draw(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
408        let active = game.active_player();
409        // Skip draw on turn 1
410        if game.turn.turn_number > 1 {
411            use crate::replacement::replacement_handler::{
412                apply_replacements_with_agents, ReplacementEvent,
413            };
414            use crate::replacement::ReplacementResult;
415
416            // Dredge is now handled through the replacement effect system:
417            // Dredge:N keywords generate a Draw replacement effect during card
418            // assembly (card_factory_util::add_dredge_replacement). When the
419            // draw fires ReplacementEvent::Draw inside player_draw_one_internal,
420            // the replacement handler detects the Dredge replacement on the
421            // graveyard card, calls confirm_replacement_effect (Optional$ True),
422            // and executes the mill + return in replace_draw::execute.
423            let mut draw_count = 1;
424            let mut draw_cards_event = ReplacementEvent::DrawCards {
425                player: active,
426                count: draw_count,
427            };
428            let result = apply_replacements_with_agents(game, agents, &mut draw_cards_event);
429            if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
430                return;
431            }
432            if let ReplacementEvent::DrawCards { count, .. } = draw_cards_event {
433                draw_count = count;
434            }
435
436            for draw_index in 0..draw_count {
437                if let Some(card_id) =
438                    game.player_draw_one_internal(active, draw_index == 0, Some(agents))
439                {
440                    let drawn_snapshot = game.player(active).drawn_this_turn;
441                    // Fire Drawn trigger for turn draw
442                    self.trigger_handler.run_trigger(
443                        TriggerType::Drawn,
444                        RunParams {
445                            card: Some(card_id),
446                            player: Some(active),
447                            drawn_this_turn_snapshot: Some(drawn_snapshot),
448                            ..Default::default()
449                        },
450                        false,
451                    );
452                    // Match Drawn triggers immediately so Number$ N checks see
453                    // the correct drawn count at fire time.
454                    if self.trigger_handler.has_number_drawn_triggers(game) {
455                        self.trigger_handler.flush_waiting_triggers(game);
456                    }
457                }
458            }
459        }
460    }
461
462    pub fn step_with_priority(
463        &mut self,
464        game: &mut GameState,
465        agents: &mut [Box<dyn PlayerAgent>],
466        is_main_phase: bool,
467    ) {
468        let _perf_scope =
469            crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Priority);
470        self.game_log.log(
471            GameLogEntryType::Info,
472            2,
473            format!(
474                "Priority window opened ({}, stack depth: {})",
475                if is_main_phase {
476                    "main-phase speed"
477                } else {
478                    "instant speed only"
479                },
480                game.stack.len()
481            ),
482        );
483        self.with_shared_state_mutation(game, agents, |_this, game, _agents| {
484            game.turn.priority_player = game.active_player();
485        });
486        loop {
487            if game.game_over {
488                return;
489            }
490
491            // 1. Give players priority (process_triggers + SBA are handled
492            //    inside priority_round at the top of each iteration, matching
493            //    Java's checkStateBasedEffects() → addAllTriggeredAbilitiesToStack()
494            //    before chooseSpellAbilityToPlay()).
495            self.priority_round(game, agents, is_main_phase);
496
497            if std::env::var("FORGE_STACK_TRACE").is_ok() {
498                let names: Vec<String> = game
499                    .stack
500                    .iter()
501                    .map(|entry| {
502                        entry
503                            .spell_ability
504                            .source
505                            .map(|cid| game.card(cid).card_name.clone())
506                            .unwrap_or_else(|| "<effect>".to_string())
507                    })
508                    .collect();
509                eprintln!(
510                    "[stack-trace] AFTER priority_round phase={:?} active={:?} priority={:?} depth={} {:?}",
511                    game.turn.phase,
512                    game.active_player(),
513                    game.turn.priority_player,
514                    game.stack.len(),
515                    names
516                );
517            }
518
519            if game.game_over {
520                return;
521            }
522
523            // 3. If stack is empty after everyone passed, the phase ends
524            if game.stack.is_empty() {
525                break;
526            }
527
528            // 4. Resolve top of stack (resolve_stack resolves one and gives priority)
529            self.with_shared_state_mutation(game, agents, |this, game, agents| {
530                this.resolve_stack(game, agents);
531            });
532        }
533    }
534    pub fn step_main_phase(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
535        self.step_with_priority(game, agents, true);
536    }
537
538    pub fn step_cleanup(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
539        // Rule 514.3a: If state-based actions are performed or triggers fire
540        // during Cleanup, players get priority and then a new Cleanup step
541        // begins. This mirrors Java's bRepeatCleanup / givePriorityToPlayer
542        // loop in PhaseHandler.java.
543        let mut is_repeat = false;
544        loop {
545            // On repeat iterations, re-notify the phase so that parity agents
546            // see the same snapshot Java emits when it re-enters onPhaseBegin(CLEANUP).
547            if is_repeat {
548                self.notify_phase_changed(game, agents);
549            }
550
551            let active = game.active_player();
552
553            // Rule 514.1: Discard down to max hand size.
554            // Reliquary Tower etc. set `unlimited_hand_size = true` via
555            // `SetMaxHandSize$ Unlimited` and skip the discard entirely.
556            let hand_size = game.zone(ZoneType::Hand, active).len() as i32;
557            let max = game.player(active).max_hand_size;
558            let unlimited = game.player(active).unlimited_hand_size;
559            if !unlimited && hand_size > max {
560                let to_discard = (hand_size - max) as usize;
561                let hand: Vec<CardId> = game.cards_in_zone(ZoneType::Hand, active).to_vec();
562                agents[active.index()].snapshot_state(game, &self.mana_pools);
563                self.game_log.log(
564                    GameLogEntryType::PriorityWaiting,
565                    2,
566                    format!(
567                        "Waiting for {} discard decision (choose {})",
568                        game.player(active).name,
569                        to_discard
570                    ),
571                );
572                let chosen = agents[active.index()].choose_discard(active, &hand, to_discard);
573                self.game_log.log(
574                    GameLogEntryType::PriorityResponse,
575                    2,
576                    format!(
577                        "{} selected {} card(s) to discard",
578                        game.player(active).name,
579                        chosen.len().min(to_discard)
580                    ),
581                );
582                for card_id in chosen.iter().take(to_discard) {
583                    if game.card(*card_id).zone == ZoneType::Hand {
584                        game.discard_card(
585                            *card_id,
586                            active,
587                            None,
588                            Some(agents),
589                            &mut self.trigger_handler,
590                        );
591                    }
592                }
593            }
594
595            // Rule 514.2: Remove damage from permanents and end "until end of
596            // turn" / "this turn" effects.  Java does this inside
597            // onPhaseBegin(CLEANUP) every iteration.
598            self.cleanup_damage_and_eot(game);
599
600            self.trigger_handler.flush_waiting_triggers(game);
601            if game.stack.is_empty() && self.trigger_handler.pre_matched_trigger_count() == 0 {
602                break;
603            }
604            self.step_with_priority(game, agents, false);
605            is_repeat = true;
606        }
607
608        // --- One-time end-of-turn resets (after the cleanup loop) ---
609
610        // Reset fog flag (issue #22: Fog effect lasts until end of turn).
611        game.prevent_all_combat_damage = false;
612
613        // Reset end-of-turn flags (issue #22).
614        game.end_turn_requested = false;
615        game.end_combat_requested = false;
616        game.extra_combat_phases = 0;
617        game.stack.reset_max_distinct_sources();
618
619        // Empty mana pool at end of turn (cleanup step), per Magic rules.
620        let active = game.active_player();
621        self.pool_mut(active).reset_pool();
622        self.trigger_handler.clear_this_turn_delayed_trigger();
623    }
624
625    /// Rule 514.2: Remove damage from permanents, expire "until end of turn"
626    /// effects, and clean up temporary state. Called every cleanup iteration
627    /// (including repeats per rule 514.3a), mirroring Java's onPhaseBegin(CLEANUP).
628    fn cleanup_damage_and_eot(&mut self, game: &mut GameState) {
629        // Remove temporary command-zone effect cards created by AB$ Effect
630        // that expire at end of turn.
631        let temp_effect_ids: Vec<CardId> = game
632            .cards
633            .iter()
634            .filter(|c| c.zone == ZoneType::Command && c.temp_effect_until_eot)
635            .map(|c| c.id)
636            .collect();
637        for effect_id in temp_effect_ids {
638            if game.card(effect_id).zone == ZoneType::Command {
639                let controller = game.card(effect_id).controller;
640                game.remove_card_from_zone(ZoneType::Command, controller, effect_id);
641                game.card_mut(effect_id).set_zone(ZoneType::None);
642            }
643        }
644
645        // Return stolen creatures (LoseControl$ EOT) to their original controllers
646        let stolen: Vec<(CardId, crate::ids::PlayerId)> = game
647            .cards
648            .iter()
649            .filter(|c| c.zone == ZoneType::Battlefield && c.original_controller_eot.is_some())
650            .map(|c| (c.id, c.original_controller_eot.unwrap()))
651            .collect();
652        for (card_id, original) in stolen {
653            let current = game.card(card_id).controller;
654            game.card_mut(card_id).clear_original_controller_eot();
655            // Remove granted keywords (e.g. Haste from Act of Aggression)
656            game.card_mut(card_id).clear_granted_keywords();
657            if current != original {
658                game.change_controller(card_id, original);
659            }
660        }
661
662        // Player-owned cleanup state that expires at end of turn.
663        let player_ids = game.player_order.clone();
664        for player in player_ids {
665            game.player_cleanup_turn_state(player);
666            crate::player::expire_end_of_turn_keywords(game, player);
667        }
668
669        for i in 0..game.cards.len() {
670            let card_id = game.cards[i].id;
671            crate::ability::effects::change_text_effect::run(game, card_id);
672        }
673
674        // Remove damage and reset until-end-of-turn effects on all battlefield permanents
675        for i in 0..game.cards.len() {
676            if game.cards[i].zone == ZoneType::Battlefield {
677                // Restore animate state before checking creature status (issue #52).
678                if let Some(state) = game.cards[i].animate_state.take() {
679                    game.cards[i].restore_animate_snapshot(
680                        state.original_type_line,
681                        state.original_base_power,
682                        state.original_base_toughness,
683                        state.original_color,
684                    );
685                    game.cards[i].clear_damage();
686                }
687                if game.cards[i]
688                    .clone_state
689                    .as_ref()
690                    .is_some_and(|state| state.expires_at_cleanup)
691                {
692                    if let Some(state) = game.cards[i].clone_state.take() {
693                        game.cards[i].restore_clone_snapshot(state);
694                    }
695                }
696
697                game.cards[i].reset_turn_modifiers();
698                game.cards[i].cant_have_keywords.clear();
699                game.cards[i].clear_pump_keywords();
700                game.cards[i].clear_pump_triggers();
701                game.cards[i].clear_deathtouch_damage();
702                game.cards[i].reset_regeneration_shields();
703                game.cards[i].reset_shield_count();
704                game.cards[i].reset_crewed();
705
706                if game.cards[i].is_creature() {
707                    let keep_damage =
708                        crate::staticability::static_ability_no_cleanup_damage::damage_not_removed(
709                            &game.cards,
710                            &game.cards[i],
711                        );
712                    if !keep_damage {
713                        game.cards[i].clear_damage();
714                    }
715                    game.cards[i].reset_turn_modifiers();
716                    game.cards[i].clear_deathtouch_damage();
717                    game.cards[i].reset_regeneration_shields();
718                    game.cards[i].reset_shield_count();
719                    game.cards[i].damage_history.new_turn();
720                }
721                // Effects with "until end of turn" duration end at cleanup
722                // (CR 514.2). Pump keywords and "can't have" tags apply to
723                // ANY permanent (e.g. Heroic Intervention grants hexproof to
724                // creatures AND artifacts/lands), so the clear must run on
725                // every battlefield card, not just creatures. Without this,
726                // an artifact like Lightning Greaves keeps Heroic
727                // Intervention's hexproof past the turn it was cast.
728                game.cards[i].cant_have_keywords.clear();
729                game.cards[i].clear_pump_keywords();
730                game.cards[i].clear_pump_triggers();
731            } else {
732                game.cards[i].cant_have_keywords.clear();
733                game.cards[i].clear_pump_keywords();
734                game.cards[i].clear_pump_triggers();
735            }
736        }
737    }
738    pub(crate) fn emit_phase_trigger(&mut self, game: &mut GameState, phase: PhaseType) {
739        let active = game.active_player();
740        self.trigger_handler.run_trigger(
741            TriggerType::Phase,
742            RunParams {
743                phase: Some(phase),
744                player: Some(active),
745                ..Default::default()
746            },
747            false,
748        );
749        self.trigger_handler.run_trigger(
750            TriggerType::Always,
751            RunParams {
752                phase: Some(phase),
753                player: Some(active),
754                ..Default::default()
755            },
756            false,
757        );
758    }
759
760    fn clear_came_under_control_since_last_upkeep(&mut self, game: &mut GameState) {
761        let active = game.active_player();
762        let turn_number = game.turn.turn_number;
763        for card in &mut game.cards {
764            if card.controller == active
765                && card.zone == ZoneType::Battlefield
766                && card.turn_in_zone < turn_number
767            {
768                card.came_under_control_since_last_upkeep = false;
769            }
770        }
771    }
772
773    /// Fire DamageDone and LifeGained triggers from combat damage events.
774    /// DamageDone fires per source-target pair; DamageDoneOnce fires once per
775    /// target with aggregated damage (mirrors Java CardDamageMap.triggerDamageOnce).
776    pub(crate) fn fire_combat_damage_triggers(&mut self, events: &[combat::CombatDamageEvent]) {
777        use crate::card::card_damage_map::{CardDamageMap, DamageTarget};
778        use crate::ids::{CardId, PlayerId};
779        use std::collections::HashMap;
780
781        // Per-event: fire DamageDone and LifeGained
782        for event in events {
783            self.trigger_handler.run_trigger(
784                TriggerType::DamageDone,
785                RunParams {
786                    damage_source: Some(event.source),
787                    damage_target_player: event.target_player,
788                    damage_target_card: event.target_card,
789                    damage_amount: Some(event.amount),
790                    is_combat_damage: Some(event.is_combat),
791                    ..Default::default()
792                },
793                false,
794            );
795            if let Some(player) = event.lifelink_player {
796                if event.lifelink_amount > 0 {
797                    self.trigger_handler.run_trigger(
798                        TriggerType::LifeGained,
799                        RunParams {
800                            player: Some(player),
801                            life_amount: Some(event.lifelink_amount),
802                            source_card: Some(event.source),
803                            ..Default::default()
804                        },
805                        false,
806                    );
807                }
808            }
809        }
810
811        // Aggregate damage by target, then fire DamageDoneOnce once per target.
812        // Mirrors Java's CardDamageMap.triggerDamageOnce() which batches damage
813        // so Enrage (e.g. Raptor Hatchling) fires only once per damage step.
814        #[derive(Hash, PartialEq, Eq, Clone, Copy)]
815        enum Target {
816            Card(CardId),
817            Player(PlayerId),
818        }
819        let mut by_target: HashMap<Target, i32> = HashMap::new();
820        let mut maps_by_target: HashMap<Target, CardDamageMap> = HashMap::new();
821        for event in events {
822            if event.amount <= 0 {
823                continue;
824            }
825            let target = if let Some(cid) = event.target_card {
826                Target::Card(cid)
827            } else if let Some(pid) = event.target_player {
828                Target::Player(pid)
829            } else {
830                continue;
831            };
832            *by_target.entry(target).or_insert(0) += event.amount;
833            let damage_target = match target {
834                Target::Card(cid) => DamageTarget::Card(cid),
835                Target::Player(pid) => DamageTarget::Player(pid),
836            };
837            maps_by_target.entry(target).or_default().put(
838                event.source,
839                damage_target,
840                event.amount,
841            );
842        }
843        for (target, total) in by_target {
844            if total <= 0 {
845                continue;
846            }
847            let params = match target {
848                Target::Card(cid) => RunParams {
849                    damage_target_card: Some(cid),
850                    damage_amount: Some(total),
851                    damage_map: maps_by_target.get(&target).cloned(),
852                    is_combat_damage: Some(true),
853                    ..Default::default()
854                },
855                Target::Player(pid) => RunParams {
856                    damage_target_player: Some(pid),
857                    damage_amount: Some(total),
858                    damage_map: maps_by_target.get(&target).cloned(),
859                    is_combat_damage: Some(true),
860                    ..Default::default()
861                },
862            };
863            self.trigger_handler
864                .run_trigger(TriggerType::DamageDoneOnce, params, false);
865        }
866    }
867
868    /// Suspend upkeep processing: for each suspended card in exile owned by the
869    /// active player, remove a time counter. If the last counter was removed,
870    /// cast the card for free (and grant haste if creature).
871    /// Mirrors Java's GameAction.handleSuspendTriggers().
872    pub(crate) fn process_suspend_upkeep(
873        &mut self,
874        game: &mut GameState,
875        agents: &mut [Box<dyn PlayerAgent>],
876    ) {
877        let active = game.active_player();
878        let exile: Vec<CardId> = game.cards_in_zone(ZoneType::Exile, active).to_vec();
879        for card_id in exile {
880            let card = game.card(card_id);
881            // Check if the card has suspend keyword and time counters
882            if card.get_suspend_cost().is_none() {
883                continue;
884            }
885            let time_counters = *card
886                .counters
887                .get(&crate::card::CounterType::Time)
888                .unwrap_or(&0);
889            if time_counters <= 0 {
890                continue;
891            }
892            // Remove one time counter
893            game.card_mut(card_id)
894                .remove_counter(&crate::card::CounterType::Time, 1);
895
896            // Emit CounterRemoved trigger
897            self.trigger_handler.run_trigger(
898                TriggerType::CounterRemoved,
899                RunParams {
900                    card: Some(card_id),
901                    player: Some(active),
902                    ..Default::default()
903                },
904                false,
905            );
906
907            let remaining = *game
908                .card(card_id)
909                .counters
910                .get(&crate::card::CounterType::Time)
911                .unwrap_or(&0);
912            if remaining <= 0 {
913                // Last counter removed — cast for free
914                let card_name = game.card(card_id).card_name.clone();
915                let is_creature = game.card(card_id).is_creature();
916                let is_permanent = game.card(card_id).is_permanent();
917                // Move from exile to stack
918                game.player_record_spell_cast(active, card_id);
919
920                // Emit SpellCast trigger
921                self.trigger_handler.run_trigger(
922                    TriggerType::SpellCast,
923                    RunParams {
924                        spell_card: Some(card_id),
925                        spell_controller: Some(active),
926                        ..Default::default()
927                    },
928                    false,
929                );
930
931                // Build SpellAbility
932                let mut sa =
933                    crate::spellability::build_spell_ability_for_card_cast(game, card_id, active);
934                sa.alt_cost = Some(crate::spellability::AlternativeCost::Suspend);
935                sa.setup_targets(game, agents, &self.mana_pools);
936
937                let entry = StackEntry {
938                    id: 0,
939                    spell_ability: sa,
940                    is_pending_cast: false,
941                    is_creature_spell: is_creature,
942                    is_permanent_spell: is_permanent,
943                    cast_from_zone: Some(ZoneType::Exile),
944                    optional_trigger_decider: None,
945                    optional_trigger_description: None,
946                    optional_trigger_source_name: None,
947                };
948
949                game.stack.push(entry);
950                self.log_stack_push(&card_name, &game.player(active).name);
951                self.move_card_with_runtime(game, card_id, ZoneType::Stack, active, agents);
952                crate::agent::notify_all_agents(
953                    agents,
954                    crate::agent::GameLogEvent::stack(format!(
955                        "Suspend: casting {} for free!",
956                        card_name
957                    ))
958                    .with_player(active)
959                    .with_card(card_id),
960                );
961
962                // Grant haste if creature (suspend creatures get haste)
963                if is_creature && !game.card(card_id).has_haste() {
964                    game.card_mut(card_id).granted_keywords.add("Haste");
965                }
966            }
967        }
968    }
969}