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                    crate::ability::effects::effect_context::add_counter_with_context(
109                        ctx.game,
110                        Some(ctx.trigger_handler),
111                        Some(ctx.agents),
112                        cid,
113                        &crate::card::CounterType::M1M1,
114                        damage,
115                        crate::event::RunParams {
116                            source_player: sa.source.map(|src_id| ctx.game.card(src_id).controller),
117                            cause: Some(sa.clone()),
118                            ..Default::default()
119                        },
120                        true,
121                    );
122                }
123            } else if use_damage_map {
124                if let Some(src_id) = sa.source {
125                    if let Some(map) = ctx.game.pending_damage_map.as_mut() {
126                        map.put(src_id, DamageTarget::Card(cid), damage);
127                    }
128                }
129            } else {
130                ctx.game
131                    .deal_damage_to_card_from(cid, damage, sa.source, false);
132            }
133            if !use_damage_map {
134                ctx.trigger_handler.run_trigger(
135                    crate::trigger::TriggerType::DamageDone,
136                    crate::event::RunParams {
137                        damage_source: sa.source,
138                        damage_target_card: Some(cid),
139                        damage_amount: Some(damage),
140                        is_combat_damage: Some(false),
141                        ..Default::default()
142                    },
143                    false,
144                );
145            }
146        }
147        let _ = crate::ability::spell_ability_effect::replace_dying(ctx.game, sa);
148        return;
149    }
150
151    for target_player in target_players {
152        let source_has_infect = if let Some(src_id) = sa.source {
153            let src = ctx.game.card(src_id);
154            source_has_infect_keyword
155                || crate::staticability::static_ability_infect_damage::is_infect_damage(
156                    ctx.game,
157                    &ctx.game.cards,
158                    target_player,
159                    src.controller,
160                )
161        } else {
162            false
163        };
164        if source_has_infect {
165            // Infect: deal damage to players as poison counters
166            if use_damage_map {
167                if let Some(src_id) = sa.source {
168                    if let Some(map) = ctx.game.pending_damage_map.as_mut() {
169                        map.put(src_id, DamageTarget::Player(target_player), damage);
170                    }
171                }
172            } else {
173                ctx.add_player_counter(
174                    target_player,
175                    &crate::card::CounterType::Poison,
176                    damage,
177                    sa,
178                    crate::event::RunParams {
179                        source_player: sa.source.map(|source| ctx.game.card(source).controller),
180                        ..Default::default()
181                    },
182                );
183            }
184        } else if use_damage_map {
185            if let Some(src_id) = sa.source {
186                if let Some(map) = ctx.game.pending_damage_map.as_mut() {
187                    map.put(src_id, DamageTarget::Player(target_player), damage);
188                }
189            }
190        } else {
191            let dealt =
192                ctx.game
193                    .deal_damage_to_player_from(target_player, damage, sa.source, false);
194            ctx.game
195                .record_player_damage_assignment(sa.source, Some(target_player), dealt, false);
196        }
197
198        // Record damage dealt by source for TotalDamageDoneByThisTurn SVar
199        if !use_damage_map {
200            if let Some(src_id) = sa.source {
201                if damage > 0 {
202                    ctx.game.card_mut(src_id).total_damage_done_this_turn += damage;
203                    ctx.game
204                        .card_mut(src_id)
205                        .damage_history
206                        .record_damage(damage, false);
207                }
208            }
209        }
210
211        // Fire DamageDone trigger
212        if !use_damage_map {
213            ctx.trigger_handler.run_trigger(
214                crate::trigger::TriggerType::DamageDone,
215                crate::event::RunParams {
216                    damage_source: sa.source,
217                    damage_target_player: Some(target_player),
218                    damage_amount: Some(damage),
219                    is_combat_damage: Some(false),
220                    ..Default::default()
221                },
222                false,
223            );
224            ctx.trigger_handler.run_trigger(
225                crate::trigger::TriggerType::DamageDoneOnce,
226                crate::event::RunParams {
227                    damage_target_player: Some(target_player),
228                    damage_amount: Some(damage),
229                    is_combat_damage: Some(false),
230                    ..Default::default()
231                },
232                false,
233            );
234            ctx.trigger_handler.flush_waiting_triggers(ctx.game);
235        }
236    }
237    let mut target_cards = sa.target_chosen.target_card.into_iter().collect::<Vec<_>>();
238    target_cards.extend(card_util::get_radiance(ctx.game, sa).iter().copied());
239    target_cards.sort_unstable_by_key(|cid| cid.0);
240    target_cards.dedup();
241    for target_card in target_cards {
242        if ctx.game.card(target_card).zone == ZoneType::Battlefield {
243            // Protection: prevents all damage from matching sources
244            if let Some(src_id) = sa.source {
245                if crate::staticability::static_ability_colorless_damage_source::target_is_protected_from_source(
246                    &ctx.game.cards,
247                    ctx.game.card(target_card),
248                    ctx.game.card(src_id),
249                ) {
250                    return;
251                }
252            }
253
254            // Track damage source for DamagedBy trigger filters
255            if let Some(src_id) = sa.source {
256                if !ctx
257                    .game
258                    .card(target_card)
259                    .damage_sources_this_turn
260                    .contains(&src_id)
261                {
262                    ctx.game
263                        .card_mut(target_card)
264                        .damage_sources_this_turn
265                        .push(src_id);
266                }
267            }
268            if source_has_infect_keyword || source_has_wither {
269                // Infect/Wither: damage to creatures as -1/-1 counters
270                if use_damage_map {
271                    if let Some(src_id) = sa.source {
272                        if let Some(map) = ctx.game.pending_damage_map.as_mut() {
273                            map.put(src_id, DamageTarget::Card(target_card), damage);
274                        }
275                    }
276                } else if !crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_card(
277                    &ctx.game.cards,
278                    ctx.game.card(target_card),
279                    &crate::card::CounterType::M1M1,
280                ) {
281                    crate::ability::effects::effect_context::add_counter_with_context(
282                        ctx.game,
283                        Some(ctx.trigger_handler),
284                        Some(ctx.agents),
285                        target_card,
286                        &crate::card::CounterType::M1M1,
287                        damage,
288                        crate::event::RunParams {
289                            source_player: sa.source.map(|src_id| ctx.game.card(src_id).controller),
290                            cause: Some(sa.clone()),
291                            ..Default::default()
292                        },
293                        true,
294                    );
295                }
296            } else if use_damage_map {
297                if let Some(src_id) = sa.source {
298                    if let Some(map) = ctx.game.pending_damage_map.as_mut() {
299                        map.put(src_id, DamageTarget::Card(target_card), damage);
300                    }
301                }
302            } else {
303                ctx.game
304                    .deal_damage_to_card_from(target_card, damage, sa.source, false);
305            }
306
307            // Record damage dealt by source for TotalDamageDoneByThisTurn SVar
308            if !use_damage_map {
309                if let Some(src_id) = sa.source {
310                    if damage > 0 {
311                        ctx.game.card_mut(src_id).total_damage_done_this_turn += damage;
312                        ctx.game
313                            .card_mut(src_id)
314                            .damage_history
315                            .record_damage(damage, false);
316                    }
317                }
318            }
319
320            // Fire DamageDone trigger
321            if !use_damage_map {
322                ctx.trigger_handler.run_trigger(
323                    crate::trigger::TriggerType::DamageDone,
324                    crate::event::RunParams {
325                        damage_source: sa.source,
326                        damage_target_card: Some(target_card),
327                        damage_amount: Some(damage),
328                        is_combat_damage: Some(false),
329                        ..Default::default()
330                    },
331                    false,
332                );
333                // Fire DamageDoneOnce batch trigger for non-map (non-combat)
334                // damage.  Java fires this from CardDamageMap.triggerDamageOnce
335                // which is called for ALL damage paths.  Without this, "when
336                // dealt damage" triggers using DamageDoneOnce (e.g. Raptor
337                // Hatchling Enrage) would never fire for spell damage.
338                ctx.trigger_handler.run_trigger(
339                    crate::trigger::TriggerType::DamageDoneOnce,
340                    crate::event::RunParams {
341                        damage_target_card: Some(target_card),
342                        damage_amount: Some(damage),
343                        is_combat_damage: Some(false),
344                        ..Default::default()
345                    },
346                    false,
347                );
348                // Pre-match damage triggers while the creature is still on the
349                // battlefield.  SBAs run after resolution and would move
350                // lethally damaged creatures to the graveyard, causing their
351                // Enrage triggers to fail the active-zone check.
352                ctx.trigger_handler.flush_waiting_triggers(ctx.game);
353            }
354
355            if sa.ir.remember_damaged_creature {
356                if let Some(src_id) = sa.source {
357                    let src = ctx.game.card_mut(src_id);
358                    src.add_remembered_card(target_card);
359                }
360            }
361        }
362    }
363
364    let _ = crate::ability::spell_ability_effect::replace_dying(ctx.game, sa);
365}
366
367/// Resolve the NumDmg$ parameter, supporting both integer literals and SVar
368/// references (e.g. `NumDmg$ X` where `SVar:X:ParentTargeted$CardPower`).
369/// Mirrors Java's `AbilityUtils.calculateAmount(sa, "NumDmg", sa)`.
370fn resolve_damage_amount(ctx: &EffectContext, sa: &SpellAbility) -> i32 {
371    if let Some(EffectIr::DealDamage(ir)) = &sa.ir.effect {
372        if let Some(amount) = &ir.amount {
373            if let Some(value) = resolve_amount_expr(ctx, sa, amount) {
374                #[cfg(debug_assertions)]
375                debug_assert_eq!(
376                    value,
377                    resolve_damage_amount_from_params(ctx, sa),
378                    "compiled DealDamage amount diverged from string params"
379                );
380                return value;
381            }
382        }
383    }
384
385    resolve_damage_amount_from_params(ctx, sa)
386}
387
388fn resolve_amount_expr(ctx: &EffectContext, sa: &SpellAbility, amount: &AmountExpr) -> Option<i32> {
389    match amount {
390        AmountExpr::Literal(value) => Some(*value),
391        AmountExpr::X => Some(resolve_x_amount(ctx, sa)),
392        AmountExpr::SVar(name) => Some(resolve_svar_amount(ctx, sa, name)),
393        AmountExpr::Raw(_) => None,
394    }
395}
396
397fn resolve_damage_amount_from_params(ctx: &EffectContext, sa: &SpellAbility) -> i32 {
398    resolve_numeric_svar(ctx.game, sa, keys::NUM_DMG, 0)
399}
400
401fn resolve_x_amount(ctx: &EffectContext, sa: &SpellAbility) -> i32 {
402    if let Some(source_id) = sa.source {
403        if let Some(svar_expr) = ctx.game.card(source_id).get_s_var("X") {
404            return evaluate_svar_expr(ctx, sa, svar_expr);
405        }
406    }
407    sa.x_mana_cost_paid as i32
408}
409
410fn resolve_svar_amount(ctx: &EffectContext, sa: &SpellAbility, var_name: &str) -> i32 {
411    if let Some(source_id) = sa.source {
412        let svar_val = ctx
413            .game
414            .card(source_id)
415            .get_s_var(var_name)
416            .map(str::to_string);
417        if let Some(expr) = svar_val {
418            return evaluate_svar_expr(ctx, sa, &expr);
419        }
420    }
421
422    0
423}
424
425/// Evaluate a simple SVar expression string.
426/// Mirrors Java's `AbilityUtils.calculateAmount` for common SVar patterns.
427fn evaluate_svar_expr(ctx: &EffectContext, sa: &SpellAbility, expr: &str) -> i32 {
428    // Count$Kicked.X.Y — delegate to shared evaluator
429    if expr.starts_with("Count$Kicked.") {
430        return super::evaluate_svar(expr, sa);
431    }
432    // Count$ expressions — delegate to shared game-aware resolver
433    if expr.starts_with("Count$") {
434        if let Some(source_id) = sa.source {
435            return crate::svar::resolve_count_svar_for_sa(
436                expr,
437                ctx.game,
438                source_id,
439                sa.activating_player,
440                sa,
441            );
442        }
443    }
444    // Sacrificed$CardPower / Sacrificed$CardToughness — LKI from cost payment.
445    // Used by Rite of Consumption: SVar:X:Sacrificed$CardPower
446    if expr == "Sacrificed$CardPower" || expr == "Sacrificed$CardToughness" {
447        if let Some(sac_id) = ctx.game.last_sacrificed_card {
448            let sac_card = ctx.game.card(sac_id);
449            let val = if expr.ends_with("Power") {
450                sac_card
451                    .lki_power
452                    .unwrap_or(sac_card.base_power.unwrap_or(0))
453            } else {
454                sac_card
455                    .lki_toughness
456                    .unwrap_or(sac_card.base_toughness.unwrap_or(0))
457            };
458            return val;
459        }
460        return 0;
461    }
462    if expr == "TriggeredCard$CardPower" || expr == "TriggeredCard$CardToughness" {
463        let trigger_value_key = if expr.ends_with("CardPower") {
464            "TriggeredCardPower"
465        } else {
466            "TriggeredCardToughness"
467        };
468        if let Some(value) = crate::ability::ability_key::from_string(trigger_value_key)
469            .and_then(|key| sa.get_triggering_value(key))
470            .and_then(|value| value.trim().parse::<i32>().ok())
471        {
472            return value;
473        }
474
475        let triggered_card = sa
476            .get_triggering_card(crate::ability::AbilityKey::Card)
477            .or(sa.trigger_source);
478        if let Some(card_id) = triggered_card {
479            return if expr.ends_with("CardPower") {
480                crate::lki::resolve_lki_power(ctx.game, card_id)
481            } else {
482                crate::lki::resolve_lki_toughness(ctx.game, card_id)
483            };
484        }
485        return 0;
486    }
487    match expr {
488        // X mana cost paid value
489        "Count$xPaid" | "Count$XPaid" => sa.x_mana_cost_paid as i32,
490        // Power / toughness of the parent SA's chosen target card.
491        // Used by Ram Through: SVar:X:ParentTargeted$CardPower
492        "ParentTargeted$CardPower" => ctx
493            .parent_target_card
494            .map(|id| ctx.game.card(id).power())
495            .unwrap_or(0),
496        "ParentTargeted$CardToughness" => ctx
497            .parent_target_card
498            .map(|id| ctx.game.card(id).toughness())
499            .unwrap_or(0),
500        _ => 0,
501    }
502}