diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 5fc1c73d4d..235ba01b86 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -539,6 +539,18 @@ pub(super) fn strip_additional_cost_conditional(text: &str) -> (Option &str { + opt(alt(( + tag::<_, _, OracleError<'_>>(", "), + tag(". "), + tag(" "), + ))) + .parse(input) + .map(|(rest, _)| rest) + .unwrap_or(input) +} + pub(super) fn strip_if_you_do_conditional(text: &str) -> (Option, String) { let lower = text.to_lowercase(); @@ -562,23 +574,36 @@ pub(super) fn strip_if_you_do_conditional(text: &str) -> (Option>("if ").parse(lower.as_str()) { + if let Ok((rest, prefix)) = alt(( + value("if ", tag::<_, _, OracleError<'_>>("if ")), + value("when ", tag("when ")), + )) + .parse(lower.as_str()) + { if let Ok((after_clause, (filter, _negated))) = crate::parser::oracle_nom::condition::parse_zone_changed_this_way_clause(rest) { - // Strip leading punctuation/space between "this way" and the body. - // Possible separators: ", ", ". ", " ". - let body_lower = after_clause - .strip_prefix(", ") // allow-noncombinator: structural separator after parsed clause - .or_else(|| after_clause.strip_prefix(". ")) // allow-noncombinator: structural separator after parsed clause - .or_else(|| after_clause.strip_prefix(' ')) // allow-noncombinator: structural separator after parsed clause - .unwrap_or(after_clause); + let body_lower = strip_reflexive_conditional_body_separator(after_clause); let offset = text.len() - body_lower.len(); return ( Some(AbilityCondition::ZoneChangedThisWay { filter }), text[offset..].to_string(), ); } + if prefix == "when " { + if let Ok((after_clause, (filter, _negated))) = + crate::parser::oracle_nom::condition::parse_you_put_onto_battlefield_this_way_clause( + rest, + ) + { + let body_lower = strip_reflexive_conditional_body_separator(after_clause); + let offset = text.len() - body_lower.len(); + return ( + Some(AbilityCondition::ZoneChangedThisWay { filter }), + text[offset..].to_string(), + ); + } + } } (None, text.to_string()) } @@ -4686,6 +4711,25 @@ mod tests { } } + #[test] + fn strip_if_you_do_conditional_gilgamesh_you_put_equipment_this_way() { + let (condition, body) = strip_if_you_do_conditional( + "when you put one or more equipment onto the battlefield this way, you may attach one of them to a samurai you control", + ); + assert_eq!(body, "you may attach one of them to a samurai you control"); + let Some(AbilityCondition::ZoneChangedThisWay { filter }) = condition else { + panic!("expected ZoneChangedThisWay condition, got {condition:?}"); + }; + match filter { + TargetFilter::Typed(TypedFilter { type_filters, .. }) => { + assert!(type_filters.iter().any( + |f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment")) + )); + } + other => panic!("expected Typed Equipment filter, got {other:?}"), + } + } + #[test] fn suffix_outcome_this_way_kaya_creature_card_exiled() { // Kaya, Orzhov Usurper +1 (PR #2447): "Exile up to two target cards diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index fcdace7829..77b5985f66 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -9614,6 +9614,37 @@ mod tests { assert!(matches!(target, TargetFilter::ParentTarget)); } + /// CR 608.2c + CR 301.5b: Gilgamesh attach body — moved Equipment binds on + /// the attachment side; the Samurai recipient stays explicitly typed. + #[test] + fn parse_attach_one_of_them_to_samurai_you_control() { + use crate::types::ability::{TypeFilter, TypedFilter}; + + let input = "attach one of them to a Samurai you control"; + let lower = input.to_lowercase(); + let result = parse_utility_imperative_ast(input, &lower, &mut ParseContext::default()); + let Some(UtilityImperativeAst::Attach { attachment, target }) = result else { + panic!("{input}: expected Attach, got {result:?}"); + }; + assert!( + matches!(attachment, TargetFilter::ParentTarget), + "attachment should bind to a chosen moved Equipment, got {attachment:?}" + ); + assert!( + matches!( + target, + TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::You), + ref type_filters, + .. + }) if type_filters.iter().any( + |f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Samurai")) + ) + ), + "expected Samurai you control attach target, got {target:?}" + ); + } + /// CR 608.2k regression — issue #319 sibling. /// "attach ~ to it" inside a typed-subject trigger ("Whenever a Samurai /// or Warrior you control attacks alone … attach this Equipment to it" diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 119bfb59c7..add6198e3e 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -10940,6 +10940,23 @@ fn replace_definition_targets_with_parent(def: &mut AbilityDefinition) { } } +/// CR 608.2c + CR 115.10a: True when an `Attach` effect names an explicit typed +/// recipient ("attach … to a Samurai you control" — Gilgamesh; "attach it to a +/// creature you control" — Stonehewer Giant). The chunk-loop anaphor rewriter +/// must not collapse such recipients to `ParentTarget` just because the +/// attachment side uses a set anaphor ("one of them"). +fn attach_recipient_is_explicitly_typed(target: &TargetFilter) -> bool { + match target { + TargetFilter::Typed(tf) => { + !tf.type_filters.is_empty() || tf.controller.is_some() || !tf.properties.is_empty() + } + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + filters.iter().any(attach_recipient_is_explicitly_typed) + } + _ => false, + } +} + /// Replace the target filter on an effect with ParentTarget. /// Used for anaphoric "it"/"that creature" references in compound sub-effects. fn replace_target_with_parent(effect: &mut Effect) { @@ -10997,7 +11014,10 @@ fn replace_target_with_parent(effect: &mut Effect) { { *target = TargetFilter::ParentTarget; } - Effect::Attach { target, .. } if !matches!(target, TargetFilter::LastCreated) => { + Effect::Attach { target, .. } + if !matches!(target, TargetFilter::LastCreated) + && !attach_recipient_is_explicitly_typed(target) => + { *target = TargetFilter::ParentTarget; } Effect::UnattachAll { target, .. } if !matches!(target, TargetFilter::LastCreated) => { @@ -49827,6 +49847,94 @@ mod tests { } } + /// Gilgamesh, Master-at-Arms (issue #3286): any-number Equipment dig-from-among + /// plus CR 603.12 active-voice reflexive attach to Samurai. + #[test] + fn attach_just_moved_gilgamesh_any_number_equipment_reflexive_attach() { + use crate::types::ability::{TypeFilter, TypedFilter}; + + let def = parse_effect_chain( + "Look at the top six cards of your library. You may put any number of Equipment cards from among them onto the battlefield. Put the rest on the bottom of your library in a random order. When you put one or more Equipment onto the battlefield this way, you may attach one of them to a Samurai you control.", + AbilityKind::Spell, + ); + + let Effect::Dig { + count, + destination, + keep_count, + up_to, + filter, + rest_destination, + .. + } = &*def.effect + else { + panic!("expected outer Dig, got {:?}", def.effect); + }; + assert_eq!(*count, QuantityExpr::Fixed { value: 6 }); + assert_eq!(*destination, Some(Zone::Battlefield)); + assert_eq!(*keep_count, Some(u32::MAX), "any number → unbounded keep"); + assert!(*up_to); + assert!( + matches!( + filter, + TargetFilter::Typed(TypedFilter { ref type_filters, .. }) + if type_filters.iter().any( + |f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment")) + ) + ), + "expected Equipment filter on Dig, got {filter:?}" + ); + assert_eq!(*rest_destination, Some(Zone::Library)); + assert!( + def.forward_result, + "Dig parent must forward the just-moved Equipment to the Attach sub_ability" + ); + + let attach = def + .sub_ability + .as_ref() + .expect("expected Attach sub_ability after Dig"); + match &*attach.effect { + Effect::Attach { attachment, target } => { + assert!( + matches!(attachment, TargetFilter::ParentTarget), + "attachment should bind to a chosen moved Equipment, got {attachment:?}" + ); + assert!( + matches!( + target, + TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::You), + ref type_filters, + .. + }) if type_filters.iter().any( + |f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Samurai")) + ) + ), + "expected Samurai you control attach target, got {target:?}" + ); + } + other => panic!("expected Attach sub_ability, got {other:?}"), + } + match &attach.condition { + Some(AbilityCondition::ZoneChangedThisWay { filter }) => match filter { + TargetFilter::Typed(t) => assert!( + t.type_filters.iter().any( + |f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment")) + ), + "expected Equipment on ZoneChangedThisWay, got {:?}", + t.type_filters + ), + other => panic!("expected Typed Equipment filter, got {other:?}"), + }, + other => panic!("expected ZoneChangedThisWay condition on Attach, got {other:?}"), + } + assert!( + attach.optional, + "\"you may attach\" makes the attach step optional" + ); + } + /// Quest for the Holy Relic / Stonehewer Giant pattern: /// SearchLibrary → ChangeZone(destination=Battlefield) → Attach. The /// rewire detects the ChangeZone-to-battlefield parent and sets diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 5e84e23bc6..27e77f58ed 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -3260,56 +3260,6 @@ pub(super) fn parse_dig_from_among(lower: &str, original: &str) -> Option>(" of those cards"), - take_until(" of those"), - take_until(" of them"), - )) - .parse(lower) - { - let before_of = before_of.trim(); - let after_put = alt((tag::<_, _, OracleError<'_>>("you may put "), tag("put "))) - .parse(before_of) - .map(|(rest, _)| rest) - .unwrap_or(before_of); - - // Delegate to nom combinator (input already lowercase from lower). - let quantity = if let Ok((_rest, _)) = alt(( - tag::<_, _, OracleError<'_>>("any number of "), - tag("any number"), - )) - .parse(after_put) - { - PutCount::AnyNumber - } else if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("up to ").parse(after_put) { - nom_primitives::parse_number - .parse(rest) - .map_or(PutCount::Up(1), |(_, n)| PutCount::Up(n)) - } else if let Ok((_, n)) = nom_primitives::parse_number.parse(after_put) { - PutCount::Exactly(n) - } else { - // "a/an" or unrecognized → treat as up_to 1 - PutCount::Up(1) - }; - - // Detect rest destination from "and the rest on the bottom/into graveyard" suffix. - let rest_destination = parse_of_them_rest_destination(lower); - - return Some(ContinuationAst::DigFromAmong { - quantity, - filter: TargetFilter::Any, - destination, - rest_destination, - enters_under: None, - face_down_profile: None, - enter_tapped, - // "put N of them" strips only "put" — never a reveal verb (private). - reveal_verb: false, - }); - } - // CR 701.17c + CR 608.2c: "return a card milled this way to your hand" // is the same tracked-set continuation as "from among the milled cards", // but its filter appears before the "milled this way" marker rather than @@ -3434,107 +3384,167 @@ pub(super) fn parse_dig_from_among(lower: &str, original: &str) -> Option>("from among") - .parse(lower) - .ok()?; - let before_from = &before_from.trim(); - - // Strip leading "put " or "you may reveal " using nom combinators. - // CR 701.20a vs 701.20e: capture whether the stripped verb was "reveal" - // (a public action) vs "put"/"return" (a private look) so the patch arm can - // promote the Dig to `reveal: true` even when the kept card routes to a - // fixed destination (Fertile Thicket). - let (after_put, reveal_verb) = alt(( - value(true, tag::<_, _, OracleError<'_>>("you may reveal ")), - value(false, tag("you may put ")), - value(false, tag("you may return ")), - value(false, tag("put ")), - value(true, tag("reveal ")), - value(false, tag("return ")), - )) - .parse(*before_from) - .unwrap_or((before_from, false)); - - // CR 701.20e: Mass quantifier ("put all/each from among them onto - // the battlefield ...") moves the entire matching set → `PutCount::All`. - let (after_put_q, mass) = - match alt((tag::<_, _, OracleError<'_>>("all "), tag("each "))).parse(after_put) { - Ok((rest, _)) => (rest, true), - Err(_) => (after_put, false), + // CR 701.20e + CR 608.2c: "put/return/reveal … from among them/those + // cards …" — the more specific grammar; try before the bare "put N of them" + // idiom so "any number of Equipment cards from among them" never falls into + // the count-only arm. + if let Ok((_, before_from)) = take_until::<_, _, OracleError<'_>>("from among").parse(lower) { + let before_from = before_from.trim(); + + // Strip leading "put " or "you may reveal " using nom combinators. + // CR 701.20a vs 701.20e: capture whether the stripped verb was "reveal" + // (a public action) vs "put"/"return" (a private look) so the patch arm can + // promote the Dig to `reveal: true` even when the kept card routes to a + // fixed destination (Fertile Thicket). + let (after_put, reveal_verb) = alt(( + value(true, tag::<_, _, OracleError<'_>>("you may reveal ")), + value(false, tag("you may put ")), + value(false, tag("you may return ")), + value(false, tag("put ")), + value(true, tag("reveal ")), + value(false, tag("return ")), + )) + .parse(before_from) + .unwrap_or((before_from, false)); + + // CR 701.20e: Mass quantifier ("put all/each from among them onto + // the battlefield ...") moves the entire matching set → `PutCount::All`. + let (after_put_q, mass) = + match alt((tag::<_, _, OracleError<'_>>("all "), tag("each "))).parse(after_put) { + Ok((rest, _)) => (rest, true), + Err(_) => (after_put, false), + }; + + // Parse "up to N" or "a/an" or just a number + // Delegate to nom combinator (input already lowercase from lower). + let (quantity, filter_text) = if mass { + (PutCount::All, after_put_q) + } else if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("up to ").parse(after_put) { + if let Ok((remainder, n)) = nom_primitives::parse_number.parse(rest) { + (PutCount::Up(n), remainder.trim()) + } else { + (PutCount::Up(1), rest) + } + } else if let Ok((rest, _)) = + tag::<_, _, OracleError<'_>>("any number of ").parse(after_put) + { + (PutCount::AnyNumber, rest) + } else if let Ok((rest, _)) = nom_primitives::parse_article.parse(after_put) { + // "a creature card" / "an artifact card" — up_to 1 (player may choose none) + (PutCount::Up(1), rest) + } else if let Ok((remainder, n)) = nom_primitives::parse_number.parse(after_put) { + // Explicit numeric count: "two creature cards" → exactly 2 + (PutCount::Exactly(n), remainder.trim()) + } else { + (PutCount::Up(1), after_put) }; - // Parse "up to N" or "a/an" or just a number - // Delegate to nom combinator (input already lowercase from lower). - let (quantity, filter_text) = if mass { - (PutCount::All, after_put_q) - } else if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("up to ").parse(after_put) { - if let Ok((remainder, n)) = nom_primitives::parse_number.parse(rest) { - (PutCount::Up(n), remainder.trim()) + // Parse the filter from the remaining text (e.g., "creature cards with mana value 3 or less") + let filter = if filter_text.is_empty() + || filter_text == "card" + || filter_text == "cards" + || filter_text == "of them" + { + TargetFilter::Any } else { - (PutCount::Up(1), rest) - } - } else if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("any number of ").parse(after_put) { - (PutCount::AnyNumber, rest) - } else if let Ok((rest, _)) = nom_primitives::parse_article.parse(after_put) { - // "a creature card" / "an artifact card" — up_to 1 (player may choose none) - (PutCount::Up(1), rest) - } else if let Ok((remainder, n)) = nom_primitives::parse_number.parse(after_put) { - // Explicit numeric count: "two creature cards" → exactly 2 - (PutCount::Exactly(n), remainder.trim()) - } else { - (PutCount::Up(1), after_put) - }; + let (parsed_filter, _) = parse_target(filter_text); + parsed_filter + }; + // CR 202.3 + CR 107.3i: Bind the literal `X` in the filter's `Cmc` bound + // with the stripped "where X is " defining clause. + let filter = apply_where_x_to_filter(filter, where_x_expression.as_deref()); - // Parse the filter from the remaining text (e.g., "creature cards with mana value 3 or less") - let filter = if filter_text.is_empty() - || filter_text == "card" - || filter_text == "cards" - || filter_text == "of them" + // CR 110.2a + CR 708.2a/708.3: detect "under your control" / "face down" on + // the full clause for the from-among put-step. + let enters_under = if nom_primitives::scan_contains(lower, "under your control") { + Some(ControllerRef::You) + } else { + None + }; + let face_down_profile = if nom_primitives::scan_contains(lower, "face down") { + Some(FaceDownProfile::vanilla_2_2()) + } else { + None + }; + + // CR 608.2c: A trailing "and the rest on the bottom ..." rider sits in the + // SAME clause as the from-among put-step when the rest-subject ("the rest") + // does not begin with a recognized imperative verb, so `split_clause_sequence` + // never splits it off into a standalone `PutRest` continuation (Muxus, Goblin + // Grandee: "Put all ... from among them onto the battlefield and the rest on + // the bottom of your library in a random order"). Capture it here with the + // shared rest-anaphor matcher so the rest pile is routed correctly instead of + // falling through to the `None`→Graveyard default. A genuinely separate + // "Put the rest ..." sentence still patches via its own PutRest continuation. + let rest_destination = parse_of_them_rest_destination(lower); + + return Some(ContinuationAst::DigFromAmong { + quantity, + filter, + destination, + rest_destination, + enters_under, + face_down_profile, + enter_tapped, + reveal_verb, + }); + } + + // CR 701.20e + CR 608.2c: bare "put N of them into your hand [and the rest on + // the bottom]" — no filter, count explicit. Requires a put/reveal/return verb + // prefix so unrelated "of them" anaphors (Gilgamesh's "attach one of them to …") + // do not match. + if let Ok((_, before_of)) = alt(( + take_until::<_, _, OracleError<'_>>(" of those cards"), + take_until(" of those"), + take_until(" of them"), + )) + .parse(lower) { - TargetFilter::Any - } else { - let (parsed_filter, _) = parse_target(filter_text); - parsed_filter - }; - // CR 202.3 + CR 107.3i: Bind the literal `X` in the filter's `Cmc` bound - // with the stripped "where X is " defining clause. - let filter = apply_where_x_to_filter(filter, where_x_expression.as_deref()); - - // CR 110.2a + CR 708.2a/708.3: detect "under your control" / "face down" on - // the full clause for the from-among put-step. - let enters_under = if nom_primitives::scan_contains(lower, "under your control") { - Some(ControllerRef::You) - } else { - None - }; - let face_down_profile = if nom_primitives::scan_contains(lower, "face down") { - Some(FaceDownProfile::vanilla_2_2()) - } else { - None - }; + let before_of = before_of.trim(); + if let Ok((after_put, _)) = alt(( + tag::<_, _, OracleError<'_>>("you may put "), + tag("put "), + tag("you may reveal "), + tag("reveal "), + tag("you may return "), + tag("return "), + )) + .parse(before_of) + { + let quantity = if let Ok((_rest, _)) = alt(( + tag::<_, _, OracleError<'_>>("any number of "), + tag("any number"), + )) + .parse(after_put) + { + PutCount::AnyNumber + } else if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("up to ").parse(after_put) { + nom_primitives::parse_number + .parse(rest) + .map_or(PutCount::Up(1), |(_, n)| PutCount::Up(n)) + } else if let Ok((_, n)) = nom_primitives::parse_number.parse(after_put) { + PutCount::Exactly(n) + } else { + PutCount::Up(1) + }; - // CR 608.2c: A trailing "and the rest on the bottom ..." rider sits in the - // SAME clause as the from-among put-step when the rest-subject ("the rest") - // does not begin with a recognized imperative verb, so `split_clause_sequence` - // never splits it off into a standalone `PutRest` continuation (Muxus, Goblin - // Grandee: "Put all ... from among them onto the battlefield and the rest on - // the bottom of your library in a random order"). Capture it here with the - // shared rest-anaphor matcher so the rest pile is routed correctly instead of - // falling through to the `None`→Graveyard default. A genuinely separate - // "Put the rest ..." sentence still patches via its own PutRest continuation. - let rest_destination = parse_of_them_rest_destination(lower); - - Some(ContinuationAst::DigFromAmong { - quantity, - filter, - destination, - rest_destination, - enters_under, - face_down_profile, - enter_tapped, - reveal_verb, - }) + let rest_destination = parse_of_them_rest_destination(lower); + + return Some(ContinuationAst::DigFromAmong { + quantity, + filter: TargetFilter::Any, + destination, + rest_destination, + enters_under: None, + face_down_profile: None, + enter_tapped, + reveal_verb: false, + }); + } + } + + None } fn parse_dig_kept_destination(lower: &str) -> (Option, bool) { @@ -8400,4 +8410,75 @@ mod tests { "a pure-peek 'you may reveal it' must not be fused into the Gonti exile continuation" ); } + + #[test] + fn from_among_any_number_equipment_not_misrouted_as_of_them() { + use crate::types::ability::{TypeFilter, TypedFilter}; + + let dig = make_dig_effect(); + let result = parse_followup_continuation_ast( + "You may put any number of Equipment cards from among them onto the battlefield.", + &dig, + &mut ParseContext::default(), + ); + let Some(ContinuationAst::DigFromAmong { + quantity, + filter, + destination, + .. + }) = result + else { + panic!("expected DigFromAmong continuation, got {result:?}"); + }; + assert_eq!(quantity, PutCount::AnyNumber); + assert!( + matches!( + filter, + TargetFilter::Typed(TypedFilter { + ref type_filters, + .. + }) if type_filters.iter().any( + |f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment")) + ) + ), + "expected Equipment filter, got {filter:?}" + ); + assert_eq!(destination, Some(Zone::Battlefield)); + } + + #[test] + fn put_two_of_them_into_hand_still_uses_of_them_arm() { + let dig = make_dig_effect(); + let result = parse_followup_continuation_ast( + "Put two of them into your hand.", + &dig, + &mut ParseContext::default(), + ); + assert!( + matches!( + result, + Some(ContinuationAst::DigFromAmong { + quantity: PutCount::Exactly(2), + filter: TargetFilter::Any, + destination: Some(Zone::Hand), + .. + }) + ), + "expected bare of-them DigFromAmong, got {result:?}" + ); + } + + #[test] + fn attach_one_of_them_reflexive_gate_is_not_dig_from_among() { + let dig = make_dig_effect(); + let result = parse_followup_continuation_ast( + "When you put one or more Equipment onto the battlefield this way, you may attach one of them to a Samurai you control.", + &dig, + &mut ParseContext::default(), + ); + assert!( + result.is_none(), + "reflexive attach gate must not re-patch the Dig, got {result:?}" + ); + } } diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index cfa68c83c0..9f991030ce 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -5900,6 +5900,34 @@ pub fn parse_zone_changed_this_way_clause(input: &str) -> OracleResult<'_, (Targ Ok((rest, (filter, negated))) } +/// CR 603.12 + CR 608.2c: Parse "you put [quantifier] [type] onto the battlefield +/// this way" — the active-voice reflexive gate (Gilgamesh, Master-at-Arms: +/// "When you put one or more Equipment onto the battlefield this way, you may +/// attach one of them to a Samurai you control."). Semantically identical to the +/// passive `parse_zone_changed_this_way_clause` existential check against +/// `state.last_zone_changed_ids`. +pub fn parse_you_put_onto_battlefield_this_way_clause( + input: &str, +) -> OracleResult<'_, (TargetFilter, bool)> { + let (rest, _) = tag("you put ").parse(input)?; + let (rest, _) = alt(( + value((), tag::<_, _, OracleError<'_>>("at least one ")), + value((), tag("one or more ")), + parse_article, + )) + .parse(rest)?; + let (filter, after_filter) = parse_type_phrase(rest); + if matches!(filter, TargetFilter::Any) { + return Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Fail, + ))); + } + let after_filter = after_filter.trim_start(); + let (rest, _) = tag("onto the battlefield this way").parse(after_filter)?; + Ok((rest, (filter, false))) +} + /// CR 603.12 + CR 608.2c: Recognize a leading reflexive-conditional connector /// and return the corresponding AbilityCondition with the connector consumed. /// Single authority for this set; consumed by both @@ -11585,16 +11613,34 @@ mod tests { .unwrap(); assert_eq!(rest, ", you may attach it to a creature you control"); assert!(!negated); - // Subtype Equipment must round-trip (parse_type_phrase canonicalizes - // "equipment" → Subtype::Equipment via the oracle subtype dictionary). match filter { TargetFilter::Typed(TypedFilter { type_filters, .. }) => { - assert!( - type_filters - .iter() - .any(|f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment"))), - "expected Subtype Equipment, got {type_filters:?}" - ); + assert!(type_filters.iter().any( + |f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment")) + )); + } + other => panic!("expected Typed Equipment, got {other:?}"), + } + } + + /// CR 603.12: Gilgamesh active-voice reflexive gate — "you put one or more + /// [type] onto the battlefield this way". + #[test] + fn test_you_put_onto_battlefield_this_way_equipment() { + let (rest, (filter, negated)) = parse_you_put_onto_battlefield_this_way_clause( + "you put one or more equipment onto the battlefield this way, you may attach one of them to a samurai you control", + ) + .unwrap(); + assert_eq!( + rest, + ", you may attach one of them to a samurai you control" + ); + assert!(!negated); + match filter { + TargetFilter::Typed(TypedFilter { type_filters, .. }) => { + assert!(type_filters.iter().any( + |f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment")) + )); } other => panic!("expected Typed Equipment, got {other:?}"), }