From 27c548252e49f1e879a8e740a8273f4f3665f065 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:50:40 +0200 Subject: [PATCH 1/3] feat(parser): parse played-by-opponents enter tapped replacements Adds WasPlayed filter property and Uphill Battle / Contamination class replacement parsing distinct from control-based enter-tapped effects. Co-authored-by: Cursor --- crates/engine/src/game/coverage.rs | 1 + crates/engine/src/game/filter.rs | 8 ++ .../engine/src/parser/oracle_replacement.rs | 99 +++++++++++++++++++ crates/engine/src/types/ability.rs | 4 + 4 files changed, 112 insertions(+) diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index d98f8b38d5..f90d3dabf5 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -466,6 +466,7 @@ fn fmt_typed_filter(tf: &TypedFilter) -> String { match prop { FilterProp::Token => parts.push("token".into()), FilterProp::NonToken => parts.push("nontoken".into()), + FilterProp::WasPlayed => parts.push("was played".into()), FilterProp::Attacking { defender } => match defender { None => parts.push("attacking".into()), Some(ControllerRef::You) => parts.push("attacking you".into()), diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 5f32446718..eae9f1fa18 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -146,6 +146,7 @@ fn filter_prop_uses_object_population(prop: &FilterProp) -> bool { | FilterProp::ColorCount { .. } | FilterProp::Token | FilterProp::NonToken + | FilterProp::WasPlayed | FilterProp::Attacking { .. } | FilterProp::Blocking | FilterProp::BlockingSource @@ -347,6 +348,7 @@ fn entered_object_perturbs_filter_prop( | FilterProp::ColorCount { .. } | FilterProp::Token | FilterProp::NonToken + | FilterProp::WasPlayed | FilterProp::Attacking { .. } | FilterProp::Blocking | FilterProp::BlockingSource @@ -2580,6 +2582,7 @@ fn spell_record_matches_property(record: &SpellCastRecord, prop: &FilterProp) -> // for this snapshot shape. FilterProp::Token => false, FilterProp::NonToken => true, + FilterProp::WasPlayed => true, FilterProp::InZone { zone: required } => record.from_zone == *required, // CR 400.1 + CR 601.2a: cast-origin membership — the record's captured // from_zone (populated when the spell was put on the stack from where it @@ -2888,6 +2891,8 @@ fn matches_filter_prop( FilterProp::Token => obj.is_token, // CR 111.1: Nontoken identity of the matched object or event-time snapshot. FilterProp::NonToken => !obj.is_token, + // CR 305.1 + CR 601.2a: "played by" entry replacements (Uphill Battle). + FilterProp::WasPlayed => obj.played_from_zone.is_some() || obj.cast_from_zone.is_some(), // CR 508.1b: Attacking creatures may be scoped by defending player // relation ("attacking", "attacking you", "attacking your opponents"). FilterProp::Attacking { defender } => state.combat.as_ref().is_some_and(|combat| { @@ -3630,6 +3635,9 @@ fn zone_change_record_matches_property( FilterProp::Token => record.is_token, // CR 111.1 + CR 603.6a: Nontoken identity as of the zone change. FilterProp::NonToken => !record.is_token, + // CR 305.1 + CR 601.2a: zone-change snapshots carry `from_zone` when + // the object existed in a prior zone (cast/played entry). + FilterProp::WasPlayed => record.from_zone.is_some(), // -------- Group 2: source/event relational -------- // CR 109.1 "another": same-object check against the triggering source. diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 3f227570ad..8056fb4d2e 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -155,6 +155,11 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option Option { + if !nom_primitives::scan_contains(norm_lower, "played by your opponents enter") { + return None; + } + let stripped = norm_lower.trim_end_matches('.'); + // allow-noncombinator: peel fixed played-by entry suffix after scan_contains gate + let subject = stripped + .strip_suffix(" played by your opponents enter the battlefield tapped") // allow-noncombinator: fixed suffix after scan_contains gate + .or_else(|| { + stripped.strip_suffix(" played by your opponents enter tapped") // allow-noncombinator: fixed suffix after scan_contains gate + })?; + let (filter, subject_rest) = parse_type_phrase(subject.trim()); + if !subject_rest.trim().is_empty() { + return None; + } + let mut typed = match filter { + TargetFilter::Typed(tf) => tf, + TargetFilter::Or { filters } if filters.len() == 1 => match &filters[0] { + TargetFilter::Typed(tf) => tf.clone(), + _ => return None, + }, + _ => return None, + }; + typed.controller = Some(ControllerRef::Opponent); + typed.properties.push(FilterProp::WasPlayed); + let effect = Effect::SetTapState { + target: TargetFilter::SelfRef, + scope: EffectScope::Single, + state: TapStateChange::Tap, + }; + Some( + ReplacementDefinition::new(ReplacementEvent::ChangeZone) + .execute(AbilityDefinition::new(AbilityKind::Spell, effect)) + .valid_card(TargetFilter::Typed(typed)) + .destination_zone(Zone::Battlefield) + .description(original_text.to_string()), + ) +} + /// CR 614.1a: Parse "If [filter] would die, …instead…" replacement effects. /// Handles non-self creature filters like "another creature", "a nontoken /// creature an opponent controls", "a creature an opponent controls", and @@ -9779,6 +9830,54 @@ mod tests { } } + #[test] + fn uphill_battle_played_by_opponents_enter_tapped() { + let text = "Creatures played by your opponents enter the battlefield tapped."; + assert!( + parse_played_by_opponents_entry(&text.to_lowercase(), text).is_some(), + "direct played-by parser must match Uphill Battle" + ); + let def = parse_replacement_line(text, "Uphill Battle") + .expect("Uphill Battle played-by entry"); + assert_eq!(def.event, ReplacementEvent::ChangeZone); + assert_eq!(def.destination_zone, Some(Zone::Battlefield)); + match &def.valid_card { + Some(TargetFilter::Typed(tf)) => { + assert!(tf.type_filters.contains(&TypeFilter::Creature)); + assert_eq!(tf.controller, Some(ControllerRef::Opponent)); + assert!(tf.properties.contains(&FilterProp::WasPlayed)); + } + other => panic!("Expected Typed filter with WasPlayed, got {other:?}"), + } + } + + #[test] + fn played_by_opponents_entry_covers_creature_and_land() { + for (text, card, type_filter) in [ + ( + "Creatures played by your opponents enter the battlefield tapped.", + "Uphill Battle", + TypeFilter::Creature, + ), + ( + "Lands played by your opponents enter tapped.", + "Contamination", + TypeFilter::Land, + ), + ] { + let def = parse_replacement_line(text, card) + .unwrap_or_else(|| panic!("failed to parse {text}")); + assert_eq!(def.event, ReplacementEvent::ChangeZone); + match &def.valid_card { + Some(TargetFilter::Typed(tf)) => { + assert!(tf.type_filters.contains(&type_filter)); + assert!(tf.properties.contains(&FilterProp::WasPlayed)); + } + other => panic!("expected Typed filter, got {other:?}"), + } + } + } + #[test] fn blind_obedience_compound_or_filter() { let def = parse_replacement_line( diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 4564907c74..937ceb54da 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -2222,6 +2222,10 @@ pub enum FilterProp { Token, /// CR 111.1: Matches objects that are not tokens. NonToken, + /// CR 305.1 + CR 601.2a: Matches objects entering from being played + /// (land play) or cast (spell), excluding tokens put directly onto the + /// battlefield without a prior zone. + WasPlayed, /// CR 508.1b: Matches attacking creatures, optionally scoped by which player /// the creature is attacking. Attacking { From 66a872a1e98a06438dbfe89368f071b613caaeee Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:57:02 +0200 Subject: [PATCH 2/3] test(engine): Uphill Battle WasPlayed filter runtime discrimination Ensures played-by-opponents enter-tapped replacements match cast or played entries but not tokens put directly onto the battlefield. Co-authored-by: Cursor --- crates/engine/src/game/replacement.rs | 83 +++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 877628f998..b116ef2b6b 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -7369,6 +7369,27 @@ mod tests { .destination_zone(Zone::Battlefield) } + fn uphill_battle_replacement() -> ReplacementDefinition { + use crate::types::ability::{ + AbilityKind, ControllerRef, FilterProp, TargetFilter, TypedFilter, + }; + ReplacementDefinition::new(ReplacementEvent::ChangeZone) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::SetTapState { + target: TargetFilter::SelfRef, + scope: EffectScope::Single, + state: TapStateChange::Tap, + }, + )) + .valid_card(TargetFilter::Typed( + TypedFilter::creature() + .controller(ControllerRef::Opponent) + .properties(vec![FilterProp::WasPlayed]), + )) + .destination_zone(Zone::Battlefield) + } + fn spelunking_replacement() -> ReplacementDefinition { use crate::types::ability::{AbilityKind, ControllerRef, TargetFilter, TypedFilter}; ReplacementDefinition::new(ReplacementEvent::ChangeZone) @@ -10454,6 +10475,68 @@ mod tests { assert_eq!(count, 2, "five tokens halved (rounded down) → two"); } + /// CR 305.1 + CR 614.1c: Uphill Battle taps cast creatures but not tokens + /// put directly onto the battlefield. + #[test] + fn uphill_battle_was_played_filter_matches_cast_creature_not_token() { + use crate::types::card_type::CoreType; + + let uphill_id = ObjectId(10); + let mut state = test_state_with_object(uphill_id, Zone::Battlefield, vec![ + uphill_battle_replacement(), + ]); + let registry = build_replacement_registry(); + + let cast_creature = ObjectId(20); + let mut creature = GameObject::new( + cast_creature, + CardId(2), + PlayerId(1), + "Grizzly Bears".to_string(), + Zone::Hand, + ); + creature.card_types.core_types.push(CoreType::Creature); + creature.cast_from_zone = Some(Zone::Hand); + state.objects.insert(cast_creature, creature); + + let cast_event = ProposedEvent::ZoneChange { + object_id: cast_creature, + from: Zone::Hand, + to: Zone::Battlefield, + cause: None, + attach_to: None, + enter_tapped: EtbTapState::Unspecified, + enter_with_counters: Vec::new(), + controller_override: None, + enter_transformed: false, + face_down_profile: None, + applied: HashSet::new(), + }; + let cast_matches = find_applicable_replacements(&state, &cast_event, ®istry); + assert!( + cast_matches + .iter() + .any(|rid| rid.source == uphill_id), + "cast creature must match Uphill Battle WasPlayed filter" + ); + + let token_event = ProposedEvent::CreateToken { + owner: PlayerId(1), + count: 1, + spec: Box::new(test_token_spec(PlayerId(1), CoreType::Creature)), + copy: None, + enter_tapped: EtbTapState::Unspecified, + applied: HashSet::new(), + }; + let token_matches = find_applicable_replacements(&state, &token_event, ®istry); + assert!( + !token_matches + .iter() + .any(|rid| rid.source == uphill_id), + "tokens put directly onto the battlefield must not match WasPlayed filter" + ); + } + /// CR 616.1: Mixed `Double` and `Plus` quantity modifications do NOT commute /// and should trigger a CR 616.1 ordering prompt. Doubling Season (`Double`) /// and Hardened Scales (`Plus{1}`) on a counter placement must prompt the player. From 4134ba69f12825964c5962736640a586e04d21f3 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:20:52 +0200 Subject: [PATCH 3/3] style(parser): apply rustfmt to played-by-opponents tests Co-authored-by: Cursor --- crates/engine/src/game/replacement.rs | 16 +++++++--------- crates/engine/src/parser/oracle_replacement.rs | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index b116ef2b6b..7beb4b984b 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -10482,9 +10482,11 @@ mod tests { use crate::types::card_type::CoreType; let uphill_id = ObjectId(10); - let mut state = test_state_with_object(uphill_id, Zone::Battlefield, vec![ - uphill_battle_replacement(), - ]); + let mut state = test_state_with_object( + uphill_id, + Zone::Battlefield, + vec![uphill_battle_replacement()], + ); let registry = build_replacement_registry(); let cast_creature = ObjectId(20); @@ -10514,9 +10516,7 @@ mod tests { }; let cast_matches = find_applicable_replacements(&state, &cast_event, ®istry); assert!( - cast_matches - .iter() - .any(|rid| rid.source == uphill_id), + cast_matches.iter().any(|rid| rid.source == uphill_id), "cast creature must match Uphill Battle WasPlayed filter" ); @@ -10530,9 +10530,7 @@ mod tests { }; let token_matches = find_applicable_replacements(&state, &token_event, ®istry); assert!( - !token_matches - .iter() - .any(|rid| rid.source == uphill_id), + !token_matches.iter().any(|rid| rid.source == uphill_id), "tokens put directly onto the battlefield must not match WasPlayed filter" ); } diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 8056fb4d2e..6fa9f5ec97 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -9837,8 +9837,8 @@ mod tests { parse_played_by_opponents_entry(&text.to_lowercase(), text).is_some(), "direct played-by parser must match Uphill Battle" ); - let def = parse_replacement_line(text, "Uphill Battle") - .expect("Uphill Battle played-by entry"); + let def = + parse_replacement_line(text, "Uphill Battle").expect("Uphill Battle played-by entry"); assert_eq!(def.event, ReplacementEvent::ChangeZone); assert_eq!(def.destination_zone, Some(Zone::Battlefield)); match &def.valid_card {