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