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