Skip to main content

manabrew_engine/ability/effects/
copy_permanent_effect.rs

1use forge_foundation::color::ColorSet;
2use forge_foundation::ZoneType;
3
4use super::token_effect_base::{TokenCreateTable, TokenEffectBase, TOKEN_EFFECT_BASE};
5use super::EffectContext;
6use crate::card::card_zone_table::CardZoneTable;
7use crate::card::Card;
8use crate::ids::CardId;
9use crate::spellability::SpellAbility;
10
11/// Struct form of this effect so it can participate in the
12/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
13/// `CopyPermanentEffect` class extending `SpellAbilityEffect`.
14#[manabrew_engine_macros::spell_effect(CopyPermanentEffect)]
15fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
16    let amount = super::resolve_numeric_svar(ctx.game, sa, "NumCopies", 1).max(0) as usize;
17    let controllers = resolve_copy_controllers(ctx, sa);
18    if controllers.is_empty() {
19        return;
20    }
21
22    let mut token_table = TokenCreateTable::default();
23    for controller in controllers {
24        if !ctx.game.player(controller).is_alive() {
25            continue;
26        }
27
28        let originals = resolve_originals(ctx, sa, controller);
29        for original_id in originals {
30            if ctx.game.card(original_id).type_line.is_instant()
31                || ctx.game.card(original_id).type_line.is_sorcery()
32            {
33                continue;
34            }
35            if sa.ir.defined_text.is_none()
36                && sa.ir.choices.is_none()
37                && ctx.game.card(original_id).zone != ZoneType::Battlefield
38            {
39                continue;
40            }
41
42            if let Some(for_each) = sa.ir.for_each_text.as_deref() {
43                let players = crate::ability::ability_utils::resolve_defined_players_with_sa(
44                    for_each,
45                    sa,
46                    sa.activating_player,
47                    ctx.game,
48                );
49                for player in players {
50                    let mut proto = get_proto_type(sa, ctx.game.card(original_id), controller);
51                    proto.copied_permanent = Some(original_id);
52                    proto.add_remembered_player(player);
53                    token_table.put(controller, proto, amount);
54                }
55            } else {
56                let mut proto = get_proto_type(sa, ctx.game.card(original_id), controller);
57                proto.copied_permanent = Some(original_id);
58                token_table.put(controller, proto, amount);
59            }
60        }
61    }
62
63    if token_table.is_empty() {
64        return;
65    }
66
67    let mut trigger_list = CardZoneTable::default();
68    let result = TOKEN_EFFECT_BASE.make_token_table(ctx, token_table, true, &mut trigger_list, sa);
69    if !result.created.is_empty() {
70        trigger_list.trigger_changes_zone_all(ctx.trigger_handler, ctx.game, Some(sa));
71    }
72}
73
74/// Build the in-memory copy of `original` that a Copy/Embalm/Eternalize effect
75/// will place onto the battlefield. Mirrors Java
76/// `CopyPermanentEffect.getProtoType(SpellAbility, Card, Player)`.
77///
78/// Returned `Card` carries a placeholder `CardId(0)`; callers must invoke
79/// `GameState::create_card` to receive the real id. Mana-cost strip,
80/// `SetColor`, `AddTypes`, `SetPower/Toughness`, and `AddKeywords` are applied
81/// here. Shared token lifecycle params such as `PumpKeywords` are applied by
82/// `TokenEffectBase` after the token receives its real id.
83pub fn get_proto_type(sa: &SpellAbility, original: &Card, new_owner: crate::ids::PlayerId) -> Card {
84    let mut copy = Card::new(
85        CardId(0),
86        original.card_name.clone(),
87        new_owner,
88        original.type_line.clone(),
89        original.mana_cost.clone(),
90        original.color,
91        original.base_power,
92        original.base_toughness,
93        original.keywords.as_string_list(),
94        original.abilities.clone(),
95    );
96    copy.set_triggers(original.copiable_triggers());
97    copy.set_svars_map(original.svars.clone());
98    copy.set_static_abilities(original.static_abilities.clone());
99    copy.set_replacement_effects(original.copiable_replacement_effects());
100    copy.set_perpetual(original, false);
101    copy.initial_loyalty = original.initial_loyalty.clone();
102    copy.set_code = original.set_code.clone();
103    // Copies are tokens for zone-change purposes (cease to exist off battlefield).
104    copy.set_is_token(true);
105
106    // Apply SetColor$ (e.g. Embalm sets color to White).
107    if let Some(set_color) = sa.ir.set_color.as_deref() {
108        copy.set_color(ColorSet::from_names(set_color));
109    }
110
111    // Apply AddTypes$ (e.g. Embalm adds "Zombie").
112    if let Some(add_types) = sa.ir.add_types.as_deref() {
113        for t in add_types.split(" & ") {
114            let t = t.trim();
115            if !t.is_empty() {
116                copy.add_type(t);
117            }
118        }
119    }
120
121    // Apply SetPower$/SetToughness$ (e.g. Eternalize sets to 4/4).
122    if let Some(p) = sa
123        .ir
124        .set_power
125        .as_deref()
126        .and_then(|value| value.parse().ok())
127    {
128        copy.set_base_power(Some(p));
129    }
130    if let Some(t) = sa
131        .ir
132        .set_toughness
133        .as_deref()
134        .and_then(|value| value.parse().ok())
135    {
136        copy.set_base_toughness(Some(t));
137    }
138
139    // PumpKeywords$ are NOT applied at the proto stage. Mirrors Java
140    // `TokenEffectBase.java:179-182`, which applies them post-creation via
141    // `addChangedCardKeywords` + `addPumpUntil` so they expire per
142    // `PumpDuration$`. Applying them as intrinsic here would make e.g.
143    // Ashling's "Haste until end of turn" persist forever.
144    // The Rust mirror lives in `token_effect_base.rs::create_single_token`.
145
146    // Apply AddKeywords$ (e.g. additional keywords on the copy).
147    if let Some(add_kws) = sa.ir.add_keywords.as_deref() {
148        for kw in add_kws.split(" & ") {
149            let kw = kw.trim();
150            if !kw.is_empty() {
151                copy.add_intrinsic_keyword(kw);
152            }
153        }
154    }
155
156    // Strip mana cost for Embalm/Eternalize copies (they have no mana cost).
157    if sa
158        .ir
159        .set_mana_cost
160        .as_deref()
161        .is_some_and(|v| v == "0" || v.is_empty())
162    {
163        copy.set_mana_cost(forge_foundation::mana::ManaCost::no_cost());
164    }
165
166    copy
167}
168
169fn resolve_copy_controllers(ctx: &EffectContext, sa: &SpellAbility) -> Vec<crate::ids::PlayerId> {
170    if let Some(controller) = sa.ir.controller_text.as_deref() {
171        let players = crate::ability::ability_utils::resolve_defined_players_with_sa(
172            controller,
173            sa,
174            sa.activating_player,
175            ctx.game,
176        );
177        if !players.is_empty() {
178            return players;
179        }
180    }
181    vec![sa.activating_player]
182}
183
184fn resolve_originals(
185    ctx: &mut EffectContext,
186    sa: &SpellAbility,
187    controller: crate::ids::PlayerId,
188) -> Vec<CardId> {
189    if let Some(choices) = sa.ir.choices.as_deref() {
190        let candidates: Vec<CardId> = ctx
191            .game
192            .cards
193            .iter()
194            .filter(|card| card.zone == ZoneType::Battlefield)
195            .map(|card| card.id)
196            .filter(|&card_id| {
197                let Some(source_id) = sa.source else {
198                    return false;
199                };
200                crate::card::valid_filter::matches_valid(
201                    choices,
202                    Some(ctx.game.card(card_id)),
203                    None,
204                    ctx.game.card(source_id),
205                    sa.activating_player,
206                )
207            })
208            .collect();
209        if candidates.is_empty() {
210            return Vec::new();
211        }
212
213        let chooser = sa
214            .ir
215            .chooser
216            .as_deref()
217            .and_then(|defined| {
218                crate::ability::ability_utils::resolve_defined_players_with_sa(
219                    defined,
220                    sa,
221                    sa.activating_player,
222                    ctx.game,
223                )
224                .into_iter()
225                .next()
226            })
227            .unwrap_or(sa.activating_player);
228        ctx.agents[chooser.index()].snapshot_state(ctx.game, ctx.mana_pools);
229        return ctx.agents[chooser.index()]
230            .choose_single_card_for_zone_change(
231                ctx.game,
232                chooser,
233                &candidates,
234                "Choose a card",
235                false,
236            )
237            .into_iter()
238            .collect();
239    }
240
241    if let Some(defined) = sa.defined() {
242        match defined {
243            "Self" => return sa.source.into_iter().collect(),
244            "ParentTarget" => return sa.target_chosen.target_card.into_iter().collect(),
245            "TriggeredCard" | "TriggeredCardLKICopy" | "TriggeredSacrificedCard" => {
246                return sa
247                    .get_triggering_card(crate::ability::AbilityKey::Card)
248                    .into_iter()
249                    .collect();
250            }
251            _ => {
252                return crate::ability::ability_utils::get_defined_cards(
253                    ctx.game,
254                    sa.source,
255                    defined,
256                    Some(controller),
257                );
258            }
259        }
260    }
261
262    // Check Defined$ parameter first.
263    // Fall back to targeting.
264    sa.target_chosen.target_card.into_iter().collect()
265}