Skip to main content

manabrew_engine/ability/effects/
pump_effect.rs

1use forge_foundation::ZoneType;
2
3use super::EffectContext;
4use crate::ability::ability_ir::DefinedRef;
5use crate::card::card_util;
6use crate::card::perpetual::perpetual_interface::PerpetualInterface;
7use crate::card::perpetual::{perpetual_keywords, perpetual_pt_boost};
8
9/// Parsed `NumAtt$`/`NumDef$` bonus spec: either a fixed literal or a
10/// target-relative scale (Java L469–L481).
11#[derive(Clone, Copy)]
12enum PtBonus {
13    Fixed(i32),
14    Double,
15    Triple,
16}
17
18impl PtBonus {
19    fn parse(raw: Option<&str>, fallback: impl FnOnce() -> i32) -> Self {
20        match raw {
21            Some("Double") => PtBonus::Double,
22            Some("Triple") => PtBonus::Triple,
23            _ => PtBonus::Fixed(fallback()),
24        }
25    }
26
27    /// Resolve the bonus against a concrete target's current P or T.
28    fn resolve(self, current: i32) -> i32 {
29        match self {
30            PtBonus::Fixed(n) => n,
31            PtBonus::Double => current,
32            PtBonus::Triple => current * 2,
33        }
34    }
35}
36
37/// End-of-turn revert for Pump. Mirrors the `GameCommand.run()` in Java
38/// `PumpEffect` that reverses the P/T bonus and removes granted keywords
39/// when the effect duration expires.
40pub fn run(
41    game: &mut crate::game::GameState,
42    card_id: crate::ids::CardId,
43    att_bonus: i32,
44    def_bonus: i32,
45    keywords: &[String],
46) {
47    if game.card(card_id).zone != ZoneType::Battlefield {
48        return;
49    }
50    game.card_mut(card_id).power_modifier -= att_bonus;
51    game.card_mut(card_id).toughness_modifier -= def_bonus;
52    for kw in keywords {
53        game.card_mut(card_id).pump_keywords.remove(kw);
54    }
55}
56
57/// Struct form of this effect so it can participate in the
58/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
59/// `PumpEffect` class extending `SpellAbilityEffect`.
60#[manabrew_engine_macros::spell_effect(PumpEffect)]
61fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
62    let mut pumped_targets: Vec<crate::ids::CardId> = Vec::new();
63
64    // `Optional$` — activator confirms before any pump applies (Java L283–L292).
65    if sa.ir.optional_present {
66        let card_name = sa.source.map(|cid| ctx.game.card(cid).card_name.clone());
67        let prompt = sa
68            .ir
69            .option_question
70            .as_deref()
71            .unwrap_or("Apply pump to target?");
72        let activator = sa.activating_player;
73        if !ctx.agents[activator.index()].confirm_action(
74            activator,
75            Some("OptionalPump"),
76            prompt,
77            &[],
78            sa.source,
79            sa.api,
80        ) {
81            return;
82        }
83    }
84
85    let att_bonus = PtBonus::parse(sa.ir.num_att.as_deref(), || {
86        match sa.ir.num_att.as_deref() {
87            Some(raw) => super::resolve_numeric_value(ctx.game, sa, raw, 0),
88            None => 0,
89        }
90    });
91    let def_bonus = PtBonus::parse(sa.ir.num_def.as_deref(), || {
92        match sa.ir.num_def.as_deref() {
93            Some(raw) => super::resolve_numeric_value(ctx.game, sa, raw, 0),
94            None => 0,
95        }
96    });
97
98    // Parse KW$ parameter for keyword grants (e.g. "KW$ Haste" or "KW$ Flying & Trample")
99    let mut keywords: Vec<String> = sa
100        .ir
101        .kw
102        .as_deref()
103        .map(|kw_str| {
104            kw_str
105                .split('&')
106                .map(|s| s.trim().to_string())
107                .filter(|s| !s.is_empty())
108                .collect()
109        })
110        .unwrap_or_default();
111
112    // `KWChoice$` — activator picks one keyword from a comma-separated list
113    // (Java L297–L302). Reuses `choose_mode` which maps to a pick-one dialog
114    // in concrete agents.
115    if let Some(kw_choice) = sa.ir.kw_choice.as_deref() {
116        let options: Vec<String> = kw_choice
117            .split(',')
118            .map(|s| s.trim().to_string())
119            .filter(|s| !s.is_empty())
120            .collect();
121        if !options.is_empty() {
122            let activator = sa.activating_player;
123            let picks =
124                ctx.agents[activator.index()].choose_mode(activator, &options, 1, 1, sa.source);
125            if let Some(&idx) = picks.first() {
126                if let Some(kw) = options.get(idx) {
127                    keywords.push(kw.clone());
128                }
129            }
130        }
131    }
132
133    // `CanBlockAny$` — synthetic keyword grant (Java L79–L85 / L240–L253).
134    // Rust has no dedicated `addCanBlockAny` / `addCanBlockAdditional`, so we
135    // encode the permission as pump keywords that block-restriction code can
136    // match on ("CanBlockAny" / "CanBlock:N"). Full block-amount support lands
137    // once the combat module reads these markers.
138    if sa.ir.can_block_any {
139        keywords.push("CanBlockAny".to_string());
140    }
141    if let Some(amt) = sa.ir.can_block_amount.as_deref() {
142        keywords.push(format!("CanBlock:{}", amt));
143    }
144
145    let is_perpetual = sa.ir.perpetual_duration;
146    let resolve_ts = if is_perpetual {
147        Some(ctx.game.next_effect_timestamp())
148    } else {
149        None
150    };
151
152    // Overload: apply pump to ALL valid creatures instead of the chosen target.
153    if sa.overloaded {
154        let valid_tgts = sa.ir.valid_tgts_text.clone().unwrap_or_default();
155        let valid_tgts_selector = sa.ir.valid_tgts_selector.as_ref();
156        let all_bf: Vec<crate::ids::CardId> = ctx
157            .game
158            .player_order
159            .clone()
160            .iter()
161            .flat_map(|&pid| ctx.game.cards_in_zone(ZoneType::Battlefield, pid).to_vec())
162            .collect();
163        for cid in all_bf {
164            if ctx.game.card(cid).zone != ZoneType::Battlefield {
165                continue;
166            }
167            if !super::matches_valid_cards_for_sa(
168                ctx.game,
169                sa,
170                ctx.game.card(cid),
171                valid_tgts_selector,
172                &valid_tgts,
173            ) {
174                continue;
175            }
176            let target = ctx.game.card(cid);
177            let att = att_bonus.resolve(target.power());
178            let def = def_bonus.resolve(target.toughness());
179            apply_pump_to_card(ctx, cid, att, def, &keywords, is_perpetual, resolve_ts);
180        }
181        return;
182    }
183
184    let mut targets = crate::ability::spell_ability_effect::get_target_cards(ctx.game, sa);
185    if targets.is_empty() && matches!(sa.defined_ref(), Some(DefinedRef::ParentTarget)) {
186        targets.extend(ctx.parent_target_card);
187    }
188    targets.extend(card_util::get_radiance(ctx.game, sa).iter().copied());
189    targets.sort_unstable_by_key(|cid| cid.0);
190    targets.dedup();
191
192    let tgt_zones = sa
193        .target_restrictions
194        .as_ref()
195        .map(|tr| tr.tgt_zone.clone())
196        .filter(|zones| !zones.is_empty())
197        .unwrap_or_else(|| vec![ZoneType::Battlefield]);
198    for target_card in targets {
199        if !tgt_zones.contains(&ctx.game.card(target_card).zone) {
200            continue;
201        }
202        let target = ctx.game.card(target_card);
203        let att = att_bonus.resolve(target.power());
204        let def = def_bonus.resolve(target.toughness());
205        apply_pump_to_card(
206            ctx,
207            target_card,
208            att,
209            def,
210            &keywords,
211            is_perpetual,
212            resolve_ts,
213        );
214        pumped_targets.push(target_card);
215    }
216
217    if pumped_targets.is_empty() && !keywords.is_empty() {
218        let pumped_players: Vec<crate::ids::PlayerId> =
219            if let Some(tp) = sa.target_chosen.target_player {
220                vec![tp]
221            } else if let Some(d) = sa.defined() {
222                crate::ability::ability_utils::resolve_defined_players_with_sa(
223                    d,
224                    sa,
225                    sa.activating_player,
226                    ctx.game,
227                )
228            } else {
229                Vec::new()
230            };
231        for player in pumped_players {
232            for kw in &keywords {
233                crate::player::add_pump_keyword_with_duration(
234                    ctx.game,
235                    player,
236                    kw.clone(),
237                    sa.ir.duration.as_ref(),
238                );
239            }
240        }
241    }
242
243    // `AtEOT$ <action>` — register an end-of-turn delayed trigger that performs
244    // `action` on the pumped targets (Java PumpEffect L486).
245    if let Some(action) = sa.ir.at_eot.as_deref() {
246        crate::ability::spell_ability_effect::register_at_eot(
247            ctx.trigger_handler,
248            ctx.game,
249            sa,
250            action,
251            pumped_targets,
252        );
253    }
254}
255
256fn apply_pump_to_card(
257    ctx: &mut EffectContext,
258    card_id: crate::ids::CardId,
259    att: i32,
260    def: i32,
261    keywords: &[String],
262    is_perpetual: bool,
263    resolve_ts: Option<i64>,
264) {
265    if is_perpetual {
266        let ts = resolve_ts.expect("perpetual resolve timestamp must exist");
267        let card = ctx.game.card_mut(card_id);
268        perpetual_pt_boost::PerpetualPtBoost {
269            timestamp: ts,
270            power: att,
271            toughness: def,
272        }
273        .apply_effect(card);
274        for kw in keywords {
275            perpetual_keywords::PerpetualKeywords {
276                timestamp: ts,
277                add_keywords: vec![kw.clone()],
278                remove_keywords: Vec::new(),
279                remove_all: false,
280            }
281            .apply_effect(card);
282        }
283    } else {
284        ctx.game.card_mut(card_id).add_pt_boost(att, def);
285        for kw in keywords {
286            ctx.game.card_mut(card_id).add_pump_keyword(kw);
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use crate::ability::spell_ability_effect::SpellAbilityEffect;
294    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
295    use std::collections::HashMap;
296
297    use crate::ability::effects::EffectContext;
298    use crate::agent::PassAgent;
299    use crate::card::Card;
300    use crate::game::GameState;
301    use crate::ids::{CardId, PlayerId};
302    use crate::mana::ManaPool;
303    use crate::spellability::SpellAbility;
304    use crate::trigger::handler::TriggerHandler;
305
306    fn make_creature(game: &mut GameState, owner: PlayerId, name: &str) -> CardId {
307        let c = Card::new(
308            CardId(0),
309            name.into(),
310            owner,
311            CardTypeLine::parse("Creature - Human Soldier"),
312            ManaCost::parse("1 W"),
313            ColorSet::WHITE,
314            Some(2),
315            Some(2),
316            vec![],
317            vec![],
318        );
319        game.create_card(c)
320    }
321
322    fn make_ctx<'a>(
323        game: &'a mut GameState,
324        agents: &'a mut Vec<Box<dyn crate::agent::PlayerAgent>>,
325        th: &'a mut TriggerHandler,
326        mp: &'a mut Vec<ManaPool>,
327        templates: &'a HashMap<String, Card>,
328        templates_variants: &'a HashMap<(String, String), usize>,
329        token_fallback: &'a HashMap<String, String>,
330        edition_dates: &'a HashMap<String, String>,
331        rng: &'a mut dyn crate::game_rng::GameRng,
332    ) -> EffectContext<'a> {
333        EffectContext {
334            game,
335            combat: None,
336            agents,
337            trigger_handler: th,
338            token_templates: templates,
339            token_art_variants: templates_variants,
340            token_fallback,
341            edition_dates,
342            mana_pools: mp,
343            parent_target_card: None,
344            rng,
345        }
346    }
347
348    #[test]
349    fn non_targeted_pump_defaults_to_self_like_java() {
350        let mut game = GameState::new(&["Alice", "Bob"], 20);
351        let p0 = PlayerId(0);
352        let guardian = make_creature(&mut game, p0, "Guardian of New Benalia");
353        game.move_card(guardian, ZoneType::Battlefield, p0);
354
355        let sa = SpellAbility::new_simple(
356            Some(guardian),
357            p0,
358            "AB$ Pump | KW$ Indestructible | SpellDescription$ CARDNAME gains indestructible until end of turn.",
359        );
360
361        let mut th = TriggerHandler::new();
362        let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
363            vec![Box::new(PassAgent), Box::new(PassAgent)];
364        let mut mp = vec![ManaPool::default(), ManaPool::default()];
365        let templates = HashMap::new();
366        let templates_variants: HashMap<(String, String), usize> = HashMap::new();
367        let token_fallback: HashMap<String, String> = HashMap::new();
368        let edition_dates: HashMap<String, String> = HashMap::new();
369        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
370        let mut ctx = make_ctx(
371            &mut game,
372            &mut agents,
373            &mut th,
374            &mut mp,
375            &templates,
376            &templates_variants,
377            &token_fallback,
378            &edition_dates,
379            &mut rng_adapter,
380        );
381
382        super::PumpEffect::resolve(&mut ctx, &sa);
383
384        assert!(ctx.game.card(guardian).has_indestructible());
385    }
386
387    #[test]
388    fn targeted_pump_does_not_fall_back_to_source() {
389        let mut game = GameState::new(&["Alice", "Bob"], 20);
390        let p0 = PlayerId(0);
391        let source = make_creature(&mut game, p0, "Source");
392        game.move_card(source, ZoneType::Battlefield, p0);
393
394        let sa = SpellAbility::new_simple(
395            Some(source),
396            p0,
397            "SP$ Pump | ValidTgts$ Creature | KW$ Indestructible",
398        );
399
400        let mut th = TriggerHandler::new();
401        let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
402            vec![Box::new(PassAgent), Box::new(PassAgent)];
403        let mut mp = vec![ManaPool::default(), ManaPool::default()];
404        let templates = HashMap::new();
405        let templates_variants: HashMap<(String, String), usize> = HashMap::new();
406        let token_fallback: HashMap<String, String> = HashMap::new();
407        let edition_dates: HashMap<String, String> = HashMap::new();
408        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
409        let mut ctx = make_ctx(
410            &mut game,
411            &mut agents,
412            &mut th,
413            &mut mp,
414            &templates,
415            &templates_variants,
416            &token_fallback,
417            &edition_dates,
418            &mut rng_adapter,
419        );
420
421        super::PumpEffect::resolve(&mut ctx, &sa);
422
423        assert!(!ctx.game.card(source).has_indestructible());
424    }
425}