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