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
197                    .as_deref()
198                    .is_some_and(|v| v.eq_ignore_ascii_case("Spell"))
199                {
200                    !card.is_land()
201                } else {
202                    true
203                }
204            })
205            .map(|card| card.id)
206            .collect();
207    }
208
209    let defined = sa.ir.defined_text.clone().unwrap_or_default();
210    if let Some(uid_str) = defined.strip_prefix("CardUID_") {
211        uid_str
212            .parse::<u32>()
213            .ok()
214            .map(CardId)
215            .into_iter()
216            .collect()
217    } else {
218        let defined = if defined.is_empty() {
219            "Self"
220        } else {
221            defined.as_str()
222        };
223        crate::ability::ability_utils::get_defined_cards(
224            ctx.game,
225            sa.source,
226            defined,
227            Some(sa.activating_player),
228        )
229    }
230}
231
232/// Push the spell onto the stack and fire SpellCast trigger.
233fn push_spell_to_stack(
234    ctx: &mut EffectContext,
235    card_id: CardId,
236    spell_sa: SpellAbility,
237    label: &str,
238) {
239    let controller = spell_sa.activating_player;
240    let is_creature = ctx.game.card(card_id).is_creature();
241    let is_permanent = ctx.game.card(card_id).is_permanent();
242    let cast_zone = Some(ctx.game.card(card_id).zone);
243    let card_name = ctx.game.card(card_id).card_name.clone();
244    let chosen_target = spell_sa.target_chosen.target_card;
245
246    let entry = StackEntry {
247        id: 0,
248        spell_ability: spell_sa,
249        is_pending_cast: false,
250        is_creature_spell: is_creature,
251        is_permanent_spell: is_permanent,
252        cast_from_zone: cast_zone,
253        optional_trigger_decider: None,
254        optional_trigger_description: None,
255        optional_trigger_source_name: None,
256    };
257    let trigger_sa = entry.spell_ability.clone();
258
259    ctx.game.stack.push(entry);
260    ctx.move_card(card_id, ZoneType::Stack, controller);
261    ctx.game.player_record_spell_cast(controller, card_id);
262
263    ctx.trigger_handler.run_trigger(
264        TriggerType::SpellCast,
265        RunParams {
266            spell_card: Some(card_id),
267            spell_controller: Some(controller),
268            source_sa: Some(trigger_sa.clone()),
269            ..Default::default()
270        },
271        false,
272    );
273    super::emit_targeting_triggers(ctx, card_id, &trigger_sa);
274
275    let mut event = GameLogEvent::stack(format!("{}: cast {}", label, card_name))
276        .with_player(controller)
277        .with_source_card(card_id);
278    if let Some(target_id) = chosen_target {
279        event = event.with_target_card(target_id);
280    }
281    crate::agent::notify_all_agents(ctx.agents, event);
282}
283
284/// Create a one-shot replacement effect on a Command-zone effect card that
285/// reroutes `card_id` to `dest_zone` if it would be put into the graveyard
286/// from the stack (e.g. Diviner of Mist's `ReplaceGraveyard$ Exile`).
287pub fn add_replace_graveyard_effect(
288    ctx: &mut EffectContext,
289    card_id: CardId,
290    _host_card: CardId,
291    sa: &SpellAbility,
292    zone: &str,
293) {
294    let controller = sa.activating_player;
295    let dest_zone = if zone.is_empty() { "Exile" } else { zone };
296
297    let mut effect = crate::player::player_factory_util::new_player_effect_card(
298        controller,
299        "ReplaceGraveyard Effect",
300        None,
301    );
302    effect.remembered_cards.push(card_id);
303    let raw = format!(
304        "R$ Event$ Moved | ValidCard$ Card.IsRemembered | Origin$ Stack | Destination$ Graveyard | NewDestination$ {} | ForgetOnMoved$ Origin | Description$ If that card would be put into your graveyard this turn, put it into {} instead.",
305        dest_zone, dest_zone
306    );
307    crate::player::player_factory_util::add_replacement_effect(&mut effect, &raw);
308    effect.exile_when_no_remembered = true;
309    effect.forget_on_moved_origin = Some(ZoneType::Stack);
310
311    let effect_id = ctx.game.create_card(effect);
312    ctx.game.move_card(effect_id, ZoneType::Command, controller);
313}