Skip to main content

manabrew_engine/ability/effects/
damage_deal_effect.rs

1use forge_foundation::ZoneType;
2
3use super::{resolve_numeric_svar, EffectContext};
4use crate::ability::ability_ir::EffectIr;
5use crate::card::card_damage_map::DamageTarget;
6use crate::card::card_util;
7use crate::parsing::amount::AmountExpr;
8use crate::parsing::keys;
9use crate::spellability::SpellAbility;
10
11/// Struct form of this effect so it can participate in the
12/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
13/// `DamageDealEffect` class extending `SpellAbilityEffect`.
14#[manabrew_engine_macros::spell_effect(DamageDealEffect)]
15fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
16    let damage = resolve_damage_amount(ctx, sa);
17    let use_damage_map = ctx.game.pending_damage_map.is_some() || sa.ir.damage_map;
18    if sa.ir.damage_map {
19        ctx.game.ensure_pending_damage_maps();
20    }
21
22    // For triggered abilities, resolve Defined$ for target. Some effects use
23    // bare `Player` to mean "each player", so collect all defined players here
24    // and fan out below instead of forcing everything through a single target.
25    let target_players: Vec<_> = if let Some(target_player) = sa.target_chosen.target_player {
26        vec![target_player]
27    } else if let Some(defined) = sa.defined() {
28        crate::ability::ability_utils::resolve_defined_players_with_sa(
29            defined,
30            sa,
31            sa.activating_player,
32            ctx.game,
33        )
34    } else {
35        Vec::new()
36    };
37
38    // Check source card for Infect/Wither keywords
39    let (source_has_infect_keyword, source_has_wither) = if let Some(src_id) = sa.source {
40        let src = ctx.game.card(src_id);
41        (
42            src.has_infect(),
43            src.has_wither()
44                || crate::staticability::static_ability_wither_damage::is_wither_damage(
45                    &ctx.game.cards,
46                    src,
47                ),
48        )
49    } else {
50        (false, false)
51    };
52
53    // Overload: deal damage to ALL valid creatures instead of the chosen target.
54    if sa.overloaded {
55        let valid_tgts = sa
56            .target_restrictions
57            .as_ref()
58            .and_then(|restrictions| restrictions.valid_tgts.first())
59            .map(String::as_str)
60            .unwrap_or_default();
61        let valid_tgts_selector = sa
62            .target_restrictions
63            .as_ref()
64            .map(|restrictions| &restrictions.valid_tgts_selector);
65        let all_bf: Vec<crate::ids::CardId> = ctx
66            .game
67            .player_order
68            .clone()
69            .iter()
70            .flat_map(|&pid| ctx.game.cards_in_zone(ZoneType::Battlefield, pid).to_vec())
71            .collect();
72        for cid in all_bf {
73            if ctx.game.card(cid).zone != ZoneType::Battlefield {
74                continue;
75            }
76            if !super::matches_valid_cards_for_sa(
77                ctx.game,
78                sa,
79                ctx.game.card(cid),
80                valid_tgts_selector,
81                valid_tgts,
82            ) {
83                continue;
84            }
85            // Track damage source for DamagedBy trigger filters
86            if let Some(src_id) = sa.source {
87                if !ctx
88                    .game
89                    .card(cid)
90                    .damage_sources_this_turn
91                    .contains(&src_id)
92                {
93                    ctx.game.card_mut(cid).add_damage_source_this_turn(src_id);
94                }
95            }
96            if source_has_infect_keyword || source_has_wither {
97                if use_damage_map {
98                    if let Some(src_id) = sa.source {
99                        if let Some(map) = ctx.game.pending_damage_map.as_mut() {
100                            map.put(src_id, DamageTarget::Card(cid), damage);
101                        }
102                    }
103                } else if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_card(
104                    &ctx.game.cards,
105                    ctx.game.card(cid),
106                    &crate::card::CounterType::M1M1,
107                ) {
108                    ctx.game
109                        .card_mut(cid)
110                        .add_counter(&crate::card::CounterType::M1M1, damage);
111                }
112            } else if use_damage_map {
113                if let Some(src_id) = sa.source {
114                    if let Some(map) = ctx.game.pending_damage_map.as_mut() {
115                        map.put(src_id, DamageTarget::Card(cid), damage);
116                    }
117                }
118            } else {
119                ctx.game
120                    .deal_damage_to_card_from(cid, damage, sa.source, false);
121            }
122            if !use_damage_map {
123                ctx.trigger_handler.run_trigger(
124                    crate::trigger::TriggerType::DamageDone,
125                    crate::event::RunParams {
126                        damage_source: sa.source,
127                        damage_target_card: Some(cid),
128                        damage_amount: Some(damage),
129                        is_combat_damage: Some(false),
130                        ..Default::default()
131                    },
132                    false,
133                );
134            }
135        }
136        let _ = crate::ability::spell_ability_effect::replace_dying(ctx.game, sa);
137        return;
138    }
139
140    for target_player in target_players {
141        let source_has_infect = if let Some(src_id) = sa.source {
142            let src = ctx.game.card(src_id);
143            source_has_infect_keyword
144                || crate::staticability::static_ability_infect_damage::is_infect_damage(
145                    ctx.game,
146                    &ctx.game.cards,
147                    target_player,
148                    src.controller,
149                )
150        } else {
151            false
152        };
153        if source_has_infect {
154            // Infect: deal damage to players as poison counters
155            if use_damage_map {
156                if let Some(src_id) = sa.source {
157                    if let Some(map) = ctx.game.pending_damage_map.as_mut() {
158                        map.put(src_id, DamageTarget::Player(target_player), damage);
159                    }
160                }
161            } else if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_player(
162                    &ctx.game.cards,
163                    target_player,
164                    &crate::card::CounterType::Poison,
165                ) {
166                ctx.game.player_add_poison(target_player, damage);
167            }
168        } else if use_damage_map {
169            if let Some(src_id) = sa.source {
170                if let Some(map) = ctx.game.pending_damage_map.as_mut() {
171                    map.put(src_id, DamageTarget::Player(target_player), damage);
172                }
173            }
174        } else {
175            let dealt =
176                ctx.game
177                    .deal_damage_to_player_from(target_player, damage, sa.source, false);
178            ctx.game
179                .record_player_damage_assignment(sa.source, Some(target_player), dealt, false);
180        }
181
182        // Record damage dealt by source for TotalDamageDoneByThisTurn SVar
183        if !use_damage_map {
184            if let Some(src_id) = sa.source {
185                if damage > 0 {
186                    ctx.game.card_mut(src_id).total_damage_done_this_turn += damage;
187                    ctx.game
188                        .card_mut(src_id)
189                        .damage_history
190                        .record_damage(damage, false);
191                }
192            }
193        }
194
195        // Fire DamageDone trigger
196        if !use_damage_map {
197            ctx.trigger_handler.run_trigger(
198                crate::trigger::TriggerType::DamageDone,
199                crate::event::RunParams {
200                    damage_source: sa.source,
201                    damage_target_player: Some(target_player),
202                    damage_amount: Some(damage),
203                    is_combat_damage: Some(false),
204                    ..Default::default()
205                },
206                false,
207            );
208            ctx.trigger_handler.run_trigger(
209                crate::trigger::TriggerType::DamageDoneOnce,
210                crate::event::RunParams {
211                    damage_target_player: Some(target_player),
212                    damage_amount: Some(damage),
213                    is_combat_damage: Some(false),
214                    ..Default::default()
215                },
216                false,
217            );
218            ctx.trigger_handler.flush_waiting_triggers(ctx.game);
219        }
220    }
221    let mut target_cards = sa.target_chosen.target_card.into_iter().collect::<Vec<_>>();
222    target_cards.extend(card_util::get_radiance(ctx.game, sa).iter().copied());
223    target_cards.sort_unstable_by_key(|cid| cid.0);
224    target_cards.dedup();
225    for target_card in target_cards {
226        if ctx.game.card(target_card).zone == ZoneType::Battlefield {
227            // Protection: prevents all damage from matching sources
228            if let Some(src_id) = sa.source {
229                if crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
230                    &ctx.game.cards,
231                    ctx.game.card(target_card),
232                    ctx.game.card(src_id),
233                ) {
234                    return;
235                }
236            }
237
238            // Track damage source for DamagedBy trigger filters
239            if let Some(src_id) = sa.source {
240                if !ctx
241                    .game
242                    .card(target_card)
243                    .damage_sources_this_turn
244                    .contains(&src_id)
245                {
246                    ctx.game
247                        .card_mut(target_card)
248                        .damage_sources_this_turn
249                        .push(src_id);
250                }
251            }
252            if source_has_infect_keyword || source_has_wither {
253                // Infect/Wither: damage to creatures as -1/-1 counters
254                if use_damage_map {
255                    if let Some(src_id) = sa.source {
256                        if let Some(map) = ctx.game.pending_damage_map.as_mut() {
257                            map.put(src_id, DamageTarget::Card(target_card), damage);
258                        }
259                    }
260                } else if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_card(
261                    &ctx.game.cards,
262                    ctx.game.card(target_card),
263                    &crate::card::CounterType::M1M1,
264                ) {
265                    ctx.game
266                        .card_mut(target_card)
267                        .add_counter(&crate::card::CounterType::M1M1, damage);
268                }
269            } else if use_damage_map {
270                if let Some(src_id) = sa.source {
271                    if let Some(map) = ctx.game.pending_damage_map.as_mut() {
272                        map.put(src_id, DamageTarget::Card(target_card), damage);
273                    }
274                }
275            } else {
276                ctx.game
277                    .deal_damage_to_card_from(target_card, damage, sa.source, false);
278            }
279
280            // Record damage dealt by source for TotalDamageDoneByThisTurn SVar
281            if !use_damage_map {
282                if let Some(src_id) = sa.source {
283                    if damage > 0 {
284                        ctx.game.card_mut(src_id).total_damage_done_this_turn += damage;
285                        ctx.game
286                            .card_mut(src_id)
287                            .damage_history
288                            .record_damage(damage, false);
289                    }
290                }
291            }
292
293            // Fire DamageDone trigger
294            if !use_damage_map {
295                ctx.trigger_handler.run_trigger(
296                    crate::trigger::TriggerType::DamageDone,
297                    crate::event::RunParams {
298                        damage_source: sa.source,
299                        damage_target_card: Some(target_card),
300                        damage_amount: Some(damage),
301                        is_combat_damage: Some(false),
302                        ..Default::default()
303                    },
304                    false,
305                );
306                // Fire DamageDoneOnce batch trigger for non-map (non-combat)
307                // damage.  Java fires this from CardDamageMap.triggerDamageOnce
308                // which is called for ALL damage paths.  Without this, "when
309                // dealt damage" triggers using DamageDoneOnce (e.g. Raptor
310                // Hatchling Enrage) would never fire for spell damage.
311                ctx.trigger_handler.run_trigger(
312                    crate::trigger::TriggerType::DamageDoneOnce,
313                    crate::event::RunParams {
314                        damage_target_card: Some(target_card),
315                        damage_amount: Some(damage),
316                        is_combat_damage: Some(false),
317                        ..Default::default()
318                    },
319                    false,
320                );
321                // Pre-match damage triggers while the creature is still on the
322                // battlefield.  SBAs run after resolution and would move
323                // lethally damaged creatures to the graveyard, causing their
324                // Enrage triggers to fail the active-zone check.
325                ctx.trigger_handler.flush_waiting_triggers(ctx.game);
326            }
327
328            if sa.ir.remember_damaged_creature {
329                if let Some(src_id) = sa.source {
330                    let src = ctx.game.card_mut(src_id);
331                    src.add_remembered_card(target_card);
332                }
333            }
334        }
335    }
336
337    let _ = crate::ability::spell_ability_effect::replace_dying(ctx.game, sa);
338}
339
340/// Resolve the NumDmg$ parameter, supporting both integer literals and SVar
341/// references (e.g. `NumDmg$ X` where `SVar:X:ParentTargeted$CardPower`).
342/// Mirrors Java's `AbilityUtils.calculateAmount(sa, "NumDmg", sa)`.
343fn resolve_damage_amount(ctx: &EffectContext, sa: &SpellAbility) -> i32 {
344    if let Some(EffectIr::DealDamage(ir)) = &sa.ir.effect {
345        if let Some(amount) = &ir.amount {
346            if let Some(value) = resolve_amount_expr(ctx, sa, amount) {
347                #[cfg(debug_assertions)]
348                debug_assert_eq!(
349                    value,
350                    resolve_damage_amount_from_params(ctx, sa),
351                    "compiled DealDamage amount diverged from string params"
352                );
353                return value;
354            }
355        }
356    }
357
358    resolve_damage_amount_from_params(ctx, sa)
359}
360
361fn resolve_amount_expr(ctx: &EffectContext, sa: &SpellAbility, amount: &AmountExpr) -> Option<i32> {
362    match amount {
363        AmountExpr::Literal(value) => Some(*value),
364        AmountExpr::X => Some(resolve_x_amount(ctx, sa)),
365        AmountExpr::SVar(name) => Some(resolve_svar_amount(ctx, sa, name)),
366        AmountExpr::Raw(_) => None,
367    }
368}
369
370fn resolve_damage_amount_from_params(ctx: &EffectContext, sa: &SpellAbility) -> i32 {
371    resolve_numeric_svar(ctx.game, sa, keys::NUM_DMG, 0)
372}
373
374fn resolve_x_amount(ctx: &EffectContext, sa: &SpellAbility) -> i32 {
375    if let Some(source_id) = sa.source {
376        if let Some(svar_expr) = ctx.game.card(source_id).get_s_var("X") {
377            return evaluate_svar_expr(ctx, sa, svar_expr);
378        }
379    }
380    sa.x_mana_cost_paid as i32
381}
382
383fn resolve_svar_amount(ctx: &EffectContext, sa: &SpellAbility, var_name: &str) -> i32 {
384    if let Some(source_id) = sa.source {
385        let svar_val = ctx
386            .game
387            .card(source_id)
388            .get_s_var(var_name)
389            .map(str::to_string);
390        if let Some(expr) = svar_val {
391            return evaluate_svar_expr(ctx, sa, &expr);
392        }
393    }
394
395    0
396}
397
398/// Evaluate a simple SVar expression string.
399/// Mirrors Java's `AbilityUtils.calculateAmount` for common SVar patterns.
400fn evaluate_svar_expr(ctx: &EffectContext, sa: &SpellAbility, expr: &str) -> i32 {
401    // Count$Kicked.X.Y — delegate to shared evaluator
402    if expr.starts_with("Count$Kicked.") {
403        return super::evaluate_svar(expr, sa);
404    }
405    // Count$ expressions — delegate to shared game-aware resolver
406    if expr.starts_with("Count$") {
407        if let Some(source_id) = sa.source {
408            return crate::svar::resolve_count_svar_for_sa(
409                expr,
410                ctx.game,
411                source_id,
412                sa.activating_player,
413                sa,
414            );
415        }
416    }
417    // Sacrificed$CardPower / Sacrificed$CardToughness — LKI from cost payment.
418    // Used by Rite of Consumption: SVar:X:Sacrificed$CardPower
419    if expr == "Sacrificed$CardPower" || expr == "Sacrificed$CardToughness" {
420        if let Some(sac_id) = ctx.game.last_sacrificed_card {
421            let sac_card = ctx.game.card(sac_id);
422            let val = if expr.ends_with("Power") {
423                sac_card
424                    .lki_power
425                    .unwrap_or(sac_card.base_power.unwrap_or(0))
426            } else {
427                sac_card
428                    .lki_toughness
429                    .unwrap_or(sac_card.base_toughness.unwrap_or(0))
430            };
431            return val;
432        }
433        return 0;
434    }
435    if expr == "TriggeredCard$CardPower" || expr == "TriggeredCard$CardToughness" {
436        let trigger_value_key = if expr.ends_with("CardPower") {
437            "TriggeredCardPower"
438        } else {
439            "TriggeredCardToughness"
440        };
441        if let Some(value) = crate::ability::ability_key::from_string(trigger_value_key)
442            .and_then(|key| sa.get_triggering_value(key))
443            .and_then(|value| value.trim().parse::<i32>().ok())
444        {
445            return value;
446        }
447
448        let triggered_card = sa
449            .get_triggering_card(crate::ability::AbilityKey::Card)
450            .or(sa.trigger_source);
451        if let Some(card_id) = triggered_card {
452            return if expr.ends_with("CardPower") {
453                crate::lki::resolve_lki_power(ctx.game, card_id)
454            } else {
455                crate::lki::resolve_lki_toughness(ctx.game, card_id)
456            };
457        }
458        return 0;
459    }
460    match expr {
461        // X mana cost paid value
462        "Count$xPaid" | "Count$XPaid" => sa.x_mana_cost_paid as i32,
463        // Power / toughness of the parent SA's chosen target card.
464        // Used by Ram Through: SVar:X:ParentTargeted$CardPower
465        "ParentTargeted$CardPower" => ctx
466            .parent_target_card
467            .map(|id| ctx.game.card(id).power())
468            .unwrap_or(0),
469        "ParentTargeted$CardToughness" => ctx
470            .parent_target_card
471            .map(|id| ctx.game.card(id).toughness())
472            .unwrap_or(0),
473        _ => 0,
474    }
475}