Skip to main content

manabrew_engine/trigger/
trigger_handler.rs

1use std::collections::HashSet;
2
3use forge_foundation::ZoneType;
4
5use crate::agent::{notify_all_agents, GameLogEvent, PlayerAgent};
6use crate::card::valid_filter;
7use crate::event::RunParams;
8use crate::game::GameState;
9use crate::ids::{CardId, PlayerId};
10use crate::mana::ManaPool;
11use crate::parsing::compare::compare_expr;
12use crate::spellability::{build_spell_ability, StackEntry};
13use crate::trigger::TriggerType;
14use crate::trigger::{parse_trigger, Trigger};
15
16/// An active trigger reference — (card_id, trigger_index) pair.
17/// In Java this is a direct Trigger object reference. In Rust,
18/// we use IDs because of the arena pattern (no references).
19#[derive(Debug, Clone)]
20struct ActiveTrigger {
21    card_id: CardId,
22    trigger_index: usize,
23}
24
25/// Mirrors Java's TriggerWaiting.
26#[derive(Debug, Clone)]
27struct TriggerWaiting {
28    mode: TriggerType,
29    params: RunParams,
30    trigger_refs: Option<Vec<(CardId, usize)>>,
31}
32
33/// A one-shot delayed trigger.
34#[derive(Debug, Clone)]
35pub struct DelayedTrigger {
36    pub mode: TriggerType,
37    pub trigger_mode: Box<dyn crate::trigger::TriggerBehavior>,
38    pub params: crate::parsing::Params,
39    pub execute_svar: String,
40    pub controller: PlayerId,
41    pub source_card: CardId,
42    /// Turn number when this delayed trigger was registered.
43    pub created_turn: u32,
44    /// Phase during which this delayed trigger was registered.
45    pub created_phase: forge_foundation::PhaseType,
46    /// Optional target card for the delayed trigger (e.g. the creature to bounce for Dash
47    /// or sacrifice for Blitz at end of turn).
48    pub target_card: Option<CardId>,
49    /// Sum of integer values remembered by the delayed trigger at creation.
50    pub remembered_amount: i32,
51    /// Cards remembered by the delayed trigger (e.g. `RememberObjects$ Remembered`
52    /// copies the source card's remembered_cards snapshot at registration time).
53    /// Exposed to the executed ability via `SpellAbility::trigger_remembered`.
54    #[allow(dead_code)]
55    pub remembered_cards: Vec<CardId>,
56    /// Players remembered by the delayed trigger (e.g.
57    /// `RememberObjects$ RememberedController` snapshots the controllers of
58    /// the source's remembered cards at registration time, used by Arcane
59    /// Denial's "may draw up to two cards" trigger to remember the controller
60    /// of the countered spell). Exposed via `SpellAbility::trigger_remembered`
61    /// as `AbilityValue::Player(_)` entries.
62    pub remembered_players: Vec<PlayerId>,
63    /// Snapshot of remembered cards for delayed-trigger `RememberedLKI` lookups.
64    pub remembered_lki_cards: Vec<CardId>,
65    /// When true, this delayed trigger should sort AFTER same-event active
66    /// triggers (resolve FIRST on the LIFO stack). Used to mirror Java's
67    /// trigger-ID-based ordering for granted-evoke sacrifice triggers, whose
68    /// trigger ID is assigned after the card's intrinsic T: triggers and thus
69    /// lands on top of the stack.
70    pub sort_after_active: bool,
71    /// Java delayed triggers are real Trigger instances and receive a trigger
72    /// id when parsed. Assign the equivalent order at registration so
73    /// simultaneous delayed triggers do not tie on host timestamp.
74    pub trigger_order: Option<u32>,
75}
76
77impl DelayedTrigger {
78    /// Build a temporary `Trigger` wrapper for calling `TriggerBehavior` trait methods
79    /// that require a `&Trigger` reference (Java's `this`).
80    pub fn as_trigger(&self, _game: &crate::game::GameState) -> crate::trigger::Trigger {
81        let Self {
82            params: delayed_params,
83            ..
84        } = self;
85        let mut base =
86            crate::game_loop::trigger_replacement_base::TriggerReplacementBase::default();
87        base.set_host_card_id(self.source_card);
88        crate::trigger::Trigger {
89            id: self.trigger_order.unwrap_or(u32::MAX),
90            base,
91            kind: self.mode,
92            mode: dyn_clone::clone_box(&*self.trigger_mode),
93            ir: crate::trigger::trigger_ir::TriggerIr::from_params(delayed_params),
94            execute: self.execute_svar.clone(),
95            optional: false,
96            description: String::new(),
97            static_trigger: false,
98            trigger_remembered: self
99                .remembered_cards
100                .iter()
101                .copied()
102                .map(crate::event::AbilityValue::Card)
103                .collect(),
104            spawning_ability: None,
105        }
106    }
107}
108
109/// A triggered ability ready to be placed on the stack, with optional metadata.
110#[derive(Debug, Clone)]
111pub struct PendingTrigger {
112    pub entry: StackEntry,
113    pub ability_triggered: Option<RunParams>,
114    /// Whether this trigger is optional (has OptionalDecider$).
115    pub optional: bool,
116    /// The player who decides whether the trigger fires (usually the controller).
117    pub decider: PlayerId,
118    /// Description text for the trigger (shown to player for optional triggers).
119    pub description: String,
120}
121
122#[derive(Debug, Clone)]
123pub struct TriggerPushLog {
124    pub source_name: String,
125    pub player_name: String,
126    pub optional: bool,
127    pub trigger_api: String,
128}
129
130type MatchedTrigger = (PendingTrigger, PlayerId, u64, u8, u32);
131
132/// Mirrors Java's TriggerHandler — central trigger dispatcher.
133/// In Java, lives on Game. In Rust, lives on GameLoop because
134/// active_triggers and waiting_triggers are transient processing state.
135#[allow(dead_code)]
136#[derive(Debug, Clone)]
137pub struct TriggerHandler {
138    active_triggers: Vec<ActiveTrigger>,
139    waiting_triggers: Vec<TriggerWaiting>,
140    delayed_triggers: Vec<DelayedTrigger>,
141    this_turn_delayed_triggers: Vec<DelayedTrigger>,
142    player_defined_delayed_triggers: Vec<(PlayerId, DelayedTrigger)>,
143    suppressed_modes: HashSet<TriggerType>,
144    all_suppressed: bool,
145    next_trigger_id: u32,
146    /// Triggers that were matched early (before SBA) and are waiting to be
147    /// placed on the stack. This ensures triggers from creatures that die to
148    /// SBA (e.g. Raptor Hatchling's enrage) are not lost.
149    /// Tuple: (PendingTrigger, controller, zone timestamp, trigger bucket, trigger order).
150    pre_matched_triggers: Vec<MatchedTrigger>,
151}
152
153impl TriggerHandler {
154    pub fn new() -> Self {
155        TriggerHandler {
156            active_triggers: Vec::new(),
157            waiting_triggers: Vec::new(),
158            delayed_triggers: Vec::new(),
159            this_turn_delayed_triggers: Vec::new(),
160            player_defined_delayed_triggers: Vec::new(),
161            suppressed_modes: HashSet::new(),
162            all_suppressed: false,
163            next_trigger_id: 0,
164            pre_matched_triggers: Vec::new(),
165        }
166    }
167
168    /// Mirrors Java's runTrigger() — main entry point.
169    /// Called from game actions when events occur.
170    /// If `hold` is true, event is queued; otherwise it's also queued
171    /// (all triggers go through the waiting queue for APNAP ordering).
172    pub fn run_trigger(&mut self, mode: TriggerType, params: RunParams, hold: bool) {
173        if self.is_trigger_suppressed(mode) {
174            return;
175        }
176
177        // Java parity: mana triggers are not held/frozen. We don't run them inline
178        // here because matching still flows through waiting processing, but we do
179        // force front-of-queue delivery when hold is false.
180        let urgent = !hold
181            && matches!(
182                mode,
183                TriggerType::Always | TriggerType::TapsForMana | TriggerType::ManaAdded
184            );
185        if urgent {
186            self.waiting_triggers
187                .insert(0, self.build_waiting_trigger(mode, params.clone()));
188        } else {
189            self.waiting_triggers
190                .push(self.build_waiting_trigger(mode, params.clone()));
191        }
192
193        if mode == TriggerType::SpellCast {
194            self.waiting_triggers
195                .push(self.build_waiting_trigger(TriggerType::SpellAbilityCast, params.clone()));
196            self.waiting_triggers
197                .push(self.build_waiting_trigger(TriggerType::SpellCastOrCopy, params.clone()));
198        }
199        if mode == TriggerType::AbilityCast {
200            self.waiting_triggers
201                .push(self.build_waiting_trigger(TriggerType::SpellAbilityCast, params.clone()));
202        }
203        if mode == TriggerType::SpellCopied {
204            self.waiting_triggers
205                .push(self.build_waiting_trigger(TriggerType::SpellCopy, params.clone()));
206            self.waiting_triggers
207                .push(self.build_waiting_trigger(TriggerType::SpellAbilityCopy, params.clone()));
208            self.waiting_triggers
209                .push(self.build_waiting_trigger(TriggerType::SpellCastOrCopy, params.clone()));
210        }
211    }
212
213    /// Java-parity entrypoint that can resolve triggers immediately when the
214    /// event should not be held/frozen.
215    pub fn run_trigger_with_game(
216        &mut self,
217        game: &GameState,
218        mode: TriggerType,
219        params: RunParams,
220        hold: bool,
221    ) -> Vec<PendingTrigger> {
222        self.run_trigger(mode, params, hold);
223        let resolve_now = mode == TriggerType::Always
224            || (!hold
225                && !game.stack.is_frozen()
226                && !matches!(mode, TriggerType::TapsForMana | TriggerType::ManaAdded));
227        if resolve_now {
228            self.run_waiting_triggers(game)
229        } else {
230            Vec::new()
231        }
232    }
233
234    /// Java parity wrapper for TriggerHandler.parseTrigger(String).
235    pub fn parse_trigger(&mut self, raw: &str) -> Option<Trigger> {
236        parse_trigger(raw, &mut self.next_trigger_id)
237    }
238
239    /// Java parity wrapper for TriggerHandler.collectTriggerForWaiting(...).
240    pub fn collect_trigger_for_waiting(&mut self, mode: TriggerType, params: RunParams) {
241        self.waiting_triggers
242            .push(self.build_waiting_trigger(mode, params));
243    }
244
245    /// Number of triggers in the waiting queue (for debug/diagnostics).
246    pub fn waiting_trigger_count(&self) -> usize {
247        self.waiting_triggers.len()
248    }
249
250    pub fn pre_matched_trigger_count(&self) -> usize {
251        self.pre_matched_triggers.len()
252    }
253
254    /// Match waiting triggers NOW, while source cards are still in their
255    /// current zones.  Stores results in `pre_matched_triggers` so that a
256    /// subsequent `run_waiting_triggers` call returns them even if SBA has
257    /// since moved the source card (e.g. Raptor Hatchling dying to combat
258    /// damage before triggers go on the stack).
259    ///
260    /// Call this immediately after firing triggers and before SBA.
261    /// Returns true if any active trigger is a Drawn trigger with Number$ set.
262    /// Used to gate flush_waiting_triggers during draws to avoid disrupting
263    /// other trigger matching when no Number$-gated Drawn triggers exist.
264    pub fn has_number_drawn_triggers(&self, game: &GameState) -> bool {
265        self.active_triggers.iter().any(|at| {
266            let card = game.card(at.card_id);
267            if at.trigger_index >= card.triggers.len() {
268                return false;
269            }
270            {
271                let t = &card.triggers[at.trigger_index];
272                t.kind == TriggerType::Drawn && t.mode.drawn_number().is_some()
273            }
274        })
275    }
276
277    pub fn flush_waiting_triggers(&mut self, game: &GameState) {
278        if self.waiting_triggers.is_empty() && self.delayed_triggers.is_empty() {
279            return;
280        }
281        let matched = self.match_waiting_triggers(game);
282        self.pre_matched_triggers.extend(matched);
283    }
284
285    /// Mirrors Java's runWaitingTriggers().
286    /// Drains waiting queue, matches triggers, returns PendingTriggers.
287    /// The caller (game_loop) handles OptionalDecider$ prompting.
288    pub fn run_waiting_triggers(&mut self, game: &GameState) -> Vec<PendingTrigger> {
289        // Start with any triggers that were pre-matched (flushed before SBA).
290        let mut entries: Vec<MatchedTrigger> = std::mem::take(&mut self.pre_matched_triggers);
291
292        if self.waiting_triggers.is_empty() && self.delayed_triggers.is_empty() {
293            if entries.is_empty() {
294                return Vec::new();
295            }
296            // Only have pre-matched — apply APNAP + zone timestamp ordering.
297            let active_player = game.active_player();
298            entries.sort_by_key(|(_, controller, ts, trigger_bucket, trigger_order)| {
299                (
300                    if *controller == active_player { 0u8 } else { 1 },
301                    if *trigger_bucket == 2 { 1u8 } else { 0 },
302                    *ts,
303                    *trigger_bucket,
304                    *trigger_order,
305                )
306            });
307            return entries
308                .into_iter()
309                .map(|(pending, _, _, _, _)| pending)
310                .collect();
311        }
312
313        // Match any remaining waiting triggers (those fired after the flush).
314        entries.extend(self.match_waiting_triggers(game));
315
316        // Fire Immediate delayed triggers — these fire "as soon as possible"
317        // without waiting for a matching event (mirrors Java registerDelayedTrigger
318        // with TriggerType.Immediate).
319        entries.extend(self.fire_immediate_delayed_triggers(game));
320
321        // APNAP ordering: active player's triggers first.
322        // Within the same player, order by zone_timestamp (older cards first),
323        // matching Java's forEachCardInGame() which iterates by Zone.cardList
324        // insertion order.
325        let active_player = game.active_player();
326        entries.sort_by_key(|(_, controller, ts, trigger_bucket, trigger_order)| {
327            (
328                if *controller == active_player { 0u8 } else { 1 },
329                if *trigger_bucket == 2 { 1u8 } else { 0 },
330                *ts,
331                *trigger_bucket,
332                *trigger_order,
333            )
334        });
335
336        entries
337            .into_iter()
338            .map(|(pending, _, _, _, _)| pending)
339            .collect()
340    }
341
342    pub fn process_waiting_triggers(
343        &mut self,
344        mana_pools: &[ManaPool],
345        game: &mut GameState,
346        agents: &mut [Box<dyn PlayerAgent>],
347    ) -> Vec<TriggerPushLog> {
348        let pending = self.run_waiting_triggers(game);
349        self.process_pending_triggers(mana_pools, game, agents, pending)
350    }
351
352    pub fn process_pending_triggers(
353        &mut self,
354        mana_pools: &[ManaPool],
355        game: &mut GameState,
356        agents: &mut [Box<dyn PlayerAgent>],
357        pending: Vec<PendingTrigger>,
358    ) -> Vec<TriggerPushLog> {
359        let mut push_logs = Vec::new();
360        for mut pt in pending {
361            let source_name = pt
362                .entry
363                .spell_ability
364                .source
365                .and_then(|id| game.cards.get(id.index()).map(|c| c.card_name.clone()))
366                .unwrap_or_else(|| "Triggered ability".to_string());
367            let player_name = game
368                .player(pt.entry.spell_ability.activating_player)
369                .name
370                .clone();
371
372            if pt.entry.spell_ability.api == Some(crate::ability::api_type::ApiType::Charm)
373                && !crate::ability::effects::charm_effect::make_choices_precast(
374                    game,
375                    agents,
376                    &mut pt.entry.spell_ability,
377                )
378            {
379                continue;
380            }
381
382            let setup_result = pt
383                .entry
384                .spell_ability
385                .setup_targets(game, agents, mana_pools);
386            if !setup_result {
387                continue;
388            }
389            if let Some(source_id) = pt.entry.spell_ability.source {
390                crate::ability::effects::emit_targeting_triggers_for_sa(
391                    self,
392                    game,
393                    source_id,
394                    &pt.entry.spell_ability,
395                );
396            }
397
398            if pt.optional {
399                pt.entry.optional_trigger_decider = Some(pt.decider);
400                pt.entry.optional_trigger_description = Some(pt.description.clone());
401                pt.entry.optional_trigger_source_name = Some(source_name.clone());
402            }
403
404            if let Some(source_id) = pt.entry.spell_ability.trigger_source {
405                if let Some(trig_idx) = pt.entry.spell_ability.trigger_index {
406                    if let Some(trigger) = game
407                        .cards
408                        .get(source_id.index())
409                        .and_then(|c| c.triggers.get(trig_idx))
410                        .cloned()
411                    {
412                        trigger.trigger_run(game, source_id);
413                    }
414                }
415            }
416
417            let trigger_mode = pt
418                .entry
419                .spell_ability
420                .trigger_source
421                .and_then(|source_id| {
422                    pt.entry.spell_ability.trigger_index.and_then(|idx| {
423                        game.cards
424                            .get(source_id.index())
425                            .and_then(|c| c.triggers.get(idx))
426                            .map(|t| t.kind.name().to_string())
427                    })
428                })
429                .unwrap_or_else(|| "DelayedOrUnknown".to_string());
430            let trigger_api = pt
431                .entry
432                .spell_ability
433                .api
434                .map(|a| a.name().to_string())
435                .unwrap_or_else(|| "Unknown".to_string());
436
437            let trigger_msg = if pt.description.is_empty() {
438                format!(
439                    "Trigger fired: mode={trigger_mode} | api={trigger_api} | source={source_name}"
440                )
441            } else {
442                format!(
443                    "Trigger fired: mode={} | api={} | source={} | {}",
444                    trigger_mode, trigger_api, source_name, pt.description
445                )
446            };
447            let mut event = GameLogEvent::stack(trigger_msg)
448                .with_player(pt.entry.spell_ability.activating_player);
449            if let Some(source_id) = pt.entry.spell_ability.source {
450                event = event.with_source_card(source_id);
451            }
452            if let Some(target_id) = pt.entry.spell_ability.target_chosen.target_card {
453                event = event.with_target_card(target_id);
454            }
455            notify_all_agents(agents, event);
456
457            let is_optional = pt.optional;
458            let pushed_entry = pt.entry.clone();
459            game.stack.push(pt.entry);
460            if pushed_entry.spell_ability.is_trigger {
461                let source_card = pushed_entry.spell_ability.source;
462                self.run_trigger(
463                    TriggerType::SpellAbilityCast,
464                    RunParams {
465                        card: source_card,
466                        spell_card: source_card,
467                        player: Some(pushed_entry.spell_ability.activating_player),
468                        activator: Some(pushed_entry.spell_ability.activating_player),
469                        spell_controller: Some(pushed_entry.spell_ability.activating_player),
470                        spell_ability: Some(pushed_entry.spell_ability.clone()),
471                        source_sa: Some(pushed_entry.spell_ability.clone()),
472                        cause: Some(pushed_entry.spell_ability.clone()),
473                        cause_card: source_card,
474                        ..Default::default()
475                    },
476                    true,
477                );
478                if let Some(mut ability_triggered) = pt.ability_triggered.clone() {
479                    ability_triggered.spell_ability = Some(pushed_entry.spell_ability.clone());
480                    ability_triggered.source_sa = Some(pushed_entry.spell_ability.clone());
481                    if ability_triggered.cause_card.is_none() {
482                        ability_triggered.cause_card = source_card;
483                    }
484                    self.run_trigger(TriggerType::AbilityTriggered, ability_triggered, false);
485                }
486            }
487            push_logs.push(TriggerPushLog {
488                source_name,
489                player_name,
490                optional: is_optional,
491                trigger_api,
492            });
493        }
494        push_logs
495    }
496
497    /// Drain `waiting_triggers`, match them against active and delayed triggers,
498    /// and return the matched entries.  This is the core matching logic shared by
499    /// both `flush_waiting_triggers` and `run_waiting_triggers`.
500    fn match_waiting_triggers(&mut self, game: &GameState) -> Vec<MatchedTrigger> {
501        let waiting = std::mem::take(&mut self.waiting_triggers);
502        let mut entries: Vec<MatchedTrigger> = Vec::new();
503
504        for event in &waiting {
505            let mut trigger_refs: Vec<(CardId, usize, usize)> =
506                if let Some(stored_refs) = &event.trigger_refs {
507                    stored_refs
508                        .iter()
509                        .enumerate()
510                        .map(|(idx, (card_id, trigger_index))| (*card_id, *trigger_index, idx))
511                        .collect()
512                } else {
513                    self.active_triggers
514                        .iter()
515                        .enumerate()
516                        .map(|(idx, active)| (active.card_id, active.trigger_index, idx))
517                        .collect()
518                };
519            if event.trigger_refs.is_none() {
520                for (card_id, trigger_index) in self.ltb_trigger_refs_for_event(game, event) {
521                    if !trigger_refs.iter().any(|(existing_card, existing_idx, _)| {
522                        *existing_card == card_id && *existing_idx == trigger_index
523                    }) {
524                        trigger_refs.push((card_id, trigger_index, usize::MAX));
525                    }
526                }
527            }
528            trigger_refs.sort_by_key(|&(card_id, trigger_index, idx)| {
529                let card = game.card(card_id);
530                let is_static = card
531                    .triggers
532                    .get(trigger_index)
533                    .map(|trigger| !trigger.is_static())
534                    .unwrap_or(true);
535                (is_static, idx)
536            });
537
538            let event_entries_start = entries.len();
539
540            // Check each active trigger plus any Java-style LTB look-back triggers.
541            for (card_id, trigger_index, _) in trigger_refs {
542                let card = game.card(card_id);
543                if trigger_index >= card.triggers.len() {
544                    continue;
545                }
546                let trigger = &card.triggers[trigger_index];
547                let host_controller = card.controller;
548                if crate::staticability::static_ability_disable_triggers::is_disabled(
549                    game,
550                    card_id,
551                    trigger,
552                    &event.params,
553                ) {
554                    continue;
555                }
556
557                let can_run = self.can_run_trigger(
558                    game,
559                    card_id,
560                    trigger_index,
561                    host_controller,
562                    &event.mode,
563                    &event.params,
564                );
565                if can_run {
566                    let sa = trigger.build_triggered_spell_ability(
567                        game,
568                        card_id,
569                        host_controller,
570                        trigger_index,
571                        &event.params,
572                    );
573
574                    let entry = StackEntry {
575                        id: 0,
576                        spell_ability: sa,
577                        is_pending_cast: false,
578                        is_creature_spell: false,
579                        is_permanent_spell: false,
580                        cast_from_zone: None,
581                        optional_trigger_decider: None,
582                        optional_trigger_description: None,
583                        optional_trigger_source_name: None,
584                    };
585                    // A trigger is optional if it has OptionalDecider$ OR if its
586                    // execute SVar has a non-mandatory, non-zero cost.  Mirrors Java's
587                    // Trigger.isOptional() which checks both the trigger flag and cost.
588                    // E.g. Smuggler's Copter loot "Cost$ Draw<1/You>" → optional.
589                    let trigger_cost_optional = entry
590                        .spell_ability
591                        .pay_costs
592                        .as_ref()
593                        .map(|c| !c.mandatory && !c.is_zero_cost())
594                        .unwrap_or(false);
595                    let effect_optional_decider =
596                        entry.spell_ability.ir.optional_decider_text.is_some();
597                    let description_lower = trigger.description.to_ascii_lowercase();
598                    let description_implies_optional = description_lower.starts_with("you may")
599                        && !entry.spell_ability.ir.optional
600                        && !effect_optional_decider;
601                    let pending = PendingTrigger {
602                        ability_triggered: Some(
603                            crate::trigger::trigger_ability_triggered::build_run_params(
604                                trigger,
605                                &entry.spell_ability,
606                                &event.params,
607                                game,
608                            ),
609                        ),
610                        entry,
611                        optional: (trigger.optional && !effect_optional_decider)
612                            || trigger_cost_optional
613                            || description_implies_optional,
614                        decider: host_controller,
615                        description: trigger.description.clone(),
616                    };
617                    let source_ts = card.zone_timestamp;
618                    entries.push((pending, host_controller, source_ts, 1, trigger.id));
619                    let extra = crate::staticability::static_ability_panharmonicon::extra_triggers(
620                        game,
621                        card_id,
622                        trigger,
623                        &event.params,
624                    );
625                    for _ in 0..extra {
626                        let sa2 = trigger.build_triggered_spell_ability(
627                            game,
628                            card_id,
629                            host_controller,
630                            trigger_index,
631                            &event.params,
632                        );
633                        let extra_entry = StackEntry {
634                            id: 0,
635                            spell_ability: sa2,
636                            is_pending_cast: false,
637                            is_creature_spell: false,
638                            is_permanent_spell: false,
639                            cast_from_zone: None,
640                            optional_trigger_decider: None,
641                            optional_trigger_description: None,
642                            optional_trigger_source_name: None,
643                        };
644                        let trigger_cost_optional = extra_entry
645                            .spell_ability
646                            .pay_costs
647                            .as_ref()
648                            .map(|c| !c.mandatory && !c.is_zero_cost())
649                            .unwrap_or(false);
650                        let effect_optional_decider =
651                            extra_entry.spell_ability.ir.optional_decider_text.is_some();
652                        entries.push((
653                            PendingTrigger {
654                                ability_triggered: Some(
655                                    crate::trigger::trigger_ability_triggered::build_run_params(
656                                        trigger,
657                                        &extra_entry.spell_ability,
658                                        &event.params,
659                                        game,
660                                    ),
661                                ),
662                                entry: extra_entry,
663                                optional: (trigger.optional && !effect_optional_decider)
664                                    || trigger_cost_optional,
665                                decider: host_controller,
666                                description: trigger.description.clone(),
667                            },
668                            host_controller,
669                            source_ts,
670                            1,
671                            trigger.id,
672                        ));
673                    }
674                }
675            }
676
677            // Check delayed triggers (one-shot, removed after firing).
678            //
679            // Java places same-event delayed triggers such as Evoke's sacrifice
680            // below normal ETB triggers by default, so the normal ETB resolves
681            // first. Since the stack is LIFO and pending triggers are pushed in
682            // list order, insert delayed matches before active matches for this
683            // event. Delayed triggers marked `sort_after_active` (granted-evoke
684            // sac) instead push AFTER active triggers and resolve first, mirroring
685            // Java's trigger-ID ascending sort when the granted keyword's trigger
686            // ID is higher than the card's intrinsic T: triggers.
687            let delayed_insert_at = event_entries_start;
688            let mut delayed_insert_offset = 0usize;
689            let mut fired_indices = Vec::new();
690            for (idx, delayed) in self.delayed_triggers.iter().enumerate() {
691                let TriggerWaiting {
692                    params: event_payload,
693                    ..
694                } = &event;
695                if delayed.mode != event.mode {
696                    continue;
697                }
698                if delayed.mode == TriggerType::Phase
699                    && delayed.created_turn == game.turn.turn_number
700                    && event_payload.phase == Some(delayed.created_phase)
701                {
702                    continue;
703                }
704                let tmp_trigger = delayed.as_trigger(game);
705                if !delayed
706                    .trigger_mode
707                    .perform_test(&tmp_trigger, event_payload, game)
708                {
709                    continue;
710                }
711                let mut sa = build_spell_ability(
712                    game,
713                    delayed.source_card,
714                    &delayed.execute_svar,
715                    delayed.controller,
716                );
717                sa.is_trigger = true;
718                sa.trigger_source = Some(delayed.source_card);
719                sa.trigger_source_zone_timestamp =
720                    Some(game.card(delayed.source_card).zone_timestamp);
721                sa.trigger_remembered_amount = delayed.remembered_amount;
722                // Propagate remembered cards (e.g. `RememberObjects$ Remembered`
723                // captured at registration) so the executed ability can target
724                // exactly the cards the parent trigger remembered.
725                sa.trigger_remembered.extend(
726                    delayed
727                        .remembered_cards
728                        .iter()
729                        .copied()
730                        .map(crate::event::AbilityValue::Card),
731                );
732                // Players from `RememberObjects$ RememberedController` (e.g.
733                // Arcane Denial's "may draw up to two cards" trigger snapshots
734                // the controller of the countered spell at registration).
735                sa.trigger_remembered.extend(
736                    delayed
737                        .remembered_players
738                        .iter()
739                        .copied()
740                        .map(crate::event::AbilityValue::Player),
741                );
742                if !delayed.remembered_lki_cards.is_empty() {
743                    sa.trigger_remembered.extend(
744                        delayed
745                            .remembered_lki_cards
746                            .iter()
747                            .copied()
748                            .map(crate::event::AbilityValue::Card),
749                    );
750                    sa.trigger_objects.insert(
751                        crate::ability::AbilityKey::RememberedLKI,
752                        delayed
753                            .remembered_lki_cards
754                            .iter()
755                            .map(|card_id| card_id.0.to_string())
756                            .collect::<Vec<_>>()
757                            .join(",")
758                            .into(),
759                    );
760                }
761
762                let entry = StackEntry {
763                    id: 0,
764                    spell_ability: sa,
765                    is_pending_cast: false,
766                    is_creature_spell: false,
767                    is_permanent_spell: false,
768                    cast_from_zone: None,
769                    optional_trigger_decider: None,
770                    optional_trigger_description: None,
771                    optional_trigger_source_name: None,
772                };
773                let pending = PendingTrigger {
774                    ability_triggered: Some(
775                        crate::trigger::trigger_ability_triggered::build_run_params(
776                            &tmp_trigger,
777                            &entry.spell_ability,
778                            event_payload,
779                            game,
780                        ),
781                    ),
782                    entry,
783                    optional: false,
784                    decider: delayed.controller,
785                    description: String::new(),
786                };
787                let delayed_ts = if delayed.mode == TriggerType::Phase {
788                    0
789                } else {
790                    game.card(delayed.source_card).zone_timestamp
791                };
792                let delayed_bucket = if delayed.sort_after_active { 2 } else { 0 };
793                let delayed_order = delayed.trigger_order.unwrap_or(0);
794                // Java parity: Panharmonicon-class statics (e.g. Yarok, Roaming Throne)
795                // double triggered abilities of permanents the owner controls. Rust
796                // implements Evoke-style sacrifice and other keyword-induced triggers
797                // as DelayedTrigger instances; the non-delayed path at ~L580 already
798                // consults `extra_triggers`, but delayed matches bypassed it until now.
799                // Replicate the same check so that when Yarok's own Panharmonicon
800                // fires during its Evoke-on-ETB sacrifice, it doubles the trigger
801                // (matching Java's `amt=2` on `valid=Card.Self+evoked`).
802                let extra_delayed =
803                    crate::staticability::static_ability_panharmonicon::extra_triggers(
804                        game,
805                        delayed.source_card,
806                        &tmp_trigger,
807                        event_payload,
808                    );
809                if delayed.sort_after_active {
810                    // Push to end of entries (above active triggers → resolves first).
811                    entries.push((
812                        pending.clone(),
813                        delayed.controller,
814                        delayed_ts,
815                        delayed_bucket,
816                        delayed_order,
817                    ));
818                    for _ in 0..extra_delayed {
819                        entries.push((
820                            pending.clone(),
821                            delayed.controller,
822                            delayed_ts,
823                            delayed_bucket,
824                            delayed_order,
825                        ));
826                    }
827                } else {
828                    entries.insert(
829                        delayed_insert_at + delayed_insert_offset,
830                        (
831                            pending.clone(),
832                            delayed.controller,
833                            delayed_ts,
834                            delayed_bucket,
835                            delayed_order,
836                        ),
837                    );
838                    delayed_insert_offset += 1;
839                    for _ in 0..extra_delayed {
840                        entries.insert(
841                            delayed_insert_at + delayed_insert_offset,
842                            (
843                                pending.clone(),
844                                delayed.controller,
845                                delayed_ts,
846                                delayed_bucket,
847                                delayed_order,
848                            ),
849                        );
850                        delayed_insert_offset += 1;
851                    }
852                }
853                fired_indices.push(idx);
854            }
855
856            // Remove fired delayed triggers (reverse order to preserve indices).
857            for idx in fired_indices.into_iter().rev() {
858                self.delayed_triggers.remove(idx);
859            }
860        }
861
862        entries
863    }
864
865    fn build_waiting_trigger(&self, mode: TriggerType, params: RunParams) -> TriggerWaiting {
866        // Snapshot active triggers for events whose matching must reflect the
867        // state AT event time, not at flush time. Java evaluates SpellCast
868        // triggers immediately against triggers active on the battlefield —
869        // a spell's own `TriggerZones$ Battlefield` trigger cannot fire for
870        // its own cast because the card is still on the stack when the event
871        // dispatches. Rust flushes later, so without a snapshot the newly
872        // battlefield-registered self-trigger would incorrectly match.
873        let snapshot_active = matches!(
874            mode,
875            TriggerType::ChangesZone
876                | TriggerType::ChangesZoneAll
877                | TriggerType::Drawn
878                | TriggerType::SpellCast
879                | TriggerType::SpellAbilityCast
880                | TriggerType::SpellCastAll
881                | TriggerType::SpellCastOnce
882                | TriggerType::SpellCastOfType
883                | TriggerType::SpellCopied
884                | TriggerType::SpellCopy
885                | TriggerType::SpellAbilityCopy
886                | TriggerType::SpellCastOrCopy
887                | TriggerType::AbilityCast
888        );
889        let trigger_refs = if snapshot_active {
890            let snap: Vec<(CardId, usize)> = self
891                .active_triggers
892                .iter()
893                .map(|active| (active.card_id, active.trigger_index))
894                .collect();
895            Some(snap)
896        } else {
897            None
898        };
899
900        TriggerWaiting {
901            mode,
902            params,
903            trigger_refs,
904        }
905    }
906
907    /// Fire Immediate delayed triggers — these fire on the next trigger
908    /// processing cycle without needing a matching event. Mirrors Java's
909    /// `TriggerImmediate` which has `performTest()` returning true always.
910    fn fire_immediate_delayed_triggers(&mut self, game: &GameState) -> Vec<MatchedTrigger> {
911        let mut entries = Vec::new();
912        let mut fired_indices = Vec::new();
913        for (idx, delayed) in self.delayed_triggers.iter().enumerate() {
914            if delayed.mode != TriggerType::Immediate {
915                continue;
916            }
917            // Look up the Execute SVar on the source card
918            let svar_text = game
919                .card(delayed.source_card)
920                .svars
921                .get(&delayed.execute_svar)
922                .cloned();
923            if let Some(text) = svar_text {
924                let mut sa =
925                    build_spell_ability(game, delayed.source_card, &text, delayed.controller);
926                sa.is_trigger = true;
927                sa.trigger_source = Some(delayed.source_card);
928                sa.trigger_source_zone_timestamp =
929                    Some(game.card(delayed.source_card).zone_timestamp);
930                if !delayed.remembered_lki_cards.is_empty() {
931                    sa.trigger_remembered.extend(
932                        delayed
933                            .remembered_lki_cards
934                            .iter()
935                            .copied()
936                            .map(crate::event::AbilityValue::Card),
937                    );
938                    sa.trigger_objects.insert(
939                        crate::ability::AbilityKey::RememberedLKI,
940                        delayed
941                            .remembered_lki_cards
942                            .iter()
943                            .map(|card_id| card_id.0.to_string())
944                            .collect::<Vec<_>>()
945                            .join(",")
946                            .into(),
947                    );
948                }
949
950                let entry = StackEntry {
951                    id: 0,
952                    spell_ability: sa,
953                    is_pending_cast: false,
954                    is_creature_spell: false,
955                    is_permanent_spell: false,
956                    cast_from_zone: None,
957                    optional_trigger_decider: None,
958                    optional_trigger_description: None,
959                    optional_trigger_source_name: None,
960                };
961                let pending = PendingTrigger {
962                    ability_triggered: Some(
963                        crate::trigger::trigger_ability_triggered::build_run_params(
964                            &delayed.as_trigger(game),
965                            &entry.spell_ability,
966                            &RunParams::default(),
967                            game,
968                        ),
969                    ),
970                    entry,
971                    optional: false,
972                    decider: delayed.controller,
973                    description: String::new(),
974                };
975                let ts = if delayed.mode == TriggerType::Phase {
976                    0
977                } else {
978                    game.card(delayed.source_card).zone_timestamp
979                };
980                let trigger_bucket = if delayed.sort_after_active { 2 } else { 0 };
981                entries.push((
982                    pending,
983                    delayed.controller,
984                    ts,
985                    trigger_bucket,
986                    delayed.trigger_order.unwrap_or(0),
987                ));
988                fired_indices.push(idx);
989            }
990        }
991        for idx in fired_indices.into_iter().rev() {
992            self.delayed_triggers.remove(idx);
993        }
994        entries
995    }
996
997    /// Register a delayed trigger (one-shot, fires once then is removed).
998    /// Mirrors Java's `TriggerHandler.registerDelayedTrigger()`.
999    pub fn register_delayed_trigger(&mut self, mut delayed: DelayedTrigger) {
1000        self.assign_delayed_trigger_order(&mut delayed);
1001        self.delayed_triggers.push(delayed);
1002    }
1003
1004    pub fn clear_delayed_trigger(&mut self) {
1005        self.delayed_triggers.clear();
1006        self.this_turn_delayed_triggers.clear();
1007    }
1008
1009    pub fn register_this_turn_delayed_trigger(&mut self, mut delayed: DelayedTrigger) {
1010        self.assign_delayed_trigger_order(&mut delayed);
1011        self.this_turn_delayed_triggers.push(delayed.clone());
1012        self.delayed_triggers.push(delayed);
1013    }
1014
1015    pub fn clear_this_turn_delayed_trigger(&mut self) {
1016        if self.this_turn_delayed_triggers.is_empty() {
1017            return;
1018        }
1019        self.delayed_triggers.retain(|d| {
1020            !self.this_turn_delayed_triggers.iter().any(|x| {
1021                x.source_card == d.source_card
1022                    && x.controller == d.controller
1023                    && x.mode == d.mode
1024                    && x.execute_svar == d.execute_svar
1025            })
1026        });
1027        self.this_turn_delayed_triggers.clear();
1028    }
1029
1030    pub fn register_player_defined_delayed_trigger(
1031        &mut self,
1032        player: PlayerId,
1033        mut delayed: DelayedTrigger,
1034    ) {
1035        self.assign_delayed_trigger_order(&mut delayed);
1036        self.player_defined_delayed_triggers.push((player, delayed));
1037    }
1038
1039    pub fn clear_player_defined_delayed_trigger(&mut self) {
1040        self.player_defined_delayed_triggers.clear();
1041    }
1042
1043    pub fn handle_player_defined_del_triggers(&mut self, player: PlayerId) {
1044        let mut to_activate = Vec::new();
1045        self.player_defined_delayed_triggers.retain(|(p, delayed)| {
1046            if *p == player {
1047                to_activate.push(delayed.clone());
1048                false
1049            } else {
1050                true
1051            }
1052        });
1053        for delayed in to_activate {
1054            self.delayed_triggers.push(delayed);
1055        }
1056    }
1057
1058    fn assign_delayed_trigger_order(&mut self, delayed: &mut DelayedTrigger) {
1059        if delayed.trigger_order.is_none() {
1060            delayed.trigger_order = Some(self.next_trigger_id);
1061            self.next_trigger_id += 1;
1062        }
1063    }
1064
1065    /// Mirrors Java's resetActiveTriggers().
1066    /// Scans all cards in game, collects active triggers.
1067    pub fn reset_active_triggers(&mut self, game: &GameState) {
1068        self.active_triggers.clear();
1069        for (i, card) in game.cards.iter().enumerate() {
1070            let card_id = CardId(i as u32);
1071            for (trig_idx, _) in card.triggers.iter().enumerate() {
1072                self.register_one_trigger(game, card_id, trig_idx);
1073            }
1074        }
1075    }
1076
1077    /// Mirrors Java's registerActiveTrigger(card).
1078    /// Registers a single card's triggers.
1079    pub fn register_active_trigger(&mut self, game: &GameState, card_id: CardId) {
1080        let card = game.card(card_id);
1081        for (trig_idx, _) in card.triggers.iter().enumerate() {
1082            self.register_one_trigger(game, card_id, trig_idx);
1083        }
1084    }
1085
1086    /// Java parity wrapper for TriggerHandler.clearActiveTriggers().
1087    pub fn clear_active_triggers(&mut self) {
1088        self.active_triggers.clear();
1089    }
1090
1091    /// Java parity wrapper for TriggerHandler.registerActiveLTBTrigger(card).
1092    /// Registers all of a card's triggers as active, using current trigger indices.
1093    pub fn register_active_ltb_trigger(&mut self, game: &GameState, card_id: CardId) {
1094        let card = game.card(card_id);
1095        for (trig_idx, trigger) in card.triggers.iter().enumerate() {
1096            if self.looks_back_in_time(trigger) {
1097                self.register_one_trigger(game, card_id, trig_idx);
1098            }
1099        }
1100    }
1101
1102    /// Java parity wrapper for TriggerHandler.registerOneTrigger(...).
1103    pub fn register_one_trigger(
1104        &mut self,
1105        game: &GameState,
1106        card_id: CardId,
1107        trigger_index: usize,
1108    ) {
1109        let card = game.card(card_id);
1110        if trigger_index >= card.triggers.len() {
1111            return;
1112        }
1113        let trigger = &card.triggers[trigger_index];
1114        if !trigger.get_active_zone().contains(&card.zone) {
1115            return;
1116        }
1117        // NOTE: Do NOT call phases_check here.  Phase-gated triggers (e.g.
1118        // "At the beginning of your upkeep") must be registered as active
1119        // regardless of the current phase; the phase filter is evaluated at
1120        // match time inside can_run_trigger.
1121        if trigger.kind == TriggerType::Always && game.stack.has_state_trigger_id(trigger.id) {
1122            return;
1123        }
1124        let already_registered = self
1125            .active_triggers
1126            .iter()
1127            .any(|at| at.card_id == card_id && at.trigger_index == trigger_index);
1128        if !already_registered {
1129            if std::env::var("FORGE_TRIGGER_TRACE").is_ok() {
1130                if let Some(trigger) = game.card(card_id).triggers.get(trigger_index) {
1131                    if trigger.kind == TriggerType::BecomesTarget {
1132                        eprintln!(
1133                            "[trigger-trace] REGISTER active BecomesTarget trigger host={}#{:?} idx={} desc={} zones={:?}",
1134                            game.card(card_id).card_name,
1135                            card_id,
1136                            trigger_index,
1137                            trigger.description,
1138                            trigger.get_active_zone()
1139                        );
1140                    }
1141                }
1142            }
1143            self.active_triggers.push(ActiveTrigger {
1144                card_id,
1145                trigger_index,
1146            });
1147        }
1148    }
1149
1150    /// Remove triggers for a card that left a trigger zone.
1151    pub fn unregister_active_triggers(&mut self, card_id: CardId) {
1152        self.active_triggers.retain(|at| at.card_id != card_id);
1153    }
1154
1155    fn looks_back_in_time(&self, trigger: &Trigger) -> bool {
1156        if matches!(
1157            trigger.kind,
1158            TriggerType::Exploited
1159                | TriggerType::Destroyed
1160                | TriggerType::Sacrificed
1161                | TriggerType::SacrificedOnce
1162        ) {
1163            return true;
1164        }
1165        if matches!(
1166            trigger.kind,
1167            TriggerType::ChangesZone | TriggerType::ChangesZoneAll
1168        ) {
1169            return trigger
1170                .ir
1171                .origin_zones
1172                .contains(&forge_foundation::ZoneType::Battlefield)
1173                || trigger.ir.destination_zones.iter().any(|zone| {
1174                    matches!(
1175                        zone,
1176                        forge_foundation::ZoneType::Library | forge_foundation::ZoneType::Hand
1177                    )
1178                });
1179        }
1180        false
1181    }
1182
1183    fn ltb_trigger_refs_for_event(
1184        &self,
1185        game: &GameState,
1186        event: &TriggerWaiting,
1187    ) -> Vec<(CardId, usize)> {
1188        let TriggerWaiting {
1189            params: event_payload,
1190            ..
1191        } = event;
1192        match event.mode {
1193            TriggerType::ChangesZone => {
1194                let destination = event_payload.destination;
1195                if event_payload.origin != Some(forge_foundation::ZoneType::Battlefield)
1196                    && !matches!(
1197                        destination,
1198                        Some(
1199                            forge_foundation::ZoneType::Library | forge_foundation::ZoneType::Hand
1200                        )
1201                    )
1202                {
1203                    return Vec::new();
1204                }
1205                let Some(card_id) = event_payload.card_lki.or(event_payload.card) else {
1206                    return Vec::new();
1207                };
1208                let mut refs: Vec<(CardId, usize)> = game
1209                    .card(card_id)
1210                    .triggers
1211                    .iter()
1212                    .enumerate()
1213                    .filter_map(|(trigger_index, trigger)| {
1214                        self.looks_back_in_time(trigger)
1215                            .then_some((card_id, trigger_index))
1216                    })
1217                    .collect();
1218
1219                // Same-SBA-batch LTB lookback: cards that were on the battlefield
1220                // when this SBA batch started but are no longer there can still
1221                // observe deaths happening in the same batch (e.g. Blood Artist
1222                // seeing Savannah Lions die simultaneously). Mirrors Java's
1223                // triggerChangesZoneAll which re-registers LTB triggers from
1224                // lastStateBattlefield. Scoping via pre_sba_battlefield prevents
1225                // stale hosts from earlier batches firing for later deaths.
1226                if event_payload.origin == Some(forge_foundation::ZoneType::Battlefield) {
1227                    for &ltb_id in &game.pre_sba_battlefield {
1228                        if ltb_id == card_id {
1229                            continue;
1230                        }
1231                        let ltb_card = game.card(ltb_id);
1232                        if ltb_card.zone == forge_foundation::ZoneType::Battlefield {
1233                            // Still alive; active_triggers already covers it.
1234                            continue;
1235                        }
1236                        for (trigger_index, trigger) in ltb_card.triggers.iter().enumerate() {
1237                            if self.looks_back_in_time(trigger) {
1238                                refs.push((ltb_id, trigger_index));
1239                            }
1240                        }
1241                    }
1242                }
1243
1244                refs
1245            }
1246            TriggerType::ChangesZoneAll => event
1247                .params
1248                .change_zone_table
1249                .as_ref()
1250                .map(|table| {
1251                    table
1252                        .last_state_battlefield()
1253                        .iter()
1254                        .flat_map(|&card_id| {
1255                            game.card(card_id).triggers.iter().enumerate().filter_map(
1256                                move |(trigger_index, trigger)| {
1257                                    self.looks_back_in_time(trigger)
1258                                        .then_some((card_id, trigger_index))
1259                                },
1260                            )
1261                        })
1262                        .collect()
1263                })
1264                .unwrap_or_default(),
1265            _ => Vec::new(),
1266        }
1267    }
1268
1269    pub fn clear_waiting_triggers(&mut self) {
1270        self.waiting_triggers.clear();
1271        self.pre_matched_triggers.clear();
1272    }
1273
1274    pub fn on_player_lost(&mut self, player: PlayerId) {
1275        self.delayed_triggers.retain(|d| d.controller != player);
1276        self.this_turn_delayed_triggers
1277            .retain(|d| d.controller != player);
1278        self.player_defined_delayed_triggers
1279            .retain(|(p, _)| *p != player);
1280    }
1281
1282    pub fn has_source_chapter_pending(&self, game: &GameState, source: CardId) -> bool {
1283        if self
1284            .pre_matched_triggers
1285            .iter()
1286            .any(|(pending, _, _, _, _)| {
1287                pending.entry.spell_ability.is_trigger
1288                    && pending.entry.spell_ability.source == Some(source)
1289                    && pending
1290                        .entry
1291                        .spell_ability
1292                        .source_trigger_id
1293                        .is_some_and(|trigger_id| {
1294                            game.card(source)
1295                                .triggers
1296                                .iter()
1297                                .any(|trigger| trigger.id == trigger_id && trigger.is_chapter())
1298                        })
1299            })
1300        {
1301            return true;
1302        }
1303
1304        for event in &self.waiting_triggers {
1305            let refs: Vec<(CardId, usize)> = event.trigger_refs.clone().unwrap_or_else(|| {
1306                self.active_triggers
1307                    .iter()
1308                    .map(|active| (active.card_id, active.trigger_index))
1309                    .collect()
1310            });
1311            for (card_id, trigger_index) in refs {
1312                if card_id != source {
1313                    continue;
1314                }
1315                let Some(trigger) = game.card(card_id).triggers.get(trigger_index) else {
1316                    continue;
1317                };
1318                if !trigger.is_chapter() {
1319                    continue;
1320                }
1321                if self.can_run_trigger(
1322                    game,
1323                    card_id,
1324                    trigger_index,
1325                    game.card(card_id).controller,
1326                    &event.mode,
1327                    &event.params,
1328                ) {
1329                    return true;
1330                }
1331            }
1332        }
1333
1334        false
1335    }
1336
1337    /// Mirrors Java's StaticAbilityDisableTriggers.disabled().
1338    /// Checks if a ChangesZone trigger is suppressed by a DisableTriggers
1339    /// static ability (e.g. Hushbringer).
1340    fn is_trigger_disabled_by_static(
1341        game: &GameState,
1342        host_card: CardId,
1343        trigger_index: usize,
1344        params: &RunParams,
1345    ) -> bool {
1346        let trigger = &game.card(host_card).triggers[trigger_index];
1347
1348        // Only applies to ChangesZone triggers
1349        if trigger.kind != TriggerType::ChangesZone {
1350            return false;
1351        }
1352
1353        // The card changing zones (the "cause")
1354        let cause_card_id = match params.card {
1355            Some(cid) => cid,
1356            None => return false,
1357        };
1358
1359        // Check cards for DisableTriggers static abilities.
1360        // For LTB triggers (Origin=Battlefield), also check cards that were on the
1361        // battlefield at the start of the current SBA check (pre_sba_battlefield).
1362        // This handles the case where a DisableTriggers source (e.g. Hushbringer)
1363        // dies in the same SBA batch as the trigger source — Hushbringer was on the
1364        // battlefield when the batch started, so its DisableTriggers still applies.
1365        // Mirrors Java's StaticAbilityDisableTriggers.disabled() which uses
1366        // lastStateBattlefield for LTB triggers (lines 20-27).
1367        let is_ltb = params.origin == Some(ZoneType::Battlefield);
1368        for card in game.cards.iter() {
1369            let on_battlefield = card.zone == ZoneType::Battlefield;
1370            let was_on_battlefield = is_ltb && game.pre_sba_battlefield.contains(&card.id);
1371            if !on_battlefield && !was_on_battlefield {
1372                continue;
1373            }
1374            for sa in &card.static_abilities {
1375                if !sa.check_mode(&crate::staticability::StaticMode::DisableTriggers) {
1376                    continue;
1377                }
1378
1379                // ValidCause$ — must match the card changing zones
1380                if let Some(valid_cause) = sa.ir.valid_cause.as_ref() {
1381                    let context = valid_filter::MatchContext::from_source(card)
1382                        .with_game(game)
1383                        .with_triggering(cause_card_id.into(), params.player);
1384                    if !valid_filter::matches_valid_card_selector_with_context(
1385                        valid_cause,
1386                        game.card(cause_card_id),
1387                        context,
1388                    ) {
1389                        continue;
1390                    }
1391                }
1392
1393                // ValidMode$ — must match the trigger's mode
1394                if let Some(valid_mode) = sa.ir.valid_mode.as_deref() {
1395                    let modes: Vec<&str> = valid_mode.split(',').collect();
1396                    if !modes.contains(&"ChangesZone") {
1397                        continue;
1398                    }
1399                }
1400
1401                // Origin$ — the event's origin zone must match
1402                if !sa.ir.origin_zones.is_empty()
1403                    && !params
1404                        .origin
1405                        .is_some_and(|zone| sa.ir.origin_zones.contains(&zone))
1406                {
1407                    continue;
1408                }
1409
1410                // Destination$ — the event's destination zone must match
1411                if !sa.ir.destination_zones.is_empty()
1412                    && !params
1413                        .destination
1414                        .is_some_and(|zone| sa.ir.destination_zones.contains(&zone))
1415                {
1416                    continue;
1417                }
1418
1419                // All conditions matched — trigger is disabled.
1420                return true;
1421            }
1422        }
1423        false
1424    }
1425
1426    /// Mirrors Java's canRunTrigger().
1427    /// Validation chain: mode → suppression → active zones → performTest.
1428    fn can_run_trigger(
1429        &self,
1430        game: &GameState,
1431        host_card: CardId,
1432        trigger_index: usize,
1433        host_controller: PlayerId,
1434        mode: &TriggerType,
1435        params: &RunParams,
1436    ) -> bool {
1437        let _perf_scope =
1438            crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Trigger);
1439        let card = game.card(host_card);
1440        if trigger_index >= card.triggers.len() {
1441            return false;
1442        }
1443        let trigger = &card.triggers[trigger_index];
1444
1445        // Check mode matches trigger type
1446        let trigger_type = trigger.mode.trigger_type();
1447
1448        let normalize = |t: TriggerType| -> TriggerType {
1449            match t {
1450                TriggerType::DungeonCompleted => TriggerType::CompletedDungeon,
1451                TriggerType::RoomEntered => TriggerType::EnteredRoom,
1452                TriggerType::Airbend
1453                | TriggerType::Earthbend
1454                | TriggerType::Firebend
1455                | TriggerType::Waterbend
1456                | TriggerType::ElementalBend => TriggerType::Elementalbend,
1457                TriggerType::TakesInitiative => TriggerType::TakeInitiative,
1458                TriggerType::PhaseIn => TriggerType::PhasedIn,
1459                TriggerType::PhaseOut => TriggerType::PhasedOut,
1460                TriggerType::PlaneswalkedFrom | TriggerType::PlaneswalkedTo => {
1461                    TriggerType::Planeswalk
1462                }
1463                TriggerType::CrankContraption => TriggerType::CrankAdvanced,
1464                TriggerType::Explores => TriggerType::Explored,
1465                TriggerType::Unattach => TriggerType::Unattached,
1466                TriggerType::Saddled | TriggerType::Stationed => TriggerType::Crewed,
1467                other => other,
1468            }
1469        };
1470        let mode_matches = normalize(trigger_type) == normalize(*mode);
1471        if !mode_matches {
1472            return false;
1473        }
1474
1475        // Check suppression
1476        if self.is_trigger_suppressed(*mode) {
1477            return false;
1478        }
1479
1480        // Common trigger phase/requirement/limit checks (Java Trigger base behavior).
1481        if !trigger.phases_check(game, host_card, params.phase) {
1482            return false;
1483        }
1484        if !trigger.requirements_check(game, host_card) {
1485            return false;
1486        }
1487        if !trigger.check_activation_limit(game, host_card) {
1488            return false;
1489        }
1490
1491        // DisableTriggers static ability check (e.g. Hushbringer).
1492        // Mirrors Java's StaticAbilityDisableTriggers.disabled().
1493        if matches!(
1494            *mode,
1495            TriggerType::ChangesZone | TriggerType::ChangesZoneAll
1496        ) && Self::is_trigger_disabled_by_static(game, host_card, trigger_index, params)
1497        {
1498            return false;
1499        }
1500
1501        // Check active zones.
1502        // For self zone-change events, use origin as LKI zone so "dies" triggers
1503        // (active on Battlefield) still fire after the card has moved.
1504        let zone_for_active_check = if *mode == TriggerType::ChangesZone
1505            && params.card == Some(host_card)
1506            && params.origin == Some(ZoneType::Battlefield)
1507            && params.destination != Some(ZoneType::Battlefield)
1508        {
1509            // LKI active-zone check for "leaves battlefield" self triggers (e.g. dies).
1510            ZoneType::Battlefield
1511        } else if *mode == TriggerType::ChangesZone
1512            && params.card == Some(host_card)
1513            && params.destination == Some(ZoneType::Battlefield)
1514            && trigger.get_active_zone().contains(&ZoneType::Battlefield)
1515            && card.zone != ZoneType::Battlefield
1516        {
1517            // LKI active-zone check for ETB triggers whose host left before
1518            // waiting triggers were put on the stack.
1519            ZoneType::Battlefield
1520        } else if *mode == TriggerType::ChangesZone
1521            && params.origin == Some(ZoneType::Battlefield)
1522            && trigger.get_active_zone().contains(&ZoneType::Battlefield)
1523            && card.zone != ZoneType::Battlefield
1524            && self.looks_back_in_time(trigger)
1525            && game.pre_sba_battlefield.contains(&host_card)
1526        {
1527            // LKI active-zone check for LTB triggers seeing OTHER creatures die
1528            // in the same SBA batch (e.g. Blood Artist seeing Savannah Lions die).
1529            // Scoped via pre_sba_battlefield so that hosts that left earlier
1530            // batches do not fire for later deaths. Mirrors Java's
1531            // lastStateBattlefield boundary.
1532            ZoneType::Battlefield
1533        } else if (*mode == TriggerType::DamageDone || *mode == TriggerType::DamageDoneOnce)
1534            && params.damage_target_card == Some(host_card)
1535            && trigger.get_active_zone().contains(&ZoneType::Battlefield)
1536            && card.zone != ZoneType::Battlefield
1537        {
1538            // LKI for DamageDone/DamageDoneOnce triggers targeting self (e.g.
1539            // Raptor Hatchling enrage). When damage kills the creature, SBAs
1540            // move it to graveyard before triggers are processed. The trigger
1541            // was queued while the card was on the battlefield, so we use LKI.
1542            ZoneType::Battlefield
1543        } else if *mode == TriggerType::Exploited
1544            && params.card == Some(host_card)
1545            && trigger.get_active_zone().contains(&ZoneType::Battlefield)
1546            && card.zone != ZoneType::Battlefield
1547        {
1548            // LKI for Exploited triggers: the exploiting creature sacrificed
1549            // itself. It was on the battlefield when Exploit fired, so use LKI.
1550            ZoneType::Battlefield
1551        } else if *mode == TriggerType::Sacrificed
1552            && params.card == Some(host_card)
1553            && trigger.get_active_zone().contains(&ZoneType::Battlefield)
1554            && card.zone != ZoneType::Battlefield
1555        {
1556            // LKI for Sacrificed triggers: the creature was on the battlefield
1557            // when it was sacrificed, use LKI.
1558            ZoneType::Battlefield
1559        } else {
1560            card.zone
1561        };
1562        if !trigger.get_active_zone().contains(&zone_for_active_check) {
1563            return false;
1564        }
1565
1566        // Phased-out hosts do not see events (CR 702.26.5). Mirrors Java
1567        // `TriggerReplacementBase.zonesCheck` which gates on `!isPhasedOut()`.
1568        if card.phased_out {
1569            return false;
1570        }
1571
1572        // performTest
1573        if !trigger.mode.perform_test(trigger, params, game) {
1574            return false;
1575        }
1576        if !trigger.meets_requirements_on_triggered_objects(game, params, host_card) {
1577            return false;
1578        }
1579
1580        // ── ActivatorThisTurnCast$ condition ──────────────────────────
1581        // Mirrors Java's TriggerSpellAbilityCast.checkActivatorThisTurnCast():
1582        // "EQ2" means the activating player must have cast exactly 2 spells
1583        // this turn (including the one triggering).
1584        if let Some(cond) = trigger.ir.activator_this_turn_cast.as_deref() {
1585            let caster = params.spell_controller.unwrap_or(host_controller);
1586            let count = game.player(caster).spells_cast_this_turn;
1587            if !compare_expr(count, cond.trim()) {
1588                return false;
1589            }
1590        }
1591
1592        true
1593    }
1594
1595    pub fn suppress_mode(&mut self, mode: TriggerType) {
1596        self.suppressed_modes.insert(mode);
1597    }
1598
1599    pub fn set_suppress_all_triggers(&mut self, suppress: bool) {
1600        self.all_suppressed = suppress;
1601    }
1602
1603    /// Mirrors Java's TriggerHandler.getActiveTrigger(TriggerType, Map<AbilityKey, Object>).
1604    /// Returns all active triggers that can run for the given mode and params.
1605    pub fn get_active_trigger(
1606        &self,
1607        game: &GameState,
1608        mode: TriggerType,
1609        params: &RunParams,
1610    ) -> Vec<Trigger> {
1611        let mut result = Vec::new();
1612        for at in &self.active_triggers {
1613            let card = game.card(at.card_id);
1614            if at.trigger_index >= card.triggers.len() {
1615                continue;
1616            }
1617            let host_controller = card.controller;
1618            if self.can_run_trigger(
1619                game,
1620                at.card_id,
1621                at.trigger_index,
1622                host_controller,
1623                &mode,
1624                params,
1625            ) {
1626                result.push(card.triggers[at.trigger_index].clone());
1627            }
1628        }
1629        result
1630    }
1631
1632    pub fn is_trigger_suppressed(&self, mode: TriggerType) -> bool {
1633        self.all_suppressed || self.suppressed_modes.contains(&mode)
1634    }
1635
1636    pub fn clear_suppression(&mut self, mode: TriggerType) {
1637        self.suppressed_modes.remove(&mode);
1638    }
1639}
1640
1641impl Default for TriggerHandler {
1642    fn default() -> Self {
1643        Self::new()
1644    }
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649    use super::*;
1650    use crate::card::Card;
1651    use crate::event::RunParams;
1652    use crate::ids::PlayerId;
1653    use crate::trigger::TriggerType;
1654    use forge_carddb::parse_card_script;
1655    use forge_foundation::PhaseType;
1656
1657    #[test]
1658    fn maralen_style_draw_phase_trigger_is_active() {
1659        let rules = parse_card_script(
1660            "Name:Maralen of the Mornsong\nManaCost:1 B B\nTypes:Legendary Creature Elf Wizard\nPT:2/3\nT:Mode$ Phase | Phase$ Draw | ValidPlayer$ Player | TriggerZones$ Battlefield | Execute$ TrigDrain | TriggerDescription$ At the beginning of each player's draw step, that player loses 3 life, searches their library for a card, puts it into their hand, then shuffles.\nSVar:TrigDrain:DB$ LoseLife | Defined$ TriggeredPlayer | LifeAmount$ 3",
1661        )
1662        .expect("card script should parse");
1663
1664        let mut game = GameState::new(&["Alice", "Bob"], 20);
1665        let mut maralen = Card::from_rules(&rules, PlayerId(0));
1666        maralen.id = crate::ids::CardId(0);
1667        maralen.zone = forge_foundation::ZoneType::Battlefield;
1668        game.cards.push(maralen);
1669        game.add_card_to_zone(
1670            forge_foundation::ZoneType::Battlefield,
1671            PlayerId(0),
1672            crate::ids::CardId(0),
1673        );
1674
1675        let mut handler = TriggerHandler::new();
1676        handler.register_active_trigger(&game, crate::ids::CardId(0));
1677
1678        let active = handler.get_active_trigger(
1679            &game,
1680            TriggerType::Phase,
1681            &RunParams {
1682                phase: Some(PhaseType::Draw),
1683                player: Some(PlayerId(0)),
1684                ..Default::default()
1685            },
1686        );
1687
1688        assert_eq!(game.card(crate::ids::CardId(0)).triggers.len(), 1);
1689        assert_eq!(active.len(), 1);
1690
1691        let pending = handler.run_trigger_with_game(
1692            &game,
1693            TriggerType::Phase,
1694            RunParams {
1695                phase: Some(PhaseType::Draw),
1696                player: Some(PlayerId(0)),
1697                ..Default::default()
1698            },
1699            false,
1700        );
1701        assert_eq!(pending.len(), 1);
1702        assert_eq!(
1703            pending[0]
1704                .entry
1705                .spell_ability
1706                .get_triggering_player(crate::ability::AbilityKey::TriggeredPlayer),
1707            Some(PlayerId(0))
1708        );
1709    }
1710}