Skip to main content

manabrew_engine/ability/effects/
sacrifice_effect.rs

1use std::collections::BTreeMap;
2
3use forge_foundation::ZoneType;
4
5use super::{emit_zone_trigger_with_lki_counters, EffectContext};
6use crate::ability::spell_ability_effect::get_target_players;
7use crate::card::CounterType;
8use crate::event::RunParams;
9use crate::ids::{CardId, PlayerId};
10use crate::spellability::SpellAbility;
11use crate::trigger::TriggerType;
12
13/// Perform the actual sacrifice of a card: fire triggers, move to graveyard, emit zone change.
14/// If `exploit_source` is Some, also fires the Exploited trigger for the Exploit keyword.
15///
16/// Returns the sacrificed `CardId` on success, or `None` if the card was not on the
17/// battlefield or was prevented from being sacrificed (Sigarda et al.). Callers use the
18/// return value to accumulate per-controller batches for the trailing `SacrificedOnce`
19/// trigger fired once at the end of `resolve()`.
20fn do_sacrifice(
21    ctx: &mut EffectContext,
22    sa: &SpellAbility,
23    card_id: crate::ids::CardId,
24    sacrificing_player: PlayerId,
25    exploit_source: Option<crate::ids::CardId>,
26) -> Option<CardId> {
27    if ctx.game.card(card_id).zone != ZoneType::Battlefield {
28        return None;
29    }
30    if crate::staticability::static_ability_cant_sacrifice::cant_sacrifice(
31        &ctx.game.cards,
32        ctx.game.card(card_id),
33        Some(sa),
34        false,
35    ) {
36        return None;
37    }
38    let owner = ctx.game.card(card_id).owner;
39
40    // Capture +1/+1 counter count BEFORE the card moves to graveyard.
41    // Needed for Modular death triggers which move counters to target
42    // artifact creature (CR 702.43b). Counters are cleared during move_card.
43    let lki_p1p1 = *ctx
44        .game
45        .card(card_id)
46        .counters
47        .get(&crate::card::CounterType::P1P1)
48        .unwrap_or(&0);
49    let lki_power = ctx.game.card(card_id).power();
50    let lki_toughness = ctx.game.card(card_id).toughness();
51    // Capture LKI counters for death triggers (e.g. Servant of the Scale)
52    let lki_counters = ctx.game.card(card_id).counters.clone();
53    ctx.game.card_mut(card_id).lki_counters = Some(lki_counters);
54    ctx.game
55        .card_mut(card_id)
56        .set_lki_power_toughness(Some(lki_power), Some(lki_toughness));
57
58    // Clear temporary Animate triggers before firing events (CR 400.7).
59    {
60        let card = ctx.game.card_mut(card_id);
61        card.clear_pump_triggers();
62    }
63    // Fire Sacrificed trigger
64    ctx.trigger_handler.run_trigger(
65        TriggerType::Sacrificed,
66        RunParams {
67            card: Some(card_id),
68            player: Some(sacrificing_player),
69            ..Default::default()
70        },
71        false,
72    );
73    // Emit ChangesZone before move so LKI state (counters, keywords)
74    // is still available for trigger matching.
75    emit_zone_trigger_with_lki_counters(
76        ctx.trigger_handler,
77        card_id,
78        ZoneType::Battlefield,
79        ZoneType::Graveyard,
80        lki_p1p1,
81        lki_power,
82        lki_toughness,
83    );
84    ctx.move_card(card_id, ZoneType::Graveyard, owner);
85    ctx.trigger_handler.flush_waiting_triggers(ctx.game);
86    // Fire Exploited trigger when the sacrifice is from the Exploit keyword
87    if let Some(source_id) = exploit_source {
88        ctx.trigger_handler.run_trigger(
89            TriggerType::Exploited,
90            RunParams {
91                card: Some(source_id),
92                exploited_card: Some(card_id),
93                player: Some(sacrificing_player),
94                ..Default::default()
95            },
96            false,
97        );
98    }
99    Some(card_id)
100}
101
102/// Struct form of this effect so it can participate in the
103/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
104/// `SacrificeEffect` class extending `SpellAbilityEffect`.
105#[manabrew_engine_macros::spell_effect(SacrificeEffect)]
106fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
107    if let Some(echo_cost_str) = sa.ir.echo.as_deref() {
108        let source_id = match sa.source {
109            Some(cid) if ctx.game.card(cid).zone == ZoneType::Battlefield => cid,
110            _ => return,
111        };
112        let controller = ctx.game.card(source_id).controller;
113        let cost = crate::cost::parse_cost(echo_cost_str);
114        let available_mana = crate::mana::calculate_available_mana(
115            &ctx.mana_pools[controller.index()],
116            ctx.game,
117            controller,
118        );
119        let can_pay = crate::cost::can_pay_with_ability(
120            &cost,
121            ctx.game,
122            &available_mana,
123            source_id,
124            controller,
125            Some(sa),
126        );
127        let cost_kind = cost.to_simple_string();
128        let cost_display = crate::cost::to_prompt_string(&cost);
129        let prompt = if cost_display.is_empty() {
130            "Pay this cost?".to_string()
131        } else {
132            format!("Pay {}?", cost_display)
133        };
134        ctx.agents[controller.index()].snapshot_state(ctx.game, ctx.mana_pools);
135        let wants_to_pay = ctx.agents[controller.index()].pay_cost_to_prevent_effect(
136            controller,
137            if cost_kind.is_empty() {
138                "Echo"
139            } else {
140                cost_kind.as_str()
141            },
142            &prompt,
143            Some(source_id),
144            sa.api,
145            true,
146            &[],
147            &if sa.stack_description.is_empty() {
148                sa.rebuilt_description()
149            } else {
150                sa.stack_description.clone()
151            },
152        );
153        let paid =
154            wants_to_pay && can_pay && super::try_pay_echo(ctx, sa, source_id, controller, &cost);
155
156        ctx.trigger_handler.run_trigger(
157            TriggerType::PayEcho,
158            RunParams {
159                card: Some(source_id),
160                echo_paid: Some(paid),
161                ..Default::default()
162            },
163            false,
164        );
165
166        if paid || ctx.game.card(source_id).controller != controller {
167            return;
168        }
169        if let Some(cid) = do_sacrifice(ctx, sa, source_id, controller, None) {
170            let mut by_controller: BTreeMap<PlayerId, Vec<CardId>> = BTreeMap::new();
171            by_controller.insert(controller, vec![cid]);
172            crate::game_loop::fire_sacrificed_once_for_batch(
173                ctx.game,
174                ctx.trigger_handler,
175                &by_controller,
176            );
177        }
178        return;
179    }
180
181    // ── Cumulative Upkeep ────────────────────────────────────────────────
182    // Mirrors Java SacrificeEffect lines 52-75: when CumulativeUpkeep$ is set,
183    // add an Age counter, build merged cost (base cost × age counters),
184    // ask player to pay, sacrifice if not paid.
185    if let Some(cum_cost_str) = sa.ir.cumulative_upkeep.as_deref() {
186        let source_id = match sa.source {
187            Some(cid) if ctx.game.card(cid).zone == ZoneType::Battlefield => cid,
188            _ => return,
189        };
190        let controller = ctx.game.card(source_id).controller;
191
192        // 1. Add Age counter (mirrors Java host.addCounter(CounterEnumType.AGE, 1, ...))
193        ctx.game
194            .card_mut(source_id)
195            .add_counter(&CounterType::Age, 1);
196
197        // 2. Count age counters to determine how many times to pay
198        let n = ctx
199            .game
200            .card(source_id)
201            .counters
202            .get(&CounterType::Age)
203            .copied()
204            .unwrap_or(0) as usize;
205
206        // 3. Build merged cost: N copies of the base cost
207        //    Mirrors Java Cost.mergeTo(cumCost, n, sa)
208        let base_cost = crate::cost::parse_cost(cum_cost_str);
209        let mut merged_parts = Vec::new();
210        let mut merged_mana: Option<(
211            forge_foundation::ManaCost,
212            i32,
213            bool,
214            bool,
215            bool,
216            Option<String>,
217        )> = None;
218        for _ in 0..n {
219            for part in base_cost.parts.iter().cloned() {
220                match part {
221                    crate::cost::CostPart::Mana {
222                        cost,
223                        x_min,
224                        is_exiled_creature_cost,
225                        is_enchanted_creature_cost,
226                        is_cost_pay_any_number_of_times,
227                        max_waterbend,
228                    } => {
229                        if let Some((
230                            total_cost,
231                            total_x_min,
232                            total_exiled,
233                            total_enchanted,
234                            total_any_times,
235                            total_max_waterbend,
236                        )) = &mut merged_mana
237                        {
238                            *total_cost = total_cost.add(&cost);
239                            *total_x_min += x_min;
240                            *total_exiled |= is_exiled_creature_cost;
241                            *total_enchanted |= is_enchanted_creature_cost;
242                            *total_any_times |= is_cost_pay_any_number_of_times;
243                            if total_max_waterbend.is_none() {
244                                *total_max_waterbend = max_waterbend;
245                            }
246                        } else {
247                            merged_mana = Some((
248                                cost,
249                                x_min,
250                                is_exiled_creature_cost,
251                                is_enchanted_creature_cost,
252                                is_cost_pay_any_number_of_times,
253                                max_waterbend,
254                            ));
255                        }
256                    }
257                    other => merged_parts.push(other),
258                }
259            }
260        }
261        if let Some((
262            cost,
263            x_min,
264            is_exiled_creature_cost,
265            is_enchanted_creature_cost,
266            is_cost_pay_any_number_of_times,
267            max_waterbend,
268        )) = merged_mana
269        {
270            merged_parts.push(crate::cost::CostPart::Mana {
271                cost,
272                x_min,
273                is_exiled_creature_cost,
274                is_enchanted_creature_cost,
275                is_cost_pay_any_number_of_times,
276                max_waterbend,
277            });
278        }
279        let merged_cost = crate::cost::Cost {
280            parts: merged_parts,
281            has_tap: false,
282            mandatory: false,
283        };
284
285        // 4. Pay the merged cost (payCostToPreventEffect flow)
286        let paid = super::try_pay_cumulative_upkeep(ctx, sa, source_id, controller, &merged_cost);
287
288        // 5. Fire PayCumulativeUpkeep trigger
289        ctx.trigger_handler.run_trigger(
290            TriggerType::PayCumulativeUpkeep,
291            RunParams {
292                card: Some(source_id),
293                cumulative_upkeep_paid: Some(paid),
294                ..Default::default()
295            },
296            false,
297        );
298
299        // 6. If not paid, sacrifice
300        if !paid {
301            if let Some(cid) = do_sacrifice(ctx, sa, source_id, controller, None) {
302                let mut by_controller: BTreeMap<PlayerId, Vec<CardId>> = BTreeMap::new();
303                by_controller.insert(controller, vec![cid]);
304                crate::game_loop::fire_sacrificed_once_for_batch(
305                    ctx.game,
306                    ctx.trigger_handler,
307                    &by_controller,
308                );
309            }
310        }
311        return;
312    }
313
314    let sac_valid = sa
315        .ir
316        .sac_valid
317        .clone()
318        .unwrap_or_else(|| "Self".to_string());
319    // How many permanents to sacrifice (e.g. Annihilator N).
320    let amount: usize = sa
321        .ir
322        .amount
323        .as_deref()
324        .and_then(|s| s.parse().ok())
325        .unwrap_or(1);
326
327    // Detect Exploit keyword sacrifice — fires TriggerType::Exploited after each sacrifice.
328    let is_exploit = sa.ir.exploit;
329    let exploit_source = if is_exploit { sa.source } else { None };
330
331    let optional = sa.ir.optional_present;
332    let is_strict = sa.ir.strict_amount;
333    let defined = sa.defined().map(|s| s.to_lowercase()).unwrap_or_default();
334
335    let sacrificing_players = get_target_players(ctx.game, sa);
336
337    // Track per-controller batches so a single SacrificedOnce trigger fires after
338    // each player's batch (mirrors Java GameAction.sacrifice line 2133-2138).
339    let mut by_controller: BTreeMap<PlayerId, Vec<CardId>> = BTreeMap::new();
340    let mut record_sac = |player: PlayerId, sacrificed: Option<CardId>| {
341        if let Some(cid) = sacrificed {
342            by_controller.entry(player).or_default().push(cid);
343        }
344    };
345
346    for sacrificing_player in sacrificing_players {
347        if optional {
348            let source_name = sa.source.map(|cid| ctx.game.card(cid).card_name.as_str());
349            let accepted = ctx.agents[sacrificing_player.index()].confirm_action(
350                sacrificing_player,
351                None,
352                "Do you want to sacrifice?",
353                &[],
354                sa.source,
355                Some(crate::ability::api_type::ApiType::Sacrifice),
356            );
357            if !accepted {
358                continue;
359            }
360        }
361
362        // When Optional$ True, Java uses choosePermanentsToSacrifice(min=0, max=amount)
363        // which allows the player to sacrifice fewer than `amount` creatures.
364        // We match this by collecting all chosen cards at once via choose_cards_for_effect.
365        if optional
366            && !sac_valid.eq_ignore_ascii_case("Self")
367            && defined.strip_prefix("carduid_").is_none()
368        {
369            let valid: Vec<_> = ctx
370                .game
371                .cards_in_zone(ZoneType::Battlefield, sacrificing_player)
372                .to_vec()
373                .into_iter()
374                .filter(|&cid| {
375                    crate::ability::ability_utils::matches_valid_cards_for_sa(
376                        ctx.game,
377                        sa,
378                        ctx.game.card(cid),
379                        None,
380                        &sac_valid,
381                    )
382                })
383                .filter(|&cid| {
384                    !crate::staticability::static_ability_cant_sacrifice::cant_sacrifice(
385                        &ctx.game.cards,
386                        ctx.game.card(cid),
387                        Some(sa),
388                        false,
389                    )
390                })
391                .collect();
392
393            let min_targets = if is_strict { amount } else { 0 };
394            let chosen = if valid.is_empty() {
395                vec![]
396            } else {
397                ctx.agents[sacrificing_player.index()].choose_cards_for_effect(
398                    sacrificing_player,
399                    &valid,
400                    min_targets,
401                    amount,
402                )
403            };
404
405            for card_id in chosen {
406                let sacrificed = do_sacrifice(ctx, sa, card_id, sacrificing_player, exploit_source);
407                record_sac(sacrificing_player, sacrificed);
408                if sa.ir.remember_sacrificed {
409                    if let Some(source_id) = sa.source {
410                        ctx.game.card_mut(source_id).add_remembered_card(card_id);
411                    }
412                }
413            }
414            continue;
415        }
416
417        // Repeat the sacrifice `amount` times (e.g. Annihilator N).
418        for _ in 0..amount {
419            let card_to_sacrifice = if let Some(uid_str) = defined.strip_prefix("carduid_") {
420                // Specific card by ID (e.g. delayed trigger for Blitz sacrifice-at-EOT)
421                uid_str
422                    .parse::<u32>()
423                    .ok()
424                    .map(crate::ids::CardId)
425                    .filter(|&cid| ctx.game.card(cid).zone == ZoneType::Battlefield)
426            } else if sac_valid.eq_ignore_ascii_case("Self") {
427                // Sacrifice the source card itself
428                sa.source
429                    .filter(|&cid| ctx.game.card(cid).zone == ZoneType::Battlefield)
430            } else {
431                // Find valid cards controlled by the sacrificing player
432                let valid: Vec<_> = ctx
433                    .game
434                    .cards_in_zone(ZoneType::Battlefield, sacrificing_player)
435                    .to_vec()
436                    .into_iter()
437                    .filter(|&cid| {
438                        crate::ability::ability_utils::matches_valid_cards_for_sa(
439                            ctx.game,
440                            sa,
441                            ctx.game.card(cid),
442                            None,
443                            &sac_valid,
444                        )
445                    })
446                    .collect();
447
448                if valid.is_empty() {
449                    None
450                } else if sa.ir.random {
451                    // Random$ True — reservoir-sample a single element, matching
452                    // Java's `Aggregates.random(Iterable, 1)` at
453                    // `forge-core/.../Aggregates.java`. The reservoir form consumes
454                    // N-1 RNG draws for an N-element source, which is materially
455                    // different from a single `nextInt(N)` call. RNG-parity with
456                    // Java breaks unless we mirror the same draw count here.
457                    let mut picked: Option<crate::ids::CardId> = None;
458                    let mut i = 0i32;
459                    for &cid in valid.iter() {
460                        i += 1;
461                        if i == 1 {
462                            picked = Some(cid);
463                        } else {
464                            let j = ctx.rng.next_int(i);
465                            if j < 1 {
466                                picked = Some(cid);
467                            }
468                        }
469                    }
470                    picked
471                } else {
472                    ctx.agents[sacrificing_player.index()].choose_sacrifice(
473                        sacrificing_player,
474                        &valid,
475                        sa.source,
476                    )
477                }
478            };
479
480            if let Some(card_id) = card_to_sacrifice {
481                let sacrificed = do_sacrifice(ctx, sa, card_id, sacrificing_player, exploit_source);
482                record_sac(sacrificing_player, sacrificed);
483                // RememberSacrificed$ True — remember the sacrificed card on the source
484                // so downstream ConditionDefined$ Remembered checks can find it.
485                if sa.ir.remember_sacrificed {
486                    if let Some(source_id) = sa.source {
487                        ctx.game.card_mut(source_id).add_remembered_card(card_id);
488                    }
489                }
490            }
491        }
492    }
493
494    // `record_sac` is unused past this point; NLL releases its &mut on
495    // `by_controller` so the next call can take a shared reference.
496    crate::game_loop::fire_sacrificed_once_for_batch(ctx.game, ctx.trigger_handler, &by_controller);
497}