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/4] 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 3f83abb218eb7d45ddf8e7d31cb563b5df34d7dd Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:21:59 +0200 Subject: [PATCH 2/4] style(parser): apply rustfmt to played-by-opponents tests Co-authored-by: Cursor --- crates/engine/src/parser/oracle_replacement.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 { From 02fd66166cd2abe6b123a2cee37700bd9230f411 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:40:21 +0200 Subject: [PATCH 3/4] feat(parser): consolidate played-by-opponents entry + runtime WasPlayed tests - Extend parse_external_entry_suffix/build_external_entry_replacement for Uphill Battle class (DRY with Authority of the Consuls family) - Fix WasPlayed zone-change record matching via cast/play provenance fields - Add runtime discrimination test: cast creature, token, and put-without-cast Addresses review on #3520; consolidates #3523 runtime coverage. Co-authored-by: Cursor --- crates/engine/src/game/derived_views.rs | 2 + crates/engine/src/game/filter.rs | 12 +- crates/engine/src/game/game_object.rs | 2 + crates/engine/src/game/replacement.rs | 111 ++++++++++ crates/engine/src/game/stack.rs | 2 + .../engine/src/parser/oracle_replacement.rs | 198 +++++++++++------- crates/engine/src/types/game_state.rs | 9 + 7 files changed, 256 insertions(+), 80 deletions(-) diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 6f29cf32ce..4be92d820c 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -1059,6 +1059,8 @@ mod tests { controller: PlayerId(1), owner: PlayerId(1), from_zone: Some(Zone::Library), + cast_from_zone: None, + played_from_zone: None, to_zone: Zone::Hand, attachments: Vec::new(), linked_exile_snapshot: Vec::new(), diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index eae9f1fa18..533abfa049 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -1222,6 +1222,8 @@ pub fn matches_target_filter_on_lki_snapshot( controller: lki.controller, owner: lki.owner, from_zone: None, + cast_from_zone: None, + played_from_zone: None, to_zone: Zone::Battlefield, attachments: vec![], linked_exile_snapshot: vec![], @@ -3635,9 +3637,11 @@ 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(), + // CR 305.1 + CR 601.2a: zone-change snapshots carry cast/play provenance + // when the object was cast or played — not mere zone moves (reanimate). + FilterProp::WasPlayed => { + record.played_from_zone.is_some() || record.cast_from_zone.is_some() + } // -------- Group 2: source/event relational -------- // CR 109.1 "another": same-object check against the triggering source. @@ -9049,6 +9053,8 @@ mod tests { controller: PlayerId(0), owner: PlayerId(0), from_zone: Some(Zone::Battlefield), + cast_from_zone: None, + played_from_zone: None, to_zone: Zone::Graveyard, attachments: vec![], linked_exile_snapshot: vec![], diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index d002ebb5ae..83c81f356b 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -1045,6 +1045,8 @@ impl GameObject { controller: self.controller, owner: self.owner, from_zone: from, + cast_from_zone: self.cast_from_zone, + played_from_zone: self.played_from_zone, to_zone: to, attachments: Vec::new(), linked_exile_snapshot: Vec::new(), diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 877628f998..826319f976 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -7387,6 +7387,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 test_token_spec( owner_controller: PlayerId, core_type: crate::types::card_type::CoreType, @@ -10389,6 +10410,96 @@ mod tests { ); } + /// CR 305.1 + CR 601.2a: Uphill Battle WasPlayed filter discriminates cast + /// creatures from tokens and from nontokens put 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" + ); + + let put_creature = ObjectId(30); + let mut put_obj = GameObject::new( + put_creature, + CardId(3), + PlayerId(1), + "Runeclaw Bear".to_string(), + Zone::Hand, + ); + put_obj.card_types.core_types.push(CoreType::Creature); + state.objects.insert(put_creature, put_obj); + + let put_event = ProposedEvent::ZoneChange { + object_id: put_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 put_matches = find_applicable_replacements(&state, &put_event, ®istry); + assert!( + !put_matches.iter().any(|rid| rid.source == uphill_id), + "nontoken creatures put onto the battlefield without being cast must not match WasPlayed filter" + ); + } + /// CR 614.1a + CR 111.1: Halving Season halves opponent token batches. #[test] fn halving_season_halves_opponent_token_creation() { diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 5d4f39c6e9..f10dcaf87f 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1710,6 +1710,8 @@ fn zone_change_record_from_spec( controller: spec.controller, owner: spec.controller, from_zone: None, + cast_from_zone: None, + played_from_zone: None, to_zone: Zone::Battlefield, attachments: Vec::new(), linked_exile_snapshot: Vec::new(), diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 6fa9f5ec97..74ece3dc9d 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -150,16 +150,11 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option Option Option<(&str, bool)> { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExternalEntryKind { + Plain { + enters_tapped: bool, + }, + /// CR 614.1d: Uphill Battle class — cast/played entry only, not tokens. + PlayedByOpponents { + enters_tapped: bool, + }, +} + +/// CR 614.1d: Peel external entry-tapped suffixes from a normalized clause. +/// Played-by-opponents variants are checked before plain enter-tapped suffixes +/// so "creatures played by your opponents enter tapped" does not fall through +/// to the Authority-of-the-Consuls control-based shape. +fn parse_external_entry_suffix(stripped: &str) -> Option<(&str, ExternalEntryKind)> { stripped - .strip_suffix(" enter tapped") - .map(|subject| (subject, true)) + .strip_suffix(" played by your opponents enter the battlefield tapped") + .map(|subject| { + ( + subject, + ExternalEntryKind::PlayedByOpponents { + enters_tapped: true, + }, + ) + }) .or_else(|| { stripped - .strip_suffix(" enters tapped") - .map(|subject| (subject, true)) + .strip_suffix(" played by your opponents enter tapped") + .map(|subject| { + ( + subject, + ExternalEntryKind::PlayedByOpponents { + enters_tapped: true, + }, + ) + }) }) .or_else(|| { - stripped - .strip_suffix(" enter untapped") - .map(|subject| (subject, false)) + stripped.strip_suffix(" enter tapped").map(|subject| { + ( + subject, + ExternalEntryKind::Plain { + enters_tapped: true, + }, + ) + }) }) .or_else(|| { - stripped - .strip_suffix(" enters untapped") - .map(|subject| (subject, false)) + stripped.strip_suffix(" enters tapped").map(|subject| { + ( + subject, + ExternalEntryKind::Plain { + enters_tapped: true, + }, + ) + }) + }) + .or_else(|| { + stripped.strip_suffix(" enter untapped").map(|subject| { + ( + subject, + ExternalEntryKind::Plain { + enters_tapped: false, + }, + ) + }) + }) + .or_else(|| { + stripped.strip_suffix(" enters untapped").map(|subject| { + ( + subject, + ExternalEntryKind::Plain { + enters_tapped: false, + }, + ) + }) }) } fn build_external_entry_replacement( subject: &str, original_text: &str, - enters_tapped: bool, + kind: ExternalEntryKind, ) -> Option { if subject.contains('~') { return None; } + let enters_tapped = match kind { + ExternalEntryKind::Plain { enters_tapped } + | ExternalEntryKind::PlayedByOpponents { enters_tapped } => enters_tapped, + }; + let (filter, rest) = parse_type_phrase(subject); if !rest.trim().is_empty() { return None; } + let valid_card = match kind { + ExternalEntryKind::PlayedByOpponents { .. } => match filter { + TargetFilter::Typed(mut tf) => { + tf.controller = Some(ControllerRef::Opponent); + tf.properties.push(FilterProp::WasPlayed); + TargetFilter::Typed(tf) + } + TargetFilter::Or { filters } if filters.len() == 1 => { + match filters.into_iter().next()? { + TargetFilter::Typed(mut tf) => { + tf.controller = Some(ControllerRef::Opponent); + tf.properties.push(FilterProp::WasPlayed); + TargetFilter::Typed(tf) + } + _ => return None, + } + } + _ => return None, + }, + ExternalEntryKind::Plain { .. } => filter, + }; + let effect = if enters_tapped { Effect::SetTapState { target: TargetFilter::SelfRef, @@ -3038,7 +3119,7 @@ fn build_external_entry_replacement( Some( ReplacementDefinition::new(ReplacementEvent::ChangeZone) .execute(AbilityDefinition::new(AbilityKind::Spell, effect)) - .valid_card(filter) + .valid_card(valid_card) .destination_zone(Zone::Battlefield) .description(original_text.to_string()), ) @@ -3057,8 +3138,8 @@ fn parse_source_state_external_entry( let condition = replacement_condition_from_static(condition)?; let rest_lower = rest.to_lowercase(); let stripped = rest_lower.trim_end_matches('.'); - let (entry_subject, enters_tapped) = parse_external_entry_suffix(stripped)?; - let mut def = build_external_entry_replacement(entry_subject, original_text, enters_tapped)?; + let (entry_subject, kind) = parse_external_entry_suffix(stripped)?; + let mut def = build_external_entry_replacement(entry_subject, original_text, kind)?; def.condition = Some(condition); Some(def) } @@ -3069,72 +3150,35 @@ fn parse_external_enters_untapped( original_text: &str, ) -> Option { let stripped = norm_lower.trim_end_matches('.'); - let (subject, enters_tapped) = parse_external_entry_suffix(stripped)?; - if enters_tapped { + let (subject, kind) = parse_external_entry_suffix(stripped)?; + let ExternalEntryKind::Plain { + enters_tapped: false, + } = kind + else { return None; - } - build_external_entry_replacement(subject, original_text, false) + }; + build_external_entry_replacement(subject, original_text, kind) } /// Parse "[Type] enter tapped" / "[Type] enters tapped" — external replacement effects. /// E.g., "Creatures your opponents control enter tapped." (Authority of the Consuls) /// E.g., "Artifacts and creatures your opponents control enter tapped." (Blind Obedience) +/// E.g., "Creatures played by your opponents enter tapped." (Uphill Battle) fn parse_external_enters_tapped( norm_lower: &str, original_text: &str, ) -> Option { let stripped = norm_lower.trim_end_matches('.'); - let (subject, enters_tapped) = parse_external_entry_suffix(stripped)?; - if !enters_tapped { - return None; - } - build_external_entry_replacement(subject, original_text, true) -} - -/// Parse "[Type] played by your opponents enter [the battlefield] tapped." -/// Uphill Battle class — distinct from "your opponents control enter tapped" -/// (Authority of the Consuls): only applies to cast/played entries, not tokens -/// put directly onto the battlefield. -fn parse_played_by_opponents_entry( - norm_lower: &str, - original_text: &str, -) -> 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 (subject, kind) = parse_external_entry_suffix(stripped)?; + match kind { + ExternalEntryKind::Plain { + enters_tapped: true, + } + | ExternalEntryKind::PlayedByOpponents { + enters_tapped: true, + } => build_external_entry_replacement(subject, original_text, kind), + _ => 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. @@ -9834,8 +9878,8 @@ mod tests { 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" + parse_external_enters_tapped(&text.to_lowercase(), text).is_some(), + "external entry parser must match Uphill Battle" ); let def = parse_replacement_line(text, "Uphill Battle").expect("Uphill Battle played-by entry"); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index a64a5932be..15af186e95 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -397,6 +397,13 @@ pub struct ZoneChangeRecord { /// on the battlefield, emblem creation in the command zone). For normal /// zone moves this carries the origin zone. pub from_zone: Option, + /// CR 601.2a: Cast origin as of the zone change — distinct from `from_zone` + /// for objects put onto the battlefield without being cast (reanimate, etc.). + #[serde(default)] + pub cast_from_zone: Option, + /// CR 305.1: Land-play provenance as of the zone change. + #[serde(default)] + pub played_from_zone: Option, pub to_zone: Zone, /// CR 603.10a + CR 603.6e: Snapshot of attachments on the object at the moment /// of the zone change. Required by look-back triggers of the form @@ -530,6 +537,8 @@ impl ZoneChangeRecord { controller: PlayerId(0), owner: PlayerId(0), from_zone: from, + cast_from_zone: None, + played_from_zone: None, to_zone: to, attachments: Vec::new(), linked_exile_snapshot: Vec::new(), From a3139b38bea8fdd2e29dbd416ad85871a3c8ff43 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:11:19 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(engine):=20CI=20=E2=80=94=20ZoneChangeR?= =?UTF-8?q?ecord=20fields=20+=20parser=20gate=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cast_from_zone/played_from_zone to madame_null integration fixture - Annotate parse_external_entry_suffix strip_suffix peels for parser gate Co-authored-by: Cursor --- crates/engine/src/parser/oracle_replacement.rs | 8 ++++++-- .../engine/tests/integration/madame_null_integration.rs | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index c3a4ad96fc..fdb3478023 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -3006,7 +3006,7 @@ enum ExternalEntryKind { /// to the Authority-of-the-Consuls control-based shape. fn parse_external_entry_suffix(stripped: &str) -> Option<(&str, ExternalEntryKind)> { stripped - .strip_suffix(" played by your opponents enter the battlefield tapped") + .strip_suffix(" played by your opponents enter the battlefield tapped") // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject .map(|subject| { ( subject, @@ -3017,7 +3017,7 @@ fn parse_external_entry_suffix(stripped: &str) -> Option<(&str, ExternalEntryKin }) .or_else(|| { stripped - .strip_suffix(" played by your opponents enter tapped") + .strip_suffix(" played by your opponents enter tapped") // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject .map(|subject| { ( subject, @@ -3028,6 +3028,7 @@ fn parse_external_entry_suffix(stripped: &str) -> Option<(&str, ExternalEntryKin }) }) .or_else(|| { + // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject stripped.strip_suffix(" enter tapped").map(|subject| { ( subject, @@ -3038,6 +3039,7 @@ fn parse_external_entry_suffix(stripped: &str) -> Option<(&str, ExternalEntryKin }) }) .or_else(|| { + // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject stripped.strip_suffix(" enters tapped").map(|subject| { ( subject, @@ -3048,6 +3050,7 @@ fn parse_external_entry_suffix(stripped: &str) -> Option<(&str, ExternalEntryKin }) }) .or_else(|| { + // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject stripped.strip_suffix(" enter untapped").map(|subject| { ( subject, @@ -3058,6 +3061,7 @@ fn parse_external_entry_suffix(stripped: &str) -> Option<(&str, ExternalEntryKin }) }) .or_else(|| { + // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject stripped.strip_suffix(" enters untapped").map(|subject| { ( subject, diff --git a/crates/engine/tests/integration/madame_null_integration.rs b/crates/engine/tests/integration/madame_null_integration.rs index b35642d543..05edcd5cc7 100644 --- a/crates/engine/tests/integration/madame_null_integration.rs +++ b/crates/engine/tests/integration/madame_null_integration.rs @@ -99,6 +99,8 @@ fn set_etb_event(state: &mut GameState, entering: ObjectId) { controller: PlayerId(0), owner: PlayerId(0), from_zone: Some(Zone::Hand), + cast_from_zone: None, + played_from_zone: None, to_zone: Zone::Battlefield, attachments: Vec::new(), linked_exile_snapshot: Vec::new(),