Skip to main content

manabrew_engine/ability/effects/
effect_effect.rs

1//! EffectEffect — mirrors Java `forge.game.ability.effects.EffectEffect`.
2//!
3//! Creates a temporary "effect" card in the Command zone that carries granted
4//! static abilities, triggers, replacement effects, and/or spell abilities for
5//! the duration specified by `Duration$` (defaults to end-of-turn).
6//!
7//! Parity caveats (documented, not yet ported):
8//! - `Boon$`, `Image$`, `SetChosenDirection` — Card lacks the setters.
9//! - `NoteCounterDefined` — no `CountersNoteEffect::noteCounters` equivalent.
10//! - `RememberLKI` — snapshots via `CardCopyService::get_lki_copy` into the
11//!   effect card's `remembered_lki_cards` list (in addition to the live IDs
12//!   in `remembered_cards`).
13
14use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
15
16use crate::card::Card;
17use crate::ids::{CardId, PlayerId};
18use crate::replacement::replacement_effect::parse_replacement_effect;
19use crate::spellability::{build_spell_ability_from_host_card, SpellAbility};
20use crate::staticability::parse_static_ability;
21use crate::trigger::trigger::parse_trigger;
22
23use super::{resolve_defined_player, resolve_defined_players, EffectContext};
24use crate::ability::ability_utils;
25use crate::ability::spell_ability_effect::{check_valid_duration, SpellAbilityEffect};
26use crate::spellability::AbilityDuration;
27
28/// Stateless marker type — mirrors Java's `class EffectEffect extends SpellAbilityEffect`.
29pub struct EffectEffect;
30
31impl SpellAbilityEffect for EffectEffect {
32    fn resolve(ctx: &mut EffectContext, sa: &SpellAbility) {
33        resolve_impl(ctx, sa);
34    }
35}
36
37fn resolve_impl(ctx: &mut EffectContext, sa: &SpellAbility) {
38    let Some(source_id) = sa.source else {
39        return;
40    };
41
42    let duration = sa.ir.duration.as_ref();
43    if !check_valid_duration(ctx.game, sa, duration) {
44        return;
45    }
46
47    let (host_name, host_remembered_cards, host_remembered_players) = {
48        let host = ctx.game.card(source_id);
49        (
50            host.card_name.clone(),
51            host.remembered_cards.clone(),
52            host.remembered_players.clone(),
53        )
54    };
55
56    let abilities = split_csv_value(sa.ir.abilities.as_deref());
57    let triggers = split_csv_value(sa.ir.triggers.as_deref());
58    let static_refs = split_csv_value(sa.ir.static_abilities.as_deref());
59    let replacement_refs = split_csv_value(sa.ir.replacement_effects.as_deref());
60
61    let effect_name = resolve_effect_name(sa, &host_name);
62    let effect_owner_defined = sa.ir.effect_owner_text.as_deref().unwrap_or("You");
63    let mut owners = resolve_defined_players(effect_owner_defined, sa.activating_player, ctx.game);
64
65    // `Unique$` — drop owners that already have an effect with this name in command.
66    if sa.ir.unique {
67        owners.retain(|pid| !player_has_effect_named(ctx, *pid, &effect_name));
68    }
69    if owners.is_empty() {
70        return;
71    }
72
73    // Resolve remember-targets upfront (Java's `RememberObjects`/`RememberSpell`/`RememberLKI`).
74    let mut remember_cards: Vec<CardId> = Vec::new();
75    let mut remember_players: Vec<PlayerId> = Vec::new();
76    let mut remember_lki_cards: Vec<Card> = Vec::new();
77    populate_remember_lists(
78        ctx,
79        sa,
80        &host_remembered_cards,
81        &host_remembered_players,
82        &mut remember_cards,
83        &mut remember_players,
84        &mut remember_lki_cards,
85    );
86
87    // Early exit — Java skips effect creation when forget/exile triggers would fire
88    // without any remembered objects to track.
89    let needs_nonempty_remember = sa.ir.forget_on_moved_text.is_some()
90        || sa.ir.exile_on_moved
91        || sa.ir.forget_on_phased_in
92        || sa.ir.forget_counter;
93    if needs_nonempty_remember
94        && remember_cards.is_empty()
95        && remember_players.is_empty()
96        && remember_lki_cards.is_empty()
97        && (sa.ir.remember_objects.is_some()
98            || sa.ir.remember_spell.is_some()
99            || sa.ir.remember_lki.is_some())
100    {
101        return;
102    }
103
104    // Pre-parse grant lists — ID collisions between players are fine because
105    // `add_trigger`/`add_replacement_effect` handle deduplication per-card.
106    let parsed_static_abilities = static_refs
107        .iter()
108        .filter_map(|svar_name| ctx.game.card(source_id).get_s_var(svar_name))
109        .filter_map(|raw| {
110            let mut static_ability = parse_static_ability(&format!("S$ {raw}"))?;
111            static_ability.ir.active_zones = vec![ZoneType::Command];
112            static_ability.ir.has_zone_keys = true;
113            static_ability.base.set_intrinsic(true);
114            Some(static_ability)
115        })
116        .collect::<Vec<_>>();
117
118    // `Triggers$` / `ReplacementEffects$` / `Abilities$` grants need the raw SVar
119    // text — gather it now so each owner's effect card gets a fresh parse.
120    let trigger_svars = collect_svar_texts(ctx, source_id, &triggers);
121    let replacement_svars = collect_svar_texts(ctx, source_id, &replacement_refs);
122    let ability_svars = collect_svar_texts(ctx, source_id, &abilities);
123
124    // Imprint snapshot — `ImprintCards$` defines cards to imprint on the effect.
125    let imprint_cards: Vec<CardId> = sa
126        .ir
127        .imprint_cards
128        .as_deref()
129        .map(|defined| {
130            ability_utils::get_defined_cards(
131                ctx.game,
132                sa.source,
133                defined,
134                Some(sa.activating_player),
135            )
136        })
137        .unwrap_or_default();
138
139    // Chosen state snapshot from host (Java copies these onto the effect card).
140    let chosen_snapshot = ChosenSnapshot::capture(ctx.game.card(source_id));
141
142    let chosen_number_override = sa
143        .ir
144        .set_chosen_number
145        .as_deref()
146        .map(ability_utils::calculate_amount);
147
148    for owner in owners {
149        let mut effect = Card::new(
150            CardId(0),
151            effect_name.clone(),
152            owner,
153            CardTypeLine::parse("Effect"),
154            ManaCost::parse("0"),
155            ColorSet::COLORLESS,
156            None,
157            None,
158            vec![],
159            vec![],
160        );
161        effect.set_controller(owner);
162        effect.set_effect_source(Some(source_id));
163        effect.set_static_abilities(parsed_static_abilities.clone());
164
165        // Triggers/replacements need fresh parses per card so host binding and
166        // IDs stay independent. Active zone is forced to Command (Java parity).
167        for svar_text in &trigger_svars {
168            let mut next_id = 1000u32;
169            if let Some(mut trig) = parse_trigger(svar_text, &mut next_id) {
170                trig.set_active_zone(vec![ZoneType::Command]);
171                trig.set_intrinsic(true);
172                effect.add_trigger(trig);
173            }
174        }
175        for svar_text in &replacement_svars {
176            if let Some(mut re) = parse_replacement_effect(svar_text) {
177                re.base.set_active_zone(vec![ZoneType::Command]);
178                re.base.card_trait_base.set_intrinsic(true);
179                effect.add_replacement_effect(re);
180            }
181        }
182        for ability_text in &ability_svars {
183            let sa_granted = build_spell_ability_from_host_card(&effect, ability_text, owner);
184            effect.add_spell_ability(&sa_granted);
185        }
186
187        // Copy SVars from host so triggers/replacements can resolve Execute$/etc.
188        let host_svars = ctx.game.card(source_id).svars.clone();
189        for (k, v) in host_svars {
190            effect.set_s_var_if_absent(k, v);
191        }
192
193        apply_duration_flags(&mut effect, duration, source_id);
194        apply_forget_on_moved_flags(&mut effect, sa);
195        effect.add_remembered_cards(remember_cards.iter().copied());
196        effect.add_remembered_players(remember_players.iter().copied());
197        effect.remembered_lki_cards = remember_lki_cards.clone();
198        for imp in &imprint_cards {
199            effect.add_imprinted_card(*imp);
200        }
201        apply_chosen_state(&mut effect, &chosen_snapshot, chosen_number_override);
202        if sa.is_intrinsic() {
203            let host_clone = ctx.game.card(source_id).clone();
204            effect.copy_changed_text_from(&host_clone);
205        }
206
207        let effect_id = ctx.game.create_card(effect);
208        ctx.move_card(effect_id, ZoneType::Command, owner);
209    }
210
211    // `AtEOT$ <action>` — register an end-of-turn delayed trigger targeting the
212    // host card (the "effect source"), not the created effect-card. Mirrors
213    // Java EffectEffect → registerDelayedTrigger with the host card as target.
214    if let Some(action) = sa.ir.at_eot.as_deref() {
215        crate::ability::spell_ability_effect::register_at_eot(
216            ctx.trigger_handler,
217            ctx.game,
218            sa,
219            action,
220            vec![source_id],
221        );
222    }
223}
224
225fn split_csv_value(value: Option<&str>) -> Vec<String> {
226    value
227        .map(|v| {
228            v.split(',')
229                .map(str::trim)
230                .filter(|s| !s.is_empty())
231                .map(str::to_string)
232                .collect()
233        })
234        .unwrap_or_default()
235}
236
237fn resolve_effect_name(sa: &SpellAbility, host_name: &str) -> String {
238    if let Some(name) = sa.ir.name_text.as_deref() {
239        return name.to_string();
240    }
241    let suffix = if sa.ir.boon { "'s Boon" } else { "'s Effect" };
242    format!("{host_name}{suffix}")
243}
244
245fn player_has_effect_named(ctx: &EffectContext, player: PlayerId, name: &str) -> bool {
246    ctx.game
247        .cards_in_zone(ZoneType::Command, player)
248        .iter()
249        .any(|cid| ctx.game.card(*cid).card_name == name)
250}
251
252fn collect_svar_texts(ctx: &EffectContext, source_id: CardId, names: &[String]) -> Vec<String> {
253    let host = ctx.game.card(source_id);
254    names
255        .iter()
256        .filter_map(|n| host.get_s_var(n).map(str::to_string))
257        .collect()
258}
259
260fn populate_remember_lists(
261    ctx: &EffectContext,
262    sa: &SpellAbility,
263    host_remembered_cards: &[CardId],
264    host_remembered_players: &[PlayerId],
265    out_cards: &mut Vec<CardId>,
266    out_players: &mut Vec<PlayerId>,
267    out_lki_cards: &mut Vec<Card>,
268) {
269    if let Some(remember) = sa.ir.remember_objects.as_deref() {
270        for token in remember.split(',').map(str::trim).filter(|s| !s.is_empty()) {
271            match token {
272                "Remembered" => {
273                    out_cards.extend(host_remembered_cards.iter().copied());
274                    out_players.extend(host_remembered_players.iter().copied());
275                }
276                "RememberedCard" => out_cards.extend(host_remembered_cards.iter().copied()),
277                "RememberedPlayer" => out_players.extend(host_remembered_players.iter().copied()),
278                "RememberedLKI" => {
279                    for &cid in host_remembered_cards {
280                        out_lki_cards.push(crate::card::card_copy_service::get_lki_copy(
281                            ctx.game.card(cid),
282                        ));
283                    }
284                }
285                "Targeted" => {
286                    if let Some(cid) = sa.target_chosen.target_card.or(ctx.parent_target_card) {
287                        out_cards.push(cid);
288                    }
289                }
290                "TargetedPlayer" => {
291                    if let Some(pid) = sa.target_chosen.target_player {
292                        out_players.push(pid);
293                    }
294                }
295                other => {
296                    // Try player resolution first, then fall through to defined-cards.
297                    if let Some(pid) = resolve_defined_player(other, sa.activating_player, ctx.game)
298                    {
299                        out_players.push(pid);
300                    } else {
301                        let defined_cards = ability_utils::get_defined_cards(
302                            ctx.game,
303                            sa.source,
304                            other,
305                            Some(sa.activating_player),
306                        );
307                        out_cards.extend(defined_cards);
308                    }
309                }
310            }
311        }
312    }
313
314    // `RememberSpell$` — Java resolves to spell abilities; Rust snapshots the
315    // source cards of those stack entries (closest approximation given we don't
316    // serialize `SpellAbility` onto effect cards).
317    if let Some(defined) = sa.ir.remember_spell.as_deref() {
318        for token in defined.split(',').map(str::trim).filter(|s| !s.is_empty()) {
319            let spells = ability_utils::get_defined_spell_abilities(token, sa, ctx.game);
320            for spell in spells {
321                if let Some(src) = spell.source {
322                    out_cards.push(src);
323                }
324            }
325        }
326    }
327
328    // `RememberLKI$` — snapshot the defined cards via CardCopyService so the
329    // effect card sees them as they were at resolution time, even after those
330    // cards leave their zone or have their stats modified.
331    if let Some(defined) = sa.ir.remember_lki.as_deref() {
332        for token in defined.split(',').map(str::trim).filter(|s| !s.is_empty()) {
333            let cards = ability_utils::get_defined_cards(
334                ctx.game,
335                sa.source,
336                token,
337                Some(sa.activating_player),
338            );
339            for cid in cards {
340                out_lki_cards.push(crate::card::card_copy_service::get_lki_copy(
341                    ctx.game.card(cid),
342                ));
343            }
344        }
345    }
346}
347
348struct ChosenSnapshot {
349    colors: Vec<String>,
350    cards: Vec<CardId>,
351    player: Option<PlayerId>,
352    player_controller: Option<PlayerId>,
353    player_revealed: bool,
354    type_: Option<String>,
355    type_controller: Option<PlayerId>,
356    type_revealed: bool,
357    type2: Option<String>,
358    named_cards: Vec<String>,
359    number: Option<i32>,
360}
361
362impl ChosenSnapshot {
363    fn capture(host: &Card) -> Self {
364        Self {
365            colors: host.chosen_colors.clone(),
366            cards: host.chosen_cards.clone(),
367            player: host.chosen_player,
368            player_controller: host.chosen_player_controller,
369            player_revealed: host.chosen_player_revealed,
370            type_: host.chosen_type.clone(),
371            type_controller: host.chosen_type_controller,
372            type_revealed: host.chosen_type_revealed,
373            type2: host.chosen_type2.clone(),
374            named_cards: host.named_cards.clone(),
375            number: host.chosen_number,
376        }
377    }
378}
379
380fn apply_chosen_state(
381    effect: &mut Card,
382    snap: &ChosenSnapshot,
383    chosen_number_override: Option<i32>,
384) {
385    if !snap.colors.is_empty() {
386        effect.clear_chosen_colors();
387        for c in &snap.colors {
388            effect.add_chosen_color(c.clone());
389        }
390    }
391    if !snap.cards.is_empty() {
392        effect.set_chosen_cards(snap.cards.clone());
393    }
394    if snap.player.is_some() {
395        effect.set_chosen_player(snap.player, snap.player_controller, snap.player_revealed);
396    }
397    if snap.type_.is_some() {
398        effect.set_chosen_type(snap.type_.clone(), snap.type_controller, snap.type_revealed);
399    }
400    if let Some(t2) = &snap.type2 {
401        effect.chosen_type2 = Some(t2.clone());
402    }
403    for name in &snap.named_cards {
404        effect.add_named_card(name);
405    }
406    if let Some(n) = chosen_number_override.or(snap.number) {
407        effect.set_chosen_number(Some(n));
408    }
409}
410
411/// `Duration$` values EffectEffect recognises. Mirrors Java's string literals.
412/// Unknown values fall through to `EndOfTurn` (Java default).
413#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumString, Default)]
414#[strum(ascii_case_insensitive)]
415pub enum EffectDuration {
416    #[default]
417    EndOfTurn,
418    Permanent,
419    UntilHostLeavesPlay,
420    UntilHostLeavesPlayOrEOT,
421}
422
423fn apply_duration_flags(effect: &mut Card, duration: Option<&AbilityDuration>, source_id: CardId) {
424    let d = match duration {
425        Some(AbilityDuration::UntilHostLeavesPlay) => EffectDuration::UntilHostLeavesPlay,
426        Some(AbilityDuration::UntilHostLeavesPlayOrEot) => EffectDuration::UntilHostLeavesPlayOrEOT,
427        Some(AbilityDuration::Unsupported(raw)) if raw.eq_ignore_ascii_case("Permanent") => {
428            EffectDuration::Permanent
429        }
430        _ => EffectDuration::EndOfTurn,
431    };
432    match d {
433        EffectDuration::Permanent => {}
434        EffectDuration::UntilHostLeavesPlay => {
435            effect.set_temp_effect_host(Some(source_id));
436        }
437        EffectDuration::UntilHostLeavesPlayOrEOT => {
438            effect.set_temp_effect_host(Some(source_id));
439            effect.set_temp_effect_until_eot(true);
440        }
441        EffectDuration::EndOfTurn => {
442            effect.set_temp_effect_until_eot(true);
443        }
444    }
445}
446
447fn apply_forget_on_moved_flags(effect: &mut Card, sa: &SpellAbility) {
448    if let Some(zone) = sa.ir.forget_on_moved_zone {
449        effect.set_forget_on_moved_origin(Some(zone));
450        // Java forget flow exiles effect when no remembered objects remain.
451        effect.set_exile_when_no_remembered(true);
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::ability::effects::EffectContext;
459    use crate::ability::spell_ability_effect::SpellAbilityEffect;
460    use crate::agent::{PassAgent, PlayerAgent};
461    use crate::card::Card;
462    use crate::game::GameState;
463    use crate::ids::{CardId, PlayerId};
464    use crate::mana::ManaPool;
465    use crate::spellability::SpellAbility;
466    use crate::trigger::handler::TriggerHandler;
467
468    #[test]
469    fn effect_adds_command_static_for_cast_with_flash() {
470        let mut game = GameState::new(&["P0", "P1"], 20);
471        let p0 = PlayerId(0);
472
473        let mut host = Card::new(
474            CardId(0),
475            "Winding Canyons".to_string(),
476            p0,
477            CardTypeLine::parse("Land"),
478            ManaCost::parse("0"),
479            ColorSet::COLORLESS,
480            None,
481            None,
482            vec![],
483            vec![],
484        );
485        host.set_s_var(
486            "GiveFlash",
487            "Mode$ CastWithFlash | ValidCard$ Creature | ValidSA$ Spell | Caster$ You",
488        );
489        let host_id = game.create_card(host);
490        game.move_card(host_id, ZoneType::Battlefield, p0);
491
492        let mut sa = SpellAbility::new_simple(
493            Some(host_id),
494            p0,
495            "AB$ Effect | StaticAbilities$ GiveFlash | SpellDescription$ test",
496        );
497        sa.is_activated = true;
498
499        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![Box::new(PassAgent), Box::new(PassAgent)];
500        let mut trigger_handler = TriggerHandler::new();
501        let token_templates = std::collections::HashMap::new();
502        let templates_variants: std::collections::HashMap<(String, String), usize> =
503            std::collections::HashMap::new();
504        let token_fallback: std::collections::HashMap<String, String> =
505            std::collections::HashMap::new();
506        let edition_dates: std::collections::HashMap<String, String> =
507            std::collections::HashMap::new();
508        let mut mana_pools = vec![ManaPool::new(), ManaPool::new()];
509        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
510        let mut ctx = EffectContext {
511            game: &mut game,
512            combat: None,
513            agents: &mut agents,
514            trigger_handler: &mut trigger_handler,
515            token_templates: &token_templates,
516            token_art_variants: &templates_variants,
517            token_fallback: &token_fallback,
518            edition_dates: &edition_dates,
519            mana_pools: &mut mana_pools,
520            parent_target_card: None,
521            rng: &mut rng_adapter,
522        };
523        EffectEffect::resolve(&mut ctx, &sa);
524
525        let command_cards = ctx.game.cards_in_zone(ZoneType::Command, p0).to_vec();
526        assert_eq!(command_cards.len(), 1);
527        let effect = ctx.game.card(command_cards[0]);
528        assert!(effect.temp_effect_until_eot);
529        assert_eq!(effect.effect_source, Some(host_id));
530
531        let mut spell_abilities = Vec::new();
532        spell_abilities.push("SP$ Permanent | Cost$ 1 G".to_string());
533        let fake_creature = Card::new(
534            CardId(999),
535            "Bear".to_string(),
536            p0,
537            CardTypeLine::parse("Creature - Bear"),
538            ManaCost::parse("1 G"),
539            ColorSet::GREEN,
540            Some(2),
541            Some(2),
542            vec![],
543            vec![],
544        );
545        assert!(
546            crate::staticability::static_ability_cast_with_flash::any_with_flash(
547                &ctx.game.cards,
548                &fake_creature,
549                p0,
550                &spell_abilities
551            )
552        );
553    }
554}