Skip to main content

manabrew_engine/game_loop/
combat_phase.rs

1use super::*;
2use crate::card::card_damage_history::TrackedEntity;
3
4impl GameLoop {
5    pub fn step_combat(&mut self, game: &mut GameState, agents: &mut [Box<dyn PlayerAgent>]) {
6        let _perf_scope =
7            crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Combat);
8        let active = game.active_player();
9        let defending = game.opponent_of(active);
10        self.combat.clear_with_cards(&mut game.cards);
11        game.turn.combat_block_assignments.clear();
12        self.combat.attacking_player = Some(active);
13        self.combat.defending_player = Some(defending);
14
15        // Begin Combat
16        self.set_phase(game, agents, PhaseType::CombatBegin);
17        self.emit_phase_trigger(game, PhaseType::CombatBegin);
18        self.step_with_priority(game, agents, false);
19        if game.game_over {
20            self.combat.clear_with_cards(&mut game.cards);
21            return;
22        }
23
24        // EndCombatPhase (issue #22): if requested, exit combat early
25        if game.end_combat_requested {
26            game.end_combat_requested = false;
27            self.combat.clear_with_cards(&mut game.cards);
28            return;
29        }
30
31        // Recompute continuous effects before evaluating attack/block legality.
32        // CantAttack / CantBlock flags are set here.
33        apply_continuous_effects(game);
34        self.trigger_handler.reset_active_triggers(game);
35
36        // LKI: Snapshot battlefield state before combat declarations.
37        // Mirrors Java's Game.copyLastState() called before declare attackers.
38        game.copy_last_state();
39
40        // Declare Attackers — freeze the stack during declarations.
41        game.stack.freeze_stack();
42        self.set_phase(game, agents, PhaseType::CombatDeclareAttackers);
43        let available_attackers = combat::get_available_attackers(game, active);
44        let possible_defenders = combat::get_possible_defenders(game, active);
45
46        // Compute attack requirements (must-attack from statics + goad)
47        let must_attackers = if available_attackers.is_empty() {
48            Vec::new()
49        } else {
50            let requirements = combat::attack_requirement::compute_attack_requirements(
51                &game.cards,
52                &available_attackers,
53                defending,
54            );
55            combat::attack_requirement::must_attack_ids(&requirements)
56        };
57
58        // Java's PhaseHandler uses a do-while loop: declare attackers, validate,
59        // and re-prompt if invalid.  We mirror this so RNG consumption matches.
60        let mut chosen_attackers: Vec<(CardId, combat::DefenderId)> = Vec::new();
61        if !available_attackers.is_empty() {
62            // Java parity: attacker declaration retries until a legal attack
63            // set is found. A low cap can prematurely accept an invalid/no-attack
64            // outcome on crowded boards (e.g. Silent Arbiter + MustAttack).
65            // Keep a very high guard only as a last-resort safety valve.
66            let max_attempts = 5000;
67            for _attempt in 0..max_attempts {
68                agents[active.index()].snapshot_state(game, &self.mana_pools);
69                self.game_log.log(
70                    GameLogEntryType::PriorityWaiting,
71                    2,
72                    format!(
73                        "Waiting for {} attacker declaration",
74                        game.player(active).name
75                    ),
76                );
77                let agent = &mut agents[active.index()];
78                let mut picked =
79                    agent.choose_attackers(active, &available_attackers, &possible_defenders);
80                if self.apply_pending_snapshot_restore(game, agents) {
81                    return;
82                }
83                self.game_log.log(
84                    GameLogEntryType::PriorityResponse,
85                    2,
86                    format!(
87                        "{} declared {} attacker(s)",
88                        game.player(active).name,
89                        picked.len()
90                    ),
91                );
92
93                // Validate attack restrictions (OnlyAlone, NotAlone, NeedGreaterPower, etc.)
94                let attacker_ids: Vec<CardId> = picked.iter().map(|(a, _)| *a).collect();
95                let illegal = combat::attack_restriction::validate_attack_restrictions(
96                    &attacker_ids,
97                    &game.cards,
98                );
99                if !illegal.is_empty() {
100                    picked.retain(|(id, _)| !illegal.contains(id));
101                }
102
103                // Check AttackRestrict limits (global + per-defender).
104                let global_max =
105                    crate::staticability::static_ability_attack_restrict::global_attack_restrict(
106                        &game.cards,
107                    );
108
109                // Global limit applies to ALL attackers regardless of defender.
110                let mut invalid = false;
111                if let Some(max) = global_max {
112                    if picked.len() > max as usize {
113                        invalid = true;
114                    }
115                }
116
117                // Mirror Java's validateAttackers + countViolations + getLegalAttackers:
118                // Count must-attack violations in the agent's raw declaration and compare
119                // against the minimum violations achievable by the best legal attack.
120                // If the agent's declaration has more violations, mark as invalid and retry
121                // (matching Java's RNG consumption for the retry loop).
122                if !invalid {
123                    let current_violations = must_attackers
124                        .iter()
125                        .filter(|&&m| !picked.iter().any(|(a, _)| *a == m))
126                        .count();
127                    if current_violations > 0 {
128                        // Compute minimum possible violations: try the best attack
129                        // which includes as many must-attackers as possible within
130                        // the global max. If all must-attackers fit within the limit,
131                        // best_violations = 0. Otherwise, best_violations = must_count - max.
132                        let max_attackers = global_max.unwrap_or(i32::MAX) as usize;
133                        let best_violations = must_attackers.len().saturating_sub(max_attackers);
134                        if current_violations > best_violations {
135                            invalid = true;
136                        }
137                    }
138                }
139
140                // Per-defender limit: only count attackers going to that defender.
141                // Crawlspace's "ValidDefender$ You" only restricts attacks against
142                // the Crawlspace controller, not attacks against planeswalkers.
143                if !invalid {
144                    let defender_max =
145                        crate::staticability::static_ability_attack_restrict::attack_restrict_num_for_defender(
146                            &game.cards,
147                            defending,
148                        );
149                    if let Some(max) = defender_max {
150                        let attackers_to_defender = picked
151                            .iter()
152                            .filter(|(_, def)| def.controlling_player(game) == defending)
153                            .count();
154                        if attackers_to_defender > max as usize {
155                            invalid = true;
156                        }
157                    }
158                }
159
160                if invalid {
161                    // Declaration invalid — re-prompt like Java's PhaseHandler.
162                    agents[active.index()].notify(
163                        crate::agent::notification::GameNotification::Event(
164                            crate::agent::GameLogEvent::warning("Attack declaration invalid"),
165                        ),
166                    );
167                    continue;
168                }
169
170                chosen_attackers = picked;
171                break;
172            }
173        }
174
175        // Java parity: pre-mark declared attackers before optional attack-cost
176        // resolution so they are not valid enlist targets.
177        // Java does this by temporarily tapping non-vigilance attackers and
178        // treating them as attacking before OptionalAttackCost is paid.
179        let premarked_attackers: Vec<(CardId, combat::DefenderId)> = chosen_attackers.clone();
180        for &(attacker_id, def) in &premarked_attackers {
181            let defending_player = def.controlling_player(game);
182            game.card_mut(attacker_id)
183                .set_attacking_player(defending_player);
184            if !game.card(attacker_id).has_vigilance() {
185                game.card_mut(attacker_id).set_tapped(true);
186            }
187        }
188
189        // Java parity: optional attack costs (Exert/Enlist) are chosen immediately
190        // after attackers are declared and before CantAttackUnless payments.
191        {
192            let declared_attackers: Vec<CardId> = chosen_attackers
193                .iter()
194                .map(|(attacker, _)| *attacker)
195                .collect();
196            let mut optional_exert_by_attacker: std::collections::HashMap<
197                CardId,
198                Vec<(i32, String)>,
199            > = std::collections::HashMap::new();
200            let mut optional_enlist_by_attacker: std::collections::HashMap<
201                CardId,
202                Vec<(i32, String)>,
203            > = std::collections::HashMap::new();
204
205            for &attacker in &declared_attackers {
206                let static_abilities = game.card(attacker).static_abilities.clone();
207                for st in &static_abilities {
208                    if !st.check_mode(&crate::staticability::StaticMode::OptionalAttackCost) {
209                        continue;
210                    }
211                    let Some(cost_raw) = st.ir.cost.as_deref() else {
212                        continue;
213                    };
214                    let parsed = crate::cost::parse_cost(cost_raw);
215                    for part in parsed.parts {
216                        match part {
217                            crate::cost::CostPart::Exert {
218                                amount,
219                                type_filter,
220                            } => {
221                                let amount_n = amount.resolve(game, attacker, active);
222                                optional_exert_by_attacker
223                                    .entry(attacker)
224                                    .or_default()
225                                    .push((amount_n, type_filter));
226                            }
227                            crate::cost::CostPart::Enlist {
228                                amount,
229                                type_filter,
230                            } => {
231                                let amount_n = amount.resolve(game, attacker, active);
232                                optional_enlist_by_attacker
233                                    .entry(attacker)
234                                    .or_default()
235                                    .push((amount_n, type_filter));
236                            }
237                            _ => {}
238                        }
239                    }
240                }
241            }
242
243            let possible_exerters: Vec<CardId> = declared_attackers
244                .iter()
245                .copied()
246                .filter(|cid| optional_exert_by_attacker.contains_key(cid))
247                .collect();
248            if !possible_exerters.is_empty() {
249                let chosen = agents[active.index()].exert_attackers(active, &possible_exerters);
250                for attacker in chosen {
251                    // Exert is paid unconditionally once chosen via exert_attackers
252                    // (mirrors HumanPlay.payCostDuringAbilityResolve's CostExert case).
253                    if let Some(parts) = optional_exert_by_attacker.get(&attacker).cloned() {
254                        for (resolved, type_filter) in parts {
255                            if resolved > 0 {
256                                self.pay_exert_cost(
257                                    game,
258                                    agents,
259                                    active,
260                                    attacker,
261                                    &type_filter,
262                                    resolved,
263                                );
264                            }
265                        }
266                    }
267                }
268            }
269
270            // Re-check enlist targets AFTER exert loop — exerting taps creatures,
271            // which can invalidate enlist candidates.
272            let enlist_can_pay = !crate::cost::get_enlist_targets(game, active).is_empty();
273            let possible_enlisters: Vec<CardId> = if enlist_can_pay {
274                declared_attackers
275                    .iter()
276                    .copied()
277                    .filter(|cid| optional_enlist_by_attacker.contains_key(cid))
278                    .collect()
279            } else {
280                Vec::new()
281            };
282
283            if !possible_enlisters.is_empty() {
284                let chosen = agents[active.index()].enlist_attackers(active, &possible_enlisters);
285                for attacker in chosen {
286                    if let Some(parts) = optional_enlist_by_attacker.get(&attacker).cloned() {
287                        for (resolved, type_filter) in parts {
288                            if resolved > 0 {
289                                self.pay_enlist_cost(
290                                    game,
291                                    agents,
292                                    active,
293                                    attacker,
294                                    &type_filter,
295                                    resolved,
296                                );
297                            }
298                        }
299                    }
300                }
301            }
302        }
303
304        // Check attack costs (Propaganda, Ghostly Prison effects)
305        {
306            let mut cost_failures = Vec::new();
307            for &(attacker_id, defender) in &chosen_attackers {
308                let cost = combat::attack_cost::get_attack_cost(
309                    &game.cards,
310                    game.card(attacker_id),
311                    defender,
312                );
313                if cost > 0 {
314                    let controller = game.card(attacker_id).controller;
315                    let attacker_name = game.card(attacker_id).card_name.clone();
316                    let description = format!("Pay {{{}}} to attack with {}", cost, attacker_name);
317
318                    // Loop: let the agent tap lands / pay / decline
319                    loop {
320                        let tappable_lands = self.get_tappable_lands(game, controller);
321                        let pool_snapshot = self.pool(controller).clone();
322                        let untappable_lands =
323                            self.get_untappable_lands(game, controller, &pool_snapshot);
324                        let pool_total = self.pool(controller).total_mana();
325                        let mana_payment_sources =
326                            crate::mana::collect_mana_payment_sources(game, controller, &[]);
327
328                        agents[controller.index()].snapshot_state(game, &self.mana_pools);
329                        let action = agents[controller.index()].pay_combat_cost(
330                            controller,
331                            attacker_id,
332                            cost,
333                            &description,
334                            &mana_payment_sources.mana_ability_options,
335                            &tappable_lands,
336                            &untappable_lands,
337                            pool_total,
338                        );
339
340                        match action {
341                            CombatCostAction::TapLand {
342                                card_id: land_id,
343                                mana_ability_index,
344                                express_choice,
345                            } => {
346                                if !tappable_lands.contains(&land_id) {
347                                    continue;
348                                }
349                                let undo_record =
350                                    self.begin_mana_undo_action(game, controller, land_id);
351                                let pool_snapshot = self.pool(controller).begin_tap_tracking();
352                                // Use actual mana ability when available
353                                let mana_ab = {
354                                    let c = game.card(land_id);
355                                    if let Some(requested_idx) = mana_ability_index {
356                                        c.activated_abilities
357                                            .iter()
358                                            .find(|ab| {
359                                                ab.is_mana_ability
360                                                    && ab.ability_index == requested_idx
361                                            })
362                                            .cloned()
363                                    } else {
364                                        c.activated_abilities
365                                            .iter()
366                                            .find(|ab| ab.is_mana_ability)
367                                            .cloned()
368                                    }
369                                };
370                                if let Some(ab) = mana_ab {
371                                    self.with_shared_state_mutation(
372                                        game,
373                                        agents,
374                                        |this, game, agents| {
375                                            this.resolve_mana_ability(
376                                                game,
377                                                agents,
378                                                controller,
379                                                land_id,
380                                                &ab,
381                                                express_choice,
382                                            );
383                                        },
384                                    );
385                                } else {
386                                    let atom_opt = {
387                                        let c = game.card(land_id);
388                                        if c.is_land() && !c.tapped {
389                                            basic_land_mana_atom(c)
390                                        } else {
391                                            None
392                                        }
393                                    };
394                                    if let Some(atom) = atom_opt {
395                                        game.tap(land_id);
396                                        self.pool_mut(controller).add(atom, 1);
397                                        self.trigger_handler.run_trigger(
398                                            TriggerType::Taps,
399                                            RunParams {
400                                                card: Some(land_id),
401                                                player: Some(controller),
402                                                ..Default::default()
403                                            },
404                                            false,
405                                        );
406                                        self.trigger_handler.run_trigger(
407                                            TriggerType::TapsForMana,
408                                            RunParams {
409                                                card: Some(land_id),
410                                                player: Some(controller),
411                                                ..Default::default()
412                                            },
413                                            false,
414                                        );
415                                    }
416                                }
417                                let produced =
418                                    self.pool(controller).end_tap_tracking(&pool_snapshot);
419                                self.finish_mana_undo_action(undo_record, produced.len());
420                            }
421                            CombatCostAction::UntapLand(land_id) => {
422                                if !untappable_lands.contains(&land_id) {
423                                    continue;
424                                }
425                                self.undo_mana_action(game, controller, land_id);
426                            }
427                            CombatCostAction::Pay => {
428                                self.invalidate_mana_undo_for_player(controller);
429                                let pool = &mut self.mana_pools[controller.index()];
430                                if pool.total_mana() >= cost {
431                                    pool.spend_generic(cost);
432                                    // Successfully paid
433                                } else {
434                                    // Not enough mana — treat as decline
435                                    cost_failures.push(attacker_id);
436                                }
437                                break;
438                            }
439                            CombatCostAction::Decline => {
440                                self.invalidate_mana_undo_for_player(controller);
441                                cost_failures.push(attacker_id);
442                                break;
443                            }
444                        }
445                    }
446                }
447            }
448            chosen_attackers.retain(|(id, _)| !cost_failures.contains(id));
449        }
450
451        // Undo temporary attack markers for attackers removed by cost payment.
452        for &(attacker_id, _) in &premarked_attackers {
453            if !chosen_attackers.iter().any(|(id, _)| *id == attacker_id) {
454                game.card_mut(attacker_id).clear_attacking_player();
455                if !game.card(attacker_id).has_vigilance() {
456                    game.card_mut(attacker_id).set_tapped(false);
457                }
458            }
459        }
460
461        if !chosen_attackers.is_empty() {
462            crate::agent::notify_all_agents(
463                agents,
464                crate::agent::GameLogEvent::action("Combat phase begins").with_player(active),
465            );
466            let attackers_msg = chosen_attackers
467                .iter()
468                .map(|(attacker_id, defender)| {
469                    let attacker_name = game.card(*attacker_id).card_name.clone();
470                    let defender_name = match defender {
471                        combat::DefenderId::Player(pid) => game.player(*pid).name.clone(),
472                        combat::DefenderId::Permanent(cid) => game.card(*cid).card_name.clone(),
473                    };
474                    format!("{attacker_name} -> {defender_name}")
475                })
476                .collect::<Vec<_>>()
477                .join(", ");
478            crate::agent::notify_all_agents(
479                agents,
480                crate::agent::GameLogEvent::action(format!("Attackers: {attackers_msg}"))
481                    .with_player(active),
482            );
483        }
484
485        // Tap attackers (Vigilance skips tapping)
486        let num_attackers = chosen_attackers.len() as i32;
487        game.player_attack_combat_reset(active);
488        for &(attacker_id, defender) in &chosen_attackers {
489            if !game.card(attacker_id).has_vigilance() {
490                // We pre-tapped attackers before OptionalAttackCost resolution to
491                // mirror Java legality checks; untap first so this tap emits the
492                // declaration-time Taps trigger once.
493                if game.card(attacker_id).tapped {
494                    game.untap(attacker_id);
495                }
496                game.tap(attacker_id);
497                // Java attacker.tap(...) emits Taps triggers when a creature becomes tapped
498                // as part of attacker declaration.
499                self.trigger_handler.run_trigger(
500                    TriggerType::Taps,
501                    RunParams {
502                        card: Some(attacker_id),
503                        player: Some(active),
504                        ..Default::default()
505                    },
506                    false,
507                );
508            }
509            game.card_mut(attacker_id).mark_attacked_this_turn();
510            // Set attacking_player to the controlling player of the defender
511            let def_player = defender.controlling_player(game);
512            game.card_mut(attacker_id).set_attacking_player(def_player);
513            self.combat.declare_attacker(
514                attacker_id,
515                defender,
516                game.card(attacker_id).zone_timestamp,
517            );
518
519            // Record attack in damage history
520            game.card_mut(attacker_id)
521                .damage_history
522                .record_attack(num_attackers - 1);
523            game.card_mut(attacker_id)
524                .damage_history
525                .set_creature_attacked_this_combat(
526                    Some(match defender {
527                        combat::DefenderId::Player(pid) => TrackedEntity::Player(pid),
528                        combat::DefenderId::Permanent(cid) => TrackedEntity::Card(cid),
529                    }),
530                    num_attackers - 1,
531                    matches!(defender, combat::DefenderId::Permanent(_)),
532                );
533            if let combat::DefenderId::Player(pid) = defender {
534                if !game
535                    .player(active)
536                    .attacked_players_this_turn
537                    .contains(&pid)
538                {
539                    game.player_record_attacked_player(active, pid);
540                }
541                if !game
542                    .player(active)
543                    .attacked_players_this_combat
544                    .contains(&pid)
545                {
546                    game.player_record_attacked_player(active, pid);
547                }
548            }
549
550            crate::ability::effects::ring_tempts_you_effect::sync_ring_effect(
551                game,
552                &mut self.trigger_handler,
553                active,
554            );
555
556            // Fire Attacks trigger for each attacker
557            self.trigger_handler.run_trigger(
558                TriggerType::Attacks,
559                RunParams {
560                    attacker: Some(attacker_id),
561                    card: Some(attacker_id),
562                    defending_player: Some(def_player),
563                    num_attackers: Some(num_attackers as usize),
564                    ..Default::default()
565                },
566                false,
567            );
568        }
569        // Fire AttackersDeclaredOneTarget-style batches first, then the aggregate event.
570        if !chosen_attackers.is_empty() {
571            let mut grouped_attackers: std::collections::HashMap<combat::DefenderId, Vec<CardId>> =
572                std::collections::HashMap::new();
573            for &(attacker_id, defender) in &chosen_attackers {
574                grouped_attackers
575                    .entry(defender)
576                    .or_default()
577                    .push(attacker_id);
578            }
579            let mut attacked_player_ids = Vec::new();
580            let mut attacked_card_ids = Vec::new();
581            for (defender, attackers) in &grouped_attackers {
582                let mut params = RunParams {
583                    attacker_ids: Some(attackers.clone()),
584                    player: Some(game.active_player()),
585                    attacking_player: Some(game.active_player()),
586                    ..Default::default()
587                };
588                match defender {
589                    combat::DefenderId::Player(pid) => {
590                        params.attacked_player = Some(*pid);
591                        params.defenders_player_ids = Some(vec![*pid]);
592                        attacked_player_ids.push(*pid);
593                    }
594                    combat::DefenderId::Permanent(cid) => {
595                        params.attacked_card = Some(*cid);
596                        params.defenders_card_ids = Some(vec![*cid]);
597                        attacked_card_ids.push(*cid);
598                    }
599                }
600                self.trigger_handler.run_trigger(
601                    TriggerType::AttackersDeclaredOneTarget,
602                    params,
603                    false,
604                );
605            }
606
607            let attacker_ids: Vec<CardId> = chosen_attackers.iter().map(|(a, _)| *a).collect();
608            self.trigger_handler.run_trigger(
609                TriggerType::AttackersDeclared,
610                RunParams {
611                    player: Some(game.active_player()),
612                    attacking_player: Some(game.active_player()),
613                    attacker_ids: Some(attacker_ids),
614                    defenders_player_ids: if attacked_player_ids.is_empty() {
615                        None
616                    } else {
617                        Some(attacked_player_ids)
618                    },
619                    defenders_card_ids: if attacked_card_ids.is_empty() {
620                        None
621                    } else {
622                        Some(attacked_card_ids)
623                    },
624                    ..Default::default()
625                },
626                false,
627            );
628        }
629        // Recompute continuous effects now that `attacking_player` is set on
630        // declared attackers.  This allows effects like Watchdog's
631        // "Affected$ Creature.attackingYou | AddPower$ -1" to apply correctly.
632        apply_continuous_effects(game);
633        self.trigger_handler.reset_active_triggers(game);
634        // Unfreeze the stack now that attackers are declared.
635        game.stack.unfreeze_stack();
636        // Java parity: PhaseHandler sets givePriorityToPlayer = inCombat() after
637        // declare attackers. In Java, inCombat() returns `combat != null` (true
638        // whenever the combat object exists, regardless of whether attackers were
639        // declared), so priority is always given here.
640        self.step_with_priority(game, agents, false);
641        if game.game_over {
642            self.combat.clear_with_cards(&mut game.cards);
643            return;
644        }
645
646        // Java parity: PhaseHandler still advances into COMBAT_DECLARE_BLOCKERS
647        // even when it will be skipped due to no attackers.
648        self.combat.remove_absent_combatants(&game.cards);
649        if !self.combat.has_attackers() {
650            self.set_phase(game, agents, PhaseType::CombatDeclareBlockers);
651        }
652        if self.combat.has_attackers() {
653            // Run DeclareBlocker replacement effects before declaring blockers.
654            {
655                use crate::replacement::replacement_handler::{
656                    apply_replacements, ReplacementEvent,
657                };
658                use crate::replacement::ReplacementResult;
659                let mut event = ReplacementEvent::DeclareBlocker { player: defending };
660                let result = apply_replacements(game, &mut event);
661                if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
662                    // Blockers phase was prevented — skip to damage
663                }
664            }
665
666            // Declare Blockers — freeze the stack during declarations.
667            game.stack.freeze_stack();
668            self.set_phase(game, agents, PhaseType::CombatDeclareBlockers);
669            let attacker_card_ids: Vec<CardId> =
670                self.combat.attackers.iter().map(|(a, _)| *a).collect();
671            let available_blockers = combat::get_available_blockers(game, defending);
672            let legal_blockers =
673                combat::filter_legal_blockers(game, &attacker_card_ids, &available_blockers);
674            let has_any_legal_blocker = !legal_blockers.is_empty();
675
676            if has_any_legal_blocker {
677                agents[defending.index()].snapshot_state(game, &self.mana_pools);
678                self.game_log.log(
679                    GameLogEntryType::PriorityWaiting,
680                    2,
681                    format!(
682                        "Waiting for {} blocker declaration",
683                        game.player(defending).name
684                    ),
685                );
686                let max_blockers = {
687                    let raw =
688                        crate::staticability::static_ability_block_restrict::block_restrict_num(
689                            &game.cards,
690                            defending,
691                        );
692                    if raw < i32::MAX {
693                        Some(raw as usize)
694                    } else {
695                        None
696                    }
697                };
698                let mut chosen_blockers = {
699                    let def_agent = &mut agents[defending.index()];
700                    def_agent.choose_blockers(
701                        defending,
702                        &attacker_card_ids,
703                        &available_blockers,
704                        max_blockers,
705                    )
706                };
707                if self.apply_pending_snapshot_restore(game, agents) {
708                    return;
709                }
710                // Ignore duplicate blocker assignments; first assignment wins.
711                let mut seen_blockers = std::collections::HashSet::new();
712                chosen_blockers.retain(|(blocker, _)| seen_blockers.insert(*blocker));
713                self.game_log.log(
714                    GameLogEntryType::PriorityResponse,
715                    2,
716                    format!(
717                        "{} declared {} blocker assignment(s)",
718                        game.player(defending).name,
719                        chosen_blockers.len()
720                    ),
721                );
722
723                for (blocker, attacker) in chosen_blockers.into_iter() {
724                    // Validate: use comprehensive evasion check
725                    if !combat::can_creature_block(game, blocker, attacker) {
726                        continue; // illegal block
727                    }
728                    self.combat.declare_blocker(
729                        blocker,
730                        attacker,
731                        game.card(blocker).zone_timestamp,
732                    );
733
734                    // Fire Blocks trigger for each (blocker, attacker) pair
735                    self.trigger_handler.run_trigger(
736                        TriggerType::Blocks,
737                        RunParams {
738                            blocker: Some(blocker),
739                            blocked_attacker: Some(attacker),
740                            card: Some(blocker),
741                            ..Default::default()
742                        },
743                        false,
744                    );
745                }
746
747                // Block cost checking (War Cadence effects)
748                {
749                    let mut block_cost_failures = Vec::new();
750                    for &(blocker_id, attacker_id) in &self.combat.blockers {
751                        let cost = combat::block_cost::get_block_cost(
752                            &game.cards,
753                            game.card(blocker_id),
754                            game.card(attacker_id),
755                        );
756                        if cost > 0 {
757                            let controller = game.card(blocker_id).controller;
758                            let pool = &mut self.mana_pools[controller.index()];
759                            if pool.total_mana() >= cost {
760                                pool.spend_generic(cost);
761                            } else {
762                                block_cost_failures.push(blocker_id);
763                            }
764                        }
765                    }
766                    self.combat
767                        .blockers
768                        .retain(|(b, _)| !block_cost_failures.contains(b));
769                }
770
771                // Block validation (Menace, can't block alone)
772                let invalid_blocks = combat::validate_blocks(game, &self.combat);
773                for (blocker_id, attacker_id) in &invalid_blocks {
774                    self.combat
775                        .blockers
776                        .retain(|(b, a)| !(b == blocker_id && a == attacker_id));
777                }
778
779                // Must-block enforcement: auto-assign blockers to required targets
780                let all_legal_blockers: Vec<CardId> = available_blockers.clone();
781                for &blocker_id in &all_legal_blockers {
782                    let must_targets =
783                        combat::compute_must_block_targets(game, &self.combat, blocker_id);
784                    if must_targets.is_empty() {
785                        continue;
786                    }
787                    let currently_blocking: Vec<CardId> = self
788                        .combat
789                        .blockers
790                        .iter()
791                        .filter(|(b, _)| *b == blocker_id)
792                        .map(|(_, a)| *a)
793                        .collect();
794                    if !must_targets.iter().any(|t| currently_blocking.contains(t)) {
795                        // Not blocking any required target — force-assign first
796                        if combat::can_creature_block(game, blocker_id, must_targets[0]) {
797                            self.combat.declare_blocker(
798                                blocker_id,
799                                must_targets[0],
800                                game.card(blocker_id).zone_timestamp,
801                            );
802                        }
803                    }
804                }
805
806                // Record damage history for blockers
807                for &(blocker_id, attacker_id) in &self.combat.blockers {
808                    game.card_mut(blocker_id).damage_history.record_block();
809                    game.card_mut(attacker_id)
810                        .damage_history
811                        .record_got_blocked();
812                }
813
814                // Publish finalized blocker assignments for UI snapshots in this combat.
815                game.turn.combat_block_assignments = self.combat.blockers.clone();
816
817                if !self.combat.blockers.is_empty() {
818                    let blockers_msg = self
819                        .combat
820                        .blockers
821                        .iter()
822                        .map(|(blocker_id, attacker_id)| {
823                            let blocker_name = game.card(*blocker_id).card_name.clone();
824                            let attacker_name = game.card(*attacker_id).card_name.clone();
825                            format!("{blocker_name} -> {attacker_name}")
826                        })
827                        .collect::<Vec<_>>()
828                        .join(", ");
829                    crate::agent::notify_all_agents(
830                        agents,
831                        crate::agent::GameLogEvent::action(format!("Blockers: {blockers_msg}"))
832                            .with_player(defending),
833                    );
834                }
835            }
836
837            // Auto-order blockers by declaration order — Java's parity
838            // harness sets `legacyOrderCombatants = false` so the agent is
839            // never prompted (Combat.java:494). Mirror that to keep the RNG
840            // and trace aligned with Java.
841            for &(attacker_id, _) in &self.combat.attackers.clone() {
842                let blockers_for = self.combat.get_blockers_for(attacker_id);
843                if blockers_for.len() > 1 {
844                    self.combat.damage_order.insert(attacker_id, blockers_for);
845                }
846            }
847
848            // Unfreeze the stack now that blockers are declared.
849            game.stack.unfreeze_stack();
850
851            // Fire BlockersDeclared batch trigger before the priority
852            // window so these triggers are on the stack when players
853            // receive priority (CR 509.4). Mirrors Java's
854            // declareBlockersTurnBasedAction() which fires all block
855            // triggers before mainLoopStep() gives priority.
856            self.trigger_handler.run_trigger(
857                TriggerType::BlockersDeclared,
858                RunParams {
859                    blocker_ids: Some(self.combat.blockers.iter().map(|(b, _)| *b).collect()),
860                    ..Default::default()
861                },
862                false,
863            );
864
865            // Fire AttackerBlocked / AttackerUnblocked triggers
866            for &(attacker_id, defender_id) in &self.combat.attackers.clone() {
867                if self.combat.is_blocked(attacker_id) {
868                    let blockers_for = self.combat.get_blockers_for(attacker_id);
869                    self.trigger_handler.run_trigger(
870                        TriggerType::AttackerBlocked,
871                        RunParams {
872                            attacker: Some(attacker_id),
873                            card: Some(attacker_id),
874                            defending_player: Some(defender_id.controlling_player(game)),
875                            ..Default::default()
876                        },
877                        false,
878                    );
879                    self.trigger_handler.run_trigger(
880                        TriggerType::AttackerBlockedOnce,
881                        RunParams {
882                            attacker: Some(attacker_id),
883                            card: Some(attacker_id),
884                            blocker_ids: Some(blockers_for.clone()),
885                            defending_player: Some(defender_id.controlling_player(game)),
886                            ..Default::default()
887                        },
888                        false,
889                    );
890                    for blocker_id in blockers_for {
891                        self.trigger_handler.run_trigger(
892                            TriggerType::AttackerBlockedByCreature,
893                            RunParams {
894                                attacker: Some(attacker_id),
895                                card: Some(attacker_id),
896                                blocker: Some(blocker_id),
897                                blocked_attacker: Some(attacker_id),
898                                defending_player: Some(defender_id.controlling_player(game)),
899                                ..Default::default()
900                            },
901                            false,
902                        );
903                    }
904                } else {
905                    self.trigger_handler.run_trigger(
906                        TriggerType::AttackerUnblocked,
907                        RunParams {
908                            attacker: Some(attacker_id),
909                            card: Some(attacker_id),
910                            ..Default::default()
911                        },
912                        false,
913                    );
914                    self.trigger_handler.run_trigger(
915                        TriggerType::AttackerUnblockedOnce,
916                        RunParams {
917                            attacker: Some(attacker_id),
918                            card: Some(attacker_id),
919                            ..Default::default()
920                        },
921                        false,
922                    );
923                }
924            }
925
926            self.step_with_priority(game, agents, false);
927            if game.game_over {
928                self.combat.clear_with_cards(&mut game.cards);
929                game.turn.combat_block_assignments.clear();
930                return;
931            }
932        }
933
934        // Java parity: combatants may leave/re-enter during declare blockers
935        // priority (e.g. sacrificing an attacker). Re-prune before damage.
936        self.combat.remove_absent_combatants(&game.cards);
937
938        // Pre-populate LKI cache for all combat participants so that if a
939        // creature dies during damage, its combat role is already recorded.
940        for &(attacker_id, _) in &self.combat.attackers.clone() {
941            self.combat.save_lki(attacker_id);
942        }
943        for &(blocker_id, _) in &self.combat.blockers.clone() {
944            self.combat.save_lki(blocker_id);
945        }
946
947        self.set_phase(game, agents, PhaseType::CombatFirstStrikeDamage);
948        self.combat.remove_absent_combatants(&game.cards);
949        if self.combat.has_attackers() {
950            // LKI: Snapshot battlefield state before first strike damage.
951            // Mirrors Java's Game.copyLastState() called before damage resolution.
952            game.copy_last_state();
953
954            let fs_unblocked_choices = self.choose_assign_as_unblocked(game, agents, true);
955            let fs_events =
956                self.combat
957                    .resolve_damage_step(game, agents, true, &fs_unblocked_choices);
958            // Record damage in source damage history for player-targeted combat damage
959            for event in &fs_events {
960                if event.target_player.is_some() && event.amount > 0 {
961                    game.card_mut(event.source)
962                        .damage_history
963                        .record_damage(event.amount, true);
964                }
965            }
966            let fs_damage_assigned = !fs_events.is_empty();
967            self.fire_combat_damage_triggers(&fs_events);
968            // Flush triggers before SBA so that triggers from creatures about
969            // to die (e.g. enrage) are matched while still on the battlefield.
970            self.trigger_handler.flush_waiting_triggers(game);
971            // Java parity: skip priority when no first-strike damage assigned.
972            if fs_damage_assigned {
973                self.step_with_priority(game, agents, false);
974            }
975            if game.game_over {
976                self.combat.clear_with_cards(&mut game.cards);
977                game.turn.combat_block_assignments.clear();
978                return;
979            }
980        }
981
982        self.set_phase(game, agents, PhaseType::CombatDamage);
983        self.combat.remove_absent_combatants(&game.cards);
984        if self.combat.has_attackers() {
985            // Run AssignDealDamage replacement effects for each attacker.
986            {
987                use crate::replacement::replacement_handler::{
988                    apply_replacements, ReplacementEvent,
989                };
990                let attacker_ids: Vec<CardId> =
991                    self.combat.attackers.iter().map(|(a, _)| *a).collect();
992                for &attacker_id in &attacker_ids {
993                    let mut event = ReplacementEvent::AssignDealDamage { card: attacker_id };
994                    apply_replacements(game, &mut event);
995                }
996            }
997
998            // LKI: Snapshot battlefield state before combat damage.
999            // Mirrors Java's Game.copyLastState() called before damage resolution.
1000            game.copy_last_state();
1001
1002            let unblocked_choices = self.choose_assign_as_unblocked(game, agents, false);
1003            let dmg_events =
1004                self.combat
1005                    .resolve_damage_step(game, agents, false, &unblocked_choices);
1006            // Record damage in source damage history for player-targeted combat damage
1007            for event in &dmg_events {
1008                if event.target_player.is_some() && event.amount > 0 {
1009                    game.card_mut(event.source)
1010                        .damage_history
1011                        .record_damage(event.amount, true);
1012                }
1013            }
1014            // Java parity: skip priority when no damage was actually assigned
1015            // (e.g. 0-power attackers). Mirrors PhaseHandler.java lines 335-343
1016            // where assignCombatDamage returns false → givePriorityToPlayer = false.
1017            let damage_assigned = !dmg_events.is_empty();
1018            if damage_assigned {
1019                self.notify_state_changed(game, agents);
1020            }
1021            self.fire_combat_damage_triggers(&dmg_events);
1022            // Flush triggers before SBA so that triggers from creatures about
1023            // to die (e.g. enrage) are matched while still on the battlefield.
1024            self.trigger_handler.flush_waiting_triggers(game);
1025            if damage_assigned {
1026                self.step_with_priority(game, agents, false);
1027            }
1028            if game.game_over {
1029                self.combat.clear_with_cards(&mut game.cards);
1030                game.turn.combat_block_assignments.clear();
1031                return;
1032            }
1033        }
1034
1035        // End combat
1036        self.set_phase(game, agents, PhaseType::CombatEnd);
1037        self.emit_phase_trigger(game, PhaseType::CombatEnd);
1038        // Revert any `ControlGain$ LoseControl$ EndOfCombat` steals (Threaten-
1039        // style "attack and return").
1040        crate::ability::effects::control_gain_effect::end_of_combat_hook(game);
1041        self.step_with_priority(game, agents, false);
1042
1043        // End-of-combat damage history reset and must_block cleanup
1044        for card in game.cards.iter_mut() {
1045            if card.zone == ZoneType::Battlefield && card.is_creature() {
1046                card.damage_history.end_combat();
1047                card.must_block = false;
1048                card.must_block_cards.clear();
1049            }
1050        }
1051
1052        self.combat.clear_with_cards(&mut game.cards);
1053        game.turn.combat_block_assignments.clear();
1054        // Recompute continuous effects after combat ends so that stale
1055        // combat-dependent modifiers (e.g. Watchdog's "creatures attacking you
1056        // get -1/-0") are cleared.  Without this, static_power_modifier lingers
1057        // until the next apply_continuous_effects call, causing snapshot drift.
1058        apply_continuous_effects(game);
1059        self.trigger_handler.reset_active_triggers(game);
1060    }
1061
1062    fn choose_assign_as_unblocked(
1063        &mut self,
1064        game: &GameState,
1065        agents: &mut [Box<dyn PlayerAgent>],
1066        first_strike_only: bool,
1067    ) -> std::collections::HashSet<CardId> {
1068        let mut choices = std::collections::HashSet::new();
1069        for &(attacker_id, _) in &self.combat.attackers {
1070            if !self.combat.is_blocked(attacker_id) {
1071                continue;
1072            }
1073            let attacker = game.card(attacker_id);
1074            let has_fs = attacker.has_first_strike();
1075            let has_ds = attacker.has_double_strike();
1076            let deals_in_step = if first_strike_only {
1077                has_fs || has_ds
1078            } else {
1079                !has_fs || has_ds
1080            };
1081            if !deals_in_step {
1082                continue;
1083            }
1084            if !crate::staticability::static_ability_assign_combat_damage_as_unblocked::has_optional_assign_as_unblocked(
1085                &game.cards,
1086                attacker,
1087            ) {
1088                continue;
1089            }
1090
1091            let controller = attacker.controller;
1092            let desc = format!(
1093                "Have {} assign combat damage as though unblocked?",
1094                attacker.card_name
1095            );
1096            agents[controller.index()].snapshot_state(game, &self.mana_pools);
1097            if agents[controller.index()].choose_optional_trigger(
1098                controller,
1099                &desc,
1100                Some(attacker_id),
1101                None,
1102            ) {
1103                choices.insert(attacker_id);
1104            }
1105        }
1106        choices
1107    }
1108}