Skip to main content

manabrew_engine/
action.rs

1use forge_foundation::ZoneType;
2
3use crate::agent::PlayerAgent;
4use crate::card::{Card, CounterType};
5use crate::event::RunParams;
6use crate::game::GameState;
7use crate::ids::{CardId, PlayerId};
8use crate::replacement::replacement_handler::{
9    apply_replacements, apply_replacements_with_agents, ReplacementEvent, ReplacementRuntime,
10};
11use crate::replacement::GameLossReason;
12use crate::replacement::ReplacementResult;
13use crate::staticability::layer::{apply_continuous_effects, apply_etb_tapped_with_agents};
14use crate::trigger::handler::TriggerHandler;
15use crate::trigger::TriggerType;
16
17/// Game state mutation methods — moving cards, dealing damage, state-based actions.
18impl GameState {
19    pub fn record_player_damage_assignment(
20        &mut self,
21        source: Option<CardId>,
22        target_player: Option<PlayerId>,
23        amount: i32,
24        is_combat: bool,
25    ) {
26        self.player_record_damage_assignment(source, target_player, amount, is_combat);
27    }
28
29    /// Move a card from its current zone to a new zone.
30    /// Move a card to a new zone. For Graveyard destinations, checks for zone-redirect
31    /// replacement effects (Rest in Peace, Leyline of the Void) and redirects to the
32    /// correct zone. Use `move_card_final` to skip the replacement check.
33    pub fn move_card(&mut self, card_id: CardId, dest_zone: ZoneType, dest_owner: PlayerId) {
34        self.move_card_internal(card_id, dest_zone, dest_owner, None, None, true, false);
35    }
36
37    pub fn move_card_with_agents(
38        &mut self,
39        card_id: CardId,
40        dest_zone: ZoneType,
41        dest_owner: PlayerId,
42        agents: &mut [Box<dyn PlayerAgent>],
43    ) {
44        self.move_card_internal(
45            card_id,
46            dest_zone,
47            dest_owner,
48            Some(agents),
49            None,
50            true,
51            false,
52        );
53    }
54
55    pub fn move_card_with_agents_and_replacement_runtime(
56        &mut self,
57        card_id: CardId,
58        dest_zone: ZoneType,
59        dest_owner: PlayerId,
60        agents: &mut [Box<dyn PlayerAgent>],
61        runtime: &mut ReplacementRuntime<'_>,
62    ) {
63        self.move_card_internal(
64            card_id,
65            dest_zone,
66            dest_owner,
67            Some(agents),
68            Some(runtime.trigger_handler),
69            true,
70            false,
71        );
72    }
73
74    fn move_card_without_replacement(
75        &mut self,
76        card_id: CardId,
77        dest_zone: ZoneType,
78        dest_owner: PlayerId,
79    ) {
80        self.move_card_internal(card_id, dest_zone, dest_owner, None, None, false, false);
81    }
82
83    /// Discard a card. Mirrors Java's `Player.discard()`.
84    ///
85    /// Records the discard, marks the card, and moves it to graveyard through
86    /// the normal zone-change machinery (which runs replacement effects like
87    /// Madness automatically). Fires Discarded triggers afterwards.
88    pub fn discard_card(
89        &mut self,
90        card_id: CardId,
91        discard_player: PlayerId,
92        sa: Option<&crate::spellability::SpellAbility>,
93        agents: Option<&mut [Box<dyn PlayerAgent>]>,
94        trigger_handler: &mut TriggerHandler,
95    ) {
96        let owner = self.card(card_id).owner;
97        self.player_record_discard(discard_player, 1);
98        self.card_mut(card_id).set_discarded(true);
99
100        // Move to graveyard through normal zone-change with is_discard=true.
101        // Replacement effects (e.g. Madness → Exile) are handled generically.
102        self.move_card_internal(
103            card_id,
104            ZoneType::Graveyard,
105            owner,
106            agents,
107            Some(trigger_handler),
108            true,
109            true, // is_discard
110        );
111
112        // RememberDiscarded
113        if let Some(sa) = sa {
114            if sa.ir.remember_discarded {
115                if let Some(source_id) = sa.source {
116                    self.card_mut(source_id).add_remembered_card(card_id);
117                }
118            }
119        }
120
121        // Register active triggers on the card in its new zone.
122        trigger_handler.register_active_trigger(self, card_id);
123
124        // Emit zone-change trigger for Hand → actual destination.
125        let dest_zone = self.card(card_id).zone;
126        crate::ability::effects::zone_triggers::emit_zone_trigger(
127            trigger_handler,
128            card_id,
129            ZoneType::Hand,
130            dest_zone,
131        );
132
133        // Fire Discarded trigger.
134        trigger_handler.run_trigger(
135            TriggerType::Discarded,
136            RunParams {
137                card: Some(card_id),
138                player: Some(discard_player),
139                ..Default::default()
140            },
141            false,
142        );
143        trigger_handler.run_trigger(
144            TriggerType::DiscardedAll,
145            RunParams {
146                card: Some(card_id),
147                cards: Some(vec![card_id]),
148                player: Some(discard_player),
149                ..Default::default()
150            },
151            false,
152        );
153    }
154
155    fn move_card_internal(
156        &mut self,
157        card_id: CardId,
158        dest_zone: ZoneType,
159        dest_owner: PlayerId,
160        mut agents: Option<&mut [Box<dyn PlayerAgent>]>,
161        mut trigger_handler: Option<&mut TriggerHandler>,
162        apply_move_replacement: bool,
163        is_discard: bool,
164    ) {
165        let (src_zone, src_owner, was_permanent, was_land, is_token) = {
166            let card = &self.cards[card_id.index()];
167            (
168                card.zone,
169                card.controller,
170                card.type_line.is_permanent(),
171                card.is_land(),
172                card.is_token,
173            )
174        };
175        if let Ok(filter) = std::env::var("FORGE_CARD_TRACE") {
176            if !filter.is_empty()
177                && self.cards[card_id.index()]
178                    .card_name
179                    .eq_ignore_ascii_case(&filter)
180            {
181                eprintln!(
182                    "[card-trace] move {} {:?} {:?} -> {:?} (owner={:?} sick={} cast_from={:?})",
183                    self.cards[card_id.index()].card_name,
184                    card_id,
185                    src_zone,
186                    dest_zone,
187                    dest_owner,
188                    self.cards[card_id.index()].summoning_sick,
189                    self.cards[card_id.index()].cast_from,
190                );
191            }
192        }
193        let mut etb_counters = std::collections::BTreeMap::new();
194        if dest_zone == ZoneType::Battlefield {
195            for keyword in self.cards[card_id.index()].keywords.as_string_list() {
196                let mut parts = keyword.split(':');
197                if !parts
198                    .next()
199                    .is_some_and(|head| head.eq_ignore_ascii_case("etbCounter"))
200                {
201                    continue;
202                }
203                let counter_type =
204                    crate::ability::effects::parse_counter_type(parts.next().unwrap_or_default());
205                let amount_text = parts.next().unwrap_or_default();
206                let amount = amount_text.parse::<i32>().unwrap_or_else(|_| {
207                    let card = &self.cards[card_id.index()];
208                    card.svars
209                        .get(amount_text)
210                        .map(|expression| {
211                            if matches!(expression.as_str(), "Count$xPaid" | "Count$XPaid") {
212                                card.svars
213                                    .get("XPaid")
214                                    .and_then(|value| value.parse().ok())
215                                    .unwrap_or(0)
216                            } else {
217                                expression.parse().unwrap_or_else(|_| {
218                                    crate::svar::resolve_count_svar(
219                                        expression, self, card_id, dest_owner,
220                                    )
221                                })
222                            }
223                        })
224                        .unwrap_or(0)
225                });
226                *etb_counters.entry(counter_type).or_default() += amount.max(0);
227            }
228            let card = &self.cards[card_id.index()];
229            if card.type_line.has_subtype("Saga") && card.has_chapter() {
230                let amount = if card.has_keyword("Read ahead") {
231                    agents
232                        .as_deref_mut()
233                        .and_then(|agents| {
234                            agents[dest_owner.index()].choose_number(
235                                dest_owner,
236                                Some(card_id),
237                                "How many lore counters?",
238                                Some("Choose a chapter and start with that many lore counters."),
239                                1,
240                                card.get_final_chapter_nr(),
241                            )
242                        })
243                        .unwrap_or(1)
244                        .clamp(1, card.get_final_chapter_nr())
245                } else {
246                    1
247                };
248                *etb_counters.entry(CounterType::Lore).or_default() += amount;
249            }
250            if card.type_line.is_planeswalker() {
251                let loyalty = card
252                    .initial_loyalty
253                    .as_deref()
254                    .and_then(|value| value.parse::<i32>().ok())
255                    .unwrap_or(0);
256                *etb_counters
257                    .entry(crate::card::CounterType::Loyalty)
258                    .or_default() += loyalty.max(0);
259            }
260            *etb_counters
261                .entry(crate::card::CounterType::P1P1)
262                .or_default() += card.etb_counters_p1p1.max(0);
263            let sunburst = card.sunburst_count();
264            if sunburst > 0 && card.has_keyword("Sunburst") {
265                let counter_type = if card.is_creature() {
266                    crate::card::CounterType::P1P1
267                } else {
268                    crate::card::CounterType::Charge
269                };
270                *etb_counters.entry(counter_type).or_default() += sunburst;
271            }
272            etb_counters.retain(|_, amount| *amount > 0);
273        }
274        let counter_cause = self.cards[card_id.index()].cast_sa.clone();
275        let counter_map = (!etb_counters.is_empty()).then(|| {
276            vec![crate::replacement::replacement_handler::CounterMapValue {
277                source: Some(dest_owner),
278                counters: etb_counters,
279            }]
280        });
281        let mut moved_event = ReplacementEvent::Moved {
282            card: card_id,
283            origin: src_zone,
284            destination: dest_zone,
285            is_discard,
286            counter_map,
287            counter_cause,
288            counter_is_effect: dest_zone == ZoneType::Battlefield,
289            after_replacement_static_abilities: Vec::new(),
290        };
291        let tapped_before_replacement = self.card(card_id).tapped;
292        if apply_move_replacement {
293            if let Some(agents) = agents.as_deref_mut() {
294                apply_replacements_with_agents(self, agents, &mut moved_event);
295            } else {
296                apply_replacements(self, &mut moved_event);
297            }
298        }
299        let (dest_zone, etb_counter_map, counter_cause, after_replacement_static_abilities) =
300            match moved_event {
301                ReplacementEvent::Moved {
302                    destination,
303                    counter_map,
304                    counter_cause,
305                    after_replacement_static_abilities,
306                    ..
307                } => (
308                    destination,
309                    counter_map,
310                    counter_cause,
311                    after_replacement_static_abilities,
312                ),
313                _ => (dest_zone, None, None, Vec::new()),
314            };
315        let replacement_marked_etb_tapped = dest_zone == ZoneType::Battlefield
316            && self.card(card_id).tapped
317            && !tapped_before_replacement;
318        let dest_owner = if dest_zone == ZoneType::Command {
319            self.card(card_id).owner
320        } else {
321            dest_owner
322        };
323        let host_left_battlefield =
324            src_zone == ZoneType::Battlefield && dest_zone != ZoneType::Battlefield;
325        if host_left_battlefield && was_permanent {
326            self.player_record_permanent_left_battlefield(src_owner);
327        }
328        // Java `Card.clearCastSA` — the cast-SA link dies once the instance
329        // leaves the battlefield (a new cast produces a fresh instance).
330        if host_left_battlefield {
331            self.card_mut(card_id).cast_sa = None;
332            // `ControlGain$ LoseControl$ LeavesPlay` — drop the scheduled
333            // revert since the card is no longer on the battlefield.
334            crate::ability::effects::control_gain_effect::leaves_play_hook(self, card_id);
335        }
336        if dest_zone == ZoneType::Graveyard && was_permanent && !is_token {
337            self.player_record_permanent_put_into_graveyard(self.card(card_id).owner);
338        }
339        let forget_effects: Vec<CardId> = self
340            .cards
341            .iter()
342            .filter(|c| {
343                c.zone == ZoneType::Command
344                    && c.forget_on_moved_origin == Some(src_zone)
345                    && c.remembered_cards.contains(&card_id)
346            })
347            .map(|c| c.id)
348            .collect();
349
350        // Tokens and copy-tokens cease to exist when leaving the battlefield (CR 110.5g).
351        // Set zone to None (limbo) and remove from source zone without adding to destination.
352        if is_token && dest_zone != ZoneType::Battlefield {
353            if let Some(table) = self.pending_change_zone_table.as_mut() {
354                table.put(Some(src_zone), Some(ZoneType::None), card_id);
355            }
356            let mut exile_effects = Vec::new();
357            for eff_id in forget_effects.iter().copied() {
358                let eff = &mut self.cards[eff_id.index()];
359                eff.remembered_cards.retain(|&rid| rid != card_id);
360                if eff.exile_when_no_remembered && eff.remembered_cards.is_empty() {
361                    exile_effects.push(eff_id);
362                }
363            }
364            self.cards[card_id.index()].zone = ZoneType::None;
365            if src_zone != ZoneType::None {
366                self.remove_card_from_zone(src_zone, src_owner, card_id);
367            }
368            // Effect cards with ForgetOnMoved should be removed from the game
369            // entirely (zone = None), not moved to Exile. Moving them to Exile
370            // creates phantom cards that diverge from Java parity.
371            for eff_id in exile_effects {
372                let controller = self.card(eff_id).controller;
373                self.remove_card_from_zone(ZoneType::Command, controller, eff_id);
374                self.cards[eff_id.index()].zone = ZoneType::None;
375            }
376            apply_continuous_effects(self);
377            debug_assert!(self.card_zone_location_matches_card(card_id));
378            return;
379        }
380
381        // Remove from source zone
382        if src_zone != ZoneType::None {
383            self.remove_card_from_zone(src_zone, src_owner, card_id);
384        }
385
386        if src_zone == ZoneType::Exile && dest_zone != ZoneType::Exile {
387            self.cards[card_id.index()]
388                .keywords
389                .retain(|kw| !kw.starts_with(crate::card::KEYWORD_PLOTTED_PREFIX));
390        }
391
392        // Update card's zone
393        self.cards[card_id.index()].zone = dest_zone;
394        if src_zone != dest_zone {
395            self.cards[card_id.index()].turn_in_zone = self.turn.turn_number;
396        }
397
398        if let Some(table) = self.pending_change_zone_table.as_mut() {
399            table.put(Some(src_zone), Some(dest_zone), card_id);
400        }
401
402        // Assign a zone timestamp so same-player triggers are ordered by
403        // zone entry order (matching Java's Zone.cardList insertion order).
404        if dest_zone != ZoneType::Stack {
405            self.assign_zone_timestamp(card_id);
406        }
407
408        // Track LKI: record which zone this card came from on the destination zone.
409        self.save_zone_lki(dest_zone, dest_owner, card_id, src_zone);
410
411        // Reset state on zone change
412        match dest_zone {
413            ZoneType::Battlefield => {
414                // A permanent enters under the destination player's control.
415                // This must be updated before ETB-trigger registration so
416                // triggered abilities inherit the correct controller.
417                self.cards[card_id.index()].controller = dest_owner;
418                self.cards[card_id.index()].enter_battlefield();
419                if replacement_marked_etb_tapped {
420                    self.cards[card_id.index()].set_tapped(true);
421                }
422                // Add to destination zone first so the card is "on the
423                // battlefield" when ETB-tapped checks run against it.
424                self.add_card_to_zone(dest_zone, dest_owner, card_id);
425                if was_land {
426                    self.player_record_landfall(dest_owner);
427                }
428                // Apply ETB-tapped effects (intrinsic + extrinsic). When the
429                // replacement chain already tapped this card it also already
430                // prompted the affected player to choose the applied effect,
431                // so neither the prompt nor the apply pass should fire again
432                // here — Java's flow runs the choose-and-apply step exactly
433                // once via the replacement chain.
434                if !replacement_marked_etb_tapped {
435                    apply_etb_tapped_with_agents(self, card_id, agents);
436                }
437                if let Some(handler) = trigger_handler.as_deref_mut() {
438                    handler.register_active_trigger(self, card_id);
439                }
440                if let Some(counter_map) = etb_counter_map {
441                    let table =
442                        crate::game_entity_counter_table::GameEntityCounterTable::from_counter_map(
443                            crate::agent::GameEntity::Card(card_id),
444                            counter_map,
445                        );
446                    table.apply_replaced_counter_effect(
447                        self,
448                        trigger_handler.as_deref_mut(),
449                        counter_cause.as_deref(),
450                        RunParams::default(),
451                    );
452                    for (source, static_abilities) in after_replacement_static_abilities {
453                        crate::replacement::replace_add_counter::apply_after_replacement_static_abilities(
454                            self,
455                            source,
456                            static_abilities,
457                        );
458                    }
459                }
460                self.cards[card_id.index()].etb_counters_p1p1 = 0;
461                // Update LKI snapshot: card just entered the battlefield.
462                // Ensures it's available for later TriggeredCard$CardPower lookups
463                // even if it dies within the same resolution chain.
464                self.update_lki_snapshot(card_id);
465                apply_continuous_effects(self);
466                debug_assert!(self.card_zone_location_matches_card(card_id));
467                return;
468            }
469            ZoneType::Graveyard | ZoneType::Hand | ZoneType::Exile | ZoneType::Library => {
470                // Detach any attachments before resetting state.
471                let attachments: Vec<CardId> = self.cards[card_id.index()].attachments.clone();
472                for aura_id in attachments {
473                    self.cards[aura_id.index()].attached_to = None;
474                    // Bestow: when host leaves, revert aura to creature
475                    self.cards[aura_id.index()].is_bestowed = false;
476                }
477                self.cards[card_id.index()].attachments.clear();
478                // Also detach this card from its host if it was an Aura/Equipment.
479                self.detach(card_id);
480
481                // Save last-known information before resetting.
482                // Mirrors Java's LKI system for trigger SVars like TriggeredCard$CardPower.
483                if src_zone == ZoneType::Battlefield {
484                    let card = &self.cards[card_id.index()];
485                    let lki_p = card.power();
486                    let lki_t = card.toughness();
487                    let card = &mut self.cards[card_id.index()];
488                    card.lki_power = Some(lki_p);
489                    card.lki_toughness = Some(lki_t);
490                }
491
492                // Reset battlefield state when leaving (including static modifiers).
493                let keep_counters =
494                    crate::staticability::static_ability_counters_remain::counters_remain(
495                        &self.cards,
496                        &self.cards[card_id.index()],
497                        dest_zone,
498                    );
499                let card = &mut self.cards[card_id.index()];
500                card.tapped = false;
501                card.damage = 0;
502                card.power_modifier = 0;
503                card.toughness_modifier = 0;
504                card.static_power_modifier = 0;
505                card.static_toughness_modifier = 0;
506                card.static_set_power = None;
507                card.static_set_toughness = None;
508                card.granted_keywords.clear();
509                if let Some(type_line) = card.static_type_line_base.take() {
510                    card.set_type_line(type_line);
511                }
512                card.static_added_subtypes.clear();
513                card.restore_changed_characteristics_baseline();
514                card.cant_attack_static = false;
515                card.cant_block_static = false;
516                card.summoning_sick = true;
517                card.monstrous = false;
518                card.controller = card.owner;
519                card.face_down = false;
520                card.is_bestowed = false;
521                // CR 400.7: a permanent that changes zones becomes a new
522                // object with no cast history. Mirrors Java's
523                // changeZone-creates-new-Card behaviour.
524                card.cast_from = None;
525                card.reset_crewed();
526                if !keep_counters {
527                    card.counters.clear();
528                }
529                // Clear temporary triggers added by Animate effects (e.g.
530                // Supernatural Stamina's "when this creature dies, return it").
531                // Per CR 400.7 a permanent that changes zones becomes a new
532                // object; it must not retain one-shot death-return triggers.
533                // Without this, a creature that dies-and-returns would still
534                // carry the trigger, making it "immortal" for the rest of the
535                // turn.
536                card.clear_pump_triggers();
537                card.clear_pump_keywords();
538                // Restore intrinsic keywords from the animate snapshot so
539                // Animate-granted keywords (e.g. Sneak Attack's `Keywords$
540                // Haste`) do not persist into the new object the card
541                // becomes when it changes zones (CR 400.7).
542                if let Some(state) = card.animate_state.take() {
543                    if let Some(orig_kws) = state.original_keywords {
544                        card.keywords = orig_kws;
545                        card.update_keywords();
546                    }
547                }
548                if let Some(state) = card.clone_state.take() {
549                    card.restore_clone_snapshot(state);
550                } else {
551                    card.remove_clone_states();
552                }
553            }
554            ZoneType::Command => {
555                // Detach any attachments before resetting state.
556                let attachments: Vec<CardId> = self.cards[card_id.index()].attachments.clone();
557                for aura_id in attachments {
558                    self.cards[aura_id.index()].attached_to = None;
559                }
560                self.cards[card_id.index()].attachments.clear();
561                self.detach(card_id);
562
563                // Commander returning to command zone: reset battlefield state.
564                let keep_counters =
565                    crate::staticability::static_ability_counters_remain::counters_remain(
566                        &self.cards,
567                        &self.cards[card_id.index()],
568                        dest_zone,
569                    );
570                let card = &mut self.cards[card_id.index()];
571                card.tapped = false;
572                card.damage = 0;
573                card.power_modifier = 0;
574                card.toughness_modifier = 0;
575                card.static_power_modifier = 0;
576                card.static_toughness_modifier = 0;
577                card.static_set_power = None;
578                card.static_set_toughness = None;
579                card.granted_keywords.clear();
580                if let Some(type_line) = card.static_type_line_base.take() {
581                    card.set_type_line(type_line);
582                }
583                card.static_added_subtypes.clear();
584                card.restore_changed_characteristics_baseline();
585                card.cant_attack_static = false;
586                card.cant_block_static = false;
587                card.summoning_sick = true;
588                card.monstrous = false;
589                card.controller = card.owner;
590                card.cast_from = None;
591                if !keep_counters {
592                    card.counters.clear();
593                }
594                if let Some(state) = card.clone_state.take() {
595                    card.restore_clone_snapshot(state);
596                } else {
597                    card.remove_clone_states();
598                }
599            }
600            _ => {}
601        }
602
603        // Add to destination zone
604        self.add_card_to_zone(dest_zone, dest_owner, card_id);
605
606        // Commander 903.9a tracking: once a commander enters graveyard or exile,
607        // SBA may offer moving it to the command zone exactly once.
608        let commander_entered_gy_or_exile = self.card(card_id).is_commander
609            && matches!(dest_zone, ZoneType::Graveyard | ZoneType::Exile);
610        self.cards[card_id.index()].move_to_command_zone = commander_entered_gy_or_exile;
611
612        // Forget remembered objects for command effects with ForgetOnMoved.
613        let mut exile_effects = Vec::new();
614        for eff_id in forget_effects {
615            let eff = &mut self.cards[eff_id.index()];
616            eff.remembered_cards.retain(|&rid| rid != card_id);
617            if eff.exile_when_no_remembered && eff.remembered_cards.is_empty() {
618                exile_effects.push(eff_id);
619            }
620        }
621        // Effect cards with ForgetOnMoved should be removed from the game
622        // entirely (zone = None), not moved to Exile.
623        for eff_id in exile_effects {
624            let controller = self.card(eff_id).controller;
625            self.remove_card_from_zone(ZoneType::Command, controller, eff_id);
626            self.cards[eff_id.index()].zone = ZoneType::None;
627        }
628
629        // Expire temporary effect cards linked to this host leaving play
630        // (Duration$ UntilHostLeavesPlay / UntilHostLeavesPlayOrEOT).
631        if host_left_battlefield {
632            let linked_effects: Vec<CardId> = self
633                .cards
634                .iter()
635                .filter(|c| c.zone == ZoneType::Command && c.temp_effect_host == Some(card_id))
636                .map(|c| c.id)
637                .collect();
638            for eff_id in linked_effects {
639                let controller = self.card(eff_id).controller;
640                self.remove_card_from_zone(ZoneType::Command, controller, eff_id);
641                self.cards[eff_id.index()].zone = ZoneType::None;
642            }
643
644            // Return cards exiled by this host via ChangeZoneAll Duration$ UntilHostLeavesPlay
645            // (e.g. Deputy of Detention: exiled permanents return when it leaves).
646            let exiled_by_host: Vec<(CardId, PlayerId)> = self
647                .cards
648                .iter()
649                .filter(|c| c.zone == ZoneType::Exile && c.exiled_by == Some(card_id))
650                .map(|c| (c.id, c.owner))
651                .collect();
652            for (exiled_id, owner) in exiled_by_host {
653                self.cards[exiled_id.index()].exiled_by = None;
654                self.move_card(exiled_id, ZoneType::Battlefield, owner);
655                if let Some(handler) = trigger_handler.as_deref_mut() {
656                    let returned_zone = self.card(exiled_id).zone;
657                    handler.register_active_trigger(self, exiled_id);
658                    crate::ability::effects::zone_triggers::emit_zone_trigger(
659                        handler,
660                        exiled_id,
661                        ZoneType::Exile,
662                        returned_zone,
663                    );
664                }
665            }
666        }
667
668        apply_continuous_effects(self);
669        debug_assert!(self.card_zone_location_matches_card(card_id));
670    }
671
672    /// Deal damage to a card (creature).
673    ///
674    /// Runs replacement effects (e.g. damage prevention) before applying.
675    /// Mirrors Java `GameAction.addDamage()` calling `ReplacementHandler.run()`.
676    pub fn deal_damage_to_card(&mut self, target: CardId, amount: i32) {
677        self.deal_damage_to_card_from(target, amount, None, false);
678    }
679
680    /// Deal damage to a card with source tracking for replacement effects.
681    pub fn deal_damage_to_card_from(
682        &mut self,
683        target: CardId,
684        amount: i32,
685        source: Option<CardId>,
686        is_combat: bool,
687    ) {
688        self.deal_damage_to_card_from_with_agents(target, amount, source, is_combat, None);
689    }
690
691    /// Deal damage to a card with source tracking and optional agents for RNG parity.
692    pub fn deal_damage_to_card_from_with_agents(
693        &mut self,
694        target: CardId,
695        amount: i32,
696        source: Option<CardId>,
697        is_combat: bool,
698        agents: Option<&mut [Box<dyn crate::agent::PlayerAgent>]>,
699    ) {
700        if amount <= 0 {
701            return;
702        }
703        if !self.card(target).can_be_dealt_damage() {
704            return;
705        }
706        let mut event = ReplacementEvent::DamageToCard {
707            target,
708            amount,
709            source,
710            is_combat,
711        };
712        if let Some(agents) = agents {
713            apply_replacements_with_agents(self, agents, &mut event);
714        } else {
715            apply_replacements(self, &mut event);
716        }
717        if let ReplacementEvent::DamageToCard {
718            amount: mut final_amount,
719            ..
720        } = event
721        {
722            // Consume PreventDamage shields. Each shield prevents 1 damage and
723            // is removed. Mirrors Java's per-shield ReplaceDamage effect cards
724            // in the Command zone, but using the legacy `damage_prevention`
725            // counter pending the proper Command-zone effect-card port.
726            let shields = self.cards[target.index()].damage_prevention;
727            if shields > 0 && final_amount > 0 {
728                let consumed = shields.min(final_amount);
729                self.cards[target.index()].damage_prevention -= consumed;
730                final_amount -= consumed;
731            }
732            if final_amount > 0 {
733                let dealt = self.cards[target.index()].add_damage_after_prevention(final_amount);
734                // Fire DealtDamage replacement event after damage is applied.
735                let mut dealt_event = ReplacementEvent::DealtDamage {
736                    target,
737                    amount: dealt,
738                    source,
739                };
740                if dealt > 0 {
741                    apply_replacements(self, &mut dealt_event);
742                }
743            }
744        }
745    }
746
747    /// Deal damage to a player.
748    ///
749    /// Runs replacement effects (e.g. damage prevention) before applying.
750    /// Mirrors Java `GameAction.addDamage()` calling `ReplacementHandler.run()`.
751    pub fn deal_damage_to_player(&mut self, target: PlayerId, amount: i32) -> i32 {
752        self.deal_damage_to_player_from(target, amount, None, false)
753    }
754
755    /// Deal damage to a player with source tracking for replacement effects.
756    pub fn deal_damage_to_player_from(
757        &mut self,
758        target: PlayerId,
759        amount: i32,
760        source: Option<CardId>,
761        is_combat: bool,
762    ) -> i32 {
763        self.deal_damage_to_player_from_with_agents(target, amount, source, is_combat, None)
764    }
765
766    /// Deal damage to a player with source tracking and optional agents for RNG parity.
767    /// Used by combat damage and spell damage to pass the source card and
768    /// combat flag so replacement effects like Torbran and Furnace of Rath
769    /// can check ValidSource$ and IsCombat$.
770    pub fn deal_damage_to_player_from_with_agents(
771        &mut self,
772        target: PlayerId,
773        amount: i32,
774        source: Option<CardId>,
775        is_combat: bool,
776        agents: Option<&mut [Box<dyn crate::agent::PlayerAgent>]>,
777    ) -> i32 {
778        if amount <= 0 {
779            return 0;
780        }
781        if crate::staticability::static_ability_cant_gain_lose_pay_life::cant_lose_life(
782            self, target,
783        ) {
784            return 0;
785        }
786        if crate::player::has_keyword(self, target, "Protection from everything") {
787            return 0;
788        }
789        let mut event = ReplacementEvent::DamageToPlayer {
790            target,
791            amount,
792            source,
793            is_combat,
794        };
795        if let Some(agents) = agents {
796            apply_replacements_with_agents(self, agents, &mut event);
797        } else {
798            apply_replacements(self, &mut event);
799        }
800        if let ReplacementEvent::DamageToPlayer {
801            amount: final_amount,
802            ..
803        } = event
804        {
805            if final_amount > 0 {
806                return self.player_deal_damage(target, final_amount);
807            }
808        }
809        0
810    }
811
812    /// Check and apply state-based actions. Returns true if any were applied.
813    pub fn check_state_based_actions(&mut self) -> bool {
814        self.check_state_based_actions_with_triggers(None, None)
815    }
816
817    /// Check and apply state-based actions. Returns true if any were applied.
818    /// If provided, emits ChangesZone triggers for SBA zone moves.
819    /// `legend_keep_fn` — optional callback for legend rule: given (player, duplicates),
820    /// returns the CardId to keep.  Mirrors Java's `chooseSingleEntityForEffect`.
821    pub fn check_state_based_actions_with_triggers(
822        &mut self,
823        trigger_handler: Option<&mut TriggerHandler>,
824        legend_keep_fn: Option<&mut dyn FnMut(PlayerId, &[CardId]) -> CardId>,
825    ) -> bool {
826        self.check_state_based_actions_impl(trigger_handler, legend_keep_fn, None)
827    }
828
829    pub fn check_state_based_actions_with_trigger_agents(
830        &mut self,
831        trigger_handler: Option<&mut TriggerHandler>,
832        agents: &mut [Box<dyn PlayerAgent>],
833    ) -> bool {
834        self.check_state_based_actions_impl(trigger_handler, None, Some(agents))
835    }
836
837    fn state_based_action_saga(
838        &self,
839        cid: CardId,
840        trigger_handler: Option<&TriggerHandler>,
841        sacrifice_list: &mut Vec<CardId>,
842    ) -> bool {
843        let card = self.card(cid);
844        if !card.type_line.has_subtype("Saga") || !card.has_chapter() {
845            return false;
846        }
847        if crate::staticability::static_ability_cant_sacrifice::cant_sacrifice(
848            &self.cards,
849            card,
850            None,
851            true,
852        ) {
853            return false;
854        }
855        if card.counter_count(&CounterType::Lore) < card.get_final_chapter_nr() {
856            return false;
857        }
858        if self.stack.has_source_chapter_on_stack(self, cid)
859            || trigger_handler.is_some_and(|handler| handler.has_source_chapter_pending(self, cid))
860        {
861            return false;
862        }
863        sacrifice_list.push(cid);
864        true
865    }
866
867    fn on_player_lost(
868        &mut self,
869        player: PlayerId,
870        trigger_handler: &mut Option<&mut TriggerHandler>,
871    ) {
872        self.player_mut(player).left_game = true;
873        let is_multiplayer = self.player_order.len() > 2;
874        let all_cards: Vec<CardId> = (0..self.cards.len()).map(|i| CardId(i as u32)).collect();
875
876        if !is_multiplayer {
877            // CR 707.9: at the end of the game every face-down card is revealed.
878            for &cid in &all_cards {
879                self.cards[cid.index()].force_turn_face_up();
880            }
881            return;
882        }
883
884        // CR 724.4 / CR 725.4. Reassigned before the sweep so the old effect
885        if self.monarch == Some(player) {
886            let heir = if self.turn.active_player == player {
887                self.next_player(player)
888            } else {
889                self.turn.active_player
890            };
891            self.player_set_monarch(heir, trigger_handler.as_deref_mut());
892        }
893        if self.initiative_holder == Some(player) {
894            let heir = if self.turn.active_player == player {
895                self.next_player(player)
896            } else {
897                self.turn.active_player
898            };
899            self.player_take_initiative(heir, trigger_handler.as_deref_mut());
900        }
901
902        let next = self.next_player(player);
903        for &cid in &all_cards {
904            let (zone, owner, controller) = {
905                let card = &self.cards[cid.index()];
906                (card.zone, card.owner, card.controller)
907            };
908            if zone == ZoneType::None {
909                continue;
910            }
911            if owner != player {
912                // CR 800.4c: nothing stays enchanting the leaving player.
913                if self.cards[cid.index()].attached_to_player == Some(player) {
914                    self.cards[cid.index()].attached_to_player = None;
915                }
916                continue;
917            }
918            if self.cards[cid.index()].effect_source.is_some() && zone == ZoneType::Command {
919                // Mirrors Java: lingering effects move to the next player so
920                // they continue to work.
921                self.remove_card_from_zone(ZoneType::Command, controller, cid);
922                self.cards[cid.index()].controller = next;
923                self.add_card_to_zone(ZoneType::Command, next, cid);
924                continue;
925            }
926            // CR 800.4a: objects owned by the leaving player leave the game.
927            for &other in &all_cards {
928                if other == cid {
929                    continue;
930                }
931                let other_card = &mut self.cards[other.index()];
932                other_card.imprinted_cards.retain(|&r| r != cid);
933                other_card.remembered_cards.retain(|&r| r != cid);
934                other_card.attachments.retain(|&r| r != cid);
935                other_card.gain_control_targets.retain(|&r| r != cid);
936                if other_card.attached_to == Some(cid) {
937                    other_card.attached_to = None;
938                }
939            }
940            if let Some(handler) = trigger_handler.as_deref_mut() {
941                crate::ability::effects::emit_zone_trigger(handler, cid, zone, ZoneType::None);
942            }
943            self.remove_card_from_zone(zone, controller, cid);
944            self.cards[cid.index()].zone = ZoneType::None;
945        }
946
947        apply_continuous_effects(self);
948
949        // CR 800.4d as Java implements it: permanents the leaving player
950        for &cid in &all_cards {
951            let (zone, owner, controller) = {
952                let card = &self.cards[cid.index()];
953                (card.zone, card.owner, card.controller)
954            };
955            if zone == ZoneType::Battlefield && controller == player && owner != player {
956                if let Some(handler) = trigger_handler.as_deref_mut() {
957                    crate::ability::effects::emit_zone_trigger(
958                        handler,
959                        cid,
960                        ZoneType::Battlefield,
961                        ZoneType::Exile,
962                    );
963                }
964                self.move_card_without_replacement(cid, ZoneType::Exile, owner);
965            }
966        }
967    }
968
969    fn move_battlefield_card_to_graveyard_for_sba(
970        &mut self,
971        cid: CardId,
972        trigger_handler: &mut Option<&mut TriggerHandler>,
973        agents: &mut Option<&mut [Box<dyn PlayerAgent>]>,
974    ) {
975        let owner = self.card(cid).owner;
976        let mut moved_event = ReplacementEvent::Moved {
977            card: cid,
978            origin: ZoneType::Battlefield,
979            destination: ZoneType::Graveyard,
980            is_discard: false,
981            counter_map: None,
982            counter_cause: None,
983            counter_is_effect: false,
984            after_replacement_static_abilities: Vec::new(),
985        };
986        if let Some(agents) = agents.as_deref_mut() {
987            apply_replacements_with_agents(self, agents, &mut moved_event);
988        } else {
989            apply_replacements(self, &mut moved_event);
990        }
991        let final_dest = if let ReplacementEvent::Moved { destination, .. } = moved_event {
992            destination
993        } else {
994            ZoneType::Graveyard
995        };
996        let old_zone = self.card(cid).zone;
997        // Emit trigger BEFORE move_card so LKI state is still available for
998        // trigger matching. Persist/Undying and Modular inspect the dying card.
999        if let Some(handler) = trigger_handler.as_deref_mut() {
1000            let lki_p1p1 = *self
1001                .card(cid)
1002                .counters
1003                .get(&CounterType::P1P1)
1004                .unwrap_or(&0);
1005            let lki_power = self.card(cid).power();
1006            let lki_toughness = self.card(cid).toughness();
1007            let lki_counters = self.card(cid).counters.clone();
1008            self.card_mut(cid).lki_counters = Some(lki_counters);
1009            self.card_mut(cid)
1010                .set_lki_power_toughness(Some(lki_power), Some(lki_toughness));
1011            crate::ability::effects::emit_zone_trigger_with_lki_counters(
1012                handler,
1013                cid,
1014                old_zone,
1015                final_dest,
1016                lki_p1p1,
1017                lki_power,
1018                lki_toughness,
1019            );
1020            handler.flush_waiting_triggers(self);
1021        }
1022        self.move_card_without_replacement(cid, final_dest, owner);
1023    }
1024
1025    fn check_state_based_actions_impl(
1026        &mut self,
1027        mut trigger_handler: Option<&mut TriggerHandler>,
1028        mut legend_keep_fn: Option<&mut dyn FnMut(PlayerId, &[CardId]) -> CardId>,
1029        mut agents: Option<&mut [Box<dyn PlayerAgent>]>,
1030    ) -> bool {
1031        // Capture battlefield state before SBA processing. Used by DisableTriggers
1032        // (Hushbringer) to check LKI — if a creature with DisableTriggers dies in
1033        // the same SBA batch as another creature, it still suppresses death triggers.
1034        // Mirrors Java's LastStateBattlefield passed through RunParams.
1035        self.pre_sba_battlefield = self
1036            .cards
1037            .iter()
1038            .filter(|c| c.zone == ZoneType::Battlefield)
1039            .map(|c| c.id)
1040            .collect();
1041
1042        let mut any_changes = false;
1043        let mut newly_lost_players: Vec<PlayerId> = Vec::new();
1044        let mut sacrifice_list: Vec<CardId> = Vec::new();
1045
1046        // Check players with 0 or less life
1047        for pid in self.player_order.clone() {
1048            if self.player(pid).tried_to_draw_from_empty_library && self.player(pid).is_alive() {
1049                self.player_mut(pid).tried_to_draw_from_empty_library = false;
1050                let mut event = ReplacementEvent::GameLoss {
1051                    player: pid,
1052                    reason: GameLossReason::Milled,
1053                };
1054                let result = apply_replacements(self, &mut event);
1055                if result != ReplacementResult::Replaced && !self.player(pid).has_lost {
1056                    self.player_mark_lost(pid, GameLossReason::Milled);
1057                    newly_lost_players.push(pid);
1058                    any_changes = true;
1059                }
1060            }
1061            if self.player(pid).life <= 0 && self.player(pid).is_alive() {
1062                let mut event = ReplacementEvent::GameLoss {
1063                    player: pid,
1064                    reason: GameLossReason::LifeReachedZero,
1065                };
1066                let result = apply_replacements(self, &mut event);
1067                if result != ReplacementResult::Replaced && !self.player(pid).has_lost {
1068                    self.player_mark_lost(pid, GameLossReason::LifeReachedZero);
1069                    newly_lost_players.push(pid);
1070                    any_changes = true;
1071                }
1072            }
1073            // Check poison counters (10+ = lose)
1074            if self.player(pid).poison_counters >= 10 && self.player(pid).is_alive() {
1075                let mut event = ReplacementEvent::GameLoss {
1076                    player: pid,
1077                    reason: GameLossReason::Poisoned,
1078                };
1079                let result = apply_replacements(self, &mut event);
1080                if result != ReplacementResult::Replaced {
1081                    if !self.player(pid).has_lost {
1082                        self.player_mark_lost(pid, GameLossReason::Poisoned);
1083                        newly_lost_players.push(pid);
1084                    }
1085                    any_changes = true;
1086                }
1087            }
1088            // Check commander damage (21+ from a single commander source = lose)
1089            if self.player(pid).commander_damage_enabled {
1090                let commander_dmg_entries: Vec<(u32, i32)> = self
1091                    .player(pid)
1092                    .commander_damage_received
1093                    .iter()
1094                    .map(|(&k, &v)| (k, v))
1095                    .collect();
1096                for (_card_raw_id, dmg) in commander_dmg_entries {
1097                    if dmg >= 21 && self.player(pid).is_alive() && !self.player(pid).has_lost {
1098                        self.player_mark_lost(pid, GameLossReason::CommanderDamage);
1099                        newly_lost_players.push(pid);
1100                        any_changes = true;
1101                    }
1102                }
1103            }
1104
1105            // CR 704.5z: If a player controls a permanent with Start your
1106            // engines! and that player has no speed, their speed becomes 1.
1107            if self.player(pid).speed == 0
1108                && self
1109                    .cards_in_zone(ZoneType::Battlefield, pid)
1110                    .iter()
1111                    .any(|&cid| self.card(cid).has_keyword("Start your engines"))
1112            {
1113                self.increase_player_speed(pid, None);
1114                any_changes = true;
1115            }
1116        }
1117
1118        for pid in self.player_order.clone() {
1119            if !self.player(pid).is_alive()
1120                && !self.player(pid).left_game
1121                && !newly_lost_players.contains(&pid)
1122            {
1123                newly_lost_players.push(pid);
1124                any_changes = true;
1125            }
1126        }
1127
1128        if !newly_lost_players.is_empty() {
1129            for pid in &newly_lost_players {
1130                self.on_player_lost(*pid, &mut trigger_handler);
1131                self.stack.remove_instances_controlled_by(*pid);
1132            }
1133            if let Some(handler) = trigger_handler.as_deref_mut() {
1134                for pid in &newly_lost_players {
1135                    handler.run_trigger(
1136                        TriggerType::LosesGame,
1137                        RunParams {
1138                            player: Some(*pid),
1139                            ..Default::default()
1140                        },
1141                        false,
1142                    );
1143                    handler.on_player_lost(*pid);
1144                }
1145            }
1146        }
1147
1148        // Check creatures with lethal damage or 0 toughness
1149        let battlefield_cards: Vec<CardId> = self
1150            .player_order
1151            .clone()
1152            .iter()
1153            .flat_map(|&pid| self.cards_in_zone(ZoneType::Battlefield, pid).to_vec())
1154            .collect();
1155
1156        for cid in battlefield_cards {
1157            let (is_creature, zero_toughness, lethal, should_die) = {
1158                let card = &self.cards[cid.index()];
1159                let is_creature = card.is_creature();
1160                let zero_toughness = card.toughness() <= 0;
1161                let lethal = card.lethal_damage() || card.has_deathtouch_damage;
1162                let should_die = zero_toughness || lethal;
1163                (is_creature, zero_toughness, lethal, should_die)
1164            };
1165            if is_creature && should_die {
1166                // Clear deathtouch flag regardless of outcome (mirrors Java
1167                // GameAction.java line 1491: c.setHasBeenDealtDeathtouchDamage(false)).
1168                self.cards[cid.index()].has_deathtouch_damage = false;
1169                // CR 702.12: Indestructible prevents death from lethal damage and
1170                // "destroy" effects, but NOT from toughness ≤ 0 (CR 704.5f vs 704.5g).
1171                // This covers K:Indestructible from Forge card scripts (e.g. Darksteel Myr).
1172                if lethal
1173                    && !zero_toughness
1174                    && self.cards[cid.index()].has_keyword("Indestructible")
1175                {
1176                    continue;
1177                }
1178                // CR 702.89: Umbra armor (Totem Armor) — if enchanted creature
1179                // would be destroyed, instead remove all damage and destroy the aura.
1180                let has_umbra = self.cards[cid.index()].attachments.iter().any(|&aid| {
1181                    aid.index() < self.cards.len()
1182                        && self.cards[aid.index()].zone == ZoneType::Battlefield
1183                        && (self.cards[aid.index()].has_keyword("Umbra armor")
1184                            || self.cards[aid.index()].has_keyword("Totem armor"))
1185                });
1186                if has_umbra && !zero_toughness {
1187                    // Find the first umbra armor aura and destroy it instead
1188                    let umbra_id =
1189                        self.cards[cid.index()]
1190                            .attachments
1191                            .iter()
1192                            .copied()
1193                            .find(|&aid| {
1194                                aid.index() < self.cards.len()
1195                                    && self.cards[aid.index()].zone == ZoneType::Battlefield
1196                                    && (self.cards[aid.index()].has_keyword("Umbra armor")
1197                                        || self.cards[aid.index()].has_keyword("Totem armor"))
1198                            });
1199                    if let Some(umbra_id) = umbra_id {
1200                        // Remove all damage from the creature
1201                        self.cards[cid.index()].damage = 0;
1202                        self.cards[cid.index()].has_deathtouch_damage = false;
1203                        // Destroy the aura instead
1204                        let umbra_owner = self.cards[umbra_id.index()].owner;
1205                        let old_zone = self.cards[umbra_id.index()].zone;
1206                        self.move_card(umbra_id, ZoneType::Graveyard, umbra_owner);
1207                        if let Some(handler) = trigger_handler.as_deref_mut() {
1208                            crate::ability::effects::emit_zone_trigger(
1209                                handler,
1210                                umbra_id,
1211                                old_zone,
1212                                ZoneType::Graveyard,
1213                            );
1214                        }
1215                        any_changes = true;
1216                        continue; // Creature survives
1217                    }
1218                }
1219
1220                if zero_toughness {
1221                    self.move_battlefield_card_to_graveyard_for_sba(
1222                        cid,
1223                        &mut trigger_handler,
1224                        &mut agents,
1225                    );
1226                    any_changes = true;
1227                    continue;
1228                }
1229
1230                // Run Destroy replacement effects (R$-based indestructible, etc.).
1231                // Mirrors Java GameAction.destroy() → ReplacementHandler.run(Destroy, …).
1232                let mut destroy_event = ReplacementEvent::Destroy { target: cid };
1233                let result = apply_replacements(self, &mut destroy_event);
1234                if result != ReplacementResult::Replaced {
1235                    self.move_battlefield_card_to_graveyard_for_sba(
1236                        cid,
1237                        &mut trigger_handler,
1238                        &mut agents,
1239                    );
1240                    // Same-SBA-batch LTB lookback is derived per-event from
1241                    // `pre_sba_battlefield` in `TriggerHandler::ltb_trigger_refs_for_event`.
1242                    // No global registration needed.
1243                    any_changes = true;
1244                } else {
1245                    // Indestructible — destruction was replaced; creature stays.
1246                    // Damage is still marked but the creature does not die.
1247                }
1248            }
1249        }
1250
1251        let battlefield_cards: Vec<CardId> = self
1252            .player_order
1253            .clone()
1254            .iter()
1255            .flat_map(|&pid| self.cards_in_zone(ZoneType::Battlefield, pid).to_vec())
1256            .collect();
1257
1258        for cid in battlefield_cards {
1259            let should_put_in_graveyard = {
1260                let card = self.card(cid);
1261                card.type_line.is_planeswalker() && card.counter_count(&CounterType::Loyalty) <= 0
1262            };
1263            if !should_put_in_graveyard {
1264                continue;
1265            }
1266
1267            self.move_battlefield_card_to_graveyard_for_sba(cid, &mut trigger_handler, &mut agents);
1268            any_changes = true;
1269        }
1270
1271        let saga_cards: Vec<CardId> = self
1272            .player_order
1273            .clone()
1274            .iter()
1275            .flat_map(|&pid| self.cards_in_zone(ZoneType::Battlefield, pid).to_vec())
1276            .collect();
1277        for cid in saga_cards {
1278            any_changes |=
1279                self.state_based_action_saga(cid, trigger_handler.as_deref(), &mut sacrifice_list);
1280        }
1281
1282        if !sacrifice_list.is_empty() {
1283            if let (Some(handler), Some(agents)) =
1284                (trigger_handler.as_deref_mut(), agents.as_deref_mut())
1285            {
1286                if !crate::game_loop::perform_sacrifice(self, handler, agents, &sacrifice_list)
1287                    .is_empty()
1288                {
1289                    any_changes = true;
1290                }
1291            } else {
1292                for cid in sacrifice_list.drain(..) {
1293                    let owner = self.card(cid).owner;
1294                    self.move_card_without_replacement(cid, ZoneType::Graveyard, owner);
1295                }
1296                any_changes = true;
1297            }
1298        }
1299
1300        // CR 704.5q: +1/+1 and -1/-1 counter cancellation
1301        for &pid in &self.player_order.clone() {
1302            let battlefield = self.cards_in_zone(ZoneType::Battlefield, pid).to_vec();
1303            for cid in battlefield {
1304                let p1 = self.card(cid).counter_count(&CounterType::P1P1);
1305                let m1 = self.card(cid).counter_count(&CounterType::M1M1);
1306                if p1 > 0 && m1 > 0 {
1307                    let cancel = p1.min(m1);
1308                    self.card_mut(cid)
1309                        .remove_counter(&CounterType::P1P1, cancel);
1310                    self.card_mut(cid)
1311                        .remove_counter(&CounterType::M1M1, cancel);
1312                    any_changes = true;
1313                }
1314            }
1315        }
1316
1317        // CR 903.9a: a commander in graveyard or exile may move to command zone.
1318        for &pid in &self.player_order.clone() {
1319            let mut commander_candidates = self.cards_in_zone(ZoneType::Graveyard, pid).to_vec();
1320            commander_candidates.extend(self.cards_in_zone(ZoneType::Exile, pid).iter().copied());
1321            for cid in commander_candidates {
1322                if !self.card(cid).can_move_to_command_zone() {
1323                    continue;
1324                }
1325                self.card_mut(cid).move_to_command_zone = false;
1326                let accepted = if let Some(agents) = agents.as_deref_mut() {
1327                    let name = self.card(cid).card_name.clone();
1328                    let message = format!(
1329                        "{}: If a commander is in a graveyard or in exile and that card was put into that zone since the last time state-based actions were checked, its owner may put it into the command zone.",
1330                        name
1331                    );
1332                    agents[pid.index()].confirm_action(
1333                        pid,
1334                        Some("ChangeZoneToAltDestination"),
1335                        &message,
1336                        &[],
1337                        Some(cid),
1338                        None,
1339                    )
1340                } else {
1341                    false
1342                };
1343                if accepted {
1344                    self.move_card_without_replacement(cid, ZoneType::Command, pid);
1345                    any_changes = true;
1346                }
1347            }
1348        }
1349
1350        // Legend rule: for each player, if they control multiple legendary
1351        // permanents with the same name, keep one and move the rest to graveyard.
1352        // IgnoreLegendRule statics exempt matching cards.
1353        for &pid in &self.player_order.clone() {
1354            let battlefield = self.cards_in_zone(ZoneType::Battlefield, pid).to_vec();
1355            let mut by_name: std::collections::BTreeMap<String, Vec<CardId>> =
1356                std::collections::BTreeMap::new();
1357            for cid in battlefield {
1358                let c = self.card(cid);
1359                if !c.type_line.is_legendary() {
1360                    continue;
1361                }
1362                if crate::staticability::static_ability_ignore_legend_rule::ignore_legend_rule(
1363                    &self.cards,
1364                    c,
1365                ) {
1366                    continue;
1367                }
1368                by_name.entry(c.card_name.clone()).or_default().push(cid);
1369            }
1370            for (_name, ids) in by_name {
1371                if ids.len() <= 1 {
1372                    continue;
1373                }
1374                // Choose which to keep: delegate to callback (mirrors Java's
1375                // chooseSingleEntityForEffect), or default to first in zone order.
1376                let keep = if let Some(ref mut chooser) = legend_keep_fn {
1377                    chooser(pid, &ids)
1378                } else if let Some(agents) = agents.as_deref_mut() {
1379                    agents[pid.index()].choose_legend_keep(pid, &ids)
1380                } else {
1381                    ids[0]
1382                };
1383                for cid in ids {
1384                    if cid == keep {
1385                        continue;
1386                    }
1387                    let owner = self.card(cid).owner;
1388                    let old_zone = self.card(cid).zone;
1389                    if let Some(agents) = agents.as_deref_mut() {
1390                        self.move_card_with_agents(cid, ZoneType::Graveyard, owner, agents);
1391                    } else {
1392                        self.move_card(cid, ZoneType::Graveyard, owner);
1393                    }
1394                    if let Some(handler) = trigger_handler.as_deref_mut() {
1395                        crate::ability::effects::emit_zone_trigger(
1396                            handler,
1397                            cid,
1398                            old_zone,
1399                            ZoneType::Graveyard,
1400                        );
1401                    }
1402                    any_changes = true;
1403                }
1404            }
1405        }
1406
1407        // CR 704.5n: Aura SBA — an Aura on the battlefield that is not attached
1408        // to a legal permanent (or whose host left the battlefield) is put into
1409        // its owner's graveyard.
1410        {
1411            let aura_ids: Vec<CardId> = self
1412                .cards
1413                .iter()
1414                .filter(|c| {
1415                    c.zone == ZoneType::Battlefield
1416                        && c.type_line.has_subtype("Aura")
1417                        && !c.type_line.is_creature() // Bestowed auras that became creatures stay
1418                })
1419                .filter(|c| {
1420                    match (c.attached_to, c.attached_to_player) {
1421                        (None, None) => true, // Not attached to anything — orphaned
1422                        (None, Some(player_id)) => {
1423                            if player_id.index() >= self.players.len() {
1424                                return true;
1425                            }
1426                            let player = &self.players[player_id.index()];
1427                            let enchant_type = c
1428                                .keywords
1429                                .iter_strings()
1430                                .find_map(|kw| {
1431                                    crate::keyword::extract_keyword_cost_str(kw, "Enchant")
1432                                })
1433                                .unwrap_or_default();
1434                            player.has_lost || !enchant_type.eq_ignore_ascii_case("Player")
1435                        }
1436                        (Some(host_id), _) => {
1437                            if host_id.index() >= self.cards.len() {
1438                                return true; // Invalid host ID
1439                            }
1440                            let host = &self.cards[host_id.index()];
1441                            // CR 704.5n: check if the enchant restriction is still met.
1442                            // E.g. "Enchant creature" requires a battlefield creature, while
1443                            // Animate Dead's "Enchant creature card in a graveyard" remains legal
1444                            // while attached to a creature card in a graveyard.
1445                            let enchant_type = c
1446                                .keywords
1447                                .iter_strings()
1448                                .find_map(|kw| {
1449                                    crate::keyword::extract_keyword_cost_str(kw, "Enchant")
1450                                })
1451                                .unwrap_or_default();
1452                            !crate::parsing::enchant_type_matches_card(enchant_type, host, Some(c))
1453                                || !can_attachment_remain_attached(&self.cards, c, host, true)
1454                        }
1455                    }
1456                })
1457                .map(|c| c.id)
1458                .collect();
1459
1460            for aura_id in aura_ids {
1461                let owner = self.card(aura_id).owner;
1462                let old_zone = self.card(aura_id).zone;
1463                self.move_card(aura_id, ZoneType::Graveyard, owner);
1464                if let Some(handler) = trigger_handler.as_deref_mut() {
1465                    crate::ability::effects::emit_zone_trigger(
1466                        handler,
1467                        aura_id,
1468                        old_zone,
1469                        ZoneType::Graveyard,
1470                    );
1471                }
1472                any_changes = true;
1473            }
1474        }
1475
1476        // Check game over
1477        let alive = self.alive_players();
1478        if alive.len() <= 1 {
1479            self.game_over = true;
1480            if alive.len() == 1 {
1481                self.winner = Some(alive[0]);
1482            }
1483        }
1484
1485        any_changes
1486    }
1487
1488    /// Untap all permanents controlled by a player.
1489    /// Runs Untap replacement effects for each permanent.
1490    pub fn untap_all(&mut self, player: PlayerId) {
1491        let cards: Vec<CardId> = self.cards_in_zone(ZoneType::Battlefield, player).to_vec();
1492        for cid in cards {
1493            // Use untap() which runs replacement effects
1494            self.untap_during_untap_step(cid, player);
1495        }
1496    }
1497
1498    /// Draw a card for a player. Returns the drawn card ID, or None if the draw
1499    /// was skipped or the library is empty.
1500    ///
1501    /// Runs Draw replacement effects before drawing.  If the draw is replaced
1502    /// (e.g. "skip your draw step"), returns `None`.
1503    ///
1504    /// Mirrors Java `GameAction.draw()` calling `ReplacementHandler.run(Draw, …)`.
1505    pub fn draw_card(&mut self, player: PlayerId) -> Option<CardId> {
1506        self.player_draw_one(player)
1507    }
1508
1509    /// Draw a card with agent access for Optional replacement effects (Dredge).
1510    pub fn draw_card_with_agents(
1511        &mut self,
1512        player: PlayerId,
1513        agents: &mut [Box<dyn crate::agent::PlayerAgent>],
1514    ) -> Option<CardId> {
1515        self.player_draw_one_internal(player, false, Some(agents))
1516    }
1517
1518    /// Draw N cards for a player. Returns drawn card IDs.
1519    pub fn draw_cards(&mut self, player: PlayerId, n: usize) -> Vec<CardId> {
1520        self.player_draw_cards(player, n)
1521    }
1522
1523    /// Shuffle a player's library using the provided RNG.
1524    pub fn shuffle_library(&mut self, player: PlayerId, rng: &mut impl rand::Rng) {
1525        self.shuffle_zone_cards_with_rand(ZoneType::Library, player, rng);
1526    }
1527
1528    /// Reset per-turn state for all cards and players of a given player.
1529    pub fn new_turn_for_player(&mut self, player: PlayerId) {
1530        self.player_new_turn(player);
1531        // Reset turn-scoped player stats for ALL non-active players too.
1532        // These counters are "this turn" in the global turn sense, not "that
1533        // player's own turn". Without this, effects like Resplendent Angel can
1534        // incorrectly carry life gained from the previous player's turn.
1535        for pid in &self.player_order.clone() {
1536            if *pid != player {
1537                self.player_reset_drawn_this_turn(*pid);
1538                let p = self.player_mut(*pid);
1539                p.life_started_this_turn_with = p.life;
1540                p.life_gained_this_turn = 0;
1541                p.life_gained_by_team_this_turn = 0;
1542                p.life_gained_times_this_turn = 0;
1543                p.life_lost_last_turn = p.life_lost_this_turn;
1544                p.life_lost_this_turn = 0;
1545            }
1546        }
1547
1548        let all_card_ids: Vec<CardId> = (0..self.cards.len()).map(|i| CardId(i as u32)).collect();
1549        for cid in all_card_ids {
1550            if self.cards[cid.index()].zone == ZoneType::Battlefield {
1551                self.cards[cid.index()].started_turn_tapped = self.cards[cid.index()].tapped;
1552            }
1553            if self.cards[cid.index()].controller == player {
1554                self.cards[cid.index()].new_turn();
1555            } else {
1556                self.cards[cid.index()].clear_global_turn_state();
1557            }
1558        }
1559    }
1560
1561    /// Tap a card. Returns true if it was untapped.
1562    /// Runs Tap replacement effects before tapping.
1563    pub fn tap(&mut self, card_id: CardId) -> bool {
1564        let card = &self.cards[card_id.index()];
1565        if card.tapped {
1566            return false;
1567        }
1568        // Run Tap replacement effects.
1569        let mut event = ReplacementEvent::Tap { card: card_id };
1570        let result = apply_replacements(self, &mut event);
1571        if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
1572            return false; // Tap was prevented
1573        }
1574        self.cards[card_id.index()].tapped = true;
1575        true
1576    }
1577
1578    /// Untap a card. Returns true if it was tapped.
1579    /// Runs Untap replacement effects before untapping.
1580    pub fn untap(&mut self, card_id: CardId) -> bool {
1581        self.untap_internal(card_id, None)
1582    }
1583
1584    pub fn untap_during_untap_step(&mut self, card_id: CardId, player: PlayerId) -> bool {
1585        self.untap_internal(card_id, Some(player))
1586    }
1587
1588    fn untap_internal(&mut self, card_id: CardId, player: Option<PlayerId>) -> bool {
1589        let card = &self.cards[card_id.index()];
1590        if !card.tapped {
1591            return false;
1592        }
1593        let stun = CounterType::Named("STUN".to_string());
1594        if card.counter_count(&stun) > 0 && card.can_remove_counters(&stun) {
1595            // Stun counters replace the untap event: remove one counter and keep the
1596            // permanent tapped. This mirrors Java's built-in stun untap replacement.
1597            self.cards[card_id.index()].remove_counter(&stun, 1);
1598            return false;
1599        }
1600        // Run Untap replacement effects.
1601        let mut event = ReplacementEvent::Untap {
1602            card: card_id,
1603            player,
1604        };
1605        let result = apply_replacements(self, &mut event);
1606        if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
1607            return false; // Untap was prevented
1608        }
1609        self.cards[card_id.index()].tapped = false;
1610        // `ControlGain$ LoseControl$ Untap` — revert scheduled steal now.
1611        crate::ability::effects::control_gain_effect::untap_hook(self, card_id);
1612        true
1613    }
1614
1615    /// Change the controller of a permanent to `new_controller`.
1616    /// Mirrors Java's `GameAction.controllerChangeZoneCorrection()` — moves the
1617    /// card between per-player zone lists and updates the controller field.
1618    pub fn change_controller(&mut self, card_id: CardId, new_controller: PlayerId) {
1619        let card = &self.cards[card_id.index()];
1620        if card.controller == new_controller {
1621            return;
1622        }
1623        let old_controller = card.controller;
1624        let zone = card.zone;
1625
1626        // Move between zone lists
1627        if zone != ZoneType::None {
1628            self.remove_card_from_zone(zone, old_controller, card_id);
1629            self.add_card_to_zone(zone, new_controller, card_id);
1630        }
1631        self.cards[card_id.index()].controller = new_controller;
1632    }
1633
1634    /// Attach `aura_id` to `target_id`.
1635    /// If `aura_id` was already attached elsewhere, detach it first.
1636    /// Mirrors Java's `Card.enchantEntity()` / `Card.equip()`.
1637    pub fn attach_to(&mut self, aura_id: CardId, target_id: CardId) {
1638        // Detach from previous host if any
1639        self.detach(aura_id);
1640        self.cards[aura_id.index()].attached_to = Some(target_id);
1641        self.cards[aura_id.index()].attached_to_player = None;
1642        self.cards[aura_id.index()].attached_this_turn = true;
1643        self.cards[target_id.index()].attachments.push(aura_id);
1644    }
1645
1646    pub fn attach_to_player(&mut self, aura_id: CardId, player_id: PlayerId) {
1647        self.detach(aura_id);
1648        self.cards[aura_id.index()].attached_to = None;
1649        self.cards[aura_id.index()].attached_to_player = Some(player_id);
1650        self.cards[aura_id.index()].attached_this_turn = true;
1651    }
1652
1653    /// Detach `aura_id` from whatever it is currently attached to.
1654    /// Mirrors Java's `Card.unattachFromEntity()`.
1655    pub fn detach(&mut self, aura_id: CardId) {
1656        if let Some(host_id) = self.cards[aura_id.index()].attached_to.take() {
1657            self.cards[host_id.index()]
1658                .attachments
1659                .retain(|&a| a != aura_id);
1660            // Bestow: when unattached, revert to a creature
1661            self.cards[aura_id.index()].is_bestowed = false;
1662        }
1663        self.cards[aura_id.index()].attached_to_player = None;
1664    }
1665
1666    /// Move a card from its current zone to the bottom of a player's library.
1667    /// Unlike `move_card`, this places the card at the bottom rather than the top.
1668    pub fn put_on_bottom_of_library(&mut self, card_id: CardId, owner: PlayerId) {
1669        let card = &self.cards[card_id.index()];
1670        let src_zone = card.zone;
1671        let src_owner = card.controller;
1672
1673        if src_zone != ZoneType::None {
1674            self.remove_card_from_zone(src_zone, src_owner, card_id);
1675        }
1676
1677        self.cards[card_id.index()].zone = ZoneType::Library;
1678        self.assign_zone_timestamp(card_id);
1679        self.add_card_to_zone_bottom(ZoneType::Library, owner, card_id);
1680    }
1681
1682    /// Remove a spell from the stack by its entry ID (used by Counter).
1683    /// Mirrors Java's `Game.getStack().remove(sa)`.
1684    pub fn remove_from_stack(&mut self, entry_id: u32) -> bool {
1685        self.stack.remove_by_id(entry_id).is_some()
1686    }
1687}
1688
1689fn can_attachment_remain_attached(
1690    cards: &[Card],
1691    attachment: &Card,
1692    target: &Card,
1693    check_sba: bool,
1694) -> bool {
1695    if target.zone != ZoneType::Battlefield {
1696        return true;
1697    }
1698    if crate::staticability::static_ability_cant_attach::cant_attach(
1699        cards, attachment, target, check_sba,
1700    ) {
1701        return false;
1702    }
1703    !crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
1704        cards, target, attachment,
1705    )
1706}
1707
1708#[cfg(test)]
1709mod tests {
1710    use super::*;
1711    use crate::card::Card;
1712    use crate::player::RegisteredPlayer;
1713    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
1714
1715    fn make_creature(game: &mut GameState, name: &str, owner: PlayerId, p: i32, t: i32) -> CardId {
1716        let card = Card::new(
1717            CardId(0),
1718            name.to_string(),
1719            owner,
1720            CardTypeLine::parse("Creature Bear"),
1721            ManaCost::parse("1 G"),
1722            ColorSet::GREEN,
1723            Some(p),
1724            Some(t),
1725            vec![],
1726            vec![],
1727        );
1728        game.create_card(card)
1729    }
1730
1731    #[test]
1732    fn move_card_to_battlefield() {
1733        let mut game = GameState::new(&["Alice", "Bob"], 20);
1734        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1735        game.move_card(cid, ZoneType::Hand, PlayerId(0));
1736        assert_eq!(game.zone(ZoneType::Hand, PlayerId(0)).len(), 1);
1737
1738        game.move_card(cid, ZoneType::Battlefield, PlayerId(0));
1739        assert_eq!(game.zone(ZoneType::Hand, PlayerId(0)).len(), 0);
1740        assert_eq!(game.zone(ZoneType::Battlefield, PlayerId(0)).len(), 1);
1741        assert_eq!(game.card(cid).zone, ZoneType::Battlefield);
1742    }
1743
1744    #[test]
1745    fn state_based_actions_lethal_damage() {
1746        let mut game = GameState::new(&["Alice", "Bob"], 20);
1747        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1748        game.move_card(cid, ZoneType::Battlefield, PlayerId(0));
1749
1750        game.deal_damage_to_card(cid, 2);
1751        assert!(game.check_state_based_actions());
1752        assert_eq!(game.zone(ZoneType::Graveyard, PlayerId(0)).len(), 1);
1753    }
1754
1755    #[test]
1756    fn state_based_actions_zero_life() {
1757        let mut game = GameState::new(&["Alice", "Bob"], 20);
1758        game.deal_damage_to_player(PlayerId(0), 20);
1759        game.check_state_based_actions();
1760        assert!(game.player(PlayerId(0)).has_lost);
1761        assert!(game.game_over);
1762        assert_eq!(game.winner, Some(PlayerId(1)));
1763    }
1764
1765    #[test]
1766    fn draw_card() {
1767        let mut game = GameState::new(&["Alice", "Bob"], 20);
1768        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1769        game.move_card(cid, ZoneType::Library, PlayerId(0));
1770
1771        let drawn = game.draw_card(PlayerId(0));
1772        assert_eq!(drawn, Some(cid));
1773        assert_eq!(game.card(cid).zone, ZoneType::Hand);
1774    }
1775
1776    #[test]
1777    fn tap_untap() {
1778        let mut game = GameState::new(&["Alice", "Bob"], 20);
1779        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1780        game.move_card(cid, ZoneType::Battlefield, PlayerId(0));
1781
1782        assert!(game.tap(cid));
1783        assert!(game.card(cid).tapped);
1784        assert!(!game.tap(cid)); // already tapped
1785        assert!(game.untap(cid));
1786        assert!(!game.card(cid).tapped);
1787    }
1788
1789    #[test]
1790    fn stun_counter_replaces_untap() {
1791        let mut game = GameState::new(&["Alice", "Bob"], 20);
1792        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1793        game.move_card(cid, ZoneType::Battlefield, PlayerId(0));
1794        game.tap(cid);
1795        game.card_mut(cid)
1796            .add_counter(&CounterType::Named("STUN".to_string()), 1);
1797
1798        assert!(!game.untap(cid));
1799        assert!(game.card(cid).tapped);
1800        assert_eq!(
1801            game.card(cid)
1802                .counter_count(&CounterType::Named("STUN".to_string())),
1803            0
1804        );
1805    }
1806}