1use std::collections::BTreeMap;
36
37use forge_foundation::{CardTypeLine, CoreType, Supertype, ZoneType};
38
39use crate::agent::PlayerAgent;
40use crate::game::GameState;
41use crate::ids::{CardId, PlayerId};
42use crate::replacement::replacement_effect::ReplacementType;
43use crate::staticability::{CardFilter, Layer, StaticAbility, StaticMode};
44
45struct PendingEffect {
49 layer: Layer,
51 target: CardId,
53 kind: EffectKind,
55}
56
57enum EffectKind {
58 SetController {
59 controller: PlayerId,
60 },
61 AddPT {
62 power: i32,
63 toughness: i32,
64 },
65 SetPT {
66 power: Option<i32>,
67 toughness: Option<i32>,
68 },
69 RemoveAllCardTraits {
70 timestamp: i64,
71 static_id: i64,
72 },
73 GrantKeyword(String),
74 GrantAbility {
76 text: String,
77 svars: BTreeMap<String, String>,
78 },
79 AddType(String),
81 GrantTrigger {
83 text: String,
84 svars: BTreeMap<String, String>,
85 },
86}
87
88pub fn classify_static_layers(sa: &StaticAbility) -> Vec<Layer> {
96 if !sa.check_mode(&StaticMode::Continuous) {
97 return Vec::new();
98 }
99
100 let ir = &sa.ir;
101 let mut layers = Vec::new();
102
103 push_layer(&mut layers, ir.gain_control_param, Layer::Control);
104 push_layer(&mut layers, ir.has_text_layer_key, Layer::Text);
105 push_layer(&mut layers, ir.has_type_layer_key, Layer::Type);
106 push_layer(&mut layers, ir.has_color_layer_key, Layer::Color);
107 push_layer(&mut layers, ir.has_ability_layer_key, Layer::Ability);
108
109 if ir.set_power || ir.set_toughness {
110 if ir.characteristic_defining {
111 push_unique_layer(&mut layers, Layer::Characteristic);
112 } else {
113 push_unique_layer(&mut layers, Layer::SetPT);
114 }
115 }
116
117 push_layer(
118 &mut layers,
119 ir.add_power || ir.add_toughness,
120 Layer::ModifyPT,
121 );
122 push_layer(&mut layers, ir.has_rules_layer_key, Layer::Rules);
123
124 if layers.is_empty() {
125 layers.push(Layer::Rules);
126 }
127
128 layers
129}
130
131fn push_layer(layers: &mut Vec<Layer>, condition: bool, layer: Layer) {
132 if condition {
133 push_unique_layer(layers, layer);
134 }
135}
136
137fn static_layer_trait_id(source_id: CardId, sa_idx: usize) -> i64 {
138 -(((source_id.index() as i64) + 1) * 10_000 + sa_idx as i64 + 1)
139}
140
141fn push_unique_layer(layers: &mut Vec<Layer>, layer: Layer) {
142 if !layers.contains(&layer) {
143 layers.push(layer);
144 }
145}
146
147fn type_line_has_token(type_line: &CardTypeLine, token: &str) -> bool {
148 if let Some(st) = Supertype::from_name(token) {
149 return type_line.supertypes.contains(&st);
150 }
151 if let Some(ct) = CoreType::from_name(token) {
152 return type_line.core_types.contains(&ct);
153 }
154 type_line
155 .subtypes
156 .iter()
157 .any(|subtype| subtype.eq_ignore_ascii_case(token))
158}
159
160pub fn apply_continuous_effects(game: &mut GameState) {
172 let _perf_timer = crate::perf::ScopeTimer::start(
173 crate::perf::Metric::ContinuousEffectsCalls,
174 crate::perf::Metric::ContinuousEffectsNs,
175 );
176 let _params_lookup_scope =
177 crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Continuous);
178 for card in game.cards.iter_mut() {
180 card.clear_static_layer_changed_card_traits();
181 if card.activated_abilities.len() > card.base_ability_count {
184 card.activated_abilities.truncate(card.base_ability_count);
185 }
186 for (ability_idx, ability) in card.activated_abilities.iter_mut().enumerate() {
187 ability.ability_index = ability_idx;
188 }
189 let intrinsic_trigger_count = card.base_trigger_count + card.pump_trigger_count;
190 if card.triggers.len() > intrinsic_trigger_count {
191 card.triggers.truncate(intrinsic_trigger_count);
192 }
193 card.static_power_modifier = 0;
194 card.static_toughness_modifier = 0;
195 if !card.face_down {
197 card.static_set_power = None;
198 card.static_set_toughness = None;
199 }
200 card.granted_keywords.clear();
201 card.granted_svars.clear();
202 if let Some(type_line) = card.static_type_line_base.take() {
204 card.set_type_line(type_line);
205 }
206 card.static_added_subtypes.clear();
207 card.cant_attack_static = false;
208 card.cant_block_static = false;
209 }
210 for player in game.players.iter_mut() {
211 player.max_land_plays_per_turn = 1;
212 player.unlimited_land_plays = false;
213 }
214
215 for card in game.cards.iter_mut() {
218 if card.zone == ZoneType::Battlefield
219 && card.has_keyword("Unleash")
220 && card.counter_count(&crate::card::CounterType::P1P1) > 0
221 {
222 card.cant_block_static = true;
223 }
224 }
225
226 for player_idx in 0..game.player_order.len() {
227 let pid = game.player_order[player_idx];
228 let player = game.player_mut(pid);
229 player.max_hand_size = 7;
230 player.unlimited_hand_size = false;
231 }
232 let player_ids: Vec<PlayerId> = game.player_order.clone();
233 for &pid in &player_ids {
234 let battlefield_cards: Vec<CardId> =
235 game.cards_in_zone(ZoneType::Battlefield, pid).to_vec();
236 for source_id in battlefield_cards {
237 let static_ability_count = game.card(source_id).static_abilities.len();
238 for sa_idx in 0..static_ability_count {
239 let card = game.card(source_id);
240 let sa = &card.static_abilities[sa_idx];
241 if !sa.check_conditions(card, game) {
242 continue;
243 }
244 if !sa.check_mode(&StaticMode::Continuous) {
245 continue;
246 }
247 let affected = sa.ir.affected_text.as_deref().unwrap_or("");
248 if !affected.eq_ignore_ascii_case("You") {
249 continue;
250 }
251 let controller = card.controller;
252 let set_value = sa.ir.set_max_hand_size.clone();
253 let raise_value = sa.ir.raise_max_hand_size.clone();
254 if let Some(value) = set_value {
255 let player = game.player_mut(controller);
256 if value.eq_ignore_ascii_case("Unlimited") {
257 player.unlimited_hand_size = true;
258 } else if let Ok(n) = value.parse::<i32>() {
259 player.max_hand_size = n;
260 }
261 }
262 if let Some(value) = raise_value {
263 if let Ok(n) = value.parse::<i32>() {
264 let player = game.player_mut(controller);
265 player.max_hand_size = player.max_hand_size.saturating_add(n);
266 }
267 }
268 }
269 }
270 }
271
272 let mut pending: Vec<PendingEffect> = Vec::new();
274 let mut cant_attack_targets: Vec<CardId> = Vec::new();
275 let mut cant_block_targets: Vec<CardId> = Vec::new();
276 let mut granted_player_rules: Vec<(CardId, StaticAbility)> = Vec::new();
277
278 let source_ids: Vec<CardId> = game.cards.iter().map(|card| card.id).collect();
279 for source_id in source_ids {
280 let static_ability_count = game.card(source_id).static_abilities.len();
281
282 for sa_idx in 0..static_ability_count {
283 let source_card = game.card(source_id).clone();
284 let sa = game.card(source_id).static_abilities[sa_idx].clone();
285
286 if !sa.check_conditions(&source_card, game) {
289 continue;
290 }
291
292 if sa.check_mode(&StaticMode::Continuous) {
293 apply_player_rules_effects(game, source_id, &sa);
294 }
295
296 let is_cda = sa.ir.characteristic_defining;
299
300 let affected_str = sa
302 .ir
303 .affected_text
304 .as_deref()
305 .or(sa.ir.valid_cards_text.as_deref())
306 .or(sa.ir.valid_card_text.as_deref())
307 .unwrap_or("Creature.YouControl");
308
309 let mut apply_to_target = |target: CardId| {
310 if sa.check_mode(&StaticMode::Continuous) {
311 if let Some(gain_control) = sa.ir.gain_control_text.as_deref() {
312 let new_controller = match gain_control {
313 "You" | "YouCtrl" => Some(source_card.controller),
314 "Opponent" => Some(game.opponent_of(source_card.controller)),
315 _ => None,
316 };
317 if let Some(controller) = new_controller {
318 pending.push(PendingEffect {
319 layer: Layer::Control,
320 target,
321 kind: EffectKind::SetController { controller },
322 });
323 }
324 }
325
326 let add_power = sa.ir.add_power_text.as_deref();
327 let add_toughness = sa.ir.add_toughness_text.as_deref();
328 if add_power.is_some() || add_toughness.is_some() {
329 let p = resolve_add_pt_value(game, source_id, add_power);
330 let t = resolve_add_pt_value(game, source_id, add_toughness);
331 pending.push(PendingEffect {
332 layer: Layer::ModifyPT,
333 target,
334 kind: EffectKind::AddPT {
335 power: p,
336 toughness: t,
337 },
338 });
339 }
340
341 let add_type = sa.ir.add_type_text.as_deref();
342 let source = game.card(source_id);
343 for added_type in resolve_added_types(source, add_type) {
344 pending.push(PendingEffect {
345 layer: Layer::Type,
346 target,
347 kind: EffectKind::AddType(added_type),
348 });
349 }
350
351 let set_power = sa.ir.set_power_text.as_deref();
352 let set_toughness = sa.ir.set_toughness_text.as_deref();
353 if set_power.is_some() || set_toughness.is_some() {
354 let sp = resolve_set_pt_value(game, source_id, set_power);
355 let st = resolve_set_pt_value(game, source_id, set_toughness);
356 let layer = if is_cda {
359 Layer::Characteristic
360 } else {
361 Layer::SetPT
362 };
363 pending.push(PendingEffect {
364 layer,
365 target,
366 kind: EffectKind::SetPT {
367 power: sp,
368 toughness: st,
369 },
370 });
371 }
372
373 if let Some(kws) = sa.ir.add_keyword_text.as_deref() {
374 for kw in kws.split('&').map(str::trim).filter(|s| !s.is_empty()) {
376 pending.push(PendingEffect {
377 layer: Layer::Ability,
378 target,
379 kind: EffectKind::GrantKeyword(kw.to_string()),
380 });
381 }
382 }
383
384 if sa.ir.remove_all_abilities {
385 pending.push(PendingEffect {
386 layer: Layer::Ability,
387 target,
388 kind: EffectKind::RemoveAllCardTraits {
389 timestamp: source_card.zone_timestamp as i64,
390 static_id: static_layer_trait_id(source_id, sa_idx),
391 },
392 });
393 }
394
395 if let Some(svar_name) = sa.ir.add_ability_text.as_deref() {
400 if let Some(ab_text) = source_card.svars.get(svar_name).cloned() {
401 pending.push(PendingEffect {
402 layer: Layer::Ability,
403 target,
404 kind: EffectKind::GrantAbility {
405 text: ab_text,
406 svars: source_card.svars.clone(),
407 },
408 });
409 }
410 }
411
412 if let Some(add_trigger) = sa.ir.add_trigger_text.as_deref() {
413 for svar_name in add_trigger
414 .split(" & ")
415 .map(str::trim)
416 .filter(|s| !s.is_empty())
417 {
418 if let Some(trig_text) = source_card.svars.get(svar_name).cloned() {
419 pending.push(PendingEffect {
420 layer: Layer::Ability,
421 target,
422 kind: EffectKind::GrantTrigger {
423 text: trig_text,
424 svars: source_card.svars.clone(),
425 },
426 });
427 }
428 }
429 }
430
431 if let Some(add_static) = sa.ir.add_static_ability_text.as_deref() {
432 for svar_name in add_static
433 .split(" & ")
434 .map(str::trim)
435 .filter(|s| !s.is_empty())
436 {
437 if let Some(static_text) = source_card.svars.get(svar_name).cloned() {
438 if let Some(granted) =
439 crate::staticability::parse_static_ability(&static_text)
440 {
441 granted_player_rules.push((target, granted));
442 }
443 }
444 }
445 }
446
447 for subtype in resolve_added_basic_land_types(&source_card, add_type) {
448 if let Some(ab_text) = basic_land_mana_ability_text(&subtype) {
449 pending.push(PendingEffect {
450 layer: Layer::Ability,
451 target,
452 kind: EffectKind::GrantAbility {
453 text: ab_text.to_string(),
454 svars: BTreeMap::new(),
455 },
456 });
457 }
458 }
459 }
460
461 if sa.check_mode(&StaticMode::CantAttack) {
462 cant_attack_targets.push(target);
463 }
464 if sa.check_mode(&StaticMode::CantBlock) {
465 cant_block_targets.push(target);
466 }
467 };
468
469 if is_cda {
470 if source_card.zone == ZoneType::Battlefield {
472 apply_to_target(source_id);
473 }
474 } else if affected_str.eq_ignore_ascii_case("Card.Self")
475 || affected_str.starts_with("Card.Self+")
476 {
477 if source_card.zone == ZoneType::Battlefield
484 && crate::card::valid_filter::matches_valid_card(
485 affected_str,
486 &source_card,
487 &source_card,
488 )
489 {
490 apply_to_target(source_id);
491 }
492 } else if affected_str.eq_ignore_ascii_case("Card.EnchantedBy")
493 || affected_str.contains(".EquippedBy")
494 || affected_str.contains(".EnchantedBy")
495 {
496 if let Some(cid) = source_card.attached_to {
502 if game.card(cid).zone == ZoneType::Battlefield {
503 apply_to_target(cid);
504 }
505 }
506 } else {
507 let filter = CardFilter::parse(affected_str);
508 let affected_zones = if sa.ir.affected_zones.is_empty() {
511 None
512 } else {
513 Some(sa.ir.affected_zones.as_slice())
514 };
515 for card in &game.cards {
516 let zone_matches = match &affected_zones {
517 Some(zones) => zones.contains(&card.zone),
518 None => card.zone == ZoneType::Battlefield,
519 };
520 if zone_matches && filter.matches_with_game(card, &source_card, game) {
521 apply_to_target(card.id);
522 }
523 }
524 }
525 }
526 }
527
528 for (source_id, granted) in granted_player_rules {
529 apply_player_rules_effects(game, source_id, &granted);
530 }
531
532 for target in cant_attack_targets {
533 game.cards[target.index()].cant_attack_static = true;
534 }
535 for target in cant_block_targets {
536 game.cards[target.index()].cant_block_static = true;
537 }
538
539 pending.sort_by_key(|e| e.layer);
544
545 for effect in pending {
546 match effect.kind {
547 EffectKind::SetController { controller } => {
548 game.change_controller(effect.target, controller);
549 }
550 EffectKind::AddPT { power, toughness } => {
551 let card = &mut game.cards[effect.target.index()];
552 card.static_power_modifier += power;
553 card.static_toughness_modifier += toughness;
554 }
555 EffectKind::SetPT { power, toughness } => {
556 let card = &mut game.cards[effect.target.index()];
557 if let Some(p) = power {
561 card.static_set_power = Some(p);
562 }
563 if let Some(t) = toughness {
564 card.static_set_toughness = Some(t);
565 }
566 }
567 EffectKind::RemoveAllCardTraits {
568 timestamp,
569 static_id,
570 } => {
571 game.cards[effect.target.index()].add_changed_card_traits(
572 crate::card::card_trait_changes::CardTraitChanges::remove_all_layer(
573 Vec::new(),
574 Vec::new(),
575 Vec::new(),
576 Vec::new(),
577 ),
578 timestamp,
579 static_id,
580 );
581 }
582 EffectKind::GrantKeyword(kw) => {
583 let card = &mut game.cards[effect.target.index()];
584 card.granted_keywords.add(&kw);
585 if let Some(cost_str) = crate::keyword::extract_keyword_cost_str(&kw, "Ward") {
586 let next_id = card
587 .triggers
588 .iter()
589 .map(|t| t.id)
590 .max()
591 .unwrap_or(0)
592 .saturating_add(1);
593 let mut next_id_mut = next_id;
594 let execute = format!("TrigWardGranted{}", next_id);
595 let raw = format!(
596 "Mode$ BecomesTarget | ValidSource$ SpellAbility.OppCtrl | ValidTarget$ Card.Self | Secondary$ True | Execute$ {} | TriggerZones$ Battlefield | TriggerDescription$ Ward",
597 execute
598 );
599 if let Some(mut trig) = crate::trigger::parse_trigger(&raw, &mut next_id_mut) {
600 trig.execute = execute.clone();
601 card.add_trigger(trig);
602 }
603 card.granted_svars.insert(
604 execute,
605 format!(
606 "DB$ Counter | Defined$ TriggeredSourceSA | UnlessCost$ {cost_str}"
607 ),
608 );
609 }
610 }
611 EffectKind::AddType(t) => {
612 let card = &mut game.cards[effect.target.index()];
613 if !type_line_has_token(&card.type_line, &t) {
614 if card.static_type_line_base.is_none() {
615 card.static_type_line_base = Some(card.type_line.clone());
616 }
617 card.add_type(&t);
618 card.static_added_subtypes.push(t);
619 }
620 }
621 EffectKind::GrantAbility { text, svars } => {
622 game.cards[effect.target.index()]
625 .granted_svars
626 .extend(svars);
627 let target_idx = effect.target.index();
628 let next_idx = game.cards[target_idx].activated_abilities.len();
629 if let Some(ab) =
630 crate::ability::activated::parse_activated_ability(&text, next_idx)
631 {
632 game.cards[target_idx].activated_abilities.push(ab);
633 }
634 }
635 EffectKind::GrantTrigger { text, svars } => {
636 game.cards[effect.target.index()]
637 .granted_svars
638 .extend(svars);
639 let next_id = game.cards[effect.target.index()]
640 .triggers
641 .iter()
642 .map(|t| t.id)
643 .max()
644 .unwrap_or(0)
645 .saturating_add(1);
646 let mut next_id_mut = next_id;
647 if let Some(trig) = crate::trigger::parse_trigger(&text, &mut next_id_mut) {
648 game.cards[effect.target.index()].add_trigger(trig);
649 }
650 }
651 }
652 }
653
654 for card in game.cards.iter_mut() {
657 if card.zone == ZoneType::Battlefield {
658 card.generate_basic_land_mana_abilities();
659 }
660 }
661}
662
663fn apply_player_rules_effects(game: &mut GameState, source_id: CardId, sa: &StaticAbility) {
664 let Some(adjust_land_plays) = sa.ir.adjust_land_plays_text.as_deref() else {
665 return;
666 };
667 let affected_players = affected_players_for_static(game, source_id, sa);
668 if affected_players.is_empty() {
669 return;
670 }
671 if adjust_land_plays.eq_ignore_ascii_case("Unlimited") {
672 for player in affected_players {
673 game.player_mut(player).unlimited_land_plays = true;
674 }
675 return;
676 }
677 let amount = resolve_rules_amount(game, source_id, adjust_land_plays);
678 for player in affected_players {
679 game.player_mut(player).max_land_plays_per_turn += amount;
680 }
681}
682
683fn affected_players_for_static(
684 game: &GameState,
685 source_id: CardId,
686 sa: &StaticAbility,
687) -> Vec<PlayerId> {
688 let Some(affected) = sa.ir.affected_text.as_deref() else {
689 return Vec::new();
690 };
691 let source = game.card(source_id);
692 game.player_order
693 .iter()
694 .copied()
695 .filter(|&player| {
696 !sa.ignore_effect_players.contains(&player)
697 && crate::card::valid_filter::matches_valid(
698 affected,
699 None,
700 Some(player),
701 source,
702 source.controller,
703 )
704 })
705 .collect()
706}
707
708fn resolve_rules_amount(game: &GameState, source_id: CardId, value: &str) -> i32 {
709 if let Ok(n) = value.trim().parse::<i32>() {
710 return n;
711 }
712 let source = game.card(source_id);
713 if let Some(svar_expr) = source.svars.get(value.trim()) {
714 if svar_expr.starts_with("Count$") {
715 return crate::ability::effects::resolve_count_svar(
716 svar_expr,
717 game,
718 source_id,
719 source.controller,
720 );
721 }
722 return crate::ability::effects::evaluate_svar(
723 svar_expr,
724 &crate::spellability::SpellAbility::new_empty(Some(source_id), source.controller),
725 );
726 }
727 0
728}
729
730pub fn apply_etb_tapped(game: &mut GameState, entering_card: CardId) {
740 apply_etb_tapped_with_agents(game, entering_card, None);
741}
742
743fn applicable_etb_tapped_replacement_sources(
744 game: &GameState,
745 entering_card: CardId,
746) -> Vec<(CardId, String)> {
747 let mut repl_sources: Vec<(CardId, String, String)> = Vec::new();
748 for c in &game.cards {
749 if c.zone != ZoneType::Battlefield {
750 continue;
751 }
752 for re in &c.replacement_effects {
753 if re.event == ReplacementType::Moved
754 && re.replace_with() == Some("ETBTapped")
755 && re.ir.destination_zone == Some(ZoneType::Battlefield)
756 && re.active_in_zone(ZoneType::Battlefield)
757 {
758 let filter = re
759 .ir
760 .valid_card_text
761 .as_deref()
762 .unwrap_or("Card.Self")
763 .to_string();
764 let desc = re.description(c, game);
765 repl_sources.push((c.id, filter, desc));
766 }
767 }
768 }
769
770 repl_sources
771 .into_iter()
772 .filter_map(|(source_id, filter_str, desc)| {
773 let tapped = if filter_str == "Card.Self" || filter_str.is_empty() {
774 source_id == entering_card
775 } else {
776 let source = &game.cards[source_id.index()];
777 let filter = CardFilter::parse(&filter_str);
778 filter.matches_with_game(&game.cards[entering_card.index()], source, game)
779 };
780 tapped.then_some((source_id, desc))
781 })
782 .collect()
783}
784
785pub fn prompt_etb_tapped_replacement_with_agents(
786 game: &mut GameState,
787 entering_card: CardId,
788 agents: &mut [Box<dyn PlayerAgent>],
789) {
790 let applicable = applicable_etb_tapped_replacement_sources(game, entering_card);
791 if applicable.is_empty() {
792 return;
793 }
794
795 let affected_player = game.cards[entering_card.index()].controller;
796 let descriptions: Vec<String> = applicable
797 .iter()
798 .map(|(source_id, desc)| format!("{}: {}", game.card(*source_id).card_name, desc))
799 .collect();
800 let _chosen = agents[affected_player.index()]
801 .choose_single_replacement_effect(affected_player, &descriptions)
802 .min(applicable.len().saturating_sub(1));
803}
804
805pub fn apply_etb_tapped_with_agents(
806 game: &mut GameState,
807 entering_card: CardId,
808 agents: Option<&mut [Box<dyn PlayerAgent>]>,
809) {
810 let etb_sources: Vec<(CardId, String)> = game
813 .cards
814 .iter()
815 .filter(|c| c.zone == ZoneType::Battlefield)
816 .flat_map(|c| {
817 c.static_abilities.iter().filter_map(move |sa| {
818 if sa.check_mode(&StaticMode::ETBTapped) {
819 let filter_str = sa
820 .ir
821 .valid_cards_text
822 .clone()
823 .or_else(|| sa.ir.affected_text.clone())
824 .unwrap_or_else(|| "Card.Self".to_string());
826 Some((c.id, filter_str))
827 } else {
828 None
829 }
830 })
831 })
832 .collect();
833
834 for (source_id, filter_str) in etb_sources {
835 let tapped = if filter_str == "Card.Self" || filter_str.is_empty() {
837 source_id == entering_card
838 } else {
839 let source = &game.cards[source_id.index()];
840 let filter = CardFilter::parse(&filter_str);
841 filter.matches_with_game(&game.cards[entering_card.index()], source, game)
842 };
843
844 if tapped {
845 game.cards[entering_card.index()].tapped = true;
846 return; }
848 }
849
850 let applicable = applicable_etb_tapped_replacement_sources(game, entering_card);
855 if applicable.is_empty() {
856 return;
857 }
858
859 if let Some(agents) = agents {
860 prompt_etb_tapped_replacement_with_agents(game, entering_card, agents);
861 }
862
863 game.cards[entering_card.index()].tapped = true;
864}
865
866pub fn get_etb_unless_life_cost(card: &crate::card::Card) -> Option<i32> {
874 for re in &card.replacement_effects {
875 if re.event != ReplacementType::Moved {
876 continue;
877 }
878 if re.ir.destination_zone != Some(ZoneType::Battlefield) {
879 continue;
880 }
881 if let Some(svar_name) = re.replace_with() {
882 if svar_name == "ETBTapped" {
883 continue;
884 }
885 if let Some(svar_val) = card.svars.get(svar_name) {
886 if svar_val.contains("DB$ Tap") && svar_val.contains("ETB$ True") {
887 if let Some(pos) = svar_val.find("PayLife<") {
889 let after = &svar_val[pos + 8..]; if let Some(end) = after.find('>') {
891 if let Ok(n) = after[..end].parse::<i32>() {
892 return Some(n);
893 }
894 }
895 }
896 }
897 }
898 }
899 }
900 None
901}
902
903pub fn get_etb_unless_reveal_cost(card: &crate::card::Card) -> Option<(i32, String)> {
910 for re in &card.replacement_effects {
911 if re.event != ReplacementType::Moved {
912 continue;
913 }
914 if re.ir.destination_zone != Some(ZoneType::Battlefield) {
915 continue;
916 }
917 if let Some(svar_name) = re.replace_with() {
918 if svar_name == "ETBTapped" {
919 continue;
920 }
921 if let Some(svar_val) = card.svars.get(svar_name) {
922 if svar_val.contains("DB$ Tap") && svar_val.contains("ETB$ True") {
923 if let Some(pos) = svar_val.find("Reveal<") {
925 let after = &svar_val[pos + 7..]; if let Some(end) = after.find('>') {
927 let inner = &after[..end]; let mut parts = inner.splitn(2, '/');
929 let n = parts
930 .next()
931 .and_then(|s| s.trim().parse::<i32>().ok())
932 .unwrap_or(1);
933 let filter = parts.next().unwrap_or("").trim().to_string();
934 return Some((n, filter));
935 }
936 }
937 }
938 }
939 }
940 }
941 None
942}
943
944fn resolve_add_pt_value(game: &GameState, source_id: CardId, val_str: Option<&str>) -> i32 {
947 let val_str = match val_str {
948 Some(val_str) => val_str,
949 None => return 0,
950 };
951
952 if let Ok(n) = val_str.trim().parse::<i32>() {
954 return n;
955 }
956
957 let source = game.card(source_id);
959 if let Some(svar_expr) = source.svars.get(val_str.trim()) {
960 if svar_expr.starts_with("Count$") {
961 return crate::ability::effects::resolve_count_svar(
962 svar_expr,
963 game,
964 source_id,
965 source.controller,
966 );
967 }
968 return crate::ability::effects::evaluate_svar(
969 svar_expr,
970 &crate::spellability::SpellAbility::new_empty(Some(source_id), source.controller),
971 );
972 }
973
974 0
975}
976
977fn resolve_set_pt_value(game: &GameState, source_id: CardId, val_str: Option<&str>) -> Option<i32> {
981 let val_str = val_str?;
982 if let Ok(n) = val_str.trim().parse::<i32>() {
984 return Some(n);
985 }
986
987 let source = game.card(source_id);
989 if let Some(svar_expr) = source.svars.get(val_str.trim()) {
990 if svar_expr.starts_with("Count$") {
991 return Some(crate::ability::effects::resolve_count_svar(
992 svar_expr,
993 game,
994 source_id,
995 source.controller,
996 ));
997 }
998 return Some(crate::ability::effects::evaluate_svar(
1000 svar_expr,
1001 &crate::spellability::SpellAbility::new_empty(Some(source_id), source.controller),
1002 ));
1003 }
1004
1005 None
1006}
1007
1008fn basic_land_mana_ability_text(subtype: &str) -> Option<&'static str> {
1009 match subtype {
1010 "Plains" => Some("AB$ Mana | Cost$ T | Produced$ W | SpellDescription$ Add {W}."),
1011 "Island" => Some("AB$ Mana | Cost$ T | Produced$ U | SpellDescription$ Add {U}."),
1012 "Swamp" => Some("AB$ Mana | Cost$ T | Produced$ B | SpellDescription$ Add {B}."),
1013 "Mountain" => Some("AB$ Mana | Cost$ T | Produced$ R | SpellDescription$ Add {R}."),
1014 "Forest" => Some("AB$ Mana | Cost$ T | Produced$ G | SpellDescription$ Add {G}."),
1015 _ => None,
1016 }
1017}
1018
1019fn resolve_added_basic_land_types(
1020 source: &crate::card::Card,
1021 add_type: Option<&str>,
1022) -> Vec<String> {
1023 resolve_added_types(source, add_type)
1024 .into_iter()
1025 .filter(|added| basic_land_mana_ability_text(added).is_some())
1026 .collect()
1027}
1028
1029fn resolve_added_types(source: &crate::card::Card, add_type: Option<&str>) -> Vec<String> {
1030 let Some(add_type) = add_type else {
1031 return Vec::new();
1032 };
1033 let mut resolved = Vec::new();
1034 for raw in add_type.split('&').map(str::trim).filter(|s| !s.is_empty()) {
1035 match raw {
1036 "ChosenType" => {
1037 if let Some(chosen) = source.chosen_type.as_ref() {
1038 resolved.push(chosen.clone());
1039 }
1040 }
1041 "ChosenType2" => {
1042 if let Some(chosen) = source.chosen_type2.as_ref() {
1043 resolved.push(chosen.clone());
1044 }
1045 }
1046 "AllBasicLandType" => {
1047 resolved.extend(
1048 ["Plains", "Island", "Swamp", "Mountain", "Forest"]
1049 .into_iter()
1050 .map(str::to_string),
1051 );
1052 }
1053 other => resolved.push(other.to_string()),
1054 }
1055 }
1056 resolved
1057}
1058
1059#[cfg(test)]
1062mod tests {
1063 use super::*;
1064 use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
1065
1066 use crate::card::Card;
1067 use crate::ids::{CardId, PlayerId};
1068
1069 fn new_game() -> GameState {
1071 GameState::new(&["Alice", "Bob"], 20)
1072 }
1073
1074 fn add_creature(
1075 game: &mut GameState,
1076 owner: PlayerId,
1077 power: i32,
1078 toughness: i32,
1079 keywords: Vec<String>,
1080 abilities: Vec<String>,
1081 ) -> CardId {
1082 let card = Card::new(
1083 CardId(0), "Creature".to_string(),
1085 owner,
1086 CardTypeLine::parse("Creature"),
1087 ManaCost::parse("1 G"),
1088 ColorSet::GREEN,
1089 Some(power),
1090 Some(toughness),
1091 keywords,
1092 abilities,
1093 );
1094 let id = game.create_card(card);
1095 game.move_card(id, ZoneType::Battlefield, owner);
1096 id
1097 }
1098
1099 fn add_enchantment(game: &mut GameState, owner: PlayerId, abilities: Vec<String>) -> CardId {
1100 let card = Card::new(
1101 CardId(0),
1102 "Enchantment".to_string(),
1103 owner,
1104 CardTypeLine::parse("Enchantment"),
1105 ManaCost::parse("2 W"),
1106 ColorSet::WHITE,
1107 None,
1108 None,
1109 vec![],
1110 abilities,
1111 );
1112 let id = game.create_card(card);
1113 game.move_card(id, ZoneType::Battlefield, owner);
1114 id
1115 }
1116
1117 fn add_land(
1118 game: &mut GameState,
1119 owner: PlayerId,
1120 name: &str,
1121 type_line: &str,
1122 abilities: Vec<String>,
1123 ) -> CardId {
1124 let card = Card::new(
1125 CardId(0),
1126 name.to_string(),
1127 owner,
1128 CardTypeLine::parse(type_line),
1129 ManaCost::no_cost(),
1130 ColorSet::COLORLESS,
1131 None,
1132 None,
1133 vec![],
1134 abilities,
1135 );
1136 let id = game.create_card(card);
1137 game.move_card(id, ZoneType::Battlefield, owner);
1138 id
1139 }
1140
1141 fn add_effect(game: &mut GameState, owner: PlayerId, abilities: Vec<String>) -> CardId {
1142 let card = Card::new(
1143 CardId(0),
1144 "Effect".to_string(),
1145 owner,
1146 CardTypeLine::parse("Effect"),
1147 ManaCost::parse("0"),
1148 ColorSet::COLORLESS,
1149 None,
1150 None,
1151 vec![],
1152 abilities,
1153 );
1154 let id = game.create_card(card);
1155 game.move_card(id, ZoneType::Command, owner);
1156 id
1157 }
1158
1159 #[test]
1162 fn anthem_boosts_your_creatures() {
1163 let mut game = new_game();
1164 let alice = PlayerId(0);
1165 let bob = PlayerId(1);
1166
1167 let a1 = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1169 let a2 = add_creature(&mut game, alice, 1, 1, vec![], vec![]);
1170 let b1 = add_creature(&mut game, bob, 2, 2, vec![], vec![]);
1171
1172 let _anthem = add_enchantment(
1174 &mut game,
1175 alice,
1176 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1 | Description$ Creatures you control get +1/+1.".to_string()],
1177 );
1178
1179 apply_continuous_effects(&mut game);
1180
1181 assert_eq!(game.card(a1).power(), 3, "Alice's 2/2 should be 3/3");
1183 assert_eq!(game.card(a1).toughness(), 3);
1184 assert_eq!(game.card(a2).power(), 2, "Alice's 1/1 should be 2/2");
1185 assert_eq!(game.card(a2).toughness(), 2);
1186
1187 assert_eq!(
1189 game.card(b1).power(),
1190 2,
1191 "Bob's creature should be unchanged"
1192 );
1193 assert_eq!(game.card(b1).toughness(), 2);
1194 }
1195
1196 #[test]
1197 fn command_effect_adjusts_land_plays_for_affected_player() {
1198 let mut game = new_game();
1199 let alice = PlayerId(0);
1200 let bob = PlayerId(1);
1201
1202 let effect = add_effect(
1203 &mut game,
1204 alice,
1205 vec![
1206 "S$ Mode$ Continuous | EffectZone$ Command | Affected$ You | AdjustLandPlays$ 1"
1207 .to_string(),
1208 ],
1209 );
1210
1211 apply_continuous_effects(&mut game);
1212
1213 assert_eq!(game.player(alice).max_land_plays_per_turn, 2);
1214 assert_eq!(game.player(bob).max_land_plays_per_turn, 1);
1215
1216 game.move_card(effect, ZoneType::Exile, alice);
1217 apply_continuous_effects(&mut game);
1218
1219 assert_eq!(game.player(alice).max_land_plays_per_turn, 1);
1220 }
1221
1222 #[test]
1223 fn anthem_resets_when_removed() {
1224 let mut game = new_game();
1225 let alice = PlayerId(0);
1226
1227 let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1228 let anthem = add_enchantment(
1229 &mut game,
1230 alice,
1231 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1".to_string()],
1232 );
1233
1234 apply_continuous_effects(&mut game);
1235 assert_eq!(game.card(creature).power(), 3);
1236
1237 game.move_card(anthem, ZoneType::Graveyard, alice);
1239 apply_continuous_effects(&mut game);
1240
1241 assert_eq!(
1242 game.card(creature).power(),
1243 2,
1244 "Bonus should be gone after anthem leaves"
1245 );
1246 }
1247
1248 #[test]
1249 fn stacking_anthems() {
1250 let mut game = new_game();
1251 let alice = PlayerId(0);
1252
1253 let creature = add_creature(&mut game, alice, 1, 1, vec![], vec![]);
1254 add_enchantment(
1256 &mut game,
1257 alice,
1258 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1".to_string()],
1259 );
1260 add_enchantment(
1261 &mut game,
1262 alice,
1263 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1".to_string()],
1264 );
1265
1266 apply_continuous_effects(&mut game);
1267 assert_eq!(game.card(creature).power(), 3, "Two anthems should give +2");
1268 assert_eq!(game.card(creature).toughness(), 3);
1269 }
1270
1271 #[test]
1274 fn grant_flying_to_your_creatures() {
1275 let mut game = new_game();
1276 let alice = PlayerId(0);
1277 let bob = PlayerId(1);
1278
1279 let a1 = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1280 let b1 = add_creature(&mut game, bob, 2, 2, vec![], vec![]);
1281
1282 add_enchantment(
1283 &mut game,
1284 alice,
1285 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddKeyword$ Flying | Description$ Creatures you control have flying.".to_string()],
1286 );
1287
1288 apply_continuous_effects(&mut game);
1289
1290 assert!(
1291 game.card(a1).has_flying(),
1292 "Alice's creature should have flying"
1293 );
1294 assert!(
1295 !game.card(b1).has_flying(),
1296 "Bob's creature should not have flying"
1297 );
1298 }
1299
1300 #[test]
1301 fn grant_multiple_keywords() {
1302 let mut game = new_game();
1303 let alice = PlayerId(0);
1304
1305 let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1306 add_enchantment(
1307 &mut game,
1308 alice,
1309 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddKeyword$ Flying & First Strike".to_string()],
1310 );
1311
1312 apply_continuous_effects(&mut game);
1313
1314 assert!(game.card(creature).has_flying());
1315 assert!(game.card(creature).has_first_strike());
1316 }
1317
1318 #[test]
1321 fn set_pt_overrides_base() {
1322 let mut game = new_game();
1323 let alice = PlayerId(0);
1324
1325 let creature = add_creature(&mut game, alice, 5, 5, vec![], vec![]);
1326 add_enchantment(
1328 &mut game,
1329 alice,
1330 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | SetPower$ 0 | SetToughness$ 1".to_string()],
1331 );
1332
1333 apply_continuous_effects(&mut game);
1334 assert_eq!(game.card(creature).power(), 0);
1335 assert_eq!(game.card(creature).toughness(), 1);
1336 }
1337
1338 #[test]
1339 fn modify_pt_adds_on_top_of_set_pt() {
1340 let mut game = new_game();
1342 let alice = PlayerId(0);
1343
1344 let creature = add_creature(&mut game, alice, 5, 5, vec![], vec![]);
1345 add_enchantment(
1347 &mut game,
1348 alice,
1349 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | SetPower$ 0 | SetToughness$ 1".to_string()],
1350 );
1351 add_enchantment(
1353 &mut game,
1354 alice,
1355 vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1".to_string()],
1356 );
1357
1358 apply_continuous_effects(&mut game);
1359 assert_eq!(game.card(creature).power(), 1);
1361 assert_eq!(game.card(creature).toughness(), 2);
1362 }
1363
1364 #[test]
1367 fn cant_attack_flag_set() {
1368 let mut game = new_game();
1369 let alice = PlayerId(0);
1370
1371 let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1372 add_enchantment(
1374 &mut game,
1375 alice,
1376 vec!["S$ Mode$ CantAttack | Affected$ Creature.YouControl | Description$ Creatures you control can't attack.".to_string()],
1377 );
1378
1379 apply_continuous_effects(&mut game);
1380 assert!(game.card(creature).cant_attack_static);
1381 }
1382
1383 #[test]
1384 fn cant_block_flag_set() {
1385 let mut game = new_game();
1386 let alice = PlayerId(0);
1387
1388 let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1389 add_enchantment(
1390 &mut game,
1391 alice,
1392 vec!["S$ Mode$ CantBlock | Affected$ Creature.YouControl".to_string()],
1393 );
1394
1395 apply_continuous_effects(&mut game);
1396 assert!(game.card(creature).cant_block_static);
1397 }
1398
1399 #[test]
1400 fn flags_reset_on_reapplication() {
1401 let mut game = new_game();
1402 let alice = PlayerId(0);
1403
1404 let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1405 let restrictor = add_enchantment(
1406 &mut game,
1407 alice,
1408 vec!["S$ Mode$ CantAttack | Affected$ Creature.YouControl".to_string()],
1409 );
1410
1411 apply_continuous_effects(&mut game);
1412 assert!(game.card(creature).cant_attack_static);
1413
1414 game.move_card(restrictor, ZoneType::Graveyard, alice);
1415 apply_continuous_effects(&mut game);
1416 assert!(
1417 !game.card(creature).cant_attack_static,
1418 "Flag should clear after enchantment leaves"
1419 );
1420 }
1421
1422 #[test]
1423 fn lands_gain_swamp_mana_ability_from_urborg_style_effect() {
1424 let mut game = new_game();
1425 let alice = PlayerId(0);
1426
1427 let urborg = add_land(
1428 &mut game,
1429 alice,
1430 "Urborg, Tomb of Yawgmoth",
1431 "Legendary Land",
1432 vec!["S$ Mode$ Continuous | Affected$ Land | AddType$ Swamp | Description$ Each land is a Swamp in addition to its other land types.".to_string()],
1433 );
1434 let black_gate = add_land(
1435 &mut game,
1436 alice,
1437 "The Black Gate",
1438 "Legendary Land Gate",
1439 vec![],
1440 );
1441
1442 apply_continuous_effects(&mut game);
1443
1444 for land_id in [urborg, black_gate] {
1445 let land = game.card(land_id);
1446 assert!(
1447 land.type_line.has_subtype("Swamp"),
1448 "{} should gain the Swamp subtype",
1449 land.card_name
1450 );
1451 assert!(
1452 land.activated_abilities.iter().any(|ab| {
1453 ab.is_mana_ability
1454 && ab
1455 .produced_ir
1456 .as_ref()
1457 .is_some_and(|ir| ir.as_script_text() == "B")
1458 }),
1459 "{} should gain an intrinsic black mana ability from Swamp",
1460 land.card_name
1461 );
1462 }
1463 }
1464
1465 #[test]
1468 fn self_etb_tapped() {
1469 let mut game = new_game();
1470 let alice = PlayerId(0);
1471
1472 let card = Card::new(
1474 CardId(0),
1475 "TappedLand".to_string(),
1476 alice,
1477 CardTypeLine::parse("Land"),
1478 ManaCost::parse(""),
1479 ColorSet::from_mask(0),
1480 None,
1481 None,
1482 vec![],
1483 vec!["S$ Mode$ ETBTapped | Description$ Enters tapped.".to_string()],
1484 );
1485 let id = game.create_card(card);
1486 game.move_card(id, ZoneType::Battlefield, alice);
1487 apply_etb_tapped(&mut game, id);
1488
1489 assert!(
1490 game.card(id).tapped,
1491 "Card with ETBTapped should enter tapped"
1492 );
1493 }
1494
1495 #[test]
1496 fn no_etb_tapped_without_ability() {
1497 let mut game = new_game();
1498 let alice = PlayerId(0);
1499
1500 let id = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1501 assert!(
1503 !game.card(id).tapped,
1504 "Normal creature should not enter tapped"
1505 );
1506 }
1507
1508 #[test]
1509 fn etb_tapped_via_replacement_effect() {
1510 let mut game = new_game();
1511 let alice = PlayerId(0);
1512
1513 let card = Card::new(
1515 CardId(0),
1516 "PathOfAncestry".to_string(),
1517 alice,
1518 CardTypeLine::parse("Land"),
1519 ManaCost::parse(""),
1520 ColorSet::from_mask(0),
1521 None,
1522 None,
1523 vec![],
1524 vec!["R:Event$ Moved | Destination$ Battlefield | ValidCard$ Card.Self | ReplaceWith$ ETBTapped | Description$ ~ enters tapped.".to_string()],
1525 );
1526 let id = game.create_card(card);
1527 game.move_card(id, ZoneType::Battlefield, alice);
1528 apply_etb_tapped(&mut game, id);
1529
1530 assert!(
1531 game.card(id).tapped,
1532 "Card with ReplaceWith$ ETBTapped replacement should enter tapped"
1533 );
1534 }
1535}