Skip to main content

manabrew_engine/ability/effects/
play_effect.rs

1use forge_foundation::ZoneType;
2
3use super::EffectContext;
4use crate::agent::{GameEntity, GameLogEvent};
5use crate::event::RunParams;
6use crate::ids::CardId;
7use crate::spellability::{SpellAbility, StackEntry};
8use crate::trigger::TriggerType;
9
10/// Struct form of this effect so it can participate in the
11/// `SpellAbilityEffect` trait hierarchy
12#[manabrew_engine_macros::spell_effect(PlayEffect)]
13fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
14    let mut candidates = resolve_target_cards(ctx, sa);
15    candidates.retain(|&cid| ctx.game.card(cid).zone != ZoneType::Stack);
16    if candidates.is_empty() {
17        return;
18    }
19
20    let controller = sa.activating_player;
21    let without_mana_cost = sa.ir.without_mana_cost;
22    let play_cost = sa.ir.play_cost_text.clone();
23    let remember = sa.ir.remember_played;
24    let optional = sa.ir.optional;
25    let is_madness = sa.ir.play_cost_text.is_some()
26        && sa
27            .source
28            .is_some_and(|src| ctx.game.card(src).has_keyword("Madness"));
29
30    // ── Step 1: Choose card
31    // For single-card + optional: isOptional=false (auto-pick), then confirmAction below.
32    let single_option = candidates.len() == 1 && optional;
33    let tgt_cards: Vec<_> = candidates.iter().copied().map(GameEntity::Card).collect();
34    let chosen = ctx.agents[controller.index()].choose_single_entity_for_effect(
35        controller,
36        &tgt_cards,
37        !single_option && optional,
38    );
39    let card_id = match chosen {
40        Some(GameEntity::Card(cid)) => cid,
41        None => return,
42        Some(GameEntity::Player(_)) => return,
43    };
44
45    // ── Step 2: Optional confirm — Java only asks the outer "Do you want
46    // to play X?" prompt in the single-option case (`PlayEffect.java:250`).
47    // For multi-option, the chooser at Step 1 already lets the player decline
48    // by returning `None`. The non-mandatory-cost confirm below is separate.
49    if single_option {
50        let card_name = ctx.game.card(card_id).card_name.clone();
51        let accepted = ctx.agents[controller.index()].confirm_action(
52            controller,
53            None,
54            &format!("Do you want to play {card_name}?"),
55            &[],
56            Some(card_id),
57            Some(crate::ability::api_type::ApiType::Play),
58        );
59        if !accepted {
60            return;
61        }
62    }
63
64    // ── Step 3: Get ability to play
65    let spell_sa_base =
66        crate::spellability::build_spell_ability_for_card_cast(ctx.game, card_id, controller);
67    let abilities = vec![spell_sa_base];
68    let sa_idx = ctx.agents[controller.index()].get_ability_to_play(controller, &abilities);
69    let mut spell_sa = match sa_idx {
70        Some(idx) => abilities.into_iter().nth(idx).unwrap(),
71        None => return,
72    };
73
74    // ── Step 3: Cost replacement ────────────────────────────────────
75    if without_mana_cost {
76        if let Some(ref mut cost) = spell_sa.pay_costs {
77            cost.parts
78                .retain(|part| !matches!(part, crate::cost::CostPart::Mana { .. }));
79        }
80    } else if let Some(ref cost_str) = play_cost {
81        let alt_mc = forge_foundation::ManaCost::parse(cost_str);
82        if let Some(ref existing) = spell_sa.pay_costs {
83            spell_sa.pay_costs = Some(existing.copy_with_defined_mana(alt_mc));
84        }
85    }
86
87    // ── Step 4: Alt-cost flags ──────────────────────────────────────
88    if is_madness {
89        spell_sa.alt_cost = Some(crate::spellability::AlternativeCost::Madness);
90    }
91
92    if let Some(ref mut cost) = spell_sa.pay_costs {
93        cost.mandatory = true;
94    }
95
96    // Remove zone restriction — allow casting from exile/library/etc.
97    spell_sa.ir.cast_from_play_effect = true;
98
99    if !spell_sa.setup_targets(ctx.game, ctx.agents, ctx.mana_pools) {
100        return;
101    }
102
103    // ── Step 6: Pay mana ────────────────────────────────────────────
104    if !without_mana_cost {
105        let mc = if let Some(ref cost_str) = play_cost {
106            forge_foundation::ManaCost::parse(cost_str)
107        } else {
108            ctx.game.card(card_id).mana_cost.clone()
109        };
110
111        let available = crate::mana::calculate_available_mana(
112            &ctx.mana_pools[controller.index()],
113            ctx.game,
114            controller,
115        );
116        if !available.can_pay(&mc) {
117            return;
118        }
119        let tapped = crate::mana::auto_tap_lands(
120            ctx.game,
121            &mut ctx.mana_pools[controller.index()],
122            controller,
123            &mc,
124            Some(card_id),
125        );
126        for &land_id in &tapped {
127            ctx.trigger_handler.run_trigger(
128                TriggerType::TapsForMana,
129                RunParams {
130                    card: Some(land_id),
131                    player: Some(controller),
132                    ..Default::default()
133                },
134                false,
135            );
136        }
137        ctx.mana_pools[controller.index()].try_pay(&mc);
138    }
139
140    // `ReplaceGraveyard$ <Zone>` — install a one-shot replacement that
141    // reroutes the played card if it would be put into the graveyard
142    // (e.g. Diviner of Mist exiles the spell on resolve).
143    if let Some(zone) = sa.ir.replace_graveyard.clone() {
144        let host_card = sa.source.unwrap_or(card_id);
145        add_replace_graveyard_effect(ctx, card_id, host_card, sa, &zone);
146    }
147
148    // ── Step 7: Push to stack ───────────────────────────────────────
149    let label = if is_madness {
150        "Madness"
151    } else if without_mana_cost {
152        "Rebound"
153    } else {
154        "Play"
155    };
156    push_spell_to_stack(ctx, card_id, spell_sa, label);
157
158    // ── Step 8: RememberPlayed ──────────────────────────────────────
159    if remember {
160        if let Some(source_id) = sa.source {
161            ctx.game.card_mut(source_id).remembered_cards.push(card_id);
162        }
163    }
164}
165
166// ── Helpers ───────────────────────────────────────────────────────────
167
168/// Resolve the target cards from `Valid$` or `Defined$` parameters.
169fn resolve_target_cards(ctx: &EffectContext, sa: &SpellAbility) -> Vec<CardId> {
170    if let Some(valid) = crate::parsing::raw_get(&sa.ability_text, crate::parsing::keys::VALID) {
171        let zones = crate::parsing::raw_get(&sa.ability_text, crate::parsing::keys::VALID_ZONE)
172            .map(|raw| {
173                raw.split(',')
174                    .filter_map(|part| crate::zone::zone_type::smart_value_of(part.trim()))
175                    .collect::<Vec<_>>()
176            })
177            .filter(|zones| !zones.is_empty())
178            .unwrap_or_else(|| vec![ZoneType::Hand]);
179        let Some(source_id) = sa.source else {
180            return Vec::new();
181        };
182        let source = ctx.game.card(source_id);
183        let selector = crate::parsing::cached_compiled_selector(valid);
184        let valid_sa = crate::parsing::raw_get(&sa.ability_text, crate::parsing::keys::VALID_SA);
185        return ctx
186            .game
187            .cards
188            .iter()
189            .filter(|card| zones.contains(&card.zone))
190            .filter(|card| {
191                crate::card::valid_filter::matches_valid_card_selector_in_game(
192                    &selector, card, source, ctx.game,
193                )
194            })
195            .filter(|card| {
196                if valid_sa.is_some_and(|v| v.eq_ignore_ascii_case("Spell")) {
197                    !card.is_land()
198                } else {
199                    true
200                }
201            })
202            .map(|card| card.id)
203            .collect();
204    }
205
206    let defined = sa.ir.defined_text.clone().unwrap_or_default();
207    if let Some(uid_str) = defined.strip_prefix("CardUID_") {
208        uid_str
209            .parse::<u32>()
210            .ok()
211            .map(CardId)
212            .into_iter()
213            .collect()
214    } else {
215        let defined = if defined.is_empty() {
216            "Self"
217        } else {
218            defined.as_str()
219        };
220        crate::ability::ability_utils::get_defined_cards(
221            ctx.game,
222            sa.source,
223            defined,
224            Some(sa.activating_player),
225        )
226    }
227}
228
229/// Push the spell onto the stack and fire SpellCast trigger.
230fn push_spell_to_stack(
231    ctx: &mut EffectContext,
232    card_id: CardId,
233    spell_sa: SpellAbility,
234    label: &str,
235) {
236    let controller = spell_sa.activating_player;
237    let is_creature = ctx.game.card(card_id).is_creature();
238    let is_permanent = ctx.game.card(card_id).is_permanent();
239    let cast_zone = Some(ctx.game.card(card_id).zone);
240    let card_name = ctx.game.card(card_id).card_name.clone();
241    let chosen_target = spell_sa.target_chosen.target_card;
242
243    let entry = StackEntry {
244        id: 0,
245        spell_ability: spell_sa,
246        is_pending_cast: false,
247        is_creature_spell: is_creature,
248        is_permanent_spell: is_permanent,
249        cast_from_zone: cast_zone,
250        optional_trigger_decider: None,
251        optional_trigger_description: None,
252        optional_trigger_source_name: None,
253    };
254    let trigger_sa = entry.spell_ability.clone();
255
256    ctx.game.stack.push(entry);
257    ctx.move_card(card_id, ZoneType::Stack, controller);
258    ctx.game.player_record_spell_cast(controller, card_id);
259
260    ctx.trigger_handler.run_trigger(
261        TriggerType::SpellCast,
262        RunParams {
263            spell_card: Some(card_id),
264            spell_controller: Some(controller),
265            source_sa: Some(trigger_sa.clone()),
266            ..Default::default()
267        },
268        false,
269    );
270    super::emit_targeting_triggers(ctx, card_id, &trigger_sa);
271
272    let mut event = GameLogEvent::stack(format!("{label}: cast {card_name}"))
273        .with_player(controller)
274        .with_source_card(card_id);
275    if let Some(target_id) = chosen_target {
276        event = event.with_target_card(target_id);
277    }
278    crate::agent::notify_all_agents(ctx.agents, event);
279}
280
281/// Create a one-shot replacement effect on a Command-zone effect card that
282/// reroutes `card_id` to `dest_zone` if it would be put into the graveyard
283/// from the stack (e.g. Diviner of Mist's `ReplaceGraveyard$ Exile`).
284pub fn add_replace_graveyard_effect(
285    ctx: &mut EffectContext,
286    card_id: CardId,
287    _host_card: CardId,
288    sa: &SpellAbility,
289    zone: &str,
290) {
291    let controller = sa.activating_player;
292    let dest_zone = if zone.is_empty() { "Exile" } else { zone };
293
294    let mut effect = crate::player::player_factory_util::new_player_effect_card(
295        controller,
296        "ReplaceGraveyard Effect",
297        None,
298    );
299    effect.remembered_cards.push(card_id);
300    let raw = format!(
301        "R$ Event$ Moved | ValidCard$ Card.IsRemembered | Origin$ Stack | Destination$ Graveyard | NewDestination$ {dest_zone} | ForgetOnMoved$ Origin | Description$ If that card would be put into your graveyard this turn, put it into {dest_zone} instead."
302    );
303    crate::player::player_factory_util::add_replacement_effect(&mut effect, &raw);
304    effect.exile_when_no_remembered = true;
305    effect.forget_on_moved_origin = Some(ZoneType::Stack);
306
307    let effect_id = ctx.game.create_card(effect);
308    ctx.game.move_card(effect_id, ZoneType::Command, controller);
309}