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