Skip to main content

manabrew_engine/combat/
mod.rs

1pub mod attack_constraints;
2pub mod attack_cost;
3pub mod attack_requirement;
4pub mod attack_restriction;
5pub mod attack_restriction_type;
6pub mod attacking_band;
7pub mod block_cost;
8pub mod combat_lki;
9pub mod combat_util;
10pub mod global_attack_restrictions;
11pub mod selector_domain;
12
13use std::collections::{HashMap, HashSet};
14
15use forge_foundation::ZoneType;
16use serde::{Deserialize, Serialize};
17
18use crate::agent::PlayerAgent;
19use crate::game::GameState;
20use crate::ids::{CardId, PlayerId};
21
22/// Identifies the target of an attack: a player or a permanent (planeswalker/battle).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub enum DefenderId {
25    Player(PlayerId),
26    Permanent(CardId),
27}
28
29impl DefenderId {
30    /// Returns the PlayerId if this is a player defender, or the controller
31    /// of the permanent if it's a planeswalker/battle.
32    pub fn controlling_player(&self, game: &GameState) -> PlayerId {
33        match self {
34            DefenderId::Player(pid) => *pid,
35            DefenderId::Permanent(cid) => game.card(*cid).controller,
36        }
37    }
38
39    /// Returns the PlayerId if this is a player defender.
40    pub fn as_player(&self) -> Option<PlayerId> {
41        match self {
42            DefenderId::Player(pid) => Some(*pid),
43            DefenderId::Permanent(_) => None,
44        }
45    }
46}
47
48pub use combat_lki::CombatLki;
49
50/// A combat damage event returned from resolve_damage_step.
51/// Used to fire DamageDone and LifeGained triggers from game_loop.rs.
52#[derive(Debug, Clone)]
53pub struct CombatDamageEvent {
54    pub source: CardId,
55    pub target_player: Option<PlayerId>,
56    pub target_card: Option<CardId>,
57    pub amount: i32,
58    pub is_combat: bool,
59    pub lifelink_player: Option<PlayerId>,
60    pub lifelink_amount: i32,
61}
62
63/// Tracks combat state for the current combat phase.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct CombatState {
66    /// Attacking player.
67    pub attacking_player: Option<PlayerId>,
68    /// Defending player.
69    pub defending_player: Option<PlayerId>,
70    /// (attacker CardId, defender — player or permanent)
71    pub attackers: Vec<(CardId, DefenderId)>,
72    /// Zone timestamp of each attacker at declare-attackers time.
73    #[serde(default)]
74    pub attacker_zone_timestamps: HashMap<CardId, u64>,
75    /// (blocker CardId, attacker CardId)
76    pub blockers: Vec<(CardId, CardId)>,
77    /// Attackers that became blocked at any point this combat, even if
78    /// blockers later left combat before damage.
79    #[serde(default)]
80    pub blocked_attackers: HashSet<CardId>,
81    /// Zone timestamp of each blocker at declare-blockers time.
82    #[serde(default)]
83    pub blocker_zone_timestamps: HashMap<CardId, u64>,
84    /// Damage assignment order: attacker → ordered list of blockers.
85    /// The attacker must assign lethal to each blocker in order before
86    /// moving to the next. Set after blocker declaration.
87    #[serde(default)]
88    pub damage_order: HashMap<CardId, Vec<CardId>>,
89    /// Last-known-information cache: snapshots of creatures that left combat.
90    /// Persists until combat ends (cleared in `clear()`).
91    #[serde(skip)]
92    pub lki_cache: HashMap<CardId, CombatLki>,
93}
94
95impl CombatState {
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    pub fn clear(&mut self) {
101        self.attacking_player = None;
102        self.defending_player = None;
103        self.attackers.clear();
104        self.attacker_zone_timestamps.clear();
105        self.blockers.clear();
106        self.blocked_attackers.clear();
107        self.blocker_zone_timestamps.clear();
108        self.damage_order.clear();
109        self.lki_cache.clear();
110    }
111
112    /// Clear combat state, including the `attacking_player` flag on each attacker card.
113    pub fn clear_with_cards(&mut self, cards: &mut [crate::card::Card]) {
114        for &(attacker_id, _) in &self.attackers {
115            cards[attacker_id.index()].attacking_player = None;
116        }
117        // Preserve lki_cache across clear_with_cards (persists until end of combat)
118        let lki = std::mem::take(&mut self.lki_cache);
119        self.clear();
120        self.lki_cache = lki;
121    }
122
123    pub fn declare_attacker(
124        &mut self,
125        attacker: CardId,
126        defending: DefenderId,
127        zone_timestamp: u64,
128    ) {
129        self.attackers.push((attacker, defending));
130        self.attacker_zone_timestamps
131            .insert(attacker, zone_timestamp);
132    }
133
134    pub fn declare_blocker(&mut self, blocker: CardId, attacker: CardId, zone_timestamp: u64) {
135        self.blockers.push((blocker, attacker));
136        self.blocked_attackers.insert(attacker);
137        self.blocker_zone_timestamps.insert(blocker, zone_timestamp);
138    }
139
140    pub fn is_attacking(&self, card: CardId) -> bool {
141        self.attackers.iter().any(|(a, _)| *a == card)
142    }
143
144    pub fn is_blocked(&self, attacker: CardId) -> bool {
145        self.blockers.iter().any(|(_, a)| *a == attacker)
146    }
147
148    /// True if attacker was blocked at any time this combat.
149    pub fn was_blocked_this_combat(&self, attacker: CardId) -> bool {
150        self.blocked_attackers.contains(&attacker) || self.is_blocked(attacker)
151    }
152
153    pub fn get_blockers_for(&self, attacker: CardId) -> Vec<CardId> {
154        self.blockers
155            .iter()
156            .filter(|(_, a)| *a == attacker)
157            .map(|(b, _)| *b)
158            .collect()
159    }
160
161    pub fn get_attackers_for(&self, blocker: CardId) -> Vec<CardId> {
162        self.blockers
163            .iter()
164            .filter(|(b, _)| *b == blocker)
165            .map(|(_, a)| *a)
166            .collect()
167    }
168
169    pub fn has_attackers(&self) -> bool {
170        !self.attackers.is_empty()
171    }
172
173    /// Snapshot a creature's combat role before it leaves the battlefield.
174    pub fn save_lki(&mut self, card_id: CardId) -> Option<CombatLki> {
175        // Check if attacker
176        if let Some((_, defender)) = self.attackers.iter().find(|(a, _)| *a == card_id) {
177            let lki = CombatLki {
178                is_attacker: true,
179                defender: Some(*defender),
180                blocked_attackers: vec![],
181            };
182            self.lki_cache.insert(card_id, lki.clone());
183            return Some(lki);
184        }
185        // Check if blocker
186        let blocked: Vec<CardId> = self
187            .blockers
188            .iter()
189            .filter(|(b, _)| *b == card_id)
190            .map(|(_, a)| *a)
191            .collect();
192        if !blocked.is_empty() {
193            let lki = CombatLki {
194                is_attacker: false,
195                defender: None,
196                blocked_attackers: blocked,
197            };
198            self.lki_cache.insert(card_id, lki.clone());
199            return Some(lki);
200        }
201        None
202    }
203
204    /// Get LKI for a creature that left combat.
205    pub fn get_combat_lki(&self, card_id: CardId) -> Option<&CombatLki> {
206        self.lki_cache.get(&card_id)
207    }
208
209    /// Check if a creature was (or is) attacking in this combat.
210    pub fn was_attacking(&self, card_id: CardId) -> bool {
211        self.attackers.iter().any(|(a, _)| *a == card_id)
212            || self.lki_cache.get(&card_id).is_some_and(|l| l.is_attacker)
213    }
214
215    /// Check if a creature was (or is) blocking in this combat.
216    pub fn was_blocking(&self, card_id: CardId) -> bool {
217        self.blockers.iter().any(|(b, _)| *b == card_id)
218            || self.lki_cache.get(&card_id).is_some_and(|l| !l.is_attacker)
219    }
220
221    /// Remove attackers/blockers that are no longer on the battlefield or are
222    /// no longer creatures. Also cleans up damage_order keys. Returns `true`
223    /// if any combatant was removed.
224    ///
225    /// Mirrors Java Forge's `Combat.removeAbsentCombatants()`.
226    pub fn remove_absent_combatants(&mut self, cards: &[crate::card::Card]) -> bool {
227        let before_attackers = self.attackers.len();
228        let before_blockers = self.blockers.len();
229
230        self.attackers.retain(|&(id, _)| {
231            let card = &cards[id.index()];
232            let timestamp_ok = self
233                .attacker_zone_timestamps
234                .get(&id)
235                .map(|&ts| ts == card.zone_timestamp)
236                .unwrap_or(true);
237            card.zone == ZoneType::Battlefield && card.is_creature() && timestamp_ok
238        });
239        self.blockers.retain(|&(id, _)| {
240            let card = &cards[id.index()];
241            let timestamp_ok = self
242                .blocker_zone_timestamps
243                .get(&id)
244                .map(|&ts| ts == card.zone_timestamp)
245                .unwrap_or(true);
246            card.zone == ZoneType::Battlefield && card.is_creature() && timestamp_ok
247        });
248
249        let attacker_ids: HashSet<CardId> = self.attackers.iter().map(|(a, _)| *a).collect();
250        self.attacker_zone_timestamps
251            .retain(|attacker_id, _| attacker_ids.contains(attacker_id));
252
253        // Clean damage_order keys for removed attackers
254        self.damage_order.retain(|k, _| attacker_ids.contains(k));
255
256        // Also remove dead blockers from damage_order values
257        let blocker_ids: HashSet<CardId> = self.blockers.iter().map(|(b, _)| *b).collect();
258        self.blocker_zone_timestamps
259            .retain(|blocker_id, _| blocker_ids.contains(blocker_id));
260        for order in self.damage_order.values_mut() {
261            order.retain(|b| blocker_ids.contains(b));
262        }
263
264        self.attackers.len() != before_attackers || self.blockers.len() != before_blockers
265    }
266
267    /// Check if any creature in combat has first strike or double strike.
268    pub fn has_first_strikers(&self, game: &GameState) -> bool {
269        for &(attacker_id, _) in &self.attackers {
270            if !game.card_is_in_zone(attacker_id, ZoneType::Battlefield) {
271                continue;
272            }
273            let card = game.card(attacker_id);
274            if card.has_first_strike() || card.has_double_strike() {
275                return true;
276            }
277        }
278        for &(blocker_id, _) in &self.blockers {
279            if !game.card_is_in_zone(blocker_id, ZoneType::Battlefield) {
280                continue;
281            }
282            let card = game.card(blocker_id);
283            if card.has_first_strike() || card.has_double_strike() {
284                return true;
285            }
286        }
287        false
288    }
289
290    /// Resolve one step of combat damage.
291    /// If `first_strike_only` is true, only first-strike and double-strike creatures deal damage.
292    /// If false, only non-first-strike and double-strike creatures deal damage.
293    /// Returns a Vec of CombatDamageEvents so the caller can fire triggers.
294    pub fn resolve_damage_step(
295        &self,
296        game: &mut GameState,
297        agents: &mut [Box<dyn PlayerAgent>],
298        first_strike_only: bool,
299        as_unblocked_choices: &HashSet<CardId>,
300    ) -> Vec<CombatDamageEvent> {
301        // Fog effect: skip all combat damage this turn (issue #22).
302        if game.prevent_all_combat_damage {
303            return Vec::new();
304        }
305
306        let mut events = Vec::new();
307        let mut blocker_damage_allocations: HashMap<(CardId, CardId), i32> = HashMap::new();
308        let mut computed_blocker_allocations: HashSet<CardId> = HashSet::new();
309        // Java parity: combat damage in a step is simultaneous, so replacement checks
310        // like Phyrexian Unlife's life condition must use life totals from step start.
311        let life_at_step_start: Vec<i32> = game.players.iter().map(|p| p.life).collect();
312
313        for (attacker_id, defender) in self.attackers.clone() {
314            // Check attacker is still alive
315            if !game.card_is_in_zone(attacker_id, ZoneType::Battlefield) {
316                continue;
317            }
318
319            let attacker = game.card(attacker_id);
320            if crate::staticability::static_ability_assign_no_combat_damage::assign_no_combat_damage(
321                &game.cards,
322                attacker,
323            ) {
324                continue;
325            }
326            let attacker_has_fs = attacker.has_first_strike();
327            let attacker_has_ds = attacker.has_double_strike();
328            let attacker_has_trample = attacker.has_trample();
329            let attacker_has_deathtouch = attacker.has_deathtouch();
330            let attacker_has_lifelink = attacker.has_lifelink();
331            let defending_player = defender.controlling_player(game);
332            let attacker_has_infect_for_player = attacker.has_infect()
333                || crate::staticability::static_ability_infect_damage::is_infect_damage_with_life_override(
334                    game,
335                    &game.cards,
336                    defending_player,
337                    attacker.controller,
338                    life_at_step_start.get(defending_player.index()).copied(),
339                );
340            let attacker_has_infect_for_creature = attacker.has_infect();
341            let attacker_has_wither = attacker.has_wither()
342                || crate::staticability::static_ability_wither_damage::is_wither_damage(
343                    &game.cards,
344                    attacker,
345                );
346            let attacker_toxic_count = attacker.get_toxic_count();
347            let attacker_controller = attacker.controller;
348            let can_divide_damage_as_choose = attacker.has_keyword(
349                "You may assign CARDNAME's combat damage divided as you choose among defending player and/or any number of creatures they control.",
350            );
351            let can_assign_unblocked_to_creature = attacker.has_keyword(
352                "If CARDNAME is unblocked, you may have it assign its combat damage to a creature defending player controls.",
353            );
354            let has_trample_planeswalker = attacker.has_keyword("Trample:Planeswalker");
355
356            // Determine if this attacker deals damage in this step
357            let attacker_deals_damage = if first_strike_only {
358                attacker_has_fs || attacker_has_ds
359            } else {
360                // Regular damage step: creatures without first strike, plus double strike
361                !attacker_has_fs || attacker_has_ds
362            };
363
364            let attacker_power = if crate::staticability::static_ability_combat_damage_toughness::combat_damage_uses_toughness(
365                &game.cards,
366                game.card(attacker_id),
367            ) {
368                game.card(attacker_id).toughness()
369            } else {
370                game.card(attacker_id).power()
371            };
372
373            let attacker_card = game.card(attacker_id);
374            let assign_as_unblocked =
375                crate::staticability::static_ability_assign_combat_damage_as_unblocked::has_mandatory_assign_as_unblocked(
376                    &game.cards,
377                    attacker_card,
378                )
379                    || crate::staticability::static_ability_assign_combat_damage_as_unblocked::assign_as_unblocked(
380                        &game.cards,
381                        attacker_card,
382                        as_unblocked_choices.contains(&attacker_id),
383                    );
384
385            let attacker_was_blocked = self.was_blocked_this_combat(attacker_id);
386            let blockers = if assign_as_unblocked {
387                Vec::new()
388            } else if let Some(ordered) = self.damage_order.get(&attacker_id) {
389                // Use player-chosen damage assignment order
390                ordered.clone()
391            } else {
392                self.get_blockers_for(attacker_id)
393            };
394
395            if blockers.is_empty() && !attacker_was_blocked {
396                // Unblocked — damage goes to defender (player or permanent)
397                if !attacker_deals_damage || attacker_power <= 0 {
398                    continue;
399                }
400
401                let defending_creatures = defending_player_creatures(game, defender);
402                if can_divide_damage_as_choose
403                    && !defending_creatures.is_empty()
404                    && agents[attacker_controller.index()].confirm_action(
405                        attacker_controller,
406                        Some("AlternativeDamageAssignment"),
407                        &format!(
408                            "Assign {} combat damage divided as you choose among defending player and/or creatures they control?",
409                            game.card(attacker_id).card_name
410                        ),
411                        &[],
412                        Some(attacker_id),
413                        None,
414                    )
415                {
416                    let assignments = agents[attacker_controller.index()].assign_combat_damage(
417                        game,
418                        attacker_controller,
419                        attacker_id,
420                        &defending_creatures,
421                        Some(DefenderId::Player(defending_player)),
422                        attacker_power,
423                    );
424                    let (to_creatures, to_player) = validate_damage_assignment(
425                        game,
426                        attacker_id,
427                        &defending_creatures,
428                        Some(DefenderId::Player(defending_player)),
429                        attacker_power,
430                        &assignments,
431                    );
432
433                    for &(target_id, dmg) in &to_creatures {
434                        deal_combat_damage_to_card(
435                            game,
436                            attacker_id,
437                            target_id,
438                            dmg,
439                            attacker_has_deathtouch,
440                            attacker_has_lifelink,
441                            attacker_controller,
442                            attacker_has_wither || attacker_has_infect_for_creature,
443                        Some(agents),
444                        );
445                        events.push(CombatDamageEvent {
446                            source: attacker_id,
447                            target_player: None,
448                            target_card: Some(target_id),
449                            amount: dmg,
450                            is_combat: true,
451                            lifelink_player: if attacker_has_lifelink {
452                                Some(attacker_controller)
453                            } else {
454                                None
455                            },
456                            lifelink_amount: if attacker_has_lifelink { dmg } else { 0 },
457                        });
458                    }
459                    if to_player > 0 {
460                        deal_combat_damage_to_player(
461                            game,
462                            attacker_id,
463                            defending_player,
464                            to_player,
465                            attacker_has_lifelink,
466                            attacker_controller,
467                            attacker_has_infect_for_player,
468                            attacker_toxic_count,
469                            Some(agents),
470                        );
471                        events.push(CombatDamageEvent {
472                            source: attacker_id,
473                            target_player: Some(defending_player),
474                            target_card: None,
475                            amount: to_player,
476                            is_combat: true,
477                            lifelink_player: if attacker_has_lifelink {
478                                Some(attacker_controller)
479                            } else {
480                                None
481                            },
482                            lifelink_amount: if attacker_has_lifelink { to_player } else { 0 },
483                        });
484                        if game.player_is_commander(game.card(attacker_id).owner, attacker_id) {
485                            game.player_add_commander_damage(
486                                defending_player,
487                                attacker_id,
488                                to_player,
489                            );
490                        }
491                    }
492                    continue;
493                }
494
495                if can_assign_unblocked_to_creature
496                    && !attacker_was_blocked
497                    && !defending_creatures.is_empty()
498                    && agents[attacker_controller.index()].confirm_action(
499                        attacker_controller,
500                        Some("AlternativeDamageAssignment"),
501                        &format!(
502                            "Assign {} combat damage to a creature defending player controls?",
503                            game.card(attacker_id).card_name
504                        ),
505                        &[],
506                        Some(attacker_id),
507                        None,
508                    )
509                {
510                    if let Some(chosen) = agents[attacker_controller.index()].choose_target_card(
511                        attacker_controller,
512                        &defending_creatures,
513                        None,
514                    ) {
515                        deal_combat_damage_to_card(
516                            game,
517                            attacker_id,
518                            chosen,
519                            attacker_power,
520                            attacker_has_deathtouch,
521                            attacker_has_lifelink,
522                            attacker_controller,
523                            attacker_has_wither || attacker_has_infect_for_creature,
524                            Some(agents),
525                        );
526                        events.push(CombatDamageEvent {
527                            source: attacker_id,
528                            target_player: None,
529                            target_card: Some(chosen),
530                            amount: attacker_power,
531                            is_combat: true,
532                            lifelink_player: if attacker_has_lifelink {
533                                Some(attacker_controller)
534                            } else {
535                                None
536                            },
537                            lifelink_amount: if attacker_has_lifelink {
538                                attacker_power
539                            } else {
540                                0
541                            },
542                        });
543                        continue;
544                    }
545                }
546                match defender {
547                    DefenderId::Player(defending_player) => {
548                        deal_combat_damage_to_player(
549                            game,
550                            attacker_id,
551                            defending_player,
552                            attacker_power,
553                            attacker_has_lifelink,
554                            attacker_controller,
555                            attacker_has_infect_for_player,
556                            attacker_toxic_count,
557                            Some(agents),
558                        );
559                        events.push(CombatDamageEvent {
560                            source: attacker_id,
561                            target_player: Some(defending_player),
562                            target_card: None,
563                            amount: attacker_power,
564                            is_combat: true,
565                            lifelink_player: if attacker_has_lifelink {
566                                Some(attacker_controller)
567                            } else {
568                                None
569                            },
570                            lifelink_amount: if attacker_has_lifelink {
571                                attacker_power
572                            } else {
573                                0
574                            },
575                        });
576                        // Track commander damage
577                        if game.player_is_commander(game.card(attacker_id).owner, attacker_id) {
578                            game.player_add_commander_damage(
579                                defending_player,
580                                attacker_id,
581                                attacker_power,
582                            );
583                        }
584                    }
585                    DefenderId::Permanent(target_id) => {
586                        // Damage to planeswalker/battle
587                        deal_combat_damage_to_card(
588                            game,
589                            attacker_id,
590                            target_id,
591                            attacker_power,
592                            attacker_has_deathtouch,
593                            attacker_has_lifelink,
594                            attacker_controller,
595                            attacker_has_wither || attacker_has_infect_for_creature,
596                            Some(agents),
597                        );
598                        events.push(CombatDamageEvent {
599                            source: attacker_id,
600                            target_player: None,
601                            target_card: Some(target_id),
602                            amount: attacker_power,
603                            is_combat: true,
604                            lifelink_player: if attacker_has_lifelink {
605                                Some(attacker_controller)
606                            } else {
607                                None
608                            },
609                            lifelink_amount: if attacker_has_lifelink {
610                                attacker_power
611                            } else {
612                                0
613                            },
614                        });
615                    }
616                }
617            } else {
618                // Blocked — mutual damage.
619                // The attacker may not deal damage this step (e.g. no first strike during
620                // first-strike step), but blockers with the right timing still deal damage
621                // back to the attacker.
622                let remaining_damage = if attacker_deals_damage && attacker_power > 0 {
623                    attacker_power
624                } else {
625                    0
626                };
627                // Java-parity full damage assignment callback:
628                // - prompt for exact assignment when needed (trample or multi-block)
629                // - validate strictly (panic on invalid response; no fallback)
630                let mut alive_blockers: Vec<CardId> = blockers
631                    .iter()
632                    .copied()
633                    .filter(|&bid| game.card_is_in_zone(bid, ZoneType::Battlefield))
634                    .collect();
635                let mut effective_defender = defender;
636                if has_trample_planeswalker {
637                    if let DefenderId::Permanent(target_id) = defender {
638                        if !alive_blockers.contains(&target_id) {
639                            alive_blockers.push(target_id);
640                        }
641                        effective_defender = DefenderId::Player(defending_player);
642                    }
643                }
644
645                let defending_creatures = defending_player_creatures(game, effective_defender);
646                let use_divide_as_choose = can_divide_damage_as_choose
647                    && !defending_creatures.is_empty()
648                    && agents[attacker_controller.index()].confirm_action(
649                        attacker_controller,
650                        Some("AlternativeDamageAssignment"),
651                        &format!(
652                            "Assign {} combat damage divided as you choose among defending player and/or creatures they control?",
653                            game.card(attacker_id).card_name
654                        ),
655                        &[],
656                        Some(attacker_id),
657                        None,
658                    );
659                if use_divide_as_choose {
660                    for cid in defending_creatures {
661                        if !alive_blockers.contains(&cid) {
662                            alive_blockers.push(cid);
663                        }
664                    }
665                }
666
667                let can_assign_to_defender = attacker_has_trample || use_divide_as_choose;
668                if alive_blockers.is_empty() && !can_assign_to_defender {
669                    continue;
670                }
671                // Java's harness (`Combat.java:876-878`) always calls
672                // `assignCombatDamage` once `orderedBlockers` is non-empty —
673                // but only when the attacker actually deals damage this step.
674                // Skip the prompt for zero-damage steps (e.g. a non-first-
675                // strike attacker during the first-strike step), since Java
676                // never enters the assignment loop in that case.
677                let must_prompt_assignment =
678                    remaining_damage > 0 && (can_assign_to_defender || !alive_blockers.is_empty());
679
680                let assignments = if must_prompt_assignment {
681                    let controller = game.card(attacker_id).controller;
682                    let defender_for_prompt = if can_assign_to_defender {
683                        Some(effective_defender)
684                    } else {
685                        None
686                    };
687                    agents[controller.index()].assign_combat_damage(
688                        game,
689                        controller,
690                        attacker_id,
691                        &alive_blockers,
692                        defender_for_prompt,
693                        remaining_damage,
694                    )
695                } else if let Some(&only_blocker) = alive_blockers.first() {
696                    vec![(Some(only_blocker), remaining_damage)]
697                } else if can_assign_to_defender {
698                    vec![(None, remaining_damage)]
699                } else {
700                    Vec::new()
701                };
702
703                let (damage_assignments, defender_damage) = validate_damage_assignment(
704                    game,
705                    attacker_id,
706                    &alive_blockers,
707                    can_assign_to_defender.then_some(effective_defender),
708                    remaining_damage,
709                    &assignments,
710                );
711
712                // --- Pre-compute blocker → attacker damage BEFORE applying any damage ---
713                // Combat damage is simultaneous (rule 510.2). We must read blocker
714                // powers now, before wither/infect -1/-1 counters from attacker
715                // damage modify them.
716                struct BlockerDamageInfo {
717                    blocker_id: CardId,
718                    power: i32,
719                    has_deathtouch: bool,
720                    has_lifelink: bool,
721                    has_wither_or_infect: bool,
722                    controller: PlayerId,
723                }
724                let mut blocker_damage_infos: Vec<BlockerDamageInfo> = Vec::new();
725                for &blocker_id in &blockers {
726                    if !game.card_is_in_zone(blocker_id, ZoneType::Battlefield) {
727                        continue;
728                    }
729                    let blocker_card = game.card(blocker_id);
730                    if crate::staticability::static_ability_assign_no_combat_damage::assign_no_combat_damage(
731                        &game.cards,
732                        blocker_card,
733                    ) {
734                        continue;
735                    }
736                    let blocker_has_fs = blocker_card.has_first_strike();
737                    let blocker_has_ds = blocker_card.has_double_strike();
738                    let blocker_deals = if first_strike_only {
739                        blocker_has_fs || blocker_has_ds
740                    } else {
741                        !blocker_has_fs || blocker_has_ds
742                    };
743                    if !blocker_deals {
744                        continue;
745                    }
746                    if crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
747                        &game.cards,
748                        game.card(attacker_id),
749                        game.card(blocker_id),
750                    ) {
751                        continue;
752                    }
753                    let blocker_power = if crate::staticability::static_ability_combat_damage_toughness::combat_damage_uses_toughness(
754                        &game.cards,
755                        game.card(blocker_id),
756                    ) {
757                        game.card(blocker_id).toughness()
758                    } else {
759                        game.card(blocker_id).power()
760                    };
761                    if blocker_power > 0 {
762                        if !computed_blocker_allocations.contains(&blocker_id) {
763                            let per_attacker = compute_blocker_damage_allocations(
764                                self,
765                                game,
766                                agents,
767                                first_strike_only,
768                                blocker_id,
769                                blocker_power,
770                            );
771                            for (target_attacker, dmg) in per_attacker {
772                                blocker_damage_allocations
773                                    .insert((blocker_id, target_attacker), dmg);
774                            }
775                            computed_blocker_allocations.insert(blocker_id);
776                        }
777                        let assigned_to_this_attacker = blocker_damage_allocations
778                            .get(&(blocker_id, attacker_id))
779                            .copied()
780                            .unwrap_or(0);
781                        if assigned_to_this_attacker <= 0 {
782                            continue;
783                        }
784                        let blocker_has_infect = blocker_card.has_infect();
785                        let blocker_has_wither = blocker_card.has_wither()
786                            || crate::staticability::static_ability_wither_damage::is_wither_damage(
787                                &game.cards,
788                                blocker_card,
789                            );
790                        blocker_damage_infos.push(BlockerDamageInfo {
791                            blocker_id,
792                            power: assigned_to_this_attacker,
793                            has_deathtouch: blocker_card.has_deathtouch(),
794                            has_lifelink: blocker_card.has_lifelink(),
795                            has_wither_or_infect: blocker_has_wither || blocker_has_infect,
796                            controller: blocker_card.controller,
797                        });
798                    }
799                }
800
801                // Now apply all damage (attacker → blockers, then blockers → attacker)
802                // using pre-computed power values.
803                for &(blocker_id, damage_to_blocker) in &damage_assignments {
804                    deal_combat_damage_to_card(
805                        game,
806                        attacker_id,
807                        blocker_id,
808                        damage_to_blocker,
809                        attacker_has_deathtouch,
810                        attacker_has_lifelink,
811                        attacker_controller,
812                        attacker_has_wither || attacker_has_infect_for_creature,
813                        Some(agents),
814                    );
815                    events.push(CombatDamageEvent {
816                        source: attacker_id,
817                        target_player: None,
818                        target_card: Some(blocker_id),
819                        amount: damage_to_blocker,
820                        is_combat: true,
821                        lifelink_player: if attacker_has_lifelink {
822                            Some(attacker_controller)
823                        } else {
824                            None
825                        },
826                        lifelink_amount: if attacker_has_lifelink {
827                            damage_to_blocker
828                        } else {
829                            0
830                        },
831                    });
832                }
833
834                if defender_damage > 0 {
835                    match effective_defender {
836                        DefenderId::Player(defending_player) => {
837                            deal_combat_damage_to_player(
838                                game,
839                                attacker_id,
840                                defending_player,
841                                defender_damage,
842                                attacker_has_lifelink,
843                                attacker_controller,
844                                attacker_has_infect_for_player,
845                                attacker_toxic_count,
846                                None, // TODO: thread agents for RNG parity
847                            );
848                            events.push(CombatDamageEvent {
849                                source: attacker_id,
850                                target_player: Some(defending_player),
851                                target_card: None,
852                                amount: defender_damage,
853                                is_combat: true,
854                                lifelink_player: if attacker_has_lifelink {
855                                    Some(attacker_controller)
856                                } else {
857                                    None
858                                },
859                                lifelink_amount: if attacker_has_lifelink {
860                                    defender_damage
861                                } else {
862                                    0
863                                },
864                            });
865                            if game.card(attacker_id).is_commander {
866                                game.player_add_commander_damage(
867                                    defending_player,
868                                    attacker_id,
869                                    defender_damage,
870                                );
871                            }
872                        }
873                        DefenderId::Permanent(target_id) => {
874                            deal_combat_damage_to_card(
875                                game,
876                                attacker_id,
877                                target_id,
878                                defender_damage,
879                                attacker_has_deathtouch,
880                                attacker_has_lifelink,
881                                attacker_controller,
882                                attacker_has_wither || attacker_has_infect_for_creature,
883                                Some(agents),
884                            );
885                            events.push(CombatDamageEvent {
886                                source: attacker_id,
887                                target_player: None,
888                                target_card: Some(target_id),
889                                amount: defender_damage,
890                                is_combat: true,
891                                lifelink_player: if attacker_has_lifelink {
892                                    Some(attacker_controller)
893                                } else {
894                                    None
895                                },
896                                lifelink_amount: if attacker_has_lifelink {
897                                    defender_damage
898                                } else {
899                                    0
900                                },
901                            });
902                        }
903                    }
904                }
905
906                for info in &blocker_damage_infos {
907                    // Blocker may have been removed by an SBA or replacement
908                    if !game.card_is_in_zone(info.blocker_id, ZoneType::Battlefield) {
909                        continue;
910                    }
911                    deal_combat_damage_to_card(
912                        game,
913                        info.blocker_id,
914                        attacker_id,
915                        info.power,
916                        info.has_deathtouch,
917                        info.has_lifelink,
918                        info.controller,
919                        info.has_wither_or_infect,
920                        Some(agents),
921                    );
922                    events.push(CombatDamageEvent {
923                        source: info.blocker_id,
924                        target_player: None,
925                        target_card: Some(attacker_id),
926                        amount: info.power,
927                        is_combat: true,
928                        lifelink_player: if info.has_lifelink {
929                            Some(info.controller)
930                        } else {
931                            None
932                        },
933                        lifelink_amount: if info.has_lifelink { info.power } else { 0 },
934                    });
935                }
936
937                // Note: non-trample excess is validated/flushed to last blocker;
938                // trample excess is applied to defender.
939            }
940        }
941
942        events
943    }
944
945    // ── Missing symbols for Java Combat.java parity ──────────────────
946
947    /// Initialize attack constraints for this combat.
948    /// Mirrors Java `Combat.initConstraints()`.
949    pub fn init_constraints(&self, game: &GameState) -> attack_constraints::AttackConstraints {
950        let attacking_player = self
951            .attacking_player
952            .expect("init_constraints called without attacking player");
953        let possible_defenders = combat_util::get_possible_defenders(game, attacking_player);
954        attack_constraints::AttackConstraints::new(game, attacking_player, &possible_defenders)
955    }
956
957    /// End combat: clear all combat state and reset damage history on
958    /// battlefield creatures.
959    /// Mirrors Java `Combat.endCombat()`.
960    pub fn end_combat(&mut self, game: &mut GameState) {
961        // Reset damage history combat tracking on all battlefield creatures
962        for card in game.cards.iter_mut() {
963            if card.zone == ZoneType::Battlefield {
964                card.damage_history.end_combat();
965            }
966        }
967
968        // Clear attacking_player flag on attacker cards
969        for &(attacker_id, _) in &self.attackers {
970            game.card_mut(attacker_id).clear_attacking_player();
971        }
972
973        self.clear();
974    }
975
976    /// Remove all attacker registrations.
977    /// Mirrors Java `Combat.clearAttackers()`.
978    pub fn clear_attackers(&mut self, game: &mut GameState) {
979        let attacker_ids: Vec<CardId> = self.attackers.iter().map(|(a, _)| *a).collect();
980        for attacker_id in attacker_ids {
981            self.remove_from_combat(attacker_id, game);
982        }
983    }
984
985    /// Add an attacker to combat, targeting a defender.
986    /// Mirrors Java `Combat.addAttacker()`.
987    pub fn add_attacker(&mut self, attacker: CardId, defender: DefenderId) {
988        // Remove from any existing band first (Java parity)
989        self.attackers.retain(|(a, _)| *a != attacker);
990        self.attackers.push((attacker, defender));
991    }
992
993    /// Add a blocker assignment.
994    /// Mirrors Java `Combat.addBlocker()`.
995    pub fn add_blocker(&mut self, attacker: CardId, blocker: CardId) {
996        self.blockers.push((blocker, attacker));
997        self.blocked_attackers.insert(attacker);
998        // If damage order already exists for this attacker, add blocker to it
999        if let Some(order) = self.damage_order.get_mut(&attacker) {
1000            if !order.contains(&blocker) {
1001                order.push(blocker);
1002            }
1003        }
1004    }
1005
1006    /// Remove a specific blocker from a specific attacker.
1007    /// Mirrors Java `Combat.removeBlockAssignment()`.
1008    pub fn remove_block_assignment(&mut self, attacker: CardId, blocker: CardId) {
1009        self.blockers
1010            .retain(|&(b, a)| !(b == blocker && a == attacker));
1011        if !self.blockers.iter().any(|(b, _)| *b == blocker) {
1012            self.blocker_zone_timestamps.remove(&blocker);
1013        }
1014    }
1015
1016    /// Remove a blocker from all attacker assignments.
1017    /// Mirrors Java `Combat.undoBlockingAssignment()`.
1018    pub fn undo_blocking_assignment(&mut self, blocker: CardId) {
1019        self.blockers.retain(|&(b, _)| b != blocker);
1020        self.blocker_zone_timestamps.remove(&blocker);
1021    }
1022
1023    /// Order blockers for damage assignment. For each attacker, store the
1024    /// blocker order. If only one blocker, auto-assign.
1025    /// Mirrors Java `Combat.orderBlockersForDamageAssignment()` —
1026    /// `Combat.java:494` short-circuits the agent prompt when
1027    /// `GameRules.legacyOrderCombatants` is false (default), so the
1028    /// deterministic parity harness never sees this callback. Mirror that.
1029    pub fn order_blockers_for_damage_assignment(
1030        &mut self,
1031        _game: &GameState,
1032        _agents: &mut [Box<dyn PlayerAgent>],
1033    ) {
1034        let attacker_ids: Vec<CardId> = self.attackers.iter().map(|(a, _)| *a).collect();
1035        for attacker_id in attacker_ids {
1036            let blockers = self.get_blockers_for(attacker_id);
1037            if blockers.is_empty() {
1038                continue;
1039            }
1040            // Auto-order in declaration order — matches Java's
1041            // non-legacyOrderCombatants behaviour (Combat.java:494).
1042            self.damage_order.insert(attacker_id, blockers);
1043        }
1044    }
1045
1046    /// Add a late-entry blocker to an existing damage assignment order.
1047    /// Mirrors Java `Combat.addBlockerToDamageAssignmentOrder()`.
1048    pub fn add_blocker_to_damage_assignment_order(&mut self, attacker: CardId, blocker: CardId) {
1049        let order = self.damage_order.entry(attacker).or_default();
1050        if !order.contains(&blocker) {
1051            order.push(blocker);
1052        }
1053    }
1054
1055    /// Order attackers for damage assignment (blocker's controller orders).
1056    /// Mirrors Java `Combat.orderAttackersForDamageAssignment()`.
1057    pub fn order_attackers_for_damage_assignment(
1058        &mut self,
1059        _game: &GameState,
1060        _agents: &mut [Box<dyn PlayerAgent>],
1061    ) {
1062        // In 2-player, blocker damage assignment order is handled during
1063        // resolve_damage_step via compute_blocker_damage_allocations.
1064        // This is a no-op placeholder for parity — Java uses it for the
1065        // legacy "order combatants" rule variant.
1066    }
1067
1068    /// Remove an attacker from combat, cleaning up all indices.
1069    /// Mirrors Java `Combat.unregisterAttacker()`.
1070    pub fn unregister_attacker(&mut self, card: CardId) {
1071        // Remove from damage order
1072        self.damage_order.remove(&card);
1073
1074        // Remove from blocker damage orders (attacker listed in orders for blockers)
1075        for order in self.damage_order.values_mut() {
1076            order.retain(|&c| c != card);
1077        }
1078
1079        // Remove attacker entry
1080        self.attackers.retain(|(a, _)| *a != card);
1081        self.attacker_zone_timestamps.remove(&card);
1082        self.blockers.retain(|(_, a)| *a != card);
1083        let blocker_ids: HashSet<CardId> = self.blockers.iter().map(|(b, _)| *b).collect();
1084        self.blocker_zone_timestamps
1085            .retain(|blocker_id, _| blocker_ids.contains(blocker_id));
1086    }
1087
1088    /// Remove a blocker from combat, cleaning up all indices.
1089    /// Mirrors Java `Combat.unregisterDefender()`.
1090    pub fn unregister_defender(&mut self, card: CardId) {
1091        // Remove from damage orders for attackers this blocker was blocking
1092        for order in self.damage_order.values_mut() {
1093            order.retain(|&c| c != card);
1094        }
1095
1096        // Remove blocker entries
1097        self.blockers.retain(|(b, _)| *b != card);
1098        self.blocker_zone_timestamps.remove(&card);
1099    }
1100
1101    /// Remove a combatant (attacker or blocker) from combat.
1102    /// Mirrors Java `Combat.removeFromCombat()`.
1103    pub fn remove_from_combat(&mut self, card: CardId, game: &mut GameState) {
1104        // Check if attacker
1105        if self.attackers.iter().any(|(a, _)| *a == card) {
1106            self.unregister_attacker(card);
1107            game.card_mut(card).clear_attacking_player();
1108            return;
1109        }
1110
1111        // Check if blocker
1112        if self.blockers.iter().any(|(b, _)| *b == card) {
1113            self.unregister_defender(card);
1114        }
1115    }
1116
1117    /// Fire triggers for unblocked attackers after blockers are declared.
1118    /// Mirrors Java `Combat.fireTriggersForUnblockedAttackers()`.
1119    ///
1120    /// Returns the list of unblocked attacker IDs (for use by the game loop
1121    /// to fire TriggerType::AttackerUnblocked).
1122    pub fn fire_triggers_for_unblocked_attackers(&mut self) -> Vec<(CardId, DefenderId)> {
1123        let mut unblocked = Vec::new();
1124
1125        for &(attacker_id, defender) in &self.attackers {
1126            let is_blocked = self.blockers.iter().any(|(_, a)| *a == attacker_id);
1127            if !is_blocked {
1128                unblocked.push((attacker_id, defender));
1129            }
1130        }
1131
1132        unblocked
1133    }
1134
1135    /// Assign combat damage (delegates to resolve_damage_step).
1136    /// Mirrors Java `Combat.assignCombatDamage()`.
1137    pub fn assign_combat_damage(
1138        &self,
1139        game: &mut GameState,
1140        agents: &mut [Box<dyn PlayerAgent>],
1141        first_strike_damage: bool,
1142        as_unblocked_choices: &HashSet<CardId>,
1143    ) -> Vec<CombatDamageEvent> {
1144        self.resolve_damage_step(game, agents, first_strike_damage, as_unblocked_choices)
1145    }
1146
1147    /// Deal assigned damage (no-op in our architecture since resolve_damage_step
1148    /// applies damage immediately).
1149    /// Mirrors Java `Combat.dealAssignedDamage()`.
1150    pub fn deal_assigned_damage(&self, game: &mut GameState) {
1151        // In our Rust implementation, damage is applied immediately in
1152        // resolve_damage_step(). This method exists for parity with
1153        // Java's two-phase (assign then deal) approach.
1154        game.copy_last_state();
1155    }
1156
1157    /// Get all attacker IDs.
1158    pub fn get_attackers(&self) -> Vec<CardId> {
1159        self.attackers.iter().map(|(a, _)| *a).collect()
1160    }
1161
1162    /// Get all blocker IDs (deduplicated).
1163    pub fn get_all_blockers(&self) -> Vec<CardId> {
1164        let mut result = Vec::new();
1165        for &(b, _) in &self.blockers {
1166            if !result.contains(&b) {
1167                result.push(b);
1168            }
1169        }
1170        result
1171    }
1172
1173    /// Get the defender for an attacker.
1174    pub fn get_defender_by_attacker(&self, attacker: CardId) -> Option<DefenderId> {
1175        self.attackers
1176            .iter()
1177            .find(|(a, _)| *a == attacker)
1178            .map(|(_, d)| *d)
1179    }
1180
1181    /// Get the defending player for an attacker (resolves planeswalker/battle
1182    /// defenders to their controller).
1183    pub fn get_defender_player_by_attacker(
1184        &self,
1185        attacker: CardId,
1186        game: &GameState,
1187    ) -> Option<PlayerId> {
1188        self.get_defender_by_attacker(attacker)
1189            .map(|d| d.controlling_player(game))
1190    }
1191
1192    /// Check if a card is currently blocking.
1193    pub fn is_blocking(&self, blocker: CardId) -> bool {
1194        self.blockers.iter().any(|(b, _)| *b == blocker)
1195    }
1196
1197    /// Check if a card is blocking a specific attacker.
1198    pub fn is_blocking_attacker(&self, blocker: CardId, attacker: CardId) -> bool {
1199        self.blockers
1200            .iter()
1201            .any(|&(b, a)| b == blocker && a == attacker)
1202    }
1203
1204    /// Check if an attacker is unblocked (declared, blockers declared, but none assigned).
1205    pub fn is_unblocked(&self, attacker: CardId) -> bool {
1206        self.is_attacking(attacker) && !self.is_blocked(attacker)
1207    }
1208
1209    /// Get all unblocked attacker IDs.
1210    pub fn get_unblocked_attackers(&self) -> Vec<CardId> {
1211        self.attackers
1212            .iter()
1213            .filter(|(a, _)| !self.is_blocked(*a))
1214            .map(|(a, _)| *a)
1215            .collect()
1216    }
1217}
1218
1219fn validate_damage_assignment(
1220    game: &GameState,
1221    attacker_id: CardId,
1222    blockers_in_order: &[CardId],
1223    defender: Option<DefenderId>,
1224    total_damage: i32,
1225    assignments: &[(Option<CardId>, i32)],
1226) -> (Vec<(CardId, i32)>, i32) {
1227    if total_damage <= 0 {
1228        return (Vec::new(), 0);
1229    }
1230
1231    let mut per_blocker: HashMap<CardId, i32> = HashMap::new();
1232    let mut defender_damage = 0;
1233    let mut assigned_total = 0;
1234
1235    let mut invalid = false;
1236
1237    for &(assignee, amount) in assignments {
1238        if amount < 0 {
1239            invalid = true;
1240            break;
1241        }
1242        if amount == 0 {
1243            continue;
1244        }
1245        assigned_total += amount;
1246        match assignee {
1247            Some(blocker_id) => {
1248                if !blockers_in_order.contains(&blocker_id) {
1249                    invalid = true;
1250                    break;
1251                }
1252                *per_blocker.entry(blocker_id).or_insert(0) += amount;
1253            }
1254            None => {
1255                if defender.is_none() {
1256                    invalid = true;
1257                    break;
1258                }
1259                defender_damage += amount;
1260            }
1261        }
1262    }
1263
1264    if assigned_total != total_damage {
1265        invalid = true;
1266    }
1267
1268    let has_deathtouch = game.card(attacker_id).has_deathtouch();
1269    let mut can_move_to_next = true;
1270    for &blocker_id in blockers_in_order {
1271        if !game.card_is_in_zone(blocker_id, ZoneType::Battlefield) {
1272            continue;
1273        }
1274        if crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
1275            &game.cards,
1276            game.card(blocker_id),
1277            game.card(attacker_id),
1278        ) {
1279            continue;
1280        }
1281
1282        let assigned = per_blocker.get(&blocker_id).copied().unwrap_or(0);
1283        let lethal = if has_deathtouch {
1284            1
1285        } else if game.card(blocker_id).type_line.is_planeswalker() {
1286            game.card(blocker_id)
1287                .counter_count(&crate::card::CounterType::Loyalty)
1288                .max(0)
1289        } else {
1290            damage_needed_to_kill_for_assignment(game, blocker_id, attacker_id, assigned.max(1))
1291        };
1292
1293        if !can_move_to_next && assigned > 0 {
1294            invalid = true;
1295            break;
1296        }
1297        if assigned < lethal {
1298            can_move_to_next = false;
1299        }
1300    }
1301
1302    if defender_damage > 0 && !can_move_to_next {
1303        invalid = true;
1304    }
1305
1306    if invalid {
1307        return fallback_damage_assignment(
1308            game,
1309            attacker_id,
1310            blockers_in_order,
1311            defender,
1312            total_damage,
1313        );
1314    }
1315
1316    let mut ordered_blocker_assignments: Vec<(CardId, i32)> = Vec::new();
1317    for &blocker_id in blockers_in_order {
1318        if let Some(amount) = per_blocker.get(&blocker_id).copied() {
1319            if amount > 0 {
1320                ordered_blocker_assignments.push((blocker_id, amount));
1321            }
1322        }
1323    }
1324
1325    (ordered_blocker_assignments, defender_damage)
1326}
1327
1328fn fallback_damage_assignment(
1329    game: &GameState,
1330    attacker_id: CardId,
1331    blockers_in_order: &[CardId],
1332    defender: Option<DefenderId>,
1333    total_damage: i32,
1334) -> (Vec<(CardId, i32)>, i32) {
1335    if total_damage <= 0 {
1336        return (Vec::new(), 0);
1337    }
1338
1339    let mut assignments: Vec<(CardId, i32)> = Vec::new();
1340    let mut damage_left = total_damage;
1341    let has_deathtouch = game.card(attacker_id).has_deathtouch();
1342
1343    for &blocker_id in blockers_in_order {
1344        if damage_left <= 0 {
1345            break;
1346        }
1347        if !game.card_is_in_zone(blocker_id, ZoneType::Battlefield) {
1348            continue;
1349        }
1350        if crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
1351            &game.cards,
1352            game.card(blocker_id),
1353            game.card(attacker_id),
1354        ) {
1355            continue;
1356        }
1357
1358        let lethal = if has_deathtouch {
1359            1
1360        } else if game.card(blocker_id).type_line.is_planeswalker() {
1361            game.card(blocker_id)
1362                .counter_count(&crate::card::CounterType::Loyalty)
1363                .max(0)
1364        } else {
1365            damage_needed_to_kill_for_assignment(game, blocker_id, attacker_id, damage_left)
1366        };
1367        let assign = lethal.min(damage_left);
1368        if assign > 0 {
1369            assignments.push((blocker_id, assign));
1370            damage_left -= assign;
1371        }
1372    }
1373
1374    if damage_left > 0 {
1375        if defender.is_some() {
1376            return (assignments, damage_left);
1377        }
1378        if let Some((_, amount)) = assignments.last_mut() {
1379            *amount += damage_left;
1380        } else if let Some(&first) = blockers_in_order.first() {
1381            assignments.push((first, damage_left));
1382        }
1383        return (assignments, 0);
1384    }
1385
1386    (assignments, 0)
1387}
1388
1389fn damage_needed_to_kill_for_assignment(
1390    game: &GameState,
1391    target: CardId,
1392    source: CardId,
1393    max_damage: i32,
1394) -> i32 {
1395    if max_damage <= 0 {
1396        return 0;
1397    }
1398
1399    let target_card = game.card(target);
1400    let source_card = game.card(source);
1401    let mut kill_damage = (target_card.toughness() - target_card.damage).max(0);
1402
1403    if target_card.has_keyword("Indestructible")
1404        && !source_card.has_wither()
1405        && !source_card.has_infect()
1406    {
1407        return max_damage + 1;
1408    }
1409    if source_card.has_deathtouch() && target_card.is_creature() {
1410        kill_damage = 1;
1411    }
1412
1413    for damage in 1..=max_damage {
1414        let mut sim = game.clone();
1415        let mut event = crate::replacement::replacement_handler::ReplacementEvent::DamageToCard {
1416            target,
1417            amount: damage,
1418            source: Some(source),
1419            is_combat: true,
1420        };
1421        let _ = crate::replacement::replacement_handler::apply_replacements(&mut sim, &mut event);
1422        let final_damage = match event {
1423            crate::replacement::replacement_handler::ReplacementEvent::DamageToCard {
1424                amount,
1425                ..
1426            } => amount.max(0),
1427            _ => 0,
1428        };
1429        if final_damage >= kill_damage {
1430            return damage;
1431        }
1432    }
1433
1434    max_damage + 1
1435}
1436
1437fn defending_player_creatures(game: &GameState, defender: DefenderId) -> Vec<CardId> {
1438    let defending_player = defender.controlling_player(game);
1439    game.cards_in_zone(ZoneType::Battlefield, defending_player)
1440        .iter()
1441        .copied()
1442        .filter(|&cid| game.card(cid).is_creature())
1443        .collect()
1444}
1445
1446fn compute_blocker_damage_allocations(
1447    combat: &CombatState,
1448    game: &GameState,
1449    agents: &mut [Box<dyn PlayerAgent>],
1450    first_strike_only: bool,
1451    blocker_id: CardId,
1452    blocker_power: i32,
1453) -> Vec<(CardId, i32)> {
1454    if blocker_power <= 0 {
1455        return Vec::new();
1456    }
1457
1458    let blocker = game.card(blocker_id);
1459    let has_fs = blocker.has_first_strike();
1460    let has_ds = blocker.has_double_strike();
1461    let deals_this_step = if first_strike_only {
1462        has_fs || has_ds
1463    } else {
1464        !has_fs || has_ds
1465    };
1466    if !deals_this_step {
1467        return Vec::new();
1468    }
1469
1470    let attackers_for_blocker: Vec<CardId> = combat
1471        .get_attackers_for(blocker_id)
1472        .into_iter()
1473        .filter(|&aid| game.card_is_in_zone(aid, ZoneType::Battlefield))
1474        .collect();
1475    if attackers_for_blocker.is_empty() {
1476        return Vec::new();
1477    }
1478
1479    // Java's `Combat.assignBlockersDamage` (Combat.java:705-757) always
1480    // calls `assigningPlayer.getController().assignCombatDamage(...)` for
1481    // every blocker with a non-empty attacker list, regardless of attacker
1482    // count. Mirror that — the deterministic agent's single-attacker pick
1483    // still belongs in the parity callback ledger so RNG and trace stay
1484    // aligned with Java.
1485    let controller = blocker.controller;
1486    let assignments = agents[controller.index()].assign_combat_damage(
1487        game,
1488        controller,
1489        blocker_id,
1490        &attackers_for_blocker,
1491        None,
1492        blocker_power,
1493    );
1494    let (per_attacker, _to_defender) = validate_damage_assignment(
1495        game,
1496        blocker_id,
1497        &attackers_for_blocker,
1498        None,
1499        blocker_power,
1500        &assignments,
1501    );
1502    per_attacker
1503}
1504
1505// ── Combat helper functions ─────────────────────────────────────────
1506// These delegate to combat_util for file-parity with the Java codebase.
1507
1508/// Get available attackers: untapped creatures that can attack.
1509pub fn get_available_attackers(game: &GameState, player: PlayerId) -> Vec<CardId> {
1510    combat_util::get_available_attackers(game, player)
1511}
1512
1513/// Get all possible defenders for the attacking player.
1514pub fn get_possible_defenders(game: &GameState, attacking_player: PlayerId) -> Vec<DefenderId> {
1515    combat_util::get_possible_defenders(game, attacking_player)
1516}
1517
1518/// Get available blockers: untapped creatures that can block.
1519pub fn get_available_blockers(game: &GameState, player: PlayerId) -> Vec<CardId> {
1520    combat_util::get_available_blockers(game, player)
1521}
1522
1523/// Check if a specific blocker can legally block a specific attacker.
1524pub fn can_creature_block(game: &GameState, blocker_id: CardId, attacker_id: CardId) -> bool {
1525    combat_util::can_creature_block(game, blocker_id, attacker_id)
1526}
1527
1528/// Filter blockers to only those that can legally block at least one attacker.
1529pub fn filter_legal_blockers(
1530    game: &GameState,
1531    attackers: &[CardId],
1532    blockers: &[CardId],
1533) -> Vec<CardId> {
1534    combat_util::filter_legal_blockers(game, attackers, blockers)
1535}
1536
1537/// Deal combat damage to a player, handling lifelink, Infect, and Toxic.
1538fn deal_combat_damage_to_player(
1539    game: &mut GameState,
1540    source: CardId,
1541    target: PlayerId,
1542    amount: i32,
1543    lifelink: bool,
1544    source_controller: PlayerId,
1545    source_has_infect: bool,
1546    source_toxic_count: Option<i32>,
1547    agents: Option<&mut [Box<dyn PlayerAgent>]>,
1548) {
1549    if amount > 0 {
1550        if source_has_infect {
1551            // Infect: deal damage as poison counters instead of life loss
1552            if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_player(
1553                &game.cards,
1554                target,
1555                &crate::card::CounterType::Poison,
1556            ) {
1557                game.player_add_poison(target, amount);
1558            }
1559        } else {
1560            let dealt = game.deal_damage_to_player_from_with_agents(
1561                target,
1562                amount,
1563                Some(source),
1564                true,
1565                agents,
1566            );
1567            game.record_player_damage_assignment(Some(source), Some(target), dealt, true);
1568        }
1569        // Toxic: add poison counters in addition to normal damage
1570        if let Some(toxic) = source_toxic_count {
1571            if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_player(
1572                &game.cards,
1573                target,
1574                &crate::card::CounterType::Poison,
1575            ) {
1576                game.player_add_poison(target, toxic);
1577            }
1578        }
1579        if lifelink
1580            && !crate::staticability::static_ability_cant_gain_lose_pay_life::cant_gain_life(
1581                game,
1582                source_controller,
1583            )
1584        {
1585            // Run GainLife replacement effects (e.g. Tainted Remedy).
1586            let mut gl_event =
1587                crate::replacement::replacement_handler::ReplacementEvent::GainLife {
1588                    player: source_controller,
1589                    amount,
1590                };
1591            let gl_result =
1592                crate::replacement::replacement_handler::apply_replacements(game, &mut gl_event);
1593            if gl_result != crate::replacement::ReplacementResult::Skipped
1594                && gl_result != crate::replacement::ReplacementResult::Replaced
1595            {
1596                let final_amount =
1597                    if let crate::replacement::replacement_handler::ReplacementEvent::GainLife {
1598                        amount: a,
1599                        ..
1600                    } = gl_event
1601                    {
1602                        a
1603                    } else {
1604                        amount
1605                    };
1606                if final_amount > 0 {
1607                    game.player_gain_life(source_controller, final_amount);
1608                    game.player_add_team_life_gained(source_controller, final_amount);
1609                }
1610            }
1611        }
1612        game.card_mut(source).damage_history.register_damage(
1613            amount,
1614            true,
1615            Some(source),
1616            crate::card::card_damage_history::TrackedEntity::Player(target),
1617        );
1618    }
1619}
1620
1621/// Deal combat damage to a card, handling deathtouch, lifelink, Infect/Wither.
1622fn deal_combat_damage_to_card(
1623    game: &mut GameState,
1624    source: CardId,
1625    target: CardId,
1626    amount: i32,
1627    deathtouch: bool,
1628    lifelink: bool,
1629    source_controller: PlayerId,
1630    source_has_wither_or_infect: bool,
1631    agents: Option<&mut [Box<dyn PlayerAgent>]>,
1632) {
1633    if amount > 0 {
1634        if crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
1635            &game.cards,
1636            game.card(target),
1637            game.card(source),
1638        ) {
1639            return;
1640        }
1641        // Track damage source for DamagedBy trigger filters (Sengir Vampire, etc.)
1642        if !game.card(target).damage_sources_this_turn.contains(&source) {
1643            game.card_mut(target).add_damage_source_this_turn(source);
1644        }
1645        if source_has_wither_or_infect {
1646            // Wither/Infect: damage to creatures as -1/-1 counters instead
1647            if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_card(
1648                &game.cards,
1649                game.card(target),
1650                &crate::card::CounterType::M1M1,
1651            ) {
1652                game.card_mut(target)
1653                    .add_counter(&crate::card::CounterType::M1M1, amount);
1654            }
1655        } else {
1656            game.deal_damage_to_card_from_with_agents(target, amount, Some(source), true, agents);
1657        }
1658        if deathtouch {
1659            game.card_mut(target).mark_deathtouch_damage();
1660        }
1661        if lifelink
1662            && !crate::staticability::static_ability_cant_gain_lose_pay_life::cant_gain_life(
1663                game,
1664                source_controller,
1665            )
1666        {
1667            // Run GainLife replacement effects (e.g. Tainted Remedy).
1668            let mut gl_event =
1669                crate::replacement::replacement_handler::ReplacementEvent::GainLife {
1670                    player: source_controller,
1671                    amount,
1672                };
1673            let gl_result =
1674                crate::replacement::replacement_handler::apply_replacements(game, &mut gl_event);
1675            if gl_result != crate::replacement::ReplacementResult::Skipped
1676                && gl_result != crate::replacement::ReplacementResult::Replaced
1677            {
1678                let final_amount =
1679                    if let crate::replacement::replacement_handler::ReplacementEvent::GainLife {
1680                        amount: a,
1681                        ..
1682                    } = gl_event
1683                    {
1684                        a
1685                    } else {
1686                        amount
1687                    };
1688                if final_amount > 0 {
1689                    game.player_gain_life(source_controller, final_amount);
1690                    game.player_add_team_life_gained(source_controller, final_amount);
1691                }
1692            }
1693        }
1694        game.card_mut(source).damage_history.register_damage(
1695            amount,
1696            true,
1697            Some(source),
1698            crate::card::card_damage_history::TrackedEntity::Card(target),
1699        );
1700    }
1701}
1702
1703// ── Lure / Must-Block helpers ─────────────────────────────────────────
1704// Delegated to combat_util for file parity.
1705
1706/// What kind of lure effect an attacker has.
1707#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1708pub enum LureType {
1709    /// No lure effect.
1710    None,
1711    /// "CARDNAME must be blocked if able." — at least 1 blocker required.
1712    MustBeBlockedIfAble,
1713    /// "All creatures able to block CARDNAME do so." — ALL legal blockers must block it.
1714    AllMustBlock,
1715}
1716
1717/// Determine the lure type of an attacker based on its keywords.
1718pub fn get_lure_type(card: &crate::card::Card) -> LureType {
1719    combat_util::get_lure_type(card)
1720}
1721
1722/// Get attackers that `blocker_id` MUST block (if able).
1723pub fn compute_must_block_targets(
1724    game: &GameState,
1725    combat: &CombatState,
1726    blocker_id: CardId,
1727) -> Vec<CardId> {
1728    combat_util::compute_must_block_targets(game, combat, blocker_id)
1729}
1730
1731/// Validate blocker assignments and return invalid (blocker, attacker) pairs.
1732pub fn validate_blocks(game: &GameState, combat: &CombatState) -> Vec<(CardId, CardId)> {
1733    combat_util::validate_blocks(game, combat)
1734}