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