Skip to main content

manabrew_engine/ability/effects/
damage_resolve_effect.rs

1//! DamageResolve — resolve accumulated damage from a damage map.
2//! Ported from Java's DamageResolveEffect.
3
4use super::EffectContext;
5use crate::card::card_damage_map::DamageTarget;
6use crate::card::CounterType;
7
8/// Struct form of this effect so it can participate in the
9/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
10/// `DamageResolveEffect` class extending `SpellAbilityEffect`.
11#[manabrew_engine_macros::spell_effect(DamageResolveEffect)]
12fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
13    let from_pending = sa.damage_map.is_none();
14    let damage_map_owned = sa
15        .damage_map
16        .clone()
17        .or_else(|| ctx.game.pending_damage_map.clone());
18    let Some(damage_map) = damage_map_owned.as_ref() else {
19        return;
20    };
21
22    let prevent_map_owned = sa
23        .prevent_map
24        .clone()
25        .or_else(|| ctx.game.pending_prevent_map.clone());
26    if let Some(prevent_map) = prevent_map_owned.as_ref() {
27        prevent_map.trigger_prevent_damage(ctx.trigger_handler, false);
28    }
29
30    for (source, target, amount) in damage_map.entries() {
31        if amount <= 0 {
32            continue;
33        }
34        match target {
35            DamageTarget::Card(cid) => {
36                if ctx.game.card(cid).zone == forge_foundation::ZoneType::Battlefield {
37                    // Protection prevents damage from matching sources.
38                    if crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
39                        &ctx.game.cards,
40                        ctx.game.card(cid),
41                        ctx.game.card(source),
42                    ) {
43                        continue;
44                    }
45
46                    if !ctx
47                        .game
48                        .card(cid)
49                        .damage_sources_this_turn
50                        .contains(&source)
51                    {
52                        ctx.game.card_mut(cid).add_damage_source_this_turn(source);
53                    }
54
55                    let source_has_infect = ctx.game.card(source).has_infect();
56                    let source_has_wither = ctx.game.card(source).has_wither()
57                        || crate::staticability::static_ability_wither_damage::is_wither_damage(
58                            &ctx.game.cards,
59                            ctx.game.card(source),
60                        );
61
62                    if source_has_infect || source_has_wither {
63                        if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_card(
64                            &ctx.game.cards,
65                            ctx.game.card(cid),
66                            &CounterType::M1M1,
67                        ) {
68                            ctx.game.card_mut(cid).add_counter(&CounterType::M1M1, amount);
69                        }
70                    } else {
71                        ctx.game.deal_damage_to_card(cid, amount);
72                    }
73                }
74            }
75            DamageTarget::Player(pid) => {
76                let source_has_infect = ctx.game.card(source).has_infect()
77                    || crate::staticability::static_ability_infect_damage::is_infect_damage(
78                        ctx.game,
79                        &ctx.game.cards,
80                        pid,
81                        ctx.game.card(source).controller,
82                    );
83                if source_has_infect {
84                    if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_player(
85                        &ctx.game.cards,
86                        pid,
87                        &CounterType::Poison,
88                    ) {
89                        ctx.game.player_add_poison(pid, amount);
90                    }
91                } else {
92                    let dealt = ctx.game.deal_damage_to_player(pid, amount);
93                    ctx.game
94                        .record_player_damage_assignment(Some(source), Some(pid), dealt, false);
95                }
96            }
97        }
98
99        // Track source total damage for count expressions.
100        ctx.game.card_mut(source).total_damage_done_this_turn += amount;
101        ctx.game
102            .card_mut(source)
103            .damage_history
104            .record_damage(amount, false);
105    }
106
107    damage_map.trigger_damage_done_once(ctx.game, ctx.trigger_handler, false);
108
109    // Pre-match DamageDoneOnce triggers while damaged creatures are still on
110    // the battlefield.  SBAs run after effect resolution and would move
111    // lethally damaged creatures to the graveyard, causing their "when dealt
112    // damage" triggers (e.g. Raptor Hatchling Enrage) to fail the active-zone
113    // check.  Flushing now stores them as pre-matched so they survive SBA.
114    ctx.trigger_handler.flush_waiting_triggers(ctx.game);
115
116    // Java parity hook (currently a no-op helper until full replacement wiring lands).
117    let _ = crate::ability::spell_ability_effect::replace_dying(ctx.game, sa);
118
119    if from_pending {
120        ctx.game.clear_pending_damage_maps();
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use crate::ability::spell_ability_effect::SpellAbilityEffect;
127    use std::collections::HashMap;
128
129    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
130
131    use crate::ability::effects::EffectContext;
132    use crate::agent::{PassAgent, PlayerAgent};
133    use crate::card::card_damage_map::{CardDamageMap, DamageTarget};
134    use crate::card::Card;
135    use crate::game::GameState;
136    use crate::ids::{CardId, PlayerId};
137    use crate::mana::ManaPool;
138    use crate::spellability::SpellAbility;
139    use crate::trigger::handler::TriggerHandler;
140
141    fn creature(game: &mut GameState, owner: PlayerId, name: &str) -> CardId {
142        let c = Card::new(
143            CardId(0),
144            name.to_string(),
145            owner,
146            CardTypeLine::parse("Creature"),
147            ManaCost::parse(""),
148            ColorSet::COLORLESS,
149            Some(2),
150            Some(2),
151            vec![],
152            vec![],
153        );
154        let id = game.create_card(c);
155        game.move_card(id, ZoneType::Battlefield, owner);
156        id
157    }
158
159    #[test]
160    fn damage_resolve_consumes_pending_damage_map() {
161        let mut game = GameState::new(&["A", "B"], 20);
162        let p0 = PlayerId(0);
163        let p1 = PlayerId(1);
164        let src = creature(&mut game, p0, "Src");
165        let tgt = creature(&mut game, p1, "Tgt");
166
167        let mut map = CardDamageMap::default();
168        map.put(src, DamageTarget::Card(tgt), 2);
169        map.put(src, DamageTarget::Player(p1), 1);
170        game.pending_damage_map = Some(map);
171        game.pending_prevent_map = Some(CardDamageMap::default());
172
173        let sa = SpellAbility::new_simple(Some(src), p0, "DB$ DamageResolve");
174        let mut th = TriggerHandler::new();
175        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![Box::new(PassAgent), Box::new(PassAgent)];
176        let mut pools = vec![ManaPool::default(), ManaPool::default()];
177        let templates = HashMap::new();
178        let templates_variants = HashMap::new();
179        let token_fallback = HashMap::new();
180        let edition_dates: HashMap<String, String> = HashMap::new();
181        let mut rng = crate::game_rng::ThreadRngAdapter;
182        let mut ctx = EffectContext {
183            game: &mut game,
184            combat: None,
185            agents: &mut agents,
186            trigger_handler: &mut th,
187            token_templates: &templates,
188            token_art_variants: &templates_variants,
189            token_fallback: &token_fallback,
190            edition_dates: &edition_dates,
191            mana_pools: &mut pools,
192            parent_target_card: None,
193            rng: &mut rng,
194        };
195
196        super::DamageResolveEffect::resolve(&mut ctx, &sa);
197
198        assert_eq!(ctx.game.card(tgt).damage, 2);
199        assert_eq!(ctx.game.player(p1).life, 19);
200        assert!(ctx.game.pending_damage_map.is_none());
201        assert!(ctx.game.pending_prevent_map.is_none());
202    }
203
204    #[test]
205    fn damage_resolve_replace_dying_exiles_lethal_target() {
206        let mut game = GameState::new(&["A", "B"], 20);
207        let p0 = PlayerId(0);
208        let p1 = PlayerId(1);
209        let src = creature(&mut game, p0, "Scorching Dragonfire");
210        let tgt = creature(&mut game, p1, "Talruum Minotaur");
211
212        let mut map = CardDamageMap::default();
213        map.put(src, DamageTarget::Card(tgt), 3);
214        game.pending_damage_map = Some(map);
215        game.pending_prevent_map = Some(CardDamageMap::default());
216
217        let mut sa = SpellAbility::new_simple(
218            Some(src),
219            p0,
220            "DB$ DamageResolve | ReplaceDyingDefined$ Targeted",
221        );
222        sa.target_chosen.target_card = Some(tgt);
223
224        let mut th = TriggerHandler::new();
225        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![Box::new(PassAgent), Box::new(PassAgent)];
226        let mut pools = vec![ManaPool::default(), ManaPool::default()];
227        let templates = HashMap::new();
228        let templates_variants = HashMap::new();
229        let token_fallback = HashMap::new();
230        let edition_dates: HashMap<String, String> = HashMap::new();
231        let mut rng = crate::game_rng::ThreadRngAdapter;
232        let mut ctx = EffectContext {
233            game: &mut game,
234            combat: None,
235            agents: &mut agents,
236            trigger_handler: &mut th,
237            token_templates: &templates,
238            token_art_variants: &templates_variants,
239            token_fallback: &token_fallback,
240            edition_dates: &edition_dates,
241            mana_pools: &mut pools,
242            parent_target_card: None,
243            rng: &mut rng,
244        };
245
246        super::DamageResolveEffect::resolve(&mut ctx, &sa);
247        assert_eq!(ctx.game.card(tgt).zone, ZoneType::Battlefield);
248        assert!(ctx.game.cards.iter().any(|c| {
249            c.zone == ZoneType::Command
250                && c.remembered_cards == vec![tgt]
251                && !c.replacement_effects.is_empty()
252        }));
253
254        assert!(ctx.game.check_state_based_actions());
255        assert_eq!(ctx.game.card(tgt).zone, ZoneType::Exile);
256        assert!(!ctx.game.cards.iter().any(|c| c.zone == ZoneType::Command));
257    }
258}