manabrew_engine/ability/effects/
counters_put_effect.rs1use 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#[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 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 let mut count = resolve_numeric_svar(ctx.game, sa, keys::COUNTER_NUM, 1);
99 if sa.ir.modular && sa.trigger_remembered_amount > 0 {
103 count = sa.trigger_remembered_amount;
104 }
105
106 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 let Some(card_id) = resolve_card_target(ctx.game, sa) else {
129 return;
130 };
131
132 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
228fn 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}