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