Skip to main content

manabrew_engine/ability/effects/
counters_put_effect.rs

1use forge_foundation::ZoneType;
2
3use super::{parse_counter_type, resolve_defined_player, resolve_numeric_svar, EffectContext};
4use crate::ability::ability_ir::DefinedRef;
5use crate::card::CounterType;
6use crate::event::RunParams;
7use crate::parsing::keys;
8use crate::replacement::replacement_handler::{apply_replacements_with_agents, ReplacementEvent};
9use crate::spellability::SpellAbility;
10use crate::trigger::TriggerType;
11
12/// Struct form of this effect so it can participate in the
13/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
14/// `CountersPutEffect` class extending `SpellAbilityEffect`.
15#[manabrew_engine_macros::spell_effect(CountersPutEffect)]
16fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
17    let counter_type_str = sa.ir.counter_type_text.as_deref().unwrap_or("P1P1");
18    // Mirror Java CountersPutEffect.java:625-636 — when none of the multi-type
19    // dispatch params are present, route the type through the player controller's
20    // chooseCounterType prompt (Java's chooseTypeFromList → pc.chooseCounterType).
21    // pickOne consumes RNG even for a single option, so calling the agent here
22    // keeps deterministic-parity entropy aligned with Java for fixed-type cards
23    // like Rottenmouth Viper (CounterType$ BLIGHT).
24    let counter_type = if matches_choose_from_list_path(sa) {
25        let placer_controller = sa
26            .source
27            .map(|id| ctx.game.card(id).controller)
28            .unwrap_or_else(|| ctx.game.player_order[0]);
29        let options: Vec<crate::card::CounterType> = counter_type_str
30            .split(',')
31            .map(str::trim)
32            .filter(|s| !s.is_empty())
33            .map(parse_counter_type)
34            .collect();
35        if options.is_empty() {
36            return;
37        }
38        ctx.agents[placer_controller.index()].snapshot_state(ctx.game, ctx.mana_pools);
39        match ctx.agents[placer_controller.index()].choose_counter_type(
40            placer_controller,
41            &options,
42            "Select counter type",
43        ) {
44            Some(chosen) => chosen,
45            None => return,
46        }
47    } else {
48        sa.ir
49            .counter_type
50            .clone()
51            .unwrap_or_else(|| parse_counter_type(counter_type_str))
52    };
53    // Support SVar references for CounterNum (e.g. Count$Kicked.4.0 for kicker cards)
54    let mut count = resolve_numeric_svar(ctx.game, sa, keys::COUNTER_NUM, 1);
55    // Modular death triggers: override the static Modular N with the
56    // actual LKI +1/+1 counter count from the dying creature (CR 702.43b).
57    // trigger_remembered_amount is set by the death path's LKI capture.
58    if sa.ir.modular && sa.trigger_remembered_amount > 0 {
59        count = sa.trigger_remembered_amount;
60    }
61
62    // Resolve the controller of this ability (for Defined$ You etc.)
63    let source_controller = sa
64        .source
65        .map(|id| ctx.game.card(id).controller)
66        .unwrap_or_else(|| ctx.game.player_order[0]);
67    // Check for Defined$ — if targeting a player (e.g. Defined$ You for energy),
68    // handle player-level counters like ENERGY instead of card counters.
69    if let Some(defined) = sa.defined() {
70        if let Some(target_player) = resolve_defined_player(defined, source_controller, ctx.game) {
71            match &counter_type {
72                CounterType::Named(name) if name == "ENERGY" => {
73                    ctx.game.player_add_energy(target_player, count);
74                    return;
75                }
76                _ => {
77                    // Other player-level counters (e.g. EXPERIENCE) can be
78                    // added here in the future. For now, fall through to
79                    // the card path if we somehow arrive here.
80                }
81            }
82        }
83    }
84
85    // Resolve target card: mirror Java's getDefinedEntitiesOrTargeted().
86    // When the SA uses targeting (ValidTgts$), use the chosen target.
87    // Otherwise fall back to the Defined$ parameter (default "Self").
88    let uses_targeting = sa.target_restrictions.is_some();
89    let target_id = if uses_targeting && sa.ir.defined.is_none() {
90        // Targeting mode — use the actual chosen target (Necropede death trigger, etc.)
91        sa.target_chosen.target_card
92    } else {
93        match sa.defined_ref() {
94            // Java AbilityUtils "TriggeredTarget*" / "Targeted" resolve from actual
95            // target choices only; if no target was chosen (e.g. TargetMin$ 0), they
96            // resolve to empty and do nothing.
97            Some(
98                DefinedRef::TriggeredTarget
99                | DefinedRef::TriggeredTargetLkiCopy
100                | DefinedRef::Targeted,
101            ) => sa.target_chosen.target_card,
102            _ => sa.source,
103        }
104    };
105    let Some(card_id) = target_id else { return };
106    if matches!(sa.defined_ref(), None | Some(DefinedRef::SelfCard)) && sa.source == Some(card_id) {
107        // Java parity: self-referential card effects must apply to the same object instance.
108        // If host card changed zones, this ability resolves with no effect on the new object.
109        if let Some(created_at) = sa.source_zone_timestamp {
110            if ctx.game.card(card_id).zone_timestamp != created_at {
111                return;
112            }
113        }
114    }
115    if ctx.game.card(card_id).zone != ZoneType::Battlefield {
116        return;
117    }
118
119    // Adapt gate: if Adapt$ True, only place counters if creature has no +1/+1 counters.
120    // Mirrors Java CountersPutEffect lines 498-501.
121    let is_adapt = sa.ir.adapt;
122    if is_adapt {
123        let current = ctx
124            .game
125            .card(card_id)
126            .counter_count(&crate::card::CounterType::P1P1);
127        if current > 0 {
128            return;
129        }
130    }
131
132    let is_monstrosity = sa.ir.monstrosity;
133    if is_monstrosity && ctx.game.card(card_id).monstrous {
134        return;
135    }
136
137    let is_bloodthirst = sa.ir.bloodthirst;
138    if is_bloodthirst && !ctx.game.player_has_bloodthirst(source_controller) {
139        return;
140    }
141
142    if crate::staticability::static_ability_cant_put_counter::any_cant_put_counter_on_card(
143        &ctx.game.cards,
144        ctx.game.card(card_id),
145        &counter_type,
146    ) {
147        return;
148    }
149    if let Some(max) = crate::staticability::static_ability_max_counter::max_counter(
150        &ctx.game.cards,
151        ctx.game.card(card_id),
152        &counter_type,
153    ) {
154        let current = ctx.game.card(card_id).counter_count(&counter_type);
155        if current >= max {
156            return;
157        }
158    }
159    // Run AddCounter replacement effects (e.g. Hardened Scales adds extra).
160    let mut event = ReplacementEvent::AddCounter {
161        target: card_id,
162        counter_type: counter_type.clone(),
163        count,
164        is_effect: true,
165    };
166    apply_replacements_with_agents(&mut *ctx.game, ctx.agents, &mut event);
167    let count = if let ReplacementEvent::AddCounter {
168        count: final_count, ..
169    } = event
170    {
171        final_count
172    } else {
173        count
174    };
175    let cause_player = ctx.game.card(card_id).controller;
176    ctx.game.card_mut(card_id).add_counter(&counter_type, count);
177
178    // Mark creature as renowned after successfully placing counters.
179    if sa.ir.renown {
180        ctx.game.card_mut(card_id).set_renowned(true);
181    }
182
183    // Per-target `CounterAdded` firing.
184    ctx.trigger_handler.run_trigger(
185        TriggerType::CounterAdded,
186        RunParams {
187            card: Some(card_id),
188            counter_type: Some(format!("{:?}", counter_type)),
189            counter_amount: Some(count),
190            cause_player: Some(cause_player),
191            ..Default::default()
192        },
193        false,
194    );
195    // Java fires `CounterAddedOnce` once per effect regardless of target
196    // count. Rust's counters_put_effect currently handles a single target per
197    // resolve, so firing it once here matches Java semantics.
198    ctx.trigger_handler.run_trigger(
199        TriggerType::CounterAddedOnce,
200        RunParams {
201            card: Some(card_id),
202            counter_type: Some(format!("{:?}", counter_type)),
203            counter_amount: Some(count),
204            cause_player: Some(cause_player),
205            ..Default::default()
206        },
207        false,
208    );
209
210    if is_monstrosity {
211        ctx.game.card_mut(card_id).set_monstrous(true);
212        ctx.trigger_handler.run_trigger(
213            TriggerType::BecomeMonstrous,
214            RunParams {
215                card: Some(card_id),
216                counter_amount: Some(count),
217                ..Default::default()
218            },
219            false,
220        );
221    }
222}
223
224/// True when CountersPutEffect.java:625-636 would route the CounterType
225/// through `chooseTypeFromList` (i.e. `pc.chooseCounterType`). Any of these
226/// params steers Java into a different dispatch branch above line 624 or
227/// resolves the type without prompting (UniqueType / CounterTypePerDefined
228/// also call chooseTypeFromList but inside resolvePerType, not here).
229fn matches_choose_from_list_path(sa: &SpellAbility) -> bool {
230    #[allow(dead_code)]
231    const SKIP_PARAMS: &[&str] = &[
232        "EachExistingCounter",
233        "EachFromSource",
234        "UniqueType",
235        "CounterTypePerDefined",
236        "CounterTypes",
237        "ChooseDifferent",
238        "PutOnEachOther",
239        "PutOnDefined",
240        "TriggeredCounterMap",
241        "SharedKeywords",
242    ];
243    sa.ir.simple_counter_type_choice_path
244}
245
246#[cfg(test)]
247mod tests {
248    use crate::ability::spell_ability_effect::SpellAbilityEffect;
249    use std::collections::HashMap;
250
251    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
252
253    use crate::ability::effects::EffectContext;
254    use crate::agent::PassAgent;
255    use crate::card::{Card, CounterType};
256    use crate::game::GameState;
257    use crate::ids::{CardId, PlayerId};
258    use crate::mana::ManaPool;
259    use crate::spellability::SpellAbility;
260    use crate::trigger::handler::TriggerHandler;
261
262    fn make_creature(game: &mut GameState, owner: PlayerId, name: &str) -> CardId {
263        let card = Card::new(
264            CardId(0),
265            name.to_string(),
266            owner,
267            CardTypeLine::parse("Creature - Golem"),
268            ManaCost::parse("5"),
269            ColorSet::COLORLESS,
270            Some(3),
271            Some(3),
272            vec![],
273            vec![],
274        );
275        game.create_card(card)
276    }
277
278    fn make_ctx<'a>(
279        game: &'a mut GameState,
280        agents: &'a mut Vec<Box<dyn crate::agent::PlayerAgent>>,
281        trigger_handler: &'a mut TriggerHandler,
282        mana_pools: &'a mut Vec<ManaPool>,
283        token_templates: &'a HashMap<String, Card>,
284        token_art_variants: &'a HashMap<(String, String), usize>,
285        token_fallback: &'a HashMap<String, String>,
286        edition_dates: &'a HashMap<String, String>,
287        rng: &'a mut dyn crate::game_rng::GameRng,
288    ) -> EffectContext<'a> {
289        EffectContext {
290            game,
291            combat: None,
292            agents,
293            trigger_handler,
294            token_templates,
295            token_art_variants,
296            token_fallback,
297            edition_dates,
298            mana_pools,
299            parent_target_card: None,
300            rng,
301        }
302    }
303
304    #[test]
305    fn monstrosity_only_applies_once() {
306        let mut game = GameState::new(&["Alice", "Bob"], 20);
307        let p0 = PlayerId(0);
308        let clay_golem = make_creature(&mut game, p0, "Clay Golem");
309        game.move_card(clay_golem, ZoneType::Battlefield, p0);
310
311        let sa = SpellAbility::new_simple(
312            Some(clay_golem),
313            p0,
314            "AB$ PutCounter | Defined$ Self | Monstrosity$ True | CounterNum$ 4 | CounterType$ P1P1",
315        );
316
317        let mut trigger_handler = TriggerHandler::new();
318        let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
319            vec![Box::new(PassAgent), Box::new(PassAgent)];
320        let mut mana_pools = vec![ManaPool::default(), ManaPool::default()];
321        let token_templates = HashMap::new();
322        let templates_variants: HashMap<(String, String), usize> = HashMap::new();
323        let token_fallback: HashMap<String, String> = HashMap::new();
324        let edition_dates: HashMap<String, String> = HashMap::new();
325        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
326        let mut ctx = make_ctx(
327            &mut game,
328            &mut agents,
329            &mut trigger_handler,
330            &mut mana_pools,
331            &token_templates,
332            &templates_variants,
333            &token_fallback,
334            &edition_dates,
335            &mut rng_adapter,
336        );
337
338        super::CountersPutEffect::resolve(&mut ctx, &sa);
339        assert_eq!(
340            ctx.game.card(clay_golem).counter_count(&CounterType::P1P1),
341            4
342        );
343        assert!(ctx.game.card(clay_golem).monstrous);
344
345        super::CountersPutEffect::resolve(&mut ctx, &sa);
346        assert_eq!(
347            ctx.game.card(clay_golem).counter_count(&CounterType::P1P1),
348            4
349        );
350        assert!(ctx.game.card(clay_golem).monstrous);
351    }
352
353    #[test]
354    fn monstrous_resets_after_leaving_battlefield() {
355        let mut game = GameState::new(&["Alice", "Bob"], 20);
356        let p0 = PlayerId(0);
357        let clay_golem = make_creature(&mut game, p0, "Clay Golem");
358        game.move_card(clay_golem, ZoneType::Battlefield, p0);
359        game.card_mut(clay_golem).set_monstrous(true);
360
361        game.move_card(clay_golem, ZoneType::Hand, p0);
362
363        assert!(!game.card(clay_golem).monstrous);
364    }
365}