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