Skip to main content

manabrew_engine/game_loop/
stack_resolution.rs

1use super::cost_payment::CostPaymentContext;
2use super::*;
3use crate::replacement::replacement_handler::apply_moved_replacement;
4use crate::spellability::TargetKind;
5
6impl GameLoop {
7    fn trigger_cost_after_effect(sa: &SpellAbility) -> bool {
8        if sa.api != Some(crate::ability::api_type::ApiType::Draw) {
9            return false;
10        }
11        let Some(cost) = sa.pay_costs.as_ref() else {
12            return false;
13        };
14        cost.parts.iter().any(|part| {
15            matches!(
16                part,
17                crate::cost::CostPart::Discard { type_filter, .. }
18                    if type_filter == "Card"
19            )
20        }) && !cost.parts.iter().any(|part| {
21            matches!(
22                part,
23                crate::cost::CostPart::Discard { type_filter, .. }
24                    if type_filter == "Hand"
25            ) || matches!(part, crate::cost::CostPart::Mana { .. })
26        })
27    }
28
29    fn effect_kind_for_sa(sa: &SpellAbility) -> String {
30        if let Some(api) = sa.api {
31            return api.name().to_string();
32        }
33        if sa.is_trigger {
34            if let Some(mode) = sa.ir.mode.as_ref() {
35                return format!("Trigger({mode})");
36            }
37            return "Trigger".to_string();
38        }
39        if sa.is_activated {
40            return "ActivatedAbility".to_string();
41        }
42        if sa.is_spell {
43            return "Spell".to_string();
44        }
45        "Effect".to_string()
46    }
47
48    pub fn resolve_stack(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
49        let _perf_scope = crate::perf::ParamsLookupScopeGuard::enter(
50            crate::perf::ParamsLookupScope::StackResolution,
51        );
52        if game.stack.is_empty() {
53            return;
54        }
55
56        if std::env::var("FORGE_STACK_TRACE").is_ok() {
57            let names: Vec<String> = game
58                .stack
59                .iter()
60                .map(|entry| {
61                    entry
62                        .spell_ability
63                        .source
64                        .map(|cid| game.card(cid).card_name.clone())
65                        .unwrap_or_else(|| "<effect>".to_string())
66                })
67                .collect();
68            eprintln!(
69                "[stack-trace] RESOLVE start phase={:?} active={:?} priority={:?} depth={} {:?}",
70                game.turn.phase,
71                game.active_player(),
72                game.turn.priority_player,
73                game.stack.len(),
74                names
75            );
76        }
77
78        // LKI: Snapshot battlefield state before resolution.
79        // Mirrors Java MagicStack line 623: game.copyLastState() before resolving.
80        game.copy_last_state();
81
82        let mut entry = game.stack.resolve_stack().unwrap();
83        let stack_item_name = entry
84            .spell_ability
85            .source
86            .and_then(|cid| game.cards.get(cid.index()).map(|c| c.card_name.clone()))
87            .unwrap_or_else(|| "Ability".to_string());
88        self.log_stack_resolved_item(&stack_item_name);
89        if std::env::var("FORGE_STACK_TRACE").is_ok() {
90            eprintln!(
91                "[stack-trace] POP resolving={} remaining_depth={}",
92                stack_item_name,
93                game.stack.len()
94            );
95        }
96
97        // Copy spells: resolve effect only. CR 707.10 / 111.11 — a copy of a
98        // permanent spell becomes a token (`GameAction.changeZone` line 94).
99        if entry.spell_ability.is_copy {
100            let should_create_token_copy = (entry.is_creature_spell || entry.is_permanent_spell)
101                && entry.spell_ability.source.is_some();
102            if should_create_token_copy {
103                self.resolve_copied_permanent_as_token(game, agents, &entry);
104            } else {
105                self.resolve_spell_effect(game, agents, &entry);
106            }
107            crate::perf::increment(crate::perf::Metric::SpellAbilityClones, 3);
108            self.trigger_handler.run_trigger(
109                TriggerType::AbilityResolves,
110                RunParams {
111                    card: entry.spell_ability.source,
112                    spell_card: entry.spell_ability.source,
113                    spell_controller: Some(entry.spell_ability.activating_player),
114                    spell_ability: Some(entry.spell_ability.clone()),
115                    source_sa: Some(entry.spell_ability.clone()),
116                    cause: Some(entry.spell_ability.clone()),
117                    cause_card: entry.spell_ability.source,
118                    ..Default::default()
119                },
120                false,
121            );
122            apply_continuous_effects(game);
123            return;
124        }
125
126        // Fizzle check — mirrors Java's MagicStack.hasFizzled() (CR 608.2b).
127        // A spell or ability is countered by game rules if ALL of its targets
128        // are illegal on resolution. Walk the SA chain; if every targeting node
129        // has only invalid targets, the whole thing fizzles.
130        if std::env::var("FORGE_TRIGGER_TRACE").is_ok() && entry.optional_trigger_decider.is_some()
131        {
132            eprintln!(
133                "[trigger-trace] RESOLVING optional trigger from stack: {} api={:?}",
134                stack_item_name, entry.spell_ability.api
135            );
136        }
137        if Self::has_fizzled(&mut entry.spell_ability, game) {
138            crate::agent::notify_all_agents(
139                agents,
140                crate::agent::GameLogEvent::warning(format!(
141                    "{} fizzles (all targets invalid)",
142                    stack_item_name
143                ))
144                .with_player(entry.spell_ability.activating_player),
145            );
146            // CR 608.2b: A countered spell is still put into its owner's
147            // graveyard (or exile for flashback/escape). Only triggers and
148            // activated abilities have no physical card to move.
149            if !entry.spell_ability.is_trigger
150                && !entry.spell_ability.is_activated
151                && !entry.spell_ability.is_copy
152            {
153                if let Some(card_id) = entry.spell_ability.source {
154                    let owner = game.card(card_id).owner;
155                    let dest = if entry.spell_ability.alt_cost
156                        == Some(crate::spellability::AlternativeCost::Harmonize)
157                    {
158                        apply_moved_replacement(game, card_id, ZoneType::Graveyard, Some(agents))
159                    } else if entry.spell_ability.alt_cost
160                        == Some(crate::spellability::AlternativeCost::Flashback)
161                        || entry.spell_ability.alt_cost
162                            == Some(crate::spellability::AlternativeCost::Escape)
163                    {
164                        ZoneType::Exile
165                    } else {
166                        // Apply Moved replacement WITH agents for proper RNG consumption
167                        // (e.g. Rest in Peace + Leyline of the Void both redirecting).
168                        apply_moved_replacement(game, card_id, ZoneType::Graveyard, Some(agents))
169                    };
170                    self.move_card_with_runtime(game, card_id, dest, owner, agents);
171                }
172            }
173            apply_continuous_effects(game);
174            return;
175        }
176
177        if entry.spell_ability.is_trigger || entry.spell_ability.is_activated {
178            // Optional trigger confirmation — mirrors Java's WrappedAbility.resolve()
179            // calling confirmTrigger() FIRST, before cost payment or effect resolution.
180            // This happens at resolution time, AFTER the trigger has been on the stack
181            // and priority has passed.
182            if let Some(decider) = entry.optional_trigger_decider {
183                let mut description = entry
184                    .optional_trigger_description
185                    .clone()
186                    .unwrap_or_default();
187                if let Some(triggered_card_id) = entry
188                    .spell_ability
189                    .trigger_objects
190                    .get(&crate::ability::AbilityKey::Card)
191                    .and_then(|s| s.parse::<u32>().ok())
192                    .map(crate::ids::CardId)
193                {
194                    let triggered_name = game.card(triggered_card_id).card_name.clone();
195                    if !triggered_name.is_empty() && !description.contains(&triggered_name) {
196                        if !description.is_empty() {
197                            description.push(' ');
198                        }
199                        description.push_str(&format!("Triggered by {triggered_name}."));
200                    }
201                }
202                let api = entry.spell_ability.api;
203                let accepted = agents[decider.index()].choose_optional_trigger(
204                    decider,
205                    &description,
206                    entry.spell_ability.source,
207                    api,
208                );
209                if !accepted {
210                    apply_continuous_effects(game);
211                    return;
212                }
213            }
214
215            let pay_trigger_cost_after_effect = (entry.spell_ability.is_trigger
216                || entry.spell_ability.trigger_source.is_some())
217                && Self::trigger_cost_after_effect(&entry.spell_ability);
218            if (entry.spell_ability.is_trigger || entry.spell_ability.trigger_source.is_some())
219                && !pay_trigger_cost_after_effect
220            {
221                if let Some(cost) = entry.spell_ability.pay_costs.clone() {
222                    let player = entry.spell_ability.activating_player;
223                    let source = entry.spell_ability.source.unwrap_or(CardId(0));
224                    let api = entry.spell_ability.api;
225                    let available = crate::mana::calculate_available_mana_excluding(
226                        &self.mana_pools[player.index()],
227                        game,
228                        player,
229                        Some(source),
230                    );
231                    if !crate::cost::can_pay_with_ability(
232                        &cost,
233                        game,
234                        &available,
235                        source,
236                        player,
237                        Some(&entry.spell_ability),
238                    ) {
239                        apply_continuous_effects(game);
240                        return;
241                    }
242                    if !self.pay_ability_cost(
243                        game,
244                        agents,
245                        player,
246                        source,
247                        &cost,
248                        api,
249                        cost.mandatory,
250                        CostPaymentContext::TriggerResolve,
251                        Some(&mut entry.spell_ability),
252                    ) {
253                        apply_continuous_effects(game);
254                        return;
255                    }
256                }
257            }
258
259            // Triggered/activated ability: resolve the effect
260            if let Some(source_id) = entry
261                .spell_ability
262                .trigger_source
263                .or(entry.spell_ability.source)
264            {
265                game.card_mut(source_id)
266                    .add_ability_resolved_for(Some(&entry.spell_ability));
267            }
268            self.resolve_spell_effect(game, agents, &entry);
269            if pay_trigger_cost_after_effect {
270                if let Some(cost) = entry.spell_ability.pay_costs.clone() {
271                    let player = entry.spell_ability.activating_player;
272                    let source = entry.spell_ability.source.unwrap_or(CardId(0));
273                    let api = entry.spell_ability.api;
274                    let _ = self.pay_ability_cost(
275                        game,
276                        agents,
277                        player,
278                        source,
279                        &cost,
280                        api,
281                        cost.mandatory,
282                        CostPaymentContext::TriggerResolve,
283                        Some(&mut entry.spell_ability),
284                    );
285                }
286            }
287            crate::perf::increment(crate::perf::Metric::SpellAbilityClones, 3);
288            self.trigger_handler.run_trigger(
289                TriggerType::AbilityResolves,
290                RunParams {
291                    card: entry.spell_ability.source,
292                    spell_card: entry.spell_ability.source,
293                    spell_controller: Some(entry.spell_ability.activating_player),
294                    spell_ability: Some(entry.spell_ability.clone()),
295                    source_sa: Some(entry.spell_ability.clone()),
296                    cause: Some(entry.spell_ability.clone()),
297                    cause_card: entry.spell_ability.source,
298                    ..Default::default()
299                },
300                false,
301            );
302            // Fire Cycled trigger if this was a cycling ability
303            // (mirrors Java MagicStack resolve → Player.addCycled)
304            if entry.spell_ability.is_activated {
305                let is_cycling = entry
306                    .spell_ability
307                    .ir
308                    .precost_desc
309                    .as_deref()
310                    .is_some_and(|d| d.to_lowercase().contains("cycling"));
311                if is_cycling {
312                    if let Some(source_card) = entry.spell_ability.source {
313                        self.trigger_handler.run_trigger(
314                            TriggerType::Cycled,
315                            RunParams {
316                                card: Some(source_card),
317                                player: Some(entry.spell_ability.activating_player),
318                                ..Default::default()
319                            },
320                            false,
321                        );
322                    }
323                }
324            }
325        } else if let Some(card_id) = entry.spell_ability.source {
326            let alt_cost = entry.spell_ability.alt_cost;
327            let player = entry.spell_ability.activating_player;
328
329            if entry.is_creature_spell || entry.is_permanent_spell {
330                // Permanent spell: move to battlefield
331                let origin = game.card(card_id).zone;
332
333                // Propagate kicked flag to the card so triggers with
334                // ValidCard$ Card.Self+kicked can check it after resolution.
335                if entry.spell_ability.kicked {
336                    game.card_mut(card_id).set_kicked(true);
337                }
338
339                // Mirrors Java `GameAction.changeZone()` setting `castFrom` on
340                // the resolving spell's host card so `wasCast`/`wasCastByYou`
341                // valid filters (e.g. Sunderflock's ETB) see the card as cast.
342                // Set BEFORE resolving the ETB so the trigger pickup matches.
343                game.card_mut(card_id).cast_from = entry.cast_from_zone;
344
345                // Resolve any ETB effects defined on the card
346                self.resolve_spell_effect(game, agents, &entry);
347                crate::perf::increment(crate::perf::Metric::SpellAbilityClones, 3);
348                self.trigger_handler.run_trigger(
349                    TriggerType::AbilityResolves,
350                    RunParams {
351                        card: Some(card_id),
352                        spell_card: Some(card_id),
353                        spell_controller: Some(player),
354                        spell_ability: Some(entry.spell_ability.clone()),
355                        source_sa: Some(entry.spell_ability.clone()),
356                        cause: Some(entry.spell_ability.clone()),
357                        cause_card: Some(card_id),
358                        ..Default::default()
359                    },
360                    false,
361                );
362
363                if origin != ZoneType::Battlefield {
364                    self.move_card_with_runtime(
365                        game,
366                        card_id,
367                        ZoneType::Battlefield,
368                        player,
369                        agents,
370                    );
371                }
372
373                // Attach aura to its chosen target.
374                // Mirrors Java's GameAction.changeZone (line 373-389) which detects
375                // an Aura entering from the stack while not-yet-attached and routes
376                // through `attachAuraOnIndirectETB`. For *cast* Auras the spell
377                // already has a chosen target — narrow the chooser candidates to
378                // that single target so the decision-log emits a `pick_one[1]`
379                // (matching Java's deterministic behaviour, which only sees the
380                // chosen target after the resolution-time filter). Without this
381                // narrowing, the candidate enumeration over the full battlefield
382                // can include a *second* legendary creature (e.g. a mirror-match
383                // Ashling controlled by the opponent), and the agent ends up
384                // attaching the Aura to the wrong card.
385                if game.card(card_id).type_line.has_subtype("Aura")
386                    && game.card(card_id).attached_to.is_none()
387                    && game.card(card_id).attached_to_player.is_none()
388                {
389                    let enchant_type = game
390                        .card(card_id)
391                        .keywords
392                        .iter_strings()
393                        .find_map(|kw| crate::keyword::extract_keyword_cost_str(kw, "Enchant"))
394                        .unwrap_or_default()
395                        .to_string();
396                    let normalized = enchant_type
397                        .split_once(':')
398                        .map(|(k, _)| k)
399                        .unwrap_or(&enchant_type);
400                    let can_target_player =
401                        normalized.starts_with("Player") || normalized.starts_with("Opponent");
402
403                    let mut candidates: Vec<crate::agent::types::GameEntity> = Vec::new();
404                    if let Some(target_card) = entry.spell_ability.target_chosen.target_card {
405                        // Cast Aura: only the chosen target is a valid attach
406                        // target at resolution time. Skip the full enumeration
407                        // and offer just that card.
408                        if crate::parsing::enchant_type_matches_card(
409                            &enchant_type,
410                            game.card(target_card),
411                            Some(game.card(card_id)),
412                        ) && !crate::staticability::static_ability_cant_attach::cant_attach(
413                            &game.cards,
414                            game.card(card_id),
415                            game.card(target_card),
416                            false,
417                        ) {
418                            candidates.push(crate::agent::types::GameEntity::Card(target_card));
419                        }
420                    } else if let Some(target_player) =
421                        entry.spell_ability.target_chosen.target_player
422                    {
423                        if Self::is_player_target_valid(target_player, game) {
424                            candidates.push(crate::agent::types::GameEntity::Player(target_player));
425                        }
426                    } else if can_target_player {
427                        for i in 0..game.players.len() {
428                            let p = crate::ids::PlayerId(i as u32);
429                            if Self::is_player_target_valid(p, game) {
430                                candidates.push(crate::agent::types::GameEntity::Player(p));
431                            }
432                        }
433                    } else {
434                        let battlefield: Vec<CardId> =
435                            game.cards_in_all_zones(ZoneType::Battlefield).collect();
436                        for cid in battlefield {
437                            if !crate::parsing::enchant_type_matches_card(
438                                &enchant_type,
439                                game.card(cid),
440                                Some(game.card(card_id)),
441                            ) {
442                                continue;
443                            }
444                            if crate::staticability::static_ability_cant_attach::cant_attach(
445                                &game.cards,
446                                game.card(card_id),
447                                game.card(cid),
448                                false,
449                            ) {
450                                continue;
451                            }
452                            candidates.push(crate::agent::types::GameEntity::Card(cid));
453                        }
454                    }
455
456                    if !candidates.is_empty() {
457                        let chooser = entry.spell_ability.activating_player;
458                        let chosen = agents[chooser.index()].choose_single_entity_for_effect(
459                            chooser,
460                            &candidates,
461                            false,
462                        );
463                        let attached = matches!(
464                            chosen,
465                            Some(crate::agent::types::GameEntity::Card(_))
466                                | Some(crate::agent::types::GameEntity::Player(_))
467                        );
468                        match chosen {
469                            Some(crate::agent::types::GameEntity::Card(c)) => {
470                                game.attach_to(card_id, c);
471                            }
472                            Some(crate::agent::types::GameEntity::Player(p)) => {
473                                game.attach_to_player(card_id, p);
474                            }
475                            None => {}
476                        }
477                        // Refresh continuous effects so any abilities the Aura
478                        // grants its newly enchanted host (e.g. Leyline
479                        // Immersion's `AddAbility$ AddMana` granting `{T}: Add
480                        // five mana of any combination of colors`) become
481                        // visible immediately. Without this, downstream
482                        // playability checks in the same priority loop see
483                        // the host without the granted ability and may filter
484                        // out spells the player should be able to cast.
485                        if attached {
486                            crate::staticability::layer::apply_continuous_effects(game);
487                        }
488                    }
489                }
490
491                // Evoke: register a one-shot ETB trigger that sacrifices this creature.
492                // This mirrors Forge Java semantics where Evoke uses a ChangesZone trigger
493                // and allows normal ETB abilities to trigger before the sacrifice resolves.
494                // Java parity: `CardFactoryUtil` attaches the "sacrifice when it enters"
495                // trigger once per Evoke keyword on the card, not once per cast. A card
496                // with both intrinsic `Evoke:1 U` and a granted `Evoke:4` (e.g. an
497                // Elemental in hand under Ashling, the Limitless) therefore carries
498                // two Evoke sac triggers. All of them fire on ETB; the first sacrifice
499                // succeeds and the rest are no-ops because the creature has already
500                // left the battlefield, but each one still consumes a stack entry.
501                if alt_cost == Some(crate::spellability::AlternativeCost::Evoke) {
502                    let evoke_keyword_count =
503                        (entry.spell_ability.evoke_keyword_count as usize).max(1);
504                    self.register_evoke_sacrifice_triggers(
505                        game,
506                        card_id,
507                        player,
508                        evoke_keyword_count,
509                    );
510                }
511
512                // Register triggers for the new permanent
513                self.trigger_handler.register_active_trigger(game, card_id);
514
515                if game.card(card_id).type_line.has_subtype("Room")
516                    && game.card(card_id).has_s_var("RoomRightSplitCost")
517                {
518                    let card_state_name = entry
519                        .spell_ability
520                        .ir
521                        .card_state_name
522                        .clone()
523                        .or_else(|| Some("LeftSplit".to_string()));
524                    self.trigger_handler.run_trigger(
525                        TriggerType::UnlockDoor,
526                        RunParams {
527                            card: Some(card_id),
528                            player: Some(player),
529                            card_state_name,
530                            ..Default::default()
531                        },
532                        true,
533                    );
534                }
535
536                // Emit ChangesZone trigger (ETB)
537                crate::ability::effects::emit_zone_trigger(
538                    &mut self.trigger_handler,
539                    card_id,
540                    origin,
541                    ZoneType::Battlefield,
542                );
543
544                // -- Post-ETB effects for alternative costs --
545
546                // Dash: grant haste, register delayed trigger to return to hand at EOT
547                if alt_cost == Some(crate::spellability::AlternativeCost::Dash) {
548                    game.card_mut(card_id).pump_keywords.add("Haste");
549                    self.trigger_handler.register_delayed_trigger(
550                        crate::trigger::handler::DelayedTrigger {
551                            mode: TriggerType::Phase,
552                            trigger_mode: Box::new(crate::trigger::trigger_phase::TriggerPhase {
553                                phases: vec![forge_foundation::PhaseType::EndOfTurn],
554                                valid_player: None,
555                            }) as Box<dyn crate::trigger::TriggerBehavior>,
556                            params: crate::parsing::Params::default(),
557                            execute_svar: format!(
558                                "DB$ ChangeZone | Origin$ Battlefield | Destination$ Hand | Defined$ CardUID_{}", card_id.0
559                            ),
560                            controller: player,
561                            source_card: card_id,
562                            created_turn: game.turn.turn_number,
563                            created_phase: game.turn.phase,
564                            target_card: Some(card_id),
565                            remembered_amount: 0,
566                            remembered_cards: Vec::new(),
567                            remembered_players: Vec::new(),
568                            remembered_lki_cards: Vec::new(),
569                            sort_after_active: false,
570                trigger_order: None,
571                        },
572                    );
573                }
574
575                // Warp: mark card so it can be cast from exile on a later turn,
576                // and register delayed trigger to exile at EOT.
577                // Mirrors Java PermanentEffect + StaticAbilityCastWithFlash for Warp.
578                if alt_cost == Some(crate::spellability::AlternativeCost::Warp) {
579                    game.card_mut(card_id)
580                        .keywords
581                        .add(crate::card::KEYWORD_WARP_EXILED);
582                    self.trigger_handler.register_delayed_trigger(
583                        crate::trigger::handler::DelayedTrigger {
584                            mode: TriggerType::Phase,
585                            trigger_mode: Box::new(crate::trigger::trigger_phase::TriggerPhase {
586                                phases: vec![forge_foundation::PhaseType::EndOfTurn],
587                                valid_player: None,
588                            }) as Box<dyn crate::trigger::TriggerBehavior>,
589                            params: crate::parsing::Params::default(),
590                            execute_svar: format!(
591                                "DB$ ChangeZone | Origin$ Battlefield | Destination$ Exile | Defined$ CardUID_{}", card_id.0
592                            ),
593                            controller: player,
594                            source_card: card_id,
595                            created_turn: game.turn.turn_number,
596                            created_phase: game.turn.phase,
597                            target_card: Some(card_id),
598                            remembered_amount: 0,
599                            remembered_cards: Vec::new(),
600                            remembered_players: Vec::new(),
601                            remembered_lki_cards: Vec::new(),
602                            sort_after_active: false,
603                trigger_order: None,
604                        },
605                    );
606                }
607
608                // Bestow: attach to a creature as an Aura
609                if alt_cost == Some(crate::spellability::AlternativeCost::Bestow) {
610                    if let Some(target) = entry.spell_ability.target_chosen.target_card {
611                        game.card_mut(card_id).unanimate_bestow();
612                        game.attach_to(card_id, target);
613                    }
614                }
615
616                // Morph/Megamorph: enter face-down as a 2/2 creature
617                if alt_cost.is_some_and(|ac| ac.is_morph()) {
618                    let is_mega = alt_cost == Some(crate::spellability::AlternativeCost::Megamorph);
619                    let c = game.card_mut(card_id);
620                    c.set_face_down(true);
621                    c.static_set_power = Some(crate::spellability::MORPH_PT);
622                    c.static_set_toughness = Some(crate::spellability::MORPH_PT);
623
624                    // Add "turn face up" activated ability (morph cost → SetState TurnFaceUp).
625                    // This is a game rule, not a card ability — face-down morph creatures
626                    // can always be turned face up by paying the morph cost.
627                    let morph_cost = c
628                        .get_keyword_cost(if is_mega { "Megamorph" } else { "Morph" })
629                        .unwrap_or_else(|| "3".to_string());
630                    let mega_param = if is_mega { " | Mega$ True" } else { "" };
631                    let ab_text = format!(
632                        "AB$ SetState | Cost$ {} | Mode$ TurnFaceUp{}",
633                        morph_cost, mega_param
634                    );
635                    let ab_index = c.activated_abilities.len();
636                    if let Some(parsed) =
637                        crate::ability::activated::parse_activated_ability(&ab_text, ab_index)
638                    {
639                        c.activated_abilities.push(parsed);
640                    }
641                }
642
643                // Blitz: grant haste + "dies: draw a card" + sacrifice at EOT
644                if alt_cost == Some(crate::spellability::AlternativeCost::Blitz) {
645                    game.card_mut(card_id).pump_keywords.add("Haste");
646                    let trig_id = game.card(card_id).triggers.len() as u32;
647                    let params = crate::parsing::Params::from_raw(
648                        "Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | ValidCard$ Card.Self"
649                    );
650                    let dies_trigger = crate::trigger::Trigger {
651                        id: trig_id,
652                        base: {
653                            let mut base =
654                                crate::game_loop::trigger_replacement_base::TriggerReplacementBase::default();
655                            base.card_trait_base.set_id(trig_id as i32);
656                            base.card_trait_base.set_intrinsic(false);
657                            base.valid_host_zones = Some(vec![ZoneType::Battlefield]);
658                            base
659                        },
660                        kind: crate::trigger::TriggerType::ChangesZone,
661                        mode: Box::new(crate::trigger::trigger_changes_zone::TriggerChangesZone),
662                        ir: crate::trigger::TriggerIr::from_params(&params),
663                        execute: "BlitzDiesDraw".to_string(),
664                        optional: false,
665                        description: "When this creature dies, draw a card.".to_string(),
666                        static_trigger: false,
667                        trigger_remembered: Vec::new(),
668                        spawning_ability: None,
669                    };
670                    game.card_mut(card_id).add_trigger(dies_trigger);
671                    game.card_mut(card_id)
672                        .set_s_var("BlitzDiesDraw", "DB$ Draw | NumCards$ 1 | Defined$ You");
673                    self.trigger_handler.unregister_active_triggers(card_id);
674                    self.trigger_handler.register_active_trigger(game, card_id);
675
676                    self.trigger_handler.register_delayed_trigger(
677                        crate::trigger::handler::DelayedTrigger {
678                            mode: TriggerType::Phase,
679                            trigger_mode: Box::new(crate::trigger::trigger_phase::TriggerPhase {
680                                phases: vec![forge_foundation::PhaseType::EndOfTurn],
681                                valid_player: None,
682                            })
683                                as Box<dyn crate::trigger::TriggerBehavior>,
684                            params: crate::parsing::Params::default(),
685                            execute_svar: format!("DB$ Sacrifice | Defined$ CardUID_{}", card_id.0),
686                            controller: player,
687                            source_card: card_id,
688                            created_turn: game.turn.turn_number,
689                            created_phase: game.turn.phase,
690                            target_card: Some(card_id),
691                            remembered_amount: 0,
692                            remembered_cards: Vec::new(),
693                            remembered_players: Vec::new(),
694                            remembered_lki_cards: Vec::new(),
695                            sort_after_active: false,
696                            trigger_order: None,
697                        },
698                    );
699                }
700            } else {
701                // Non-permanent spell: resolve effect, then route to destination zone
702                self.resolve_spell_effect(game, agents, &entry);
703                crate::perf::increment(crate::perf::Metric::SpellAbilityClones, 3);
704                self.trigger_handler.run_trigger(
705                    TriggerType::AbilityResolves,
706                    RunParams {
707                        card: Some(card_id),
708                        spell_card: Some(card_id),
709                        spell_controller: Some(player),
710                        spell_ability: Some(entry.spell_ability.clone()),
711                        source_sa: Some(entry.spell_ability.clone()),
712                        cause: Some(entry.spell_ability.clone()),
713                        cause_card: Some(card_id),
714                        ..Default::default()
715                    },
716                    false,
717                );
718                let owner = game.card(card_id).owner;
719                // Only move if still in stack zone (some effects move the card themselves)
720                if game.card(card_id).zone != ZoneType::Exile
721                    && game.card(card_id).zone != ZoneType::Library
722                    && game.card(card_id).zone != ZoneType::Hand
723                {
724                    // Determine destination based on alternative cost / keywords
725                    let dest = if alt_cost == Some(crate::spellability::AlternativeCost::Harmonize)
726                    {
727                        apply_moved_replacement(game, card_id, ZoneType::Graveyard, Some(agents))
728                    } else if alt_cost == Some(crate::spellability::AlternativeCost::Flashback)
729                        || alt_cost == Some(crate::spellability::AlternativeCost::Escape)
730                    {
731                        ZoneType::Exile
732                    } else if entry.spell_ability.buyback_paid {
733                        // Buyback: return to hand instead of graveyard
734                        ZoneType::Hand
735                    } else if game.card(card_id).has_rebound()
736                        && entry.cast_from_zone == Some(ZoneType::Hand)
737                    {
738                        // Rebound: exile instead of graveyard (will be cast next upkeep)
739                        self.trigger_handler.register_delayed_trigger(
740                            crate::trigger::handler::DelayedTrigger {
741                                mode: TriggerType::Phase,
742                                trigger_mode: Box::new(
743                                    crate::trigger::trigger_phase::TriggerPhase {
744                                        phases: vec![forge_foundation::PhaseType::Upkeep],
745                                        valid_player: Some(
746                                            crate::parsing::cached_compiled_selector("You"),
747                                        ),
748                                    },
749                                )
750                                    as Box<dyn crate::trigger::TriggerBehavior>,
751                                params: crate::parsing::Params::default(),
752                                execute_svar: format!(
753                                    "DB$ Play | Defined$ CardUID_{} | WithoutManaCost$ True",
754                                    card_id.0
755                                ),
756                                controller: player,
757                                source_card: card_id,
758                                created_turn: game.turn.turn_number,
759                                created_phase: game.turn.phase,
760                                target_card: Some(card_id),
761                                remembered_amount: 0,
762                                remembered_cards: Vec::new(),
763                                remembered_players: Vec::new(),
764                                remembered_lki_cards: Vec::new(),
765                                sort_after_active: false,
766                                trigger_order: None,
767                            },
768                        );
769                        ZoneType::Exile
770                    } else {
771                        apply_moved_replacement(game, card_id, ZoneType::Graveyard, Some(agents))
772                    };
773                    self.move_card_with_runtime(game, card_id, dest, owner, agents);
774                }
775            }
776        }
777
778        // Mark resolution complete on the stack.
779        game.stack.finish_resolving();
780
781        // Continuous effects might change after resolution
782        apply_continuous_effects(game);
783
784        // LKI: Second snapshot after resolution and SBAs, before processing triggers.
785        // Mirrors Java MagicStack line 676: game.copyLastState() in finishResolving().
786        // Java does not run SBA here; it defers that to the next priority loop.
787        // Keep the snapshot pre-SBA so deep parity aligns with Java's
788        // GameEventPlayerPriority boundary.
789        game.copy_last_state();
790
791        // Java parity: triggers fired during resolution are queued now and only
792        self.trigger_handler.flush_waiting_triggers(game);
793    }
794
795    pub(crate) fn resolve_spell_effect(
796        &mut self,
797        game: &mut GameState,
798        agents: &mut [Box<dyn PlayerAgent>],
799        entry: &StackEntry,
800    ) {
801        // Reset shared parity tables for this stack resolution. The
802        // change-zone table must exist (not None) so that moves performed
803        // during this resolution are recorded for the post-pass
804        // `ChangesZoneAll` trigger fire below.
805        game.clear_pending_damage_maps();
806        game.clear_pending_change_zone_table();
807        game.ensure_pending_change_zone_table();
808
809        // Mirrors Java `MagicStack.resolveStack` (MagicStack.java:651-653):
810        // call `handleRemembering` on the root SA before resolving so
811        // `RememberTargets$` populates the host's remembered lists in time
812        // for sub-abilities like `RememberObjects$ RememberedController`.
813        crate::ability::ability_utils::handle_remembering(game, &entry.spell_ability);
814
815        // Walk the SpellAbility chain: resolve each node's effect, propagating
816        // the parent SA's chosen target card so sub-abilities can resolve
817        // `Defined$ ParentTarget`. Mirrors Java's resolveApiAbility() + resolveSubAbilities().
818        let mut parent_target_card: Option<CardId> = None;
819        let mut parent_target_player = None;
820        let mut parent_target_stack_entry: Option<u32> = None;
821        let mut inherited_trigger_index = entry.spell_ability.trigger_index;
822        let root_kicked = entry.spell_ability.kicked;
823        let mut current = Some(&entry.spell_ability);
824        let mut is_first = true;
825        while let Some(sa) = current {
826            // Refresh agent snapshots between sub-abilities so that prompts
827            // (e.g. "choose discard") reflect state changes from earlier
828            // sub-abilities (e.g. "draw 2" before "discard 2").
829            if !is_first {
830                for agent in agents.iter_mut() {
831                    agent.snapshot_state(game, &self.mana_pools);
832                }
833            }
834            is_first = false;
835
836            // Propagate kicked flag from root SA to sub-abilities for condition checks
837            let mut sa_with_ctx;
838            let needs_ctx_clone = (root_kicked && !sa.kicked)
839                || (parent_target_card.is_some() && sa.target_chosen.target_card.is_none())
840                || (parent_target_player.is_some() && sa.target_chosen.target_player.is_none())
841                || (parent_target_stack_entry.is_some()
842                    && sa.target_chosen.target_stack_entry.is_none())
843                || (inherited_trigger_index.is_some() && sa.trigger_index.is_none());
844            let sa_ref = if needs_ctx_clone {
845                sa_with_ctx = sa.clone();
846                if root_kicked && !sa_with_ctx.kicked {
847                    sa_with_ctx.kicked = true;
848                }
849                if sa_with_ctx.target_chosen.target_card.is_none() {
850                    sa_with_ctx.target_chosen.target_card = parent_target_card;
851                }
852                if sa_with_ctx.target_chosen.target_player.is_none() {
853                    sa_with_ctx.target_chosen.target_player = parent_target_player;
854                }
855                if sa_with_ctx.target_chosen.target_stack_entry.is_none() {
856                    sa_with_ctx.target_chosen.target_stack_entry = parent_target_stack_entry;
857                }
858                if sa_with_ctx.trigger_index.is_none() {
859                    sa_with_ctx.trigger_index = inherited_trigger_index;
860                }
861                &sa_with_ctx
862            } else {
863                sa
864            };
865            self.resolve_single_effect(game, agents, sa_ref, parent_target_card);
866            parent_target_card = sa_ref.target_chosen.target_card;
867            parent_target_player = sa_ref.target_chosen.target_player;
868            parent_target_stack_entry = sa_ref.target_chosen.target_stack_entry;
869            inherited_trigger_index = sa_ref.trigger_index;
870            current = sa.get_sub_ability();
871        }
872
873        // Mirror Java's `SpellAbility.resolve` post-pass: fire `ChangesZoneAll`
874        // for every move accumulated during this spell/ability so triggers like
875        // Teval's "whenever a card leaves your graveyard, create a token" see
876        // moves performed by the resolving SA chain (e.g. its DBReturn pulling
877        // a land from the graveyard back onto the battlefield).
878        if let Some(table) = game.pending_change_zone_table.take() {
879            table.trigger_changes_zone_all(&mut self.trigger_handler, game, None);
880        }
881
882        // Avoid leaking shared tables into subsequent stack entries.
883        game.clear_pending_damage_maps();
884        game.clear_pending_change_zone_table();
885        game.stack.clear_recently_removed();
886    }
887
888    /// Check whether a spell/ability should fizzle (CR 608.2b).
889    /// Mirrors Java's `MagicStack.hasFizzled()`.
890    ///
891    /// Walks the SpellAbility chain. If every targeting node has only invalid
892    /// targets, the whole spell/ability is countered by game rules.
893    /// Returns `false` if no node uses targeting at all.
894    fn has_fizzled(sa: &mut SpellAbility, game: &GameState) -> bool {
895        let result = Self::has_fizzled_inner(sa, game, None);
896        // Java: `return fizzle != null && fizzle;`
897        result.unwrap_or(false)
898    }
899
900    /// Recursive helper mirroring Java's `hasFizzled(sa, source, fizzle)`.
901    /// Returns `Option<bool>`:
902    ///   `None`        = no targeting node seen in chain yet
903    ///   `Some(true)`  = all targeting nodes have only invalid targets
904    ///   `Some(false)` = at least one valid target found somewhere
905    fn has_fizzled_inner(
906        sa: &mut SpellAbility,
907        game: &GameState,
908        mut fizzle: Option<bool>,
909    ) -> Option<bool> {
910        if sa.uses_targeting() {
911            // Check if we actually have any chosen targets (mirrors Java's
912            // `!sa.isZeroTargets()` — Rust stores at most one target per slot
913            // so having any slot filled means non-zero targets)
914            let has_any_chosen = !sa.target_chosen.all_target_cards().is_empty()
915                || sa.target_chosen.target_player.is_some()
916                || sa.target_chosen.target_stack_entry.is_some();
917
918            if has_any_chosen {
919                // This node uses targeting and has chosen targets — fizzling
920                // is now possible.
921                if fizzle.is_none() {
922                    fizzle = Some(true);
923                }
924
925                // Check each chosen target. If ANY is still valid, fizzle = false.
926                // Mirrors Java's for loop over `sa.getTargets()`.
927
928                for target_card_id in sa.target_chosen.all_target_cards() {
929                    if Self::is_card_target_valid(
930                        sa,
931                        target_card_id,
932                        if sa.target_chosen.target_card == Some(target_card_id) {
933                            sa.target_chosen.target_card_zone_timestamp
934                        } else {
935                            None
936                        },
937                        game,
938                    ) {
939                        fizzle = Some(false);
940                    } else {
941                        if sa.target_chosen.target_card == Some(target_card_id) {
942                            sa.target_chosen.target_card = None;
943                            sa.target_chosen.target_card_zone_timestamp = None;
944                        }
945                        sa.target_chosen.divided_map.remove(&target_card_id);
946                    }
947                }
948
949                if let Some(target_player_id) = sa.target_chosen.target_player {
950                    if Self::is_player_target_valid(target_player_id, game) {
951                        fizzle = Some(false);
952                    } else {
953                        sa.target_chosen.target_player = None;
954                    }
955                }
956
957                if let Some(target_stack_id) = sa.target_chosen.target_stack_entry {
958                    if game.stack.find_by_id(target_stack_id).is_some() {
959                        fizzle = Some(false);
960                    } else {
961                        sa.target_chosen.target_stack_entry = None;
962                    }
963                }
964
965                // CantFizzle param (e.g. Gilded Drake) overrides fizzle
966                if sa.ir.cant_fizzle {
967                    fizzle = Some(false);
968                }
969            }
970        }
971
972        // Recurse into sub-abilities — mirrors Java's:
973        //   if (sa.getSubAbility() != null)
974        //       fizzle = hasFizzled(sa.getSubAbility(), source, fizzle);
975        if let Some(sub) = sa.get_sub_ability_mut() {
976            fizzle = Self::has_fizzled_inner(sub, game, fizzle);
977        }
978
979        fizzle
980    }
981
982    /// Check if a card target is still valid at resolution time.
983    /// The card must still be in a zone that makes it a legal target:
984    /// - For Battlefield targets (Creature/Permanent/Any): must be on battlefield
985    /// - For CardInZone targets: must be in the specified zone
986    /// - The card must also still be targetable (hexproof etc.)
987    fn is_card_target_valid(
988        sa: &SpellAbility,
989        target_card_id: CardId,
990        target_zone_timestamp: Option<u64>,
991        game: &GameState,
992    ) -> bool {
993        // Check if card index is valid
994        if target_card_id.index() >= game.cards.len() {
995            return false;
996        }
997
998        let card = game.card(target_card_id);
999
1000        // Java parity: target object identity uses both card id and game timestamp.
1001        if let Some(chosen_ts) = target_zone_timestamp {
1002            if card.zone_timestamp != chosen_ts {
1003                return false;
1004            }
1005        }
1006
1007        // Determine expected zone from target restrictions
1008        let expected_zone = if let Some(ref tr) = sa.target_restrictions {
1009            match &tr.target_kind {
1010                TargetKind::Creature(_) | TargetKind::Permanent(_) | TargetKind::Any => {
1011                    Some(ZoneType::Battlefield)
1012                }
1013                TargetKind::CardInZone { zone, .. } => Some(*zone),
1014                _ => Some(ZoneType::Battlefield), // default
1015            }
1016        } else {
1017            Some(ZoneType::Battlefield) // default
1018        };
1019
1020        // Card must be in the expected zone
1021        if let Some(zone) = expected_zone {
1022            if card.zone != zone {
1023                return false;
1024            }
1025        }
1026
1027        // Card must still be targetable (hexproof, shroud, protection, etc.)
1028        // Use the activating player as the source controller
1029        crate::spellability::target_restrictions::can_be_targeted_by_sa(
1030            game,
1031            target_card_id,
1032            sa.activating_player,
1033            sa,
1034        )
1035    }
1036
1037    /// Check if a player target is still valid (player must still be alive).
1038    fn is_player_target_valid(target_player_id: PlayerId, game: &GameState) -> bool {
1039        if target_player_id.index() >= game.players.len() {
1040            return false;
1041        }
1042        !game.player(target_player_id).has_lost
1043    }
1044
1045    /// Resolve a single effect line by delegating to the effects module.
1046    pub(crate) fn resolve_single_effect(
1047        &mut self,
1048        game: &mut GameState,
1049        agents: &mut [Box<dyn PlayerAgent>],
1050        sa: &SpellAbility,
1051        parent_target_card: Option<CardId>,
1052    ) {
1053        let source_name = sa
1054            .source
1055            .and_then(|cid| game.cards.get(cid.index()).map(|c| c.card_name.clone()))
1056            .unwrap_or_else(|| "Unknown source".to_string());
1057        let effect_kind = Self::effect_kind_for_sa(sa);
1058        let mut event = crate::agent::GameLogEvent::stack(format!(
1059            "Effect resolved: {} | source={}",
1060            effect_kind, source_name
1061        ))
1062        .with_player(sa.activating_player);
1063        if let Some(source_id) = sa.source {
1064            event = event.with_source_card(source_id);
1065        }
1066        if let Some(target_id) = sa.target_chosen.target_card {
1067            event = event.with_target_card(target_id);
1068        }
1069        crate::agent::notify_all_agents(agents, event);
1070
1071        let mut ctx = EffectContext {
1072            game,
1073            combat: Some(&mut self.combat),
1074            agents,
1075            trigger_handler: &mut self.trigger_handler,
1076            token_templates: &self.token_templates,
1077            token_art_variants: &self.token_art_variants,
1078            token_fallback: &self.token_fallback,
1079            edition_dates: &self.edition_dates,
1080            mana_pools: &mut self.mana_pools,
1081            parent_target_card,
1082            rng: &mut *self.game_rng,
1083        };
1084        effects::resolve_effect(&mut ctx, sa);
1085    }
1086
1087    /// CR 707.10 / 111.11 — copy of a permanent spell becomes a token.
1088    fn register_evoke_sacrifice_triggers(
1089        &mut self,
1090        game: &GameState,
1091        card_id: CardId,
1092        player: PlayerId,
1093        evoke_keyword_count: usize,
1094    ) {
1095        for i in 0..evoke_keyword_count {
1096            self.trigger_handler.register_delayed_trigger(
1097                crate::trigger::handler::DelayedTrigger {
1098                    mode: TriggerType::ChangesZone,
1099                    trigger_mode: Box::new(crate::trigger::trigger_changes_zone::TriggerChangesZone)
1100                        as Box<dyn crate::trigger::TriggerBehavior>,
1101                    params: crate::parsing::Params::from_raw(
1102                        "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self",
1103                    ),
1104                    execute_svar: "DB$ Sacrifice".to_string(),
1105                    controller: player,
1106                    source_card: card_id,
1107                    created_turn: game.turn.turn_number,
1108                    created_phase: game.turn.phase,
1109                    target_card: Some(card_id),
1110                    remembered_amount: 0,
1111                    remembered_cards: Vec::new(),
1112                    remembered_players: Vec::new(),
1113                    remembered_lki_cards: Vec::new(),
1114                    sort_after_active: i > 0,
1115                    trigger_order: None,
1116                },
1117            );
1118        }
1119    }
1120
1121    fn resolve_copied_permanent_as_token(
1122        &mut self,
1123        game: &mut GameState,
1124        agents: &mut [Box<dyn PlayerAgent>],
1125        entry: &StackEntry,
1126    ) {
1127        use crate::ability::effects::token_effect_base::{TokenCreateTable, TokenEffectBase};
1128        let Some(original_id) = entry.spell_ability.source else {
1129            return;
1130        };
1131        let player = entry.spell_ability.activating_player;
1132        let original = game.card(original_id).clone();
1133        let mut proto = crate::ability::effects::copy_permanent_effect::get_proto_type(
1134            &entry.spell_ability,
1135            &original,
1136            player,
1137        );
1138        proto.copied_permanent = Some(original_id);
1139        let mut token_table = TokenCreateTable::default();
1140        token_table.put(player, proto, 1);
1141        let mut trigger_list = crate::card::card_zone_table::CardZoneTable::default();
1142        let mut ctx = EffectContext {
1143            game,
1144            combat: Some(&mut self.combat),
1145            agents,
1146            trigger_handler: &mut self.trigger_handler,
1147            token_templates: &self.token_templates,
1148            token_art_variants: &self.token_art_variants,
1149            token_fallback: &self.token_fallback,
1150            edition_dates: &self.edition_dates,
1151            mana_pools: &mut self.mana_pools,
1152            parent_target_card: None,
1153            rng: &mut *self.game_rng,
1154        };
1155        let created = crate::ability::effects::token_effect_base::TOKEN_EFFECT_BASE
1156            .make_token_table(
1157                &mut ctx,
1158                token_table,
1159                true,
1160                &mut trigger_list,
1161                &entry.spell_ability,
1162            );
1163        if entry.spell_ability.alt_cost == Some(crate::spellability::AlternativeCost::Evoke) {
1164            let evoke_keyword_count = (entry.spell_ability.evoke_keyword_count as usize).max(1);
1165            for &token_id in &created.created {
1166                self.register_evoke_sacrifice_triggers(game, token_id, player, evoke_keyword_count);
1167            }
1168        }
1169        trigger_list.trigger_changes_zone_all(
1170            &mut self.trigger_handler,
1171            game,
1172            Some(&entry.spell_ability),
1173        );
1174    }
1175}