use forge_foundation::{PhaseType, ZoneType};
use serde::{Deserialize, Serialize};
use crate::ability::ability_factory::build_spell_ability;
use crate::ability::AbilityKey;
use crate::card::valid_filter::CardTraitRequirementsIr;
use crate::card::Card;
use crate::card_trait_base::{CardTrait, CardTraitBase, CardTraitIrOwner};
use crate::core::HasSVars;
use crate::game::GameState;
use crate::game_loop::trigger_replacement_base::TriggerReplacementBase;
use crate::ids::{CardId, PlayerId};
use crate::parsing::{keys, CompiledSelector, Params};
pub use crate::player::GameLossReason;
use super::replacement_handler::ReplacementEvent;
use crate::spellability::SpellAbility;
pub use super::replacement_layer::ReplacementLayer;
pub use super::replacement_type::ReplacementType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplacementEffect {
#[serde(skip, default)]
pub base: Box<TriggerReplacementBase>,
pub event: ReplacementType,
pub layer: ReplacementLayer,
pub ir: ReplacementEffectIr,
pub active_zones: Vec<ZoneType>,
#[serde(default)]
pub suppressed: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReplacementEffectIr {
#[serde(skip, default)]
pub card_trait_requirements: CardTraitRequirementsIr,
pub replace_with: Option<String>,
pub description_text: Option<String>,
pub player_turn_text: Option<String>,
pub new_destination_text: Option<String>,
pub new_destination_zone: Option<ZoneType>,
pub origin_text: Option<String>,
pub origin_zone: Option<ZoneType>,
pub destination_text: Option<String>,
pub destination_zone: Option<ZoneType>,
pub exclude_destination_text: Option<String>,
pub valid_card_text: Option<String>,
pub valid_card_selector: Option<CompiledSelector>,
pub valid_player_text: Option<String>,
pub valid_player_selector: Option<CompiledSelector>,
pub valid_object_selector: Option<CompiledSelector>,
pub valid_cause_text: Option<String>,
pub valid_target_selector: Option<CompiledSelector>,
pub valid_source_selector: Option<CompiledSelector>,
pub valid_activator_text: Option<String>,
pub valid_step_turn_to_controller_text: Option<String>,
pub valid_explorer_text: Option<String>,
pub valid_counter_type_text: Option<String>,
pub valid_lose_reason_text: Option<String>,
pub valid_sides_text: Option<String>,
pub active_phases: Vec<PhaseType>,
pub amount_text: Option<String>,
pub damage_amount_text: Option<String>,
pub result_text: Option<String>,
pub number_text: Option<String>,
pub dredge_amount: Option<usize>,
pub skip: bool,
pub prevent: bool,
pub optional: bool,
pub effect_only: bool,
pub discard: Option<bool>,
pub flashback_cast: Option<bool>,
pub harmonize_cast: Option<bool>,
pub not_first_card_in_draw_step: bool,
pub exiled_with_effect_source: bool,
pub is_combat: Option<bool>,
pub is_damage: Option<bool>,
pub max_speed: Option<bool>,
pub replacement_result: Option<String>,
pub optional_decider_text: Option<String>,
pub replace_mana_text: Option<String>,
pub replace_type_text: Option<String>,
pub replace_color_text: Option<String>,
pub replace_only_text: Option<String>,
pub replace_amount_text: Option<String>,
pub valid_lki_text: Option<String>,
pub counter_map: bool,
pub replace_with_chain: Option<ReplacementChainIr>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ReplacementChainIr {
ReplaceToken {
token_type: Option<String>,
amount_expr: Option<String>,
},
ReplaceCounter {
amount_expr: String,
choose_counter: bool,
after_replacement_static_abilities: Vec<String>,
},
ReplaceEffect {
var_name: Option<String>,
var_type: Option<String>,
var_key: Option<String>,
var_value: Option<String>,
sub_ability: Option<Box<ReplacementChainIr>>,
},
}
impl CardTrait for ReplacementEffect {
fn base(&self) -> &CardTraitBase {
&self.base.card_trait_base
}
}
impl CardTraitIrOwner for ReplacementEffect {
type Ir = ReplacementEffectIr;
fn ir(&self) -> &Self::Ir {
&self.ir
}
fn card_trait_requirements(&self) -> &CardTraitRequirementsIr {
&self.ir.card_trait_requirements
}
}
impl ReplacementEffect {
pub fn set_host_card(&mut self, host: &Card) {
self.ir.replace_with_chain = self
.replace_with()
.and_then(|svar_name| host.get_svar(svar_name))
.and_then(|raw| parse_replacement_chain(raw, host));
self.base.set_host_card_id(host.id);
}
}
impl ReplacementEffect {
pub fn new(
event: ReplacementType,
layer: ReplacementLayer,
params: Params,
active_zones: Vec<ZoneType>,
) -> Self {
let mut effect = Self {
base: Box::new(TriggerReplacementBase::default()),
event,
layer,
ir: ReplacementEffectIr::from_params(¶ms),
active_zones,
suppressed: false,
};
effect.sync_trait_base_params(¶ms);
effect
}
pub fn replace_with(&self) -> Option<&str> {
self.ir.replace_with.as_deref()
}
pub fn has_skip(&self) -> bool {
self.ir.skip
}
pub fn prevents(&self) -> bool {
self.ir.prevent
}
pub fn matches_phase(&self, phase: PhaseType) -> bool {
self.ir.active_phases.is_empty() || self.ir.active_phases.contains(&phase)
}
fn sync_trait_base_params(&mut self, params: &Params) {
let map = params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
self.base.card_trait_base.set_map_params(map);
}
pub fn active_in_zone(&self, zone: ZoneType) -> bool {
!self.suppressed && (self.active_zones.is_empty() || self.active_zones.contains(&zone))
}
pub fn description(&self, host: &Card, game: &GameState) -> String {
if self.suppressed || self.base.card_trait_base.is_suppressed() {
return String::new();
}
let Some(raw) = self.ir.description_text.as_deref() else {
return String::new();
};
let mut desc =
crate::ability::ability_utils::apply_description_text_change_effects(raw, host);
desc = desc.replace("CARDNAME", &host.card_name);
desc = desc.replace("NICKNAME", &host.card_name);
if desc.contains("EFFECTSOURCE") {
let source_name = host
.effect_source
.map(|id| game.card(id).card_name.clone())
.unwrap_or_else(|| host.card_name.clone());
desc = desc.replace("EFFECTSOURCE", &source_name);
}
if self.event == ReplacementType::DamageDone {
if let Some(rep_sa) = self.base.get_overriding_ability() {
desc = append_shield_remaining(desc, rep_sa, host);
}
}
desc
}
pub fn has_run(&self) -> bool {
false
}
pub fn requirements_check(&self, game: &GameState, source: &Card) -> bool {
if self.suppressed || self.base.card_trait_base.is_suppressed() {
return false;
}
if let Some(pt) = self.ir.player_turn_text.as_deref() {
if pt == "True" {
if game.active_player() != source.controller {
return false;
}
} else {
let players = crate::ability::ability_utils::resolve_defined_players(
pt,
source.controller,
game,
);
if !players.contains(&game.active_player()) {
return false;
}
}
}
if !self.matches_phase(game.turn.phase) {
return false;
}
self.meets_card_trait_requirements(game, source, source)
}
pub fn copy(&self) -> Self {
self.clone()
}
pub fn ensure_ability(
&self,
game: &GameState,
host_card: CardId,
activating_player: PlayerId,
) -> Option<SpellAbility> {
if let Some(overriding) = self.base.get_overriding_ability() {
return Some(overriding.clone());
}
let svar_name = self.replace_with()?;
let host = game.card(host_card);
let script = host.get_svar(svar_name)?;
Some(build_spell_ability(
game,
host_card,
script,
activating_player,
))
}
pub fn ensure_ability_mut(
&mut self,
game: &GameState,
host_card: CardId,
activating_player: PlayerId,
) -> Option<&mut SpellAbility> {
if self.base.get_overriding_ability().is_none() {
let ability = self.ensure_ability(game, host_card, activating_player)?;
self.base.overriding_ability = Some(ability);
}
self.base.overriding_ability.as_mut()
}
pub fn can_replace_etb(&self, source: &Card, affected: &Card) -> bool {
let targets_self_only = self
.ir
.valid_card_text
.as_deref()
.map(|v| v.starts_with("Card.Self"))
.unwrap_or(false);
if !targets_self_only && source.id == affected.id {
return false;
}
true
}
pub fn set_replacing_objects(
&self,
event: &ReplacementEvent,
sa: &mut crate::spellability::SpellAbility,
) {
let mut current = Some(sa);
while let Some(node) = current {
match event {
ReplacementEvent::Moved {
card,
origin,
destination,
..
} => {
let card_csv = card.0.to_string();
node.set_triggering_object(AbilityKey::Card, card_csv.as_str());
node.set_triggering_object(AbilityKey::ReplacedCard, card_csv.as_str());
node.set_triggering_object(AbilityKey::Affected, card_csv.as_str());
node.set_triggering_object(AbilityKey::Origin, format!("{origin:?}").as_str());
node.set_triggering_object(
AbilityKey::Destination,
format!("{destination:?}").as_str(),
);
}
ReplacementEvent::DamageToCard {
target,
amount,
source,
..
} => {
let target_csv = target.0.to_string();
node.set_triggering_object(AbilityKey::Target, target_csv.as_str());
node.set_triggering_object(AbilityKey::Affected, target_csv.as_str());
node.set_triggering_object(
AbilityKey::DamageAmount,
amount.to_string().as_str(),
);
if let Some(src) = source {
let src_csv = src.0.to_string();
node.set_triggering_object(AbilityKey::Source, src_csv.as_str());
node.set_triggering_object(AbilityKey::DamageSource, src_csv.as_str());
}
}
ReplacementEvent::DamageToPlayer {
target,
amount,
source,
..
} => {
node.set_triggering_object(
AbilityKey::DamageAmount,
amount.to_string().as_str(),
);
if let Some(src) = source {
let src_csv = src.0.to_string();
node.set_triggering_object(AbilityKey::Source, src_csv.as_str());
node.set_triggering_object(AbilityKey::DamageSource, src_csv.as_str());
}
node.set_triggering_object(
AbilityKey::TriggeredPlayer,
target.index().to_string().as_str(),
);
}
ReplacementEvent::Destroy { target } => {
let target_csv = target.0.to_string();
node.set_triggering_object(AbilityKey::Card, target_csv.as_str());
node.set_triggering_object(AbilityKey::Affected, target_csv.as_str());
}
ReplacementEvent::AddCounter {
target,
counter_type,
count,
..
} => {
let target_csv = match target {
crate::agent::GameEntity::Card(card) => card.0.to_string(),
crate::agent::GameEntity::Player(player) => player.0.to_string(),
};
match target {
crate::agent::GameEntity::Card(_) => {
node.set_triggering_object(AbilityKey::Card, target_csv.as_str());
}
crate::agent::GameEntity::Player(_) => {
node.set_triggering_object(AbilityKey::Player, target_csv.as_str());
}
}
node.set_triggering_object(AbilityKey::Affected, target_csv.as_str());
node.set_triggering_object(AbilityKey::Object, target_csv.as_str());
node.set_triggering_object(
AbilityKey::CounterMap,
format!("{counter_type:?}:{count}").as_str(),
);
}
ReplacementEvent::Draw {
player,
extra_draws,
..
} => {
let pid = player.index().to_string();
node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
node.set_triggering_object(AbilityKey::Affected, pid.as_str());
node.set_triggering_object(AbilityKey::Num, extra_draws.to_string().as_str());
}
ReplacementEvent::DrawCards { player, count } => {
let pid = player.index().to_string();
node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
node.set_triggering_object(AbilityKey::Affected, pid.as_str());
node.set_triggering_object(AbilityKey::Num, count.to_string().as_str());
}
ReplacementEvent::CreateToken { player, count, .. } => {
let pid = player.index().to_string();
node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
node.set_triggering_object(AbilityKey::Affected, pid.as_str());
node.set_triggering_object(AbilityKey::TokenNum, count.to_string().as_str());
}
ReplacementEvent::GainLife { player, amount }
| ReplacementEvent::PayLife { player, amount }
| ReplacementEvent::LifeReduced { player, amount, .. } => {
let pid = player.index().to_string();
node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
node.set_triggering_object(AbilityKey::Affected, pid.as_str());
node.set_triggering_object(AbilityKey::LifeAmount, amount.to_string().as_str());
}
ReplacementEvent::Mill { player, count }
| ReplacementEvent::Scry { player, count }
| ReplacementEvent::Proliferate { player, count }
| ReplacementEvent::CopySpell { player, count } => {
let pid = player.index().to_string();
node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
node.set_triggering_object(AbilityKey::Affected, pid.as_str());
node.set_triggering_object(AbilityKey::Num, count.to_string().as_str());
}
ReplacementEvent::BeginTurn { player }
| ReplacementEvent::BeginPhase { player, .. }
| ReplacementEvent::DeclareBlocker { player }
| ReplacementEvent::RollPlanarDice { player }
| ReplacementEvent::PlanarDiceResult { player }
| ReplacementEvent::LoseMana { player }
| ReplacementEvent::GameLoss { player, .. }
| ReplacementEvent::GameWin { player }
| ReplacementEvent::Cascade { player }
| ReplacementEvent::Learn { player }
| ReplacementEvent::Planeswalk { player }
| ReplacementEvent::SetInMotion { player }
| ReplacementEvent::AssembleContraption { player } => {
let pid = player.index().to_string();
node.set_triggering_object(AbilityKey::TriggeredPlayer, pid.as_str());
node.set_triggering_object(AbilityKey::Affected, pid.as_str());
}
ReplacementEvent::Counter { card }
| ReplacementEvent::Tap { card }
| ReplacementEvent::Untap { card, .. }
| ReplacementEvent::Explore { card }
| ReplacementEvent::Transform { card }
| ReplacementEvent::TurnFaceUp { card }
| ReplacementEvent::AssignDealDamage { card } => {
let csv = card.0.to_string();
node.set_triggering_object(AbilityKey::Card, csv.as_str());
node.set_triggering_object(AbilityKey::Affected, csv.as_str());
}
ReplacementEvent::DealtDamage {
target,
amount,
source,
} => {
let target_csv = target.0.to_string();
node.set_triggering_object(AbilityKey::Target, target_csv.as_str());
node.set_triggering_object(AbilityKey::Affected, target_csv.as_str());
node.set_triggering_object(
AbilityKey::DamageAmount,
amount.to_string().as_str(),
);
if let Some(src) = source {
node.set_triggering_object(
AbilityKey::DamageSource,
src.0.to_string().as_str(),
);
}
}
ReplacementEvent::RemoveCounter {
target,
counter_type,
count,
} => {
let target_csv = target.0.to_string();
node.set_triggering_object(AbilityKey::Card, target_csv.as_str());
node.set_triggering_object(
AbilityKey::CounterMap,
format!("{counter_type:?}:{count}").as_str(),
);
}
ReplacementEvent::Attached { card, target } => {
node.set_triggering_object(AbilityKey::Card, card.0.to_string().as_str());
node.set_triggering_object(AbilityKey::Target, target.0.to_string().as_str());
node.set_triggering_object(AbilityKey::Affected, target.0.to_string().as_str());
}
ReplacementEvent::ProduceMana {
source,
activator,
mana,
} => {
node.set_triggering_object(AbilityKey::Source, source.0.to_string().as_str());
node.set_triggering_object(AbilityKey::Card, source.0.to_string().as_str());
node.set_triggering_object(
AbilityKey::TriggeredPlayer,
activator.index().to_string().as_str(),
);
node.set_triggering_object(AbilityKey::Produced, mana.as_str());
}
ReplacementEvent::RollDice {
player,
sides,
number,
..
} => {
node.set_triggering_object(
AbilityKey::TriggeredPlayer,
player.index().to_string().as_str(),
);
node.set_triggering_object(AbilityKey::Num, number.to_string().as_str());
node.set_triggering_object(AbilityKey::Sides, sides.to_string().as_str());
}
}
current = node.get_sub_ability_mut();
}
}
pub fn mode_check(&self, event: &ReplacementType) -> bool {
if self.event == *event {
return true;
}
if self.event == ReplacementType::AddCounter && *event == ReplacementType::Moved {
return self.ir.counter_map;
}
false
}
}
impl ReplacementEffectIr {
fn from_params(params: &Params) -> Self {
Self {
card_trait_requirements: CardTraitRequirementsIr::from_key_values(
params.iter(),
params.selector_untracked(keys::IS_PRESENT).cloned(),
params.selector_untracked("IsPresent2").cloned(),
),
replace_with: params.get(keys::REPLACE_WITH).map(str::to_string),
description_text: params.get(keys::DESCRIPTION).map(str::to_string),
player_turn_text: params.get(keys::PLAYER_TURN).map(str::to_string),
new_destination_text: params.get(keys::NEW_DESTINATION).map(str::to_string),
new_destination_zone: parsed_zone_type(params.get(keys::NEW_DESTINATION)),
origin_text: params.get(keys::ORIGIN).map(str::to_string),
origin_zone: parsed_zone_type(params.get(keys::ORIGIN)),
destination_text: params.get(keys::DESTINATION).map(str::to_string),
destination_zone: parsed_zone_type(params.get(keys::DESTINATION)),
exclude_destination_text: params.get("ExcludeDestination").map(str::to_string),
valid_card_text: params.get(keys::VALID_CARD).map(str::to_string),
valid_card_selector: params.selector_cloned(keys::VALID_CARD),
valid_player_text: params.get(keys::VALID_PLAYER).map(str::to_string),
valid_player_selector: params.selector_cloned(keys::VALID_PLAYER),
valid_object_selector: params.selector_cloned("ValidObject"),
valid_cause_text: params.get(keys::VALID_CAUSE).map(str::to_string),
valid_target_selector: params.selector_cloned(keys::VALID_TARGET),
valid_source_selector: params.selector_cloned(keys::VALID_SOURCE),
valid_activator_text: params.get(keys::VALID_ACTIVATOR).map(str::to_string),
valid_step_turn_to_controller_text: params
.get("ValidStepTurnToController")
.map(str::to_string),
valid_explorer_text: params.get(keys::VALID_EXPLORER).map(str::to_string),
valid_counter_type_text: params.get(keys::VALID_COUNTER_TYPE).map(str::to_string),
valid_lose_reason_text: params.get(keys::VALID_LOSE_REASON).map(str::to_string),
valid_sides_text: params.get(keys::VALID_SIDES).map(str::to_string),
active_phases: parsed_phase_types(
params
.get(keys::PHASE)
.or_else(|| params.get(keys::ACTIVE_PHASES)),
),
amount_text: params.get(keys::AMOUNT).map(str::to_string),
damage_amount_text: params.get(keys::DAMAGE_AMOUNT).map(str::to_string),
result_text: params.get(keys::RESULT).map(str::to_string),
number_text: params.get(keys::NUMBER).map(str::to_string),
dredge_amount: params.get("DredgeAmount").and_then(|s| s.parse().ok()),
skip: params.has(keys::SKIP),
prevent: parsed_true(params.get(keys::PREVENT)),
optional: params.has(keys::OPTIONAL),
effect_only: params.has("EffectOnly"),
discard: parsed_bool(params.get("Discard")),
flashback_cast: parsed_bool(params.get("FlashbackCast")),
harmonize_cast: parsed_bool(params.get("HarmonizeCast")),
not_first_card_in_draw_step: parsed_true(params.get("NotFirstCardInDrawStep")),
exiled_with_effect_source: params.has("ExiledWithEffectSource"),
is_combat: parsed_bool(params.get(keys::IS_COMBAT)),
is_damage: parsed_bool(params.get(keys::IS_DAMAGE)),
max_speed: parsed_bool(params.get("MaxSpeed")),
replacement_result: params.get("ReplacementResult").map(str::to_string),
optional_decider_text: params.get(keys::OPTIONAL_DECIDER).map(str::to_string),
replace_mana_text: params.get(keys::REPLACE_MANA).map(str::to_string),
replace_type_text: params.get(keys::REPLACE_TYPE).map(str::to_string),
replace_color_text: params.get(keys::REPLACE_COLOR).map(str::to_string),
replace_only_text: params.get(keys::REPLACE_ONLY).map(str::to_string),
replace_amount_text: params.get(keys::REPLACE_AMOUNT).map(str::to_string),
valid_lki_text: params.get("ValidLKI").map(str::to_string),
counter_map: params.get("CounterMap").is_some(),
replace_with_chain: None,
}
}
}
fn append_shield_remaining(mut desc: String, rep_sa: &SpellAbility, host: &Card) -> String {
use crate::ability::api_type::ApiType;
let api = match rep_sa.api {
Some(a) => a,
None => return desc,
};
let (param_value, default_one) = match api {
ApiType::ReplaceDamage => match rep_sa.ir.amount.as_deref() {
Some(v) => (v.to_string(), false),
None => return desc,
},
ApiType::ReplaceSplitDamage => (
rep_sa
.ir
.var_name_text
.clone()
.unwrap_or_else(|| "1".to_string()),
true,
),
_ => return desc,
};
if param_value.chars().all(|c| c.is_ascii_digit()) {
if default_one && param_value == "1" {
desc.push_str(" \nShields remain: 1");
}
return desc;
}
if let Some(resolved) = host.get_svar(¶m_value) {
if let Some(rest) = resolved.strip_prefix("Number$") {
desc.push_str(" \nShields remain: ");
desc.push_str(rest);
}
}
desc
}
pub fn zone_matches(expr: &str, zone: ZoneType) -> bool {
expr.split(',').any(|part| match part.trim() {
"Battlefield" => zone == ZoneType::Battlefield,
"Graveyard" => zone == ZoneType::Graveyard,
"Hand" => zone == ZoneType::Hand,
"Library" => zone == ZoneType::Library,
"Exile" => zone == ZoneType::Exile,
"Command" => zone == ZoneType::Command,
"Stack" => zone == ZoneType::Stack,
_ => false,
})
}
pub fn parse_replacement_effect(raw: &str) -> Option<ReplacementEffect> {
let trimmed = raw.trim();
let body = if let Some(rest) = trimmed.strip_prefix("R$ ") {
rest
} else if let Some(rest) = trimmed.strip_prefix("R:") {
rest.trim_start()
} else {
trimmed
};
let params = Params::from_raw(body);
let event = match params.get(keys::EVENT) {
Some(s) => ReplacementType::smart_value_of(s),
None => return None,
};
let layer = params
.get(keys::LAYER)
.and_then(ReplacementLayer::smart_value_of)
.unwrap_or(ReplacementLayer::Other);
let active_zones = params
.get(keys::ACTIVE_ZONES)
.map(parse_zone_list)
.unwrap_or_default();
Some(ReplacementEffect::new(event, layer, params, active_zones))
}
pub(super) fn parse_zone_list(s: &str) -> Vec<ZoneType> {
s.split([',', ' '])
.filter_map(|tok| match tok.trim() {
"Battlefield" => Some(ZoneType::Battlefield),
"Graveyard" => Some(ZoneType::Graveyard),
"Hand" => Some(ZoneType::Hand),
"Library" => Some(ZoneType::Library),
"Exile" => Some(ZoneType::Exile),
"Command" => Some(ZoneType::Command),
_ => None,
})
.collect()
}
fn parsed_zone_type(value: Option<&str>) -> Option<ZoneType> {
match value? {
"Battlefield" => Some(ZoneType::Battlefield),
"Graveyard" => Some(ZoneType::Graveyard),
"Hand" => Some(ZoneType::Hand),
"Library" => Some(ZoneType::Library),
"Exile" => Some(ZoneType::Exile),
"Command" => Some(ZoneType::Command),
"Stack" => Some(ZoneType::Stack),
_ => None,
}
}
pub(crate) fn parse_replacement_chain(
raw: &str,
svars: &impl HasSVars,
) -> Option<ReplacementChainIr> {
let params = Params::from_raw(raw);
let db = params.get(keys::DB)?;
match db {
"ReplaceToken" => Some(ReplacementChainIr::ReplaceToken {
token_type: params.get("Type").map(str::to_string),
amount_expr: params.get("Amount").map(str::to_string),
}),
"ReplaceCounter" => Some(ReplacementChainIr::ReplaceCounter {
amount_expr: params.get("Amount")?.to_string(),
choose_counter: params.has("ChooseCounter"),
after_replacement_static_abilities: params
.get(keys::SUB_ABILITY)
.and_then(|name| svars.get_svar(name))
.map(Params::from_raw)
.filter(|sub| {
sub.get(keys::DB) == Some("ImmediateTrigger") && sub.has("AfterReplacement")
})
.and_then(|sub| sub.get(keys::EXECUTE).and_then(|name| svars.get_svar(name)))
.map(Params::from_raw)
.filter(|execute| execute.get(keys::DB) == Some("Effect"))
.and_then(|execute| execute.get(keys::STATIC_ABILITIES).map(str::to_string))
.map(|names| {
names
.split(',')
.filter_map(|name| svars.get_svar(name.trim()).map(str::to_string))
.collect()
})
.unwrap_or_default(),
}),
"ReplaceEffect" => {
let sub_ability = params
.get(keys::SUB_ABILITY)
.and_then(|name| svars.get_svar(name))
.and_then(|sub_raw| parse_replacement_chain(sub_raw, svars))
.map(Box::new);
Some(ReplacementChainIr::ReplaceEffect {
var_name: params.get("VarName").map(str::to_string),
var_type: params.get("VarType").map(str::to_string),
var_key: params.get("VarKey").map(str::to_string),
var_value: params.get("VarValue").map(str::to_string),
sub_ability,
})
}
_ => None,
}
}
pub(crate) fn resolve_replace_with_chain(
effect: &ReplacementEffect,
source_card: &Card,
) -> Option<ReplacementChainIr> {
effect.ir.replace_with_chain.clone().or_else(|| {
effect
.replace_with()
.and_then(|svar_name| source_card.get_svar(svar_name))
.and_then(|raw| parse_replacement_chain(raw, source_card))
})
}
fn parsed_phase_types(value: Option<&str>) -> Vec<PhaseType> {
value
.into_iter()
.flat_map(|raw| raw.split(','))
.filter_map(|phase| PhaseType::from_script_name(phase.trim()))
.collect()
}
fn parsed_true(value: Option<&str>) -> bool {
matches!(value, Some("True") | Some("true"))
}
fn parsed_bool(value: Option<&str>) -> Option<bool> {
value.map(|raw| matches!(raw, "True" | "true"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_damage_prevention() {
let raw = "R$ Event$ DamageDone | ActiveZones$ Battlefield | ValidCard$ Card.Self | Prevent$ True | Description$ Prevent all damage dealt to ~.";
let re = parse_replacement_effect(raw).expect("should parse");
assert_eq!(re.event, ReplacementType::DamageDone);
assert_eq!(re.layer, ReplacementLayer::Other);
assert!(re.ir.prevent);
assert_eq!(re.active_zones, vec![ZoneType::Battlefield]);
}
#[test]
fn parse_draw_skip() {
let raw = "R$ Event$ Draw | ValidPlayer$ You | Description$ Skip your draw step.";
let re = parse_replacement_effect(raw).expect("should parse");
assert_eq!(re.event, ReplacementType::Draw);
assert_eq!(re.ir.valid_player_text.as_deref(), Some("You"));
assert!(re.active_zones.is_empty());
}
#[test]
fn parse_destroy_replacement() {
let raw = "R$ Event$ Destroy | ValidCard$ Card.Self | Description$ ~ is indestructible.";
let re = parse_replacement_effect(raw).expect("should parse");
assert_eq!(re.event, ReplacementType::Destroy);
assert_eq!(re.ir.valid_card_text.as_deref(), Some("Card.Self"));
}
#[test]
fn parse_moved_exile_instead() {
let raw = "R$ Event$ Moved | Destination$ Graveyard | Origin$ Battlefield | ValidCard$ Card.Self | NewDestination$ Exile | Description$ If ~ would die, exile it instead.";
let re = parse_replacement_effect(raw).expect("should parse");
assert_eq!(re.event, ReplacementType::Moved);
assert_eq!(re.ir.destination_text.as_deref(), Some("Graveyard"));
assert_eq!(re.ir.origin_text.as_deref(), Some("Battlefield"));
assert_eq!(re.ir.new_destination_text.as_deref(), Some("Exile"));
}
#[test]
fn parse_cant_happen_layer() {
let raw = "R$ Event$ Destroy | Layer$ CantHappen | ValidCard$ Card.Self";
let re = parse_replacement_effect(raw).expect("should parse");
assert_eq!(re.layer, ReplacementLayer::CantHappen);
}
#[test]
fn parse_r_colon_prefix() {
let raw = "R: Event$ Draw | ValidPlayer$ You";
let re = parse_replacement_effect(raw).expect("should parse R: prefix");
assert_eq!(re.event, ReplacementType::Draw);
}
#[test]
fn non_replacement_line_returns_none() {
assert!(parse_replacement_effect("AB$ Mana | Cost$ T | Produced$ G").is_none());
assert!(
parse_replacement_effect("S$ Mode$ Continuous | Affected$ Creature.YouControl")
.is_none()
);
assert!(parse_replacement_effect("").is_none());
}
#[test]
fn active_in_zone_empty_means_always() {
let raw = "R$ Event$ Draw | ValidPlayer$ You";
let re = parse_replacement_effect(raw).unwrap();
assert!(re.active_in_zone(ZoneType::Battlefield));
assert!(re.active_in_zone(ZoneType::Hand));
assert!(re.active_in_zone(ZoneType::Graveyard));
}
#[test]
fn active_in_zone_respects_active_zones() {
let raw = "R$ Event$ DamageDone | ActiveZones$ Battlefield | Prevent$ True";
let re = parse_replacement_effect(raw).unwrap();
assert!(re.active_in_zone(ZoneType::Battlefield));
assert!(!re.active_in_zone(ZoneType::Graveyard));
assert!(!re.active_in_zone(ZoneType::Hand));
}
#[test]
fn parse_all_new_event_types() {
for (event_str, expected) in [
("Tap", ReplacementType::Tap),
("Untap", ReplacementType::Untap),
("Mill", ReplacementType::Mill),
("Scry", ReplacementType::Scry),
("Explore", ReplacementType::Explore),
("Cascade", ReplacementType::Cascade),
("Learn", ReplacementType::Learn),
("Proliferate", ReplacementType::Proliferate),
("Transform", ReplacementType::Transform),
("TurnFaceUp", ReplacementType::TurnFaceUp),
("RollDice", ReplacementType::RollDice),
] {
let raw = format!("R$ Event$ {event_str} | Description$ test");
let re = parse_replacement_effect(&raw).expect(&format!("should parse {event_str}"));
assert_eq!(re.event, expected, "failed for {event_str}");
}
}
}