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