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 move_battlefield_card_to_graveyard_for_sba(
865        &mut self,
866        cid: CardId,
867        trigger_handler: &mut Option<&mut TriggerHandler>,
868        agents: &mut Option<&mut [Box<dyn PlayerAgent>]>,
869    ) {
870        let owner = self.card(cid).owner;
871        let mut moved_event = ReplacementEvent::Moved {
872            card: cid,
873            origin: ZoneType::Battlefield,
874            destination: ZoneType::Graveyard,
875            is_discard: false,
876        };
877        if let Some(agents) = agents.as_deref_mut() {
878            apply_replacements_with_agents(self, agents, &mut moved_event);
879        } else {
880            apply_replacements(self, &mut moved_event);
881        }
882        let final_dest = if let ReplacementEvent::Moved { destination, .. } = moved_event {
883            destination
884        } else {
885            ZoneType::Graveyard
886        };
887        let old_zone = self.card(cid).zone;
888        // Emit trigger BEFORE move_card so LKI state is still available for
889        // trigger matching. Persist/Undying and Modular inspect the dying card.
890        if let Some(handler) = trigger_handler.as_deref_mut() {
891            let lki_p1p1 = *self
892                .card(cid)
893                .counters
894                .get(&CounterType::P1P1)
895                .unwrap_or(&0);
896            let lki_power = self.card(cid).power();
897            let lki_toughness = self.card(cid).toughness();
898            let lki_counters = self.card(cid).counters.clone();
899            self.card_mut(cid).lki_counters = Some(lki_counters);
900            self.card_mut(cid)
901                .set_lki_power_toughness(Some(lki_power), Some(lki_toughness));
902            crate::ability::effects::emit_zone_trigger_with_lki_counters(
903                handler,
904                cid,
905                old_zone,
906                final_dest,
907                lki_p1p1,
908                lki_power,
909                lki_toughness,
910            );
911            handler.flush_waiting_triggers(self);
912        }
913        self.move_card_without_replacement(cid, final_dest, owner);
914    }
915
916    fn check_state_based_actions_impl(
917        &mut self,
918        mut trigger_handler: Option<&mut TriggerHandler>,
919        mut legend_keep_fn: Option<&mut dyn FnMut(PlayerId, &[CardId]) -> CardId>,
920        mut agents: Option<&mut [Box<dyn PlayerAgent>]>,
921    ) -> bool {
922        // Capture battlefield state before SBA processing. Used by DisableTriggers
923        // (Hushbringer) to check LKI — if a creature with DisableTriggers dies in
924        // the same SBA batch as another creature, it still suppresses death triggers.
925        // Mirrors Java's LastStateBattlefield passed through RunParams.
926        self.pre_sba_battlefield = self
927            .cards
928            .iter()
929            .filter(|c| c.zone == ZoneType::Battlefield)
930            .map(|c| c.id)
931            .collect();
932
933        let mut any_changes = false;
934        let mut newly_lost_players: Vec<PlayerId> = Vec::new();
935
936        // Check players with 0 or less life
937        for pid in self.player_order.clone() {
938            if self.player(pid).tried_to_draw_from_empty_library && self.player(pid).is_alive() {
939                self.player_mut(pid).tried_to_draw_from_empty_library = false;
940                let mut event = ReplacementEvent::GameLoss {
941                    player: pid,
942                    reason: GameLossReason::Milled,
943                };
944                let result = apply_replacements(self, &mut event);
945                if result != ReplacementResult::Replaced && !self.player(pid).has_lost {
946                    self.player_mark_lost(pid, GameLossReason::Milled);
947                    newly_lost_players.push(pid);
948                    any_changes = true;
949                }
950            }
951            if self.player(pid).life <= 0 && self.player(pid).is_alive() {
952                let mut event = ReplacementEvent::GameLoss {
953                    player: pid,
954                    reason: GameLossReason::LifeReachedZero,
955                };
956                let result = apply_replacements(self, &mut event);
957                if result != ReplacementResult::Replaced && !self.player(pid).has_lost {
958                    self.player_mark_lost(pid, GameLossReason::LifeReachedZero);
959                    newly_lost_players.push(pid);
960                    any_changes = true;
961                }
962            }
963            // Check poison counters (10+ = lose)
964            if self.player(pid).poison_counters >= 10 && self.player(pid).is_alive() {
965                let mut event = ReplacementEvent::GameLoss {
966                    player: pid,
967                    reason: GameLossReason::Poisoned,
968                };
969                let result = apply_replacements(self, &mut event);
970                if result != ReplacementResult::Replaced {
971                    if !self.player(pid).has_lost {
972                        self.player_mark_lost(pid, GameLossReason::Poisoned);
973                        newly_lost_players.push(pid);
974                    }
975                    any_changes = true;
976                }
977            }
978            // Check commander damage (21+ from a single commander source = lose)
979            if self.player(pid).commander_damage_enabled {
980                let commander_dmg_entries: Vec<(u32, i32)> = self
981                    .player(pid)
982                    .commander_damage_received
983                    .iter()
984                    .map(|(&k, &v)| (k, v))
985                    .collect();
986                for (_card_raw_id, dmg) in commander_dmg_entries {
987                    if dmg >= 21 && self.player(pid).is_alive() && !self.player(pid).has_lost {
988                        self.player_mark_lost(pid, GameLossReason::CommanderDamage);
989                        newly_lost_players.push(pid);
990                        any_changes = true;
991                    }
992                }
993            }
994
995            // CR 704.5z: If a player controls a permanent with Start your
996            // engines! and that player has no speed, their speed becomes 1.
997            if self.player(pid).speed == 0
998                && self
999                    .cards_in_zone(ZoneType::Battlefield, pid)
1000                    .iter()
1001                    .any(|&cid| self.card(cid).has_keyword("Start your engines"))
1002            {
1003                self.increase_player_speed(pid, None);
1004                any_changes = true;
1005            }
1006        }
1007
1008        if !newly_lost_players.is_empty() {
1009            for pid in &newly_lost_players {
1010                self.stack.remove_instances_controlled_by(*pid);
1011            }
1012            if let Some(handler) = trigger_handler.as_deref_mut() {
1013                for pid in &newly_lost_players {
1014                    handler.run_trigger(
1015                        TriggerType::LosesGame,
1016                        RunParams {
1017                            player: Some(*pid),
1018                            ..Default::default()
1019                        },
1020                        false,
1021                    );
1022                    handler.on_player_lost(*pid);
1023                }
1024            }
1025        }
1026
1027        // Check creatures with lethal damage or 0 toughness
1028        let battlefield_cards: Vec<CardId> = self
1029            .player_order
1030            .clone()
1031            .iter()
1032            .flat_map(|&pid| self.cards_in_zone(ZoneType::Battlefield, pid).to_vec())
1033            .collect();
1034
1035        for cid in battlefield_cards {
1036            let (is_creature, zero_toughness, lethal, should_die) = {
1037                let card = &self.cards[cid.index()];
1038                let is_creature = card.is_creature();
1039                let zero_toughness = card.toughness() <= 0;
1040                let lethal = card.lethal_damage() || card.has_deathtouch_damage;
1041                let should_die = zero_toughness || lethal;
1042                (is_creature, zero_toughness, lethal, should_die)
1043            };
1044            if is_creature && should_die {
1045                // Clear deathtouch flag regardless of outcome (mirrors Java
1046                // GameAction.java line 1491: c.setHasBeenDealtDeathtouchDamage(false)).
1047                self.cards[cid.index()].has_deathtouch_damage = false;
1048                // CR 702.12: Indestructible prevents death from lethal damage and
1049                // "destroy" effects, but NOT from toughness ≤ 0 (CR 704.5f vs 704.5g).
1050                // This covers K:Indestructible from Forge card scripts (e.g. Darksteel Myr).
1051                if lethal
1052                    && !zero_toughness
1053                    && self.cards[cid.index()].has_keyword("Indestructible")
1054                {
1055                    continue;
1056                }
1057                // CR 702.89: Umbra armor (Totem Armor) — if enchanted creature
1058                // would be destroyed, instead remove all damage and destroy the aura.
1059                let has_umbra = self.cards[cid.index()].attachments.iter().any(|&aid| {
1060                    aid.index() < self.cards.len()
1061                        && self.cards[aid.index()].zone == ZoneType::Battlefield
1062                        && (self.cards[aid.index()].has_keyword("Umbra armor")
1063                            || self.cards[aid.index()].has_keyword("Totem armor"))
1064                });
1065                if has_umbra && !zero_toughness {
1066                    // Find the first umbra armor aura and destroy it instead
1067                    let umbra_id =
1068                        self.cards[cid.index()]
1069                            .attachments
1070                            .iter()
1071                            .copied()
1072                            .find(|&aid| {
1073                                aid.index() < self.cards.len()
1074                                    && self.cards[aid.index()].zone == ZoneType::Battlefield
1075                                    && (self.cards[aid.index()].has_keyword("Umbra armor")
1076                                        || self.cards[aid.index()].has_keyword("Totem armor"))
1077                            });
1078                    if let Some(umbra_id) = umbra_id {
1079                        // Remove all damage from the creature
1080                        self.cards[cid.index()].damage = 0;
1081                        self.cards[cid.index()].has_deathtouch_damage = false;
1082                        // Destroy the aura instead
1083                        let umbra_owner = self.cards[umbra_id.index()].owner;
1084                        let old_zone = self.cards[umbra_id.index()].zone;
1085                        self.move_card(umbra_id, ZoneType::Graveyard, umbra_owner);
1086                        if let Some(handler) = trigger_handler.as_deref_mut() {
1087                            crate::ability::effects::emit_zone_trigger(
1088                                handler,
1089                                umbra_id,
1090                                old_zone,
1091                                ZoneType::Graveyard,
1092                            );
1093                        }
1094                        any_changes = true;
1095                        continue; // Creature survives
1096                    }
1097                }
1098
1099                if zero_toughness {
1100                    self.move_battlefield_card_to_graveyard_for_sba(
1101                        cid,
1102                        &mut trigger_handler,
1103                        &mut agents,
1104                    );
1105                    any_changes = true;
1106                    continue;
1107                }
1108
1109                // Run Destroy replacement effects (R$-based indestructible, etc.).
1110                // Mirrors Java GameAction.destroy() → ReplacementHandler.run(Destroy, …).
1111                let mut destroy_event = ReplacementEvent::Destroy { target: cid };
1112                let result = apply_replacements(self, &mut destroy_event);
1113                if result != ReplacementResult::Replaced {
1114                    self.move_battlefield_card_to_graveyard_for_sba(
1115                        cid,
1116                        &mut trigger_handler,
1117                        &mut agents,
1118                    );
1119                    // Same-SBA-batch LTB lookback is derived per-event from
1120                    // `pre_sba_battlefield` in `TriggerHandler::ltb_trigger_refs_for_event`.
1121                    // No global registration needed.
1122                    any_changes = true;
1123                } else {
1124                    // Indestructible — destruction was replaced; creature stays.
1125                    // Damage is still marked but the creature does not die.
1126                }
1127            }
1128        }
1129
1130        let battlefield_cards: Vec<CardId> = self
1131            .player_order
1132            .clone()
1133            .iter()
1134            .flat_map(|&pid| self.cards_in_zone(ZoneType::Battlefield, pid).to_vec())
1135            .collect();
1136
1137        for cid in battlefield_cards {
1138            let should_put_in_graveyard = {
1139                let card = self.card(cid);
1140                card.type_line.is_planeswalker() && card.counter_count(&CounterType::Loyalty) <= 0
1141            };
1142            if !should_put_in_graveyard {
1143                continue;
1144            }
1145
1146            self.move_battlefield_card_to_graveyard_for_sba(cid, &mut trigger_handler, &mut agents);
1147            any_changes = true;
1148        }
1149
1150        // CR 704.5q: +1/+1 and -1/-1 counter cancellation
1151        for &pid in &self.player_order.clone() {
1152            let battlefield = self.cards_in_zone(ZoneType::Battlefield, pid).to_vec();
1153            for cid in battlefield {
1154                let p1 = self.card(cid).counter_count(&CounterType::P1P1);
1155                let m1 = self.card(cid).counter_count(&CounterType::M1M1);
1156                if p1 > 0 && m1 > 0 {
1157                    let cancel = p1.min(m1);
1158                    self.card_mut(cid)
1159                        .remove_counter(&CounterType::P1P1, cancel);
1160                    self.card_mut(cid)
1161                        .remove_counter(&CounterType::M1M1, cancel);
1162                    any_changes = true;
1163                }
1164            }
1165        }
1166
1167        // CR 903.9a: a commander in graveyard or exile may move to command zone.
1168        for &pid in &self.player_order.clone() {
1169            let mut commander_candidates = self.cards_in_zone(ZoneType::Graveyard, pid).to_vec();
1170            commander_candidates.extend(self.cards_in_zone(ZoneType::Exile, pid).iter().copied());
1171            for cid in commander_candidates {
1172                if !self.card(cid).can_move_to_command_zone() {
1173                    continue;
1174                }
1175                self.card_mut(cid).move_to_command_zone = false;
1176                let accepted = if let Some(agents) = agents.as_deref_mut() {
1177                    let name = self.card(cid).card_name.clone();
1178                    let message = format!(
1179                        "{}: 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.",
1180                        name
1181                    );
1182                    agents[pid.index()].confirm_action(
1183                        pid,
1184                        Some("ChangeZoneToAltDestination"),
1185                        &message,
1186                        &[],
1187                        Some(cid),
1188                        None,
1189                    )
1190                } else {
1191                    false
1192                };
1193                if accepted {
1194                    self.move_card_without_replacement(cid, ZoneType::Command, pid);
1195                    any_changes = true;
1196                }
1197            }
1198        }
1199
1200        // Legend rule: for each player, if they control multiple legendary
1201        // permanents with the same name, keep one and move the rest to graveyard.
1202        // IgnoreLegendRule statics exempt matching cards.
1203        for &pid in &self.player_order.clone() {
1204            let battlefield = self.cards_in_zone(ZoneType::Battlefield, pid).to_vec();
1205            let mut by_name: std::collections::BTreeMap<String, Vec<CardId>> =
1206                std::collections::BTreeMap::new();
1207            for cid in battlefield {
1208                let c = self.card(cid);
1209                if !c.type_line.is_legendary() {
1210                    continue;
1211                }
1212                if crate::staticability::static_ability_ignore_legend_rule::ignore_legend_rule(
1213                    &self.cards,
1214                    c,
1215                ) {
1216                    continue;
1217                }
1218                by_name.entry(c.card_name.clone()).or_default().push(cid);
1219            }
1220            for (_name, ids) in by_name {
1221                if ids.len() <= 1 {
1222                    continue;
1223                }
1224                // Choose which to keep: delegate to callback (mirrors Java's
1225                // chooseSingleEntityForEffect), or default to first in zone order.
1226                let keep = if let Some(ref mut chooser) = legend_keep_fn {
1227                    chooser(pid, &ids)
1228                } else if let Some(agents) = agents.as_deref_mut() {
1229                    agents[pid.index()].choose_legend_keep(pid, &ids)
1230                } else {
1231                    ids[0]
1232                };
1233                for cid in ids {
1234                    if cid == keep {
1235                        continue;
1236                    }
1237                    let owner = self.card(cid).owner;
1238                    let old_zone = self.card(cid).zone;
1239                    if let Some(agents) = agents.as_deref_mut() {
1240                        self.move_card_with_agents(cid, ZoneType::Graveyard, owner, agents);
1241                    } else {
1242                        self.move_card(cid, ZoneType::Graveyard, owner);
1243                    }
1244                    if let Some(handler) = trigger_handler.as_deref_mut() {
1245                        crate::ability::effects::emit_zone_trigger(
1246                            handler,
1247                            cid,
1248                            old_zone,
1249                            ZoneType::Graveyard,
1250                        );
1251                    }
1252                    any_changes = true;
1253                }
1254            }
1255        }
1256
1257        // CR 704.5n: Aura SBA — an Aura on the battlefield that is not attached
1258        // to a legal permanent (or whose host left the battlefield) is put into
1259        // its owner's graveyard.
1260        {
1261            let aura_ids: Vec<CardId> = self
1262                .cards
1263                .iter()
1264                .filter(|c| {
1265                    c.zone == ZoneType::Battlefield
1266                        && c.type_line.has_subtype("Aura")
1267                        && !c.type_line.is_creature() // Bestowed auras that became creatures stay
1268                })
1269                .filter(|c| {
1270                    match (c.attached_to, c.attached_to_player) {
1271                        (None, None) => true, // Not attached to anything — orphaned
1272                        (None, Some(player_id)) => {
1273                            if player_id.index() >= self.players.len() {
1274                                return true;
1275                            }
1276                            let player = &self.players[player_id.index()];
1277                            let enchant_type = c
1278                                .keywords
1279                                .iter_strings()
1280                                .find_map(|kw| {
1281                                    crate::keyword::extract_keyword_cost_str(kw, "Enchant")
1282                                })
1283                                .unwrap_or_default();
1284                            player.has_lost || !enchant_type.eq_ignore_ascii_case("Player")
1285                        }
1286                        (Some(host_id), _) => {
1287                            if host_id.index() >= self.cards.len() {
1288                                return true; // Invalid host ID
1289                            }
1290                            let host = &self.cards[host_id.index()];
1291                            // CR 704.5n: check if the enchant restriction is still met.
1292                            // E.g. "Enchant creature" requires a battlefield creature, while
1293                            // Animate Dead's "Enchant creature card in a graveyard" remains legal
1294                            // while attached to a creature card in a graveyard.
1295                            let enchant_type = c
1296                                .keywords
1297                                .iter_strings()
1298                                .find_map(|kw| {
1299                                    crate::keyword::extract_keyword_cost_str(kw, "Enchant")
1300                                })
1301                                .unwrap_or_default();
1302                            !crate::parsing::enchant_type_matches_card(enchant_type, host, Some(c))
1303                                || !can_attachment_remain_attached(&self.cards, c, host, true)
1304                        }
1305                    }
1306                })
1307                .map(|c| c.id)
1308                .collect();
1309
1310            for aura_id in aura_ids {
1311                let owner = self.card(aura_id).owner;
1312                let old_zone = self.card(aura_id).zone;
1313                self.move_card(aura_id, ZoneType::Graveyard, owner);
1314                if let Some(handler) = trigger_handler.as_deref_mut() {
1315                    crate::ability::effects::emit_zone_trigger(
1316                        handler,
1317                        aura_id,
1318                        old_zone,
1319                        ZoneType::Graveyard,
1320                    );
1321                }
1322                any_changes = true;
1323            }
1324        }
1325
1326        // Check game over
1327        let alive = self.alive_players();
1328        if alive.len() <= 1 {
1329            self.game_over = true;
1330            if alive.len() == 1 {
1331                self.winner = Some(alive[0]);
1332            }
1333        }
1334
1335        any_changes
1336    }
1337
1338    /// Untap all permanents controlled by a player.
1339    /// Runs Untap replacement effects for each permanent.
1340    pub fn untap_all(&mut self, player: PlayerId) {
1341        let cards: Vec<CardId> = self.cards_in_zone(ZoneType::Battlefield, player).to_vec();
1342        for cid in cards {
1343            // Use untap() which runs replacement effects
1344            self.untap_during_untap_step(cid, player);
1345        }
1346    }
1347
1348    /// Draw a card for a player. Returns the drawn card ID, or None if the draw
1349    /// was skipped or the library is empty.
1350    ///
1351    /// Runs Draw replacement effects before drawing.  If the draw is replaced
1352    /// (e.g. "skip your draw step"), returns `None`.
1353    ///
1354    /// Mirrors Java `GameAction.draw()` calling `ReplacementHandler.run(Draw, …)`.
1355    pub fn draw_card(&mut self, player: PlayerId) -> Option<CardId> {
1356        self.player_draw_one(player)
1357    }
1358
1359    /// Draw a card with agent access for Optional replacement effects (Dredge).
1360    pub fn draw_card_with_agents(
1361        &mut self,
1362        player: PlayerId,
1363        agents: &mut [Box<dyn crate::agent::PlayerAgent>],
1364    ) -> Option<CardId> {
1365        self.player_draw_one_internal(player, false, Some(agents))
1366    }
1367
1368    /// Draw N cards for a player. Returns drawn card IDs.
1369    pub fn draw_cards(&mut self, player: PlayerId, n: usize) -> Vec<CardId> {
1370        self.player_draw_cards(player, n)
1371    }
1372
1373    /// Shuffle a player's library using the provided RNG.
1374    pub fn shuffle_library(&mut self, player: PlayerId, rng: &mut impl rand::Rng) {
1375        self.shuffle_zone_cards_with_rand(ZoneType::Library, player, rng);
1376    }
1377
1378    /// Reset per-turn state for all cards and players of a given player.
1379    pub fn new_turn_for_player(&mut self, player: PlayerId) {
1380        self.player_new_turn(player);
1381        // Reset turn-scoped player stats for ALL non-active players too.
1382        // These counters are "this turn" in the global turn sense, not "that
1383        // player's own turn". Without this, effects like Resplendent Angel can
1384        // incorrectly carry life gained from the previous player's turn.
1385        for pid in &self.player_order.clone() {
1386            if *pid != player {
1387                self.player_reset_drawn_this_turn(*pid);
1388                let p = self.player_mut(*pid);
1389                p.life_started_this_turn_with = p.life;
1390                p.life_gained_this_turn = 0;
1391                p.life_gained_by_team_this_turn = 0;
1392                p.life_gained_times_this_turn = 0;
1393                p.life_lost_last_turn = p.life_lost_this_turn;
1394                p.life_lost_this_turn = 0;
1395            }
1396        }
1397
1398        let all_card_ids: Vec<CardId> = (0..self.cards.len()).map(|i| CardId(i as u32)).collect();
1399        for cid in all_card_ids {
1400            if self.cards[cid.index()].zone == ZoneType::Battlefield {
1401                self.cards[cid.index()].started_turn_tapped = self.cards[cid.index()].tapped;
1402            }
1403            if self.cards[cid.index()].controller == player {
1404                self.cards[cid.index()].new_turn();
1405            } else {
1406                self.cards[cid.index()].clear_global_turn_state();
1407            }
1408        }
1409    }
1410
1411    /// Tap a card. Returns true if it was untapped.
1412    /// Runs Tap replacement effects before tapping.
1413    pub fn tap(&mut self, card_id: CardId) -> bool {
1414        let card = &self.cards[card_id.index()];
1415        if card.tapped {
1416            return false;
1417        }
1418        // Run Tap replacement effects.
1419        let mut event = ReplacementEvent::Tap { card: card_id };
1420        let result = apply_replacements(self, &mut event);
1421        if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
1422            return false; // Tap was prevented
1423        }
1424        self.cards[card_id.index()].tapped = true;
1425        true
1426    }
1427
1428    /// Untap a card. Returns true if it was tapped.
1429    /// Runs Untap replacement effects before untapping.
1430    pub fn untap(&mut self, card_id: CardId) -> bool {
1431        self.untap_internal(card_id, None)
1432    }
1433
1434    pub fn untap_during_untap_step(&mut self, card_id: CardId, player: PlayerId) -> bool {
1435        self.untap_internal(card_id, Some(player))
1436    }
1437
1438    fn untap_internal(&mut self, card_id: CardId, player: Option<PlayerId>) -> bool {
1439        let card = &self.cards[card_id.index()];
1440        if !card.tapped {
1441            return false;
1442        }
1443        let stun = CounterType::Named("STUN".to_string());
1444        if card.counter_count(&stun) > 0 && card.can_remove_counters(&stun) {
1445            // Stun counters replace the untap event: remove one counter and keep the
1446            // permanent tapped. This mirrors Java's built-in stun untap replacement.
1447            self.cards[card_id.index()].remove_counter(&stun, 1);
1448            return false;
1449        }
1450        // Run Untap replacement effects.
1451        let mut event = ReplacementEvent::Untap {
1452            card: card_id,
1453            player,
1454        };
1455        let result = apply_replacements(self, &mut event);
1456        if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
1457            return false; // Untap was prevented
1458        }
1459        self.cards[card_id.index()].tapped = false;
1460        // `ControlGain$ LoseControl$ Untap` — revert scheduled steal now.
1461        crate::ability::effects::control_gain_effect::untap_hook(self, card_id);
1462        true
1463    }
1464
1465    /// Change the controller of a permanent to `new_controller`.
1466    /// Mirrors Java's `GameAction.controllerChangeZoneCorrection()` — moves the
1467    /// card between per-player zone lists and updates the controller field.
1468    pub fn change_controller(&mut self, card_id: CardId, new_controller: PlayerId) {
1469        let card = &self.cards[card_id.index()];
1470        if card.controller == new_controller {
1471            return;
1472        }
1473        let old_controller = card.controller;
1474        let zone = card.zone;
1475
1476        // Move between zone lists
1477        if zone != ZoneType::None {
1478            self.remove_card_from_zone(zone, old_controller, card_id);
1479            self.add_card_to_zone(zone, new_controller, card_id);
1480        }
1481        self.cards[card_id.index()].controller = new_controller;
1482    }
1483
1484    /// Attach `aura_id` to `target_id`.
1485    /// If `aura_id` was already attached elsewhere, detach it first.
1486    /// Mirrors Java's `Card.enchantEntity()` / `Card.equip()`.
1487    pub fn attach_to(&mut self, aura_id: CardId, target_id: CardId) {
1488        // Detach from previous host if any
1489        self.detach(aura_id);
1490        self.cards[aura_id.index()].attached_to = Some(target_id);
1491        self.cards[aura_id.index()].attached_to_player = None;
1492        self.cards[aura_id.index()].attached_this_turn = true;
1493        self.cards[target_id.index()].attachments.push(aura_id);
1494    }
1495
1496    pub fn attach_to_player(&mut self, aura_id: CardId, player_id: PlayerId) {
1497        self.detach(aura_id);
1498        self.cards[aura_id.index()].attached_to = None;
1499        self.cards[aura_id.index()].attached_to_player = Some(player_id);
1500        self.cards[aura_id.index()].attached_this_turn = true;
1501    }
1502
1503    /// Detach `aura_id` from whatever it is currently attached to.
1504    /// Mirrors Java's `Card.unattachFromEntity()`.
1505    pub fn detach(&mut self, aura_id: CardId) {
1506        if let Some(host_id) = self.cards[aura_id.index()].attached_to.take() {
1507            self.cards[host_id.index()]
1508                .attachments
1509                .retain(|&a| a != aura_id);
1510            // Bestow: when unattached, revert to a creature
1511            self.cards[aura_id.index()].is_bestowed = false;
1512        }
1513        self.cards[aura_id.index()].attached_to_player = None;
1514    }
1515
1516    /// Move a card from its current zone to the bottom of a player's library.
1517    /// Unlike `move_card`, this places the card at the bottom rather than the top.
1518    pub fn put_on_bottom_of_library(&mut self, card_id: CardId, owner: PlayerId) {
1519        let card = &self.cards[card_id.index()];
1520        let src_zone = card.zone;
1521        let src_owner = card.controller;
1522
1523        if src_zone != ZoneType::None {
1524            self.remove_card_from_zone(src_zone, src_owner, card_id);
1525        }
1526
1527        self.cards[card_id.index()].zone = ZoneType::Library;
1528        self.assign_zone_timestamp(card_id);
1529        self.add_card_to_zone_bottom(ZoneType::Library, owner, card_id);
1530    }
1531
1532    /// Remove a spell from the stack by its entry ID (used by Counter).
1533    /// Mirrors Java's `Game.getStack().remove(sa)`.
1534    pub fn remove_from_stack(&mut self, entry_id: u32) -> bool {
1535        self.stack.remove_by_id(entry_id).is_some()
1536    }
1537}
1538
1539fn can_attachment_remain_attached(
1540    cards: &[Card],
1541    attachment: &Card,
1542    target: &Card,
1543    check_sba: bool,
1544) -> bool {
1545    if target.zone != ZoneType::Battlefield {
1546        return true;
1547    }
1548    if crate::staticability::static_ability_cant_attach::cant_attach(
1549        cards, attachment, target, check_sba,
1550    ) {
1551        return false;
1552    }
1553    !crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
1554        cards, target, attachment,
1555    )
1556}
1557
1558#[cfg(test)]
1559mod tests {
1560    use super::*;
1561    use crate::card::Card;
1562    use crate::player::RegisteredPlayer;
1563    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
1564
1565    fn make_creature(game: &mut GameState, name: &str, owner: PlayerId, p: i32, t: i32) -> CardId {
1566        let card = Card::new(
1567            CardId(0),
1568            name.to_string(),
1569            owner,
1570            CardTypeLine::parse("Creature Bear"),
1571            ManaCost::parse("1 G"),
1572            ColorSet::GREEN,
1573            Some(p),
1574            Some(t),
1575            vec![],
1576            vec![],
1577        );
1578        game.create_card(card)
1579    }
1580
1581    #[test]
1582    fn move_card_to_battlefield() {
1583        let mut game = GameState::new(&["Alice", "Bob"], 20);
1584        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1585        game.move_card(cid, ZoneType::Hand, PlayerId(0));
1586        assert_eq!(game.zone(ZoneType::Hand, PlayerId(0)).len(), 1);
1587
1588        game.move_card(cid, ZoneType::Battlefield, PlayerId(0));
1589        assert_eq!(game.zone(ZoneType::Hand, PlayerId(0)).len(), 0);
1590        assert_eq!(game.zone(ZoneType::Battlefield, PlayerId(0)).len(), 1);
1591        assert_eq!(game.card(cid).zone, ZoneType::Battlefield);
1592    }
1593
1594    #[test]
1595    fn state_based_actions_lethal_damage() {
1596        let mut game = GameState::new(&["Alice", "Bob"], 20);
1597        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1598        game.move_card(cid, ZoneType::Battlefield, PlayerId(0));
1599
1600        game.deal_damage_to_card(cid, 2);
1601        assert!(game.check_state_based_actions());
1602        assert_eq!(game.zone(ZoneType::Graveyard, PlayerId(0)).len(), 1);
1603    }
1604
1605    #[test]
1606    fn state_based_actions_zero_life() {
1607        let mut game = GameState::new(&["Alice", "Bob"], 20);
1608        game.deal_damage_to_player(PlayerId(0), 20);
1609        game.check_state_based_actions();
1610        assert!(game.player(PlayerId(0)).has_lost);
1611        assert!(game.game_over);
1612        assert_eq!(game.winner, Some(PlayerId(1)));
1613    }
1614
1615    #[test]
1616    fn draw_card() {
1617        let mut game = GameState::new(&["Alice", "Bob"], 20);
1618        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1619        game.move_card(cid, ZoneType::Library, PlayerId(0));
1620
1621        let drawn = game.draw_card(PlayerId(0));
1622        assert_eq!(drawn, Some(cid));
1623        assert_eq!(game.card(cid).zone, ZoneType::Hand);
1624    }
1625
1626    #[test]
1627    fn tap_untap() {
1628        let mut game = GameState::new(&["Alice", "Bob"], 20);
1629        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1630        game.move_card(cid, ZoneType::Battlefield, PlayerId(0));
1631
1632        assert!(game.tap(cid));
1633        assert!(game.card(cid).tapped);
1634        assert!(!game.tap(cid)); // already tapped
1635        assert!(game.untap(cid));
1636        assert!(!game.card(cid).tapped);
1637    }
1638
1639    #[test]
1640    fn stun_counter_replaces_untap() {
1641        let mut game = GameState::new(&["Alice", "Bob"], 20);
1642        let cid = make_creature(&mut game, "Bear", PlayerId(0), 2, 2);
1643        game.move_card(cid, ZoneType::Battlefield, PlayerId(0));
1644        game.tap(cid);
1645        game.card_mut(cid)
1646            .add_counter(&CounterType::Named("STUN".to_string()), 1);
1647
1648        assert!(!game.untap(cid));
1649        assert!(game.card(cid).tapped);
1650        assert_eq!(
1651            game.card(cid)
1652                .counter_count(&CounterType::Named("STUN".to_string())),
1653            0
1654        );
1655    }
1656}