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                    let extra_turn_player = game.extra_turns.front().map(|et| et.player);
331                    if let Some((player, _skip)) =
332                        game.turn.advance_turn(&mut game.extra_turns, &player_order)
333                    {
334                        game.player_set_skip_untap(player);
335                    }
336                    if let Some(player) = extra_turn_player {
337                        if agents[player.index()]
338                            .get_pass_until()
339                            .is_some_and(|target| target.player != player)
340                        {
341                            agents[player.index()].clear_pass_until();
342                        }
343                    }
344                });
345            }
346        }
347    }
348
349    pub fn step_untap(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
350        let active = game.active_player();
351        let cards_snapshot = game.cards.clone();
352
353        // Delegate phasing to the phase module.
354        crate::phase::untap::do_phasing(game, active);
355
356        // Untap permanents — uses agent interaction for "may choose not to untap".
357        let cards: Vec<crate::ids::CardId> =
358            game.cards_in_zone(ZoneType::Battlefield, active).to_vec();
359        for cid in cards {
360            if game.card(cid).exerted {
361                game.card_mut(cid).clear_exerted();
362            } else {
363                let optional_keyword =
364                    "You may choose not to untap CARDNAME during your untap step.";
365                let should_untap = if game.card(cid).has_keyword(optional_keyword) {
366                    let question = format!("Untap {}?", game.card(cid).card_name);
367                    let source_name = game.card(cid).card_name.clone();
368                    agents[active.index()].choose_binary(
369                        active,
370                        &question,
371                        crate::agent::BinaryChoiceKind::UntapOrLeaveTapped,
372                        Some(true),
373                        Some(cid),
374                        None,
375                    )
376                } else {
377                    true
378                };
379                if should_untap {
380                    game.untap_during_untap_step(cid, active);
381                }
382            }
383        }
384
385        // Java parity: untap permanents on other players' battlefields that
386        // have an UntapOtherPlayer static ability applying during the active
387        // player's untap step, e.g. Bender's Waterskin.
388        let other_battlefield_cards: Vec<_> = game
389            .player_order
390            .iter()
391            .copied()
392            .filter(|&pid| pid != active)
393            .flat_map(|pid| {
394                game.cards_in_zone(ZoneType::Battlefield, pid)
395                    .iter()
396                    .copied()
397            })
398            .collect();
399        for cid in other_battlefield_cards {
400            let card = game.card(cid);
401            if !card.tapped {
402                continue;
403            }
404            if crate::staticability::static_ability_untap_other_player::untap(
405                &cards_snapshot,
406                card,
407                active,
408            ) {
409                game.untap_during_untap_step(cid, active);
410            }
411        }
412
413        self.pool_mut(active).reset_pool();
414    }
415
416    pub fn step_draw(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
417        let active = game.active_player();
418        // Skip draw on turn 1
419        if game.turn.turn_number > 1 {
420            use crate::replacement::replacement_handler::{
421                apply_replacements_with_agents, ReplacementEvent,
422            };
423            use crate::replacement::ReplacementResult;
424
425            // Dredge is now handled through the replacement effect system:
426            // Dredge:N keywords generate a Draw replacement effect during card
427            // assembly (card_factory_util::add_dredge_replacement). When the
428            // draw fires ReplacementEvent::Draw inside player_draw_one_internal,
429            // the replacement handler detects the Dredge replacement on the
430            // graveyard card, calls confirm_replacement_effect (Optional$ True),
431            // and executes the mill + return in replace_draw::execute.
432            let mut draw_count = 1;
433            let mut draw_cards_event = ReplacementEvent::DrawCards {
434                player: active,
435                count: draw_count,
436            };
437            let result = apply_replacements_with_agents(game, agents, &mut draw_cards_event);
438            if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
439                return;
440            }
441            if let ReplacementEvent::DrawCards { count, .. } = draw_cards_event {
442                draw_count = count;
443            }
444
445            for draw_index in 0..draw_count {
446                if let Some(card_id) =
447                    game.player_draw_one_internal(active, draw_index == 0, Some(agents))
448                {
449                    let drawn_snapshot = game.player(active).drawn_this_turn;
450                    // Fire Drawn trigger for turn draw
451                    self.trigger_handler.run_trigger(
452                        TriggerType::Drawn,
453                        RunParams {
454                            card: Some(card_id),
455                            player: Some(active),
456                            drawn_this_turn_snapshot: Some(drawn_snapshot),
457                            ..Default::default()
458                        },
459                        false,
460                    );
461                    // Match Drawn triggers immediately so Number$ N checks see
462                    // the correct drawn count at fire time.
463                    if self.trigger_handler.has_number_drawn_triggers(game) {
464                        self.trigger_handler.flush_waiting_triggers(game);
465                    }
466                }
467            }
468        }
469    }
470
471    pub fn step_with_priority(
472        &mut self,
473        game: &mut GameState,
474        agents: &mut [Box<dyn PlayerAgent>],
475        is_main_phase: bool,
476    ) {
477        let _perf_scope =
478            crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Priority);
479        self.game_log.log(
480            GameLogEntryType::Info,
481            2,
482            format!(
483                "Priority window opened ({}, stack depth: {})",
484                if is_main_phase {
485                    "main-phase speed"
486                } else {
487                    "instant speed only"
488                },
489                game.stack.len()
490            ),
491        );
492        self.with_shared_state_mutation(game, agents, |_this, game, _agents| {
493            game.turn.priority_player = game.active_player();
494        });
495        loop {
496            if game.game_over {
497                return;
498            }
499
500            // 1. Give players priority (process_triggers + SBA are handled
501            //    inside priority_round at the top of each iteration, matching
502            //    Java's checkStateBasedEffects() → addAllTriggeredAbilitiesToStack()
503            //    before chooseSpellAbilityToPlay()).
504            self.priority_round(game, agents, is_main_phase);
505
506            if std::env::var("FORGE_STACK_TRACE").is_ok() {
507                let names: Vec<String> = game
508                    .stack
509                    .iter()
510                    .map(|entry| {
511                        entry
512                            .spell_ability
513                            .source
514                            .map(|cid| game.card(cid).card_name.clone())
515                            .unwrap_or_else(|| "<effect>".to_string())
516                    })
517                    .collect();
518                eprintln!(
519                    "[stack-trace] AFTER priority_round phase={:?} active={:?} priority={:?} depth={} {:?}",
520                    game.turn.phase,
521                    game.active_player(),
522                    game.turn.priority_player,
523                    game.stack.len(),
524                    names
525                );
526            }
527
528            if game.game_over {
529                return;
530            }
531
532            // 3. If stack is empty after everyone passed, the phase ends
533            if game.stack.is_empty() {
534                break;
535            }
536
537            // 4. Resolve top of stack (resolve_stack resolves one and gives priority)
538            self.with_shared_state_mutation(game, agents, |this, game, agents| {
539                this.resolve_stack(game, agents);
540            });
541        }
542    }
543    pub fn step_main_phase(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
544        self.step_with_priority(game, agents, true);
545    }
546
547    pub fn step_cleanup(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
548        // Rule 514.3a: If state-based actions are performed or triggers fire
549        // during Cleanup, players get priority and then a new Cleanup step
550        // begins. This mirrors Java's bRepeatCleanup / givePriorityToPlayer
551        // loop in PhaseHandler.java.
552        let mut is_repeat = false;
553        loop {
554            // On repeat iterations, re-notify the phase so that parity agents
555            // see the same snapshot Java emits when it re-enters onPhaseBegin(CLEANUP).
556            if is_repeat {
557                self.notify_phase_changed(game, agents);
558            }
559
560            let active = game.active_player();
561
562            // Rule 514.1: Discard down to max hand size.
563            // Reliquary Tower etc. set `unlimited_hand_size = true` via
564            // `SetMaxHandSize$ Unlimited` and skip the discard entirely.
565            let hand_size = game.zone(ZoneType::Hand, active).len() as i32;
566            let max = game.player(active).max_hand_size;
567            let unlimited = game.player(active).unlimited_hand_size;
568            if !unlimited && hand_size > max {
569                let to_discard = (hand_size - max) as usize;
570                let hand: Vec<CardId> = game.cards_in_zone(ZoneType::Hand, active).to_vec();
571                agents[active.index()].snapshot_state(game, &self.mana_pools);
572                self.game_log.log(
573                    GameLogEntryType::PriorityWaiting,
574                    2,
575                    format!(
576                        "Waiting for {} discard decision (choose {})",
577                        game.player(active).name,
578                        to_discard
579                    ),
580                );
581                let chosen = agents[active.index()].choose_discard(active, &hand, to_discard);
582                self.game_log.log(
583                    GameLogEntryType::PriorityResponse,
584                    2,
585                    format!(
586                        "{} selected {} card(s) to discard",
587                        game.player(active).name,
588                        chosen.len().min(to_discard)
589                    ),
590                );
591                for card_id in chosen.iter().take(to_discard) {
592                    if game.card(*card_id).zone == ZoneType::Hand {
593                        game.discard_card(
594                            *card_id,
595                            active,
596                            None,
597                            Some(agents),
598                            &mut self.trigger_handler,
599                        );
600                    }
601                }
602            }
603
604            // Rule 514.2: Remove damage from permanents and end "until end of
605            // turn" / "this turn" effects.  Java does this inside
606            // onPhaseBegin(CLEANUP) every iteration.
607            self.cleanup_damage_and_eot(game);
608
609            self.trigger_handler.flush_waiting_triggers(game);
610            if game.stack.is_empty() && self.trigger_handler.pre_matched_trigger_count() == 0 {
611                break;
612            }
613            self.step_with_priority(game, agents, false);
614            is_repeat = true;
615        }
616
617        // --- One-time end-of-turn resets (after the cleanup loop) ---
618
619        // Reset fog flag (issue #22: Fog effect lasts until end of turn).
620        game.prevent_all_combat_damage = false;
621
622        // Reset end-of-turn flags (issue #22).
623        game.end_turn_requested = false;
624        game.end_combat_requested = false;
625        game.extra_combat_phases = 0;
626        game.stack.reset_max_distinct_sources();
627
628        // Empty mana pool at end of turn (cleanup step), per Magic rules.
629        let active = game.active_player();
630        self.pool_mut(active).reset_pool();
631        self.trigger_handler.clear_this_turn_delayed_trigger();
632    }
633
634    /// Rule 514.2: Remove damage from permanents, expire "until end of turn"
635    /// effects, and clean up temporary state. Called every cleanup iteration
636    /// (including repeats per rule 514.3a), mirroring Java's onPhaseBegin(CLEANUP).
637    fn cleanup_damage_and_eot(&mut self, game: &mut GameState) {
638        // Remove temporary command-zone effect cards created by AB$ Effect
639        // that expire at end of turn.
640        let temp_effect_ids: Vec<CardId> = game
641            .cards
642            .iter()
643            .filter(|c| c.zone == ZoneType::Command && c.temp_effect_until_eot)
644            .map(|c| c.id)
645            .collect();
646        for effect_id in temp_effect_ids {
647            if game.card(effect_id).zone == ZoneType::Command {
648                let controller = game.card(effect_id).controller;
649                game.remove_card_from_zone(ZoneType::Command, controller, effect_id);
650                game.card_mut(effect_id).set_zone(ZoneType::None);
651            }
652        }
653
654        // Return stolen creatures (LoseControl$ EOT) to their original controllers
655        let stolen: Vec<(CardId, crate::ids::PlayerId)> = game
656            .cards
657            .iter()
658            .filter(|c| c.zone == ZoneType::Battlefield && c.original_controller_eot.is_some())
659            .map(|c| (c.id, c.original_controller_eot.unwrap()))
660            .collect();
661        for (card_id, original) in stolen {
662            let current = game.card(card_id).controller;
663            game.card_mut(card_id).clear_original_controller_eot();
664            // Remove granted keywords (e.g. Haste from Act of Aggression)
665            game.card_mut(card_id).clear_granted_keywords();
666            if current != original {
667                game.change_controller(card_id, original);
668            }
669        }
670
671        // Player-owned cleanup state that expires at end of turn.
672        let player_ids = game.player_order.clone();
673        for player in player_ids {
674            game.player_cleanup_turn_state(player);
675            crate::player::expire_end_of_turn_keywords(game, player);
676        }
677
678        for i in 0..game.cards.len() {
679            let card_id = game.cards[i].id;
680            crate::ability::effects::change_text_effect::run(game, card_id);
681        }
682
683        // Remove damage and reset until-end-of-turn effects on all battlefield permanents
684        for i in 0..game.cards.len() {
685            if game.cards[i].zone == ZoneType::Battlefield {
686                // Restore animate state before checking creature status (issue #52).
687                if let Some(state) = game.cards[i].animate_state.take() {
688                    game.cards[i].restore_animate_snapshot(
689                        state.original_type_line,
690                        state.original_base_power,
691                        state.original_base_toughness,
692                        state.original_color,
693                    );
694                    game.cards[i].clear_damage();
695                }
696                if game.cards[i]
697                    .clone_state
698                    .as_ref()
699                    .is_some_and(|state| state.expires_at_cleanup)
700                {
701                    if let Some(state) = game.cards[i].clone_state.take() {
702                        game.cards[i].restore_clone_snapshot(state);
703                    }
704                }
705
706                game.cards[i].reset_turn_modifiers();
707                game.cards[i].cant_have_keywords.clear();
708                game.cards[i].clear_pump_keywords();
709                game.cards[i].clear_pump_triggers();
710                game.cards[i].clear_deathtouch_damage();
711                game.cards[i].reset_regeneration_shields();
712                game.cards[i].reset_shield_count();
713                game.cards[i].reset_crewed();
714
715                if game.cards[i].is_creature() {
716                    let keep_damage =
717                        crate::staticability::static_ability_no_cleanup_damage::damage_not_removed(
718                            &game.cards,
719                            &game.cards[i],
720                        );
721                    if !keep_damage {
722                        game.cards[i].clear_damage();
723                    }
724                    game.cards[i].reset_turn_modifiers();
725                    game.cards[i].clear_deathtouch_damage();
726                    game.cards[i].reset_regeneration_shields();
727                    game.cards[i].reset_shield_count();
728                    game.cards[i].damage_history.new_turn();
729                }
730                // Effects with "until end of turn" duration end at cleanup
731                // (CR 514.2). Pump keywords and "can't have" tags apply to
732                // ANY permanent (e.g. Heroic Intervention grants hexproof to
733                // creatures AND artifacts/lands), so the clear must run on
734                // every battlefield card, not just creatures. Without this,
735                // an artifact like Lightning Greaves keeps Heroic
736                // Intervention's hexproof past the turn it was cast.
737                game.cards[i].cant_have_keywords.clear();
738                game.cards[i].clear_pump_keywords();
739                game.cards[i].clear_pump_triggers();
740            } else {
741                game.cards[i].cant_have_keywords.clear();
742                game.cards[i].clear_pump_keywords();
743                game.cards[i].clear_pump_triggers();
744            }
745        }
746    }
747    pub(crate) fn emit_phase_trigger(&mut self, game: &mut GameState, phase: PhaseType) {
748        let active = game.active_player();
749        self.trigger_handler.run_trigger(
750            TriggerType::Phase,
751            RunParams {
752                phase: Some(phase),
753                player: Some(active),
754                ..Default::default()
755            },
756            false,
757        );
758        self.trigger_handler.run_trigger(
759            TriggerType::Always,
760            RunParams {
761                phase: Some(phase),
762                player: Some(active),
763                ..Default::default()
764            },
765            false,
766        );
767    }
768
769    fn clear_came_under_control_since_last_upkeep(&mut self, game: &mut GameState) {
770        let active = game.active_player();
771        let turn_number = game.turn.turn_number;
772        for card in &mut game.cards {
773            if card.controller == active
774                && card.zone == ZoneType::Battlefield
775                && card.turn_in_zone < turn_number
776            {
777                card.came_under_control_since_last_upkeep = false;
778            }
779        }
780    }
781
782    /// Fire DamageDone and LifeGained triggers from combat damage events.
783    /// DamageDone fires per source-target pair; DamageDoneOnce fires once per
784    /// target with aggregated damage (mirrors Java CardDamageMap.triggerDamageOnce).
785    pub(crate) fn fire_combat_damage_triggers(&mut self, events: &[combat::CombatDamageEvent]) {
786        use crate::card::card_damage_map::{CardDamageMap, DamageTarget};
787        use crate::ids::{CardId, PlayerId};
788        use std::collections::HashMap;
789
790        // Per-event: fire DamageDone and LifeGained
791        for event in events {
792            self.trigger_handler.run_trigger(
793                TriggerType::DamageDone,
794                RunParams {
795                    damage_source: Some(event.source),
796                    damage_target_player: event.target_player,
797                    damage_target_card: event.target_card,
798                    damage_amount: Some(event.amount),
799                    is_combat_damage: Some(event.is_combat),
800                    ..Default::default()
801                },
802                false,
803            );
804            if let Some(player) = event.lifelink_player {
805                if event.lifelink_amount > 0 {
806                    self.trigger_handler.run_trigger(
807                        TriggerType::LifeGained,
808                        RunParams {
809                            player: Some(player),
810                            life_amount: Some(event.lifelink_amount),
811                            source_card: Some(event.source),
812                            ..Default::default()
813                        },
814                        false,
815                    );
816                }
817            }
818        }
819
820        // Aggregate damage by target, then fire DamageDoneOnce once per target.
821        // Mirrors Java's CardDamageMap.triggerDamageOnce() which batches damage
822        // so Enrage (e.g. Raptor Hatchling) fires only once per damage step.
823        #[derive(Hash, PartialEq, Eq, Clone, Copy)]
824        enum Target {
825            Card(CardId),
826            Player(PlayerId),
827        }
828        let mut by_target: HashMap<Target, i32> = HashMap::new();
829        let mut maps_by_target: HashMap<Target, CardDamageMap> = HashMap::new();
830        for event in events {
831            if event.amount <= 0 {
832                continue;
833            }
834            let target = if let Some(cid) = event.target_card {
835                Target::Card(cid)
836            } else if let Some(pid) = event.target_player {
837                Target::Player(pid)
838            } else {
839                continue;
840            };
841            *by_target.entry(target).or_insert(0) += event.amount;
842            let damage_target = match target {
843                Target::Card(cid) => DamageTarget::Card(cid),
844                Target::Player(pid) => DamageTarget::Player(pid),
845            };
846            maps_by_target.entry(target).or_default().put(
847                event.source,
848                damage_target,
849                event.amount,
850            );
851        }
852        for (target, total) in by_target {
853            if total <= 0 {
854                continue;
855            }
856            let params = match target {
857                Target::Card(cid) => RunParams {
858                    damage_target_card: Some(cid),
859                    damage_amount: Some(total),
860                    damage_map: maps_by_target.get(&target).cloned(),
861                    is_combat_damage: Some(true),
862                    ..Default::default()
863                },
864                Target::Player(pid) => RunParams {
865                    damage_target_player: Some(pid),
866                    damage_amount: Some(total),
867                    damage_map: maps_by_target.get(&target).cloned(),
868                    is_combat_damage: Some(true),
869                    ..Default::default()
870                },
871            };
872            self.trigger_handler
873                .run_trigger(TriggerType::DamageDoneOnce, params, false);
874        }
875    }
876
877    /// Suspend upkeep processing: for each suspended card in exile owned by the
878    /// active player, remove a time counter. If the last counter was removed,
879    /// cast the card for free (and grant haste if creature).
880    /// Mirrors Java's GameAction.handleSuspendTriggers().
881    pub(crate) fn process_suspend_upkeep(
882        &mut self,
883        game: &mut GameState,
884        agents: &mut [Box<dyn PlayerAgent>],
885    ) {
886        let active = game.active_player();
887        let exile: Vec<CardId> = game.cards_in_zone(ZoneType::Exile, active).to_vec();
888        for card_id in exile {
889            let card = game.card(card_id);
890            // Check if the card has suspend keyword and time counters
891            if card.get_suspend_cost().is_none() {
892                continue;
893            }
894            let time_counters = *card
895                .counters
896                .get(&crate::card::CounterType::Time)
897                .unwrap_or(&0);
898            if time_counters <= 0 {
899                continue;
900            }
901            // Remove one time counter
902            game.card_mut(card_id)
903                .remove_counter(&crate::card::CounterType::Time, 1);
904
905            // Emit CounterRemoved trigger
906            self.trigger_handler.run_trigger(
907                TriggerType::CounterRemoved,
908                RunParams {
909                    card: Some(card_id),
910                    player: Some(active),
911                    ..Default::default()
912                },
913                false,
914            );
915
916            let remaining = *game
917                .card(card_id)
918                .counters
919                .get(&crate::card::CounterType::Time)
920                .unwrap_or(&0);
921            if remaining <= 0 {
922                // Last counter removed — cast for free
923                let card_name = game.card(card_id).card_name.clone();
924                let is_creature = game.card(card_id).is_creature();
925                let is_permanent = game.card(card_id).is_permanent();
926                // Move from exile to stack
927                game.player_record_spell_cast(active, card_id);
928
929                // Emit SpellCast trigger
930                self.trigger_handler.run_trigger(
931                    TriggerType::SpellCast,
932                    RunParams {
933                        spell_card: Some(card_id),
934                        spell_controller: Some(active),
935                        ..Default::default()
936                    },
937                    false,
938                );
939
940                // Build SpellAbility
941                let mut sa =
942                    crate::spellability::build_spell_ability_for_card_cast(game, card_id, active);
943                sa.alt_cost = Some(crate::spellability::AlternativeCost::Suspend);
944                sa.setup_targets(game, agents, &self.mana_pools);
945
946                let entry = StackEntry {
947                    id: 0,
948                    spell_ability: sa,
949                    is_pending_cast: false,
950                    is_creature_spell: is_creature,
951                    is_permanent_spell: is_permanent,
952                    cast_from_zone: Some(ZoneType::Exile),
953                    optional_trigger_decider: None,
954                    optional_trigger_description: None,
955                    optional_trigger_source_name: None,
956                };
957
958                game.stack.push(entry);
959                self.log_stack_push(&card_name, &game.player(active).name);
960                self.move_card_with_runtime(game, card_id, ZoneType::Stack, active, agents);
961                crate::agent::notify_all_agents(
962                    agents,
963                    crate::agent::GameLogEvent::stack(format!(
964                        "Suspend: casting {} for free!",
965                        card_name
966                    ))
967                    .with_player(active)
968                    .with_card(card_id),
969                );
970
971                // Grant haste if creature (suspend creatures get haste)
972                if is_creature && !game.card(card_id).has_haste() {
973                    game.card_mut(card_id).granted_keywords.add("Haste");
974                }
975            }
976        }
977    }
978}