Skip to main content

manabrew_engine/ability/effects/
animate_effect.rs

1use forge_foundation::{ColorSet, ZoneType};
2
3use super::trait_animate_effect::parse_animate_params;
4use super::EffectContext;
5use crate::ability::ability_ir::DefinedRef;
6use crate::card::card_trait_changes::CardTraitChanges;
7use crate::card::perpetual::perpetual_interface::PerpetualInterface;
8use crate::card::perpetual::{
9    perpetual_abilities, perpetual_colors, perpetual_incorporate, perpetual_keywords,
10    perpetual_mana_cost, perpetual_new_pt, perpetual_types,
11};
12use crate::card::AnimateState;
13use crate::spellability::SpellAbility;
14use crate::trigger::parse_trigger;
15use forge_foundation::ManaCost;
16
17/// `SP$ Animate` — turn a non-creature permanent into a creature (or modify creature stats).
18///
19/// Mirrors Java's `AnimateEffectBase.java` + `AnimateEffect.java`.
20///
21/// # Params
22/// - `Defined$` — target card(s) (default: source card itself)
23/// - `Power` — set base power
24/// - `Toughness` — set base toughness
25/// - `Types` — comma-separated types to add (e.g. "Creature,Land")
26/// - `Keywords` — comma-separated keywords to grant (until EOT)
27/// - `Colors` — comma-separated colors to set (e.g. "White,Blue")
28/// - `OverwriteTypes` — if "True", replace type_line instead of adding
29///
30/// The animate_state is saved so step_cleanup can restore the original card state.
31/// Struct form of this effect so it can participate in the
32/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
33/// `AnimateEffect` class extending `SpellAbilityEffect`.
34#[manabrew_engine_macros::spell_effect(AnimateEffect)]
35fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
36    let controller = sa.activating_player;
37
38    // Determine target card
39    let target_ids = resolve_animate_targets(ctx, sa, controller);
40
41    // Use shared base-class parsing for common animate params
42    let anim_params = parse_animate_params(sa);
43    let power_str = anim_params.power.map(|p| p.to_string());
44    let toughness_str = anim_params.toughness.map(|t| t.to_string());
45    let types_str = if anim_params.add_types.is_empty() {
46        None
47    } else {
48        Some(anim_params.add_types.join(","))
49    };
50    let keywords_str = if anim_params.add_keywords.is_empty() {
51        None
52    } else {
53        Some(anim_params.add_keywords.join(","))
54    };
55    let remove_keywords_str = sa.ir.animate_remove_keywords_text.clone();
56    let colors_str = anim_params.colors.map(|c| c.join(","));
57    let triggers_str = sa.ir.animate_triggers_text.clone();
58    let overwrite_types = anim_params.overwrite_types;
59    let incorporate_cost = sa.ir.animate_incorporate_text.clone();
60    let mana_cost_override = sa.ir.animate_mana_cost_override_text.clone();
61    let is_perpetual = matches!(
62        sa.ir.duration,
63        Some(crate::spellability::AbilityDuration::Perpetual)
64    );
65    let is_permanent_duration = matches!(
66        sa.ir.duration,
67        Some(crate::spellability::AbilityDuration::Permanent)
68    );
69    let resolve_ts = ctx.game.next_effect_timestamp();
70
71    // Resolve Triggers$ SVars from the source card into parsed Trigger objects.
72    // These will be temporarily added to each target card.
73    let mut parsed_triggers: Vec<crate::trigger::Trigger> = Vec::new();
74    if let Some(ref trigs) = triggers_str {
75        // The source card holds the SVars that define the triggers.
76        let source_id = sa.source.unwrap_or(crate::ids::CardId(0));
77        let source_svars = ctx.game.card(source_id).svars.clone();
78        let mut next_trig_id = 1000u32; // high base to avoid collisions
79        for trig_name in trigs.split(',') {
80            let trig_name = trig_name.trim();
81            if trig_name.is_empty() {
82                continue;
83            }
84            if let Some(svar_text) = source_svars.get(trig_name) {
85                if let Some(mut trig) = parse_trigger(svar_text, &mut next_trig_id) {
86                    trig.execute = trig.execute.clone();
87                    parsed_triggers.push(trig);
88                }
89            }
90        }
91    }
92
93    // Snapshot target IDs for AtEOT$ delayed trigger registration after the loop.
94    let eot_targets = target_ids.clone();
95
96    for card_id in target_ids {
97        // Only animate cards on the battlefield
98        if ctx.game.card(card_id).zone != ZoneType::Battlefield {
99            continue;
100        }
101
102        let effect_ts = if is_perpetual { Some(resolve_ts) } else { None };
103
104        if let Some(ref mc) = incorporate_cost {
105            let ts = resolve_ts;
106            perpetual_incorporate::PerpetualIncorporate {
107                timestamp: ts,
108                incorporate: ManaCost::parse(mc),
109            }
110            .apply_effect(ctx.game.card_mut(card_id));
111        }
112        if let Some(ref mc) = mana_cost_override {
113            if let Some(ts) = effect_ts {
114                perpetual_mana_cost::PerpetualManaCost {
115                    timestamp: ts,
116                    mana_cost: ManaCost::parse(mc),
117                }
118                .apply_effect(ctx.game.card_mut(card_id));
119            }
120        }
121
122        // Save original state (only if not already animated this turn)
123        if ctx.game.card(card_id).animate_state.is_none() {
124            let original_type_line = ctx.game.card(card_id).type_line.clone();
125            let original_base_power = ctx.game.card(card_id).base_power;
126            let original_base_toughness = ctx.game.card(card_id).base_toughness;
127            let original_color = ctx.game.card(card_id).color;
128            let original_keywords = ctx.game.card(card_id).keywords.clone();
129            ctx.game
130                .card_mut(card_id)
131                .set_animate_state(Some(AnimateState {
132                    original_type_line,
133                    original_base_power,
134                    original_base_toughness,
135                    original_color,
136                    original_keywords: Some(original_keywords),
137                }));
138        }
139
140        // Apply type changes
141        if let Some(ref types) = types_str {
142            if overwrite_types {
143                ctx.game
144                    .card_mut(card_id)
145                    .set_type_line(forge_foundation::CardTypeLine::new());
146            }
147            for t in types.split(',') {
148                let t = t.trim();
149                if !t.is_empty() {
150                    if let Some(ts) = effect_ts {
151                        perpetual_types::PerpetualTypes {
152                            timestamp: ts,
153                            add_types: vec![t.to_string()],
154                        }
155                        .apply_effect(ctx.game.card_mut(card_id));
156                    } else {
157                        ctx.game.card_mut(card_id).add_type(t);
158                    }
159                }
160            }
161        }
162
163        if sa
164            .ir
165            .spell_description_text
166            .as_deref()
167            .is_some_and(|desc| desc.to_ascii_lowercase().starts_with("crew"))
168        {
169            ctx.game.card_mut(card_id).becomes_crewed();
170        }
171
172        // Apply P/T
173        let parsed_power = power_str
174            .as_deref()
175            .and_then(|p| p.trim().parse::<i32>().ok());
176        let parsed_toughness = toughness_str
177            .as_deref()
178            .and_then(|t| t.trim().parse::<i32>().ok());
179        if let Some(ts) = effect_ts {
180            if parsed_power.is_some() || parsed_toughness.is_some() {
181                perpetual_new_pt::PerpetualNewPt {
182                    timestamp: ts,
183                    power: parsed_power,
184                    toughness: parsed_toughness,
185                }
186                .apply_effect(ctx.game.card_mut(card_id));
187            }
188        } else {
189            if let Some(val) = parsed_power {
190                ctx.game.card_mut(card_id).set_base_power(Some(val));
191            }
192            if let Some(val) = parsed_toughness {
193                ctx.game.card_mut(card_id).set_base_toughness(Some(val));
194            }
195        }
196
197        // Apply keyword changes. Permanent-duration animate effects need to mutate
198        // the card's live keyword set (e.g. Animate Dead changing its Enchant text),
199        // not just temporary pump keywords.
200        let add_keywords: Vec<String> = keywords_str
201            .as_deref()
202            .map(|kws| {
203                kws.split(',')
204                    .map(str::trim)
205                    .filter(|kw| !kw.is_empty())
206                    .map(str::to_string)
207                    .collect()
208            })
209            .unwrap_or_default();
210        let remove_keywords: Vec<String> = remove_keywords_str
211            .as_deref()
212            .map(|kws| {
213                kws.split(',')
214                    .map(str::trim)
215                    .filter(|kw| !kw.is_empty())
216                    .map(str::to_string)
217                    .collect()
218            })
219            .unwrap_or_default();
220        if !add_keywords.is_empty() || !remove_keywords.is_empty() {
221            if let Some(ts) = effect_ts {
222                perpetual_keywords::PerpetualKeywords {
223                    timestamp: ts,
224                    add_keywords,
225                    remove_keywords,
226                    remove_all: false,
227                }
228                .apply_effect(ctx.game.card_mut(card_id));
229            } else if is_permanent_duration {
230                let card = ctx.game.card_mut(card_id);
231                for kw in &remove_keywords {
232                    card.remove_changed_card_keywords(kw);
233                    card.remove_hidden_extrinsic_keywords(kw);
234                    card.pump_keywords.remove(kw);
235                }
236                for kw in &add_keywords {
237                    card.add_changed_card_keywords(kw);
238                }
239            } else {
240                let card = ctx.game.card_mut(card_id);
241                for kw in &remove_keywords {
242                    card.pump_keywords.remove(kw);
243                }
244                for kw in &add_keywords {
245                    card.add_pump_keyword(kw);
246                }
247            }
248        }
249
250        // Apply triggers (e.g. Supernatural Stamina's death → return trigger)
251        // Triggers are added from the SOURCE card's SVars, not the target's.
252        // Also copy the SVars needed by the trigger's Execute$ reference.
253        if !parsed_triggers.is_empty() {
254            let source_id = sa.source.unwrap_or(crate::ids::CardId(0));
255            let source_svars = ctx.game.card(source_id).svars.clone();
256            if let Some(ts) = effect_ts {
257                let changes = CardTraitChanges {
258                    triggers: parsed_triggers.clone(),
259                    ..Default::default()
260                };
261                perpetual_abilities::PerpetualAbilities {
262                    timestamp: ts,
263                    changes,
264                }
265                .apply_effect(ctx.game.card_mut(card_id));
266                for trig in &parsed_triggers {
267                    if !trig.execute.is_empty() {
268                        if let Some(exec_svar) = source_svars.get(&trig.execute) {
269                            ctx.game
270                                .card_mut(card_id)
271                                .set_s_var_if_absent(trig.execute.clone(), exec_svar.clone());
272                        }
273                    }
274                }
275            } else {
276                for trig in &parsed_triggers {
277                    ctx.game.card_mut(card_id).add_trigger(trig.clone());
278                    ctx.game.card_mut(card_id).increment_pump_trigger_count();
279                    // Copy the Execute SVar from source to target so trigger resolution
280                    // can find it (e.g. SupernaturalStaminaTrigChangeZone)
281                    if !trig.execute.is_empty() {
282                        if let Some(exec_svar) = source_svars.get(&trig.execute) {
283                            ctx.game
284                                .card_mut(card_id)
285                                .set_s_var_if_absent(trig.execute.clone(), exec_svar.clone());
286                        }
287                    }
288                }
289            }
290            // Re-register this card's triggers so the new ones are active
291            ctx.trigger_handler
292                .register_active_trigger(ctx.game, card_id);
293        }
294
295        // Apply color
296        if let Some(ref colors) = colors_str {
297            let mut new_color = ColorSet::COLORLESS;
298            for c in colors.split(',') {
299                let c = c.trim().to_lowercase();
300                match c.as_str() {
301                    "white" | "w" => new_color = new_color.union(ColorSet::WHITE),
302                    "blue" | "u" => new_color = new_color.union(ColorSet::BLUE),
303                    "black" | "b" => new_color = new_color.union(ColorSet::BLACK),
304                    "red" | "r" => new_color = new_color.union(ColorSet::RED),
305                    "green" | "g" => new_color = new_color.union(ColorSet::GREEN),
306                    _ => {}
307                }
308            }
309            if let Some(ts) = effect_ts {
310                perpetual_colors::PerpetualColors {
311                    timestamp: ts,
312                    colors: new_color,
313                    overwrite: true,
314                }
315                .apply_effect(ctx.game.card_mut(card_id));
316            } else {
317                ctx.game.card_mut(card_id).set_color(new_color);
318            }
319        }
320    }
321
322    // `AtEOT$ <action>` — register an end-of-turn delayed trigger on the
323    // animated cards still on the battlefield.
324    if let Some(action) = sa.ir.at_eot.as_deref() {
325        let remembered: Vec<crate::ids::CardId> = eot_targets
326            .iter()
327            .copied()
328            .filter(|&cid| ctx.game.card(cid).zone == ZoneType::Battlefield)
329            .collect();
330        crate::ability::spell_ability_effect::register_at_eot(
331            ctx.trigger_handler,
332            ctx.game,
333            sa,
334            action,
335            remembered,
336        );
337    }
338}
339
340/// Resolve which card(s) to animate.
341fn resolve_animate_targets(
342    ctx: &mut EffectContext,
343    sa: &SpellAbility,
344    _controller: crate::ids::PlayerId,
345) -> Vec<crate::ids::CardId> {
346    // Check for explicit target
347    if let Some(target) = sa.target_chosen.target_card {
348        return vec![target];
349    }
350
351    // Check Defined$ param
352    if let Some(defined) = sa.defined_ref() {
353        match defined {
354            DefinedRef::SelfCard => {
355                if let Some(src) = sa.source {
356                    return vec![src];
357                }
358            }
359            DefinedRef::ParentTarget => {
360                if let Some(pt) = ctx.parent_target_card {
361                    return vec![pt];
362                }
363            }
364            DefinedRef::Remembered => {
365                if let Some(src) = sa.source {
366                    return ctx.game.card(src).remembered_cards.clone();
367                }
368            }
369            _ => {}
370        }
371    }
372
373    // Default: animate source card itself
374    if let Some(src) = sa.source {
375        vec![src]
376    } else {
377        vec![]
378    }
379}