diff --git a/crates/engine/src/parser/oracle_nom/quantity.rs b/crates/engine/src/parser/oracle_nom/quantity.rs index 4174be72c4..4ca34a0079 100644 --- a/crates/engine/src/parser/oracle_nom/quantity.rs +++ b/crates/engine/src/parser/oracle_nom/quantity.rs @@ -3052,13 +3052,6 @@ fn parse_for_each_clause_ref_with_they_controller( // "basic land type" is not mis-consumed as a creature/permanent type. // Anaphoric "they control" binds to the iterating/scoped player here. |i| parse_basic_land_types_among_lands_controlled_by_ref(i, they_controller.clone()), - // CR 122.1 + CR 109.4: "[other] you control with a counter on - // it" — a controller-scoped count gated on a counter predicate. Must - // precede `parse_for_each_controlled_type`, whose bare " you control" - // match would otherwise strand the trailing counter clause as an - // unconsumed remainder (Armorcraft Judge, High Sentinels of Arashin, - // Inspiring Call). - parse_for_each_controlled_type_with_counter, parse_for_each_controlled_type, // CR 201.2: "for each [other] named you control" // (Seven Dwarves). The `named X` qualifier sits between the type word @@ -4086,23 +4079,7 @@ fn parse_for_each_battlefield_type_with_keyword(input: &str) -> OracleResult<'_, )) } -/// CR 122.1 + CR 109.4: Parse "[other] you [already] control with a -/// counter on it" in a "for each" clause -> a controller-scoped -/// (`ControllerRef::You`) population count of permanents of the given type that -/// carry the named counter, with an optional "other"/"another" exclusion of the -/// source object. -/// -/// The trailing counter predicate is delegated to the shared -/// `oracle_target::parse_counter_suffix` building block (the same authority that -/// backs "target creature with a +1/+1 counter on it"), so the whole -/// with/without/with-no and typed/any counter grammar is covered — this arm only -/// adds the controller scoping. Must precede `parse_for_each_controlled_type`, -/// whose bare " you control" arm would otherwise match first and strand the -/// " with a … counter on it" clause as an unconsumed remainder, dropping the -/// quantity. Backs dynamic P/T anthems and per-count effects such as High -/// Sentinels of Arashin ("~ gets +1/+1 for each other creature you control with -/// a +1/+1 counter on it"), Armorcraft Judge, Inspiring Call, and Hamza. -fn parse_for_each_controlled_type_with_counter(input: &str) -> OracleResult<'_, QuantityRef> { + let (rest, has_other) = opt(alt((value((), tag("other ")), value((), tag("another "))))).parse(input)?; let (rest, tf) = parse_type_filter_word(rest)?; @@ -4110,23 +4087,12 @@ fn parse_for_each_controlled_type_with_counter(input: &str) -> OracleResult<'_, // tolerating the "already" adverb (" you already control with …"). let (rest, _) = tag(" you").parse(rest)?; let (rest, _) = opt(tag(" already")).parse(rest)?; - let (rest, _) = tag(" control").parse(rest)?; - // Delegate " with a counter on it" to the shared counter-suffix - // combinator, which returns the typed `FilterProp::Counters` and the number - // of bytes it consumed from `rest`. - let Some((counter_prop, consumed)) = parse_counter_suffix(rest) else { - return Err(nom::Err::Error(nom::error::Error::new( - rest, - nom::error::ErrorKind::Fail, - ))); - }; - let rest = &rest[consumed..]; + let mut properties = Vec::new(); if has_other.is_some() { properties.push(FilterProp::Another); } - properties.push(counter_prop); Ok(( rest, @@ -4687,6 +4653,79 @@ mod tests { } } + /// CR 109.4: controller-scoped "for each [other] you control with + /// " count — the "you control" sibling of + /// `parse_for_each_battlefield_type_with_keyword`, generalized over the + /// KEYWORDS table and the optional "other"/"another" exclusion. Backs Skycat + /// Sovereign, Aven Gagglemaster, and Overgrown Battlement. + #[test] + fn parse_for_each_controlled_type_with_keyword_scoped_count() { + for (clause, other, kw) in [ + ( + "other creature you control with flying", + true, + Keyword::Flying, + ), + ( + "creature you control with vigilance", + false, + Keyword::Vigilance, + ), + ( + "creature you control with defender", + false, + Keyword::Defender, + ), + ] { + let (rest, q) = parse_for_each_clause_ref(clause).unwrap(); + assert_eq!(rest, "", "{clause:?} should fully consume"); + match q { + QuantityRef::ObjectCount { + filter: TargetFilter::Typed(tf), + } => { + assert_eq!( + tf.controller, + Some(ControllerRef::You), + "{clause:?}: scoped to the source's controller" + ); + assert_eq!( + tf.properties.contains(&FilterProp::Another), + other, + "{clause:?}: Another presence must match the other/another prefix" + ); + assert!( + tf.properties + .contains(&FilterProp::WithKeyword { value: kw }), + "{clause:?}: must gate on the named keyword" + ); + } + other => panic!("{clause:?}: expected ObjectCount, got {other:?}"), + } + } + } + + /// CR 109.4: the bare "for each you control" arm still parses without + /// a keyword predicate — the new keyword arm must not shadow it. + #[test] + fn parse_for_each_controlled_type_bare_still_parses() { + let (rest, q) = parse_for_each_clause_ref("creature you control").unwrap(); + assert_eq!(rest, ""); + match q { + QuantityRef::ObjectCount { + filter: TargetFilter::Typed(tf), + } => { + assert_eq!(tf.controller, Some(ControllerRef::You)); + assert!( + !tf.properties + .iter() + .any(|p| matches!(p, FilterProp::WithKeyword { .. })), + "bare arm must not gate on a keyword" + ); + } + other => panic!("expected ObjectCount, got {other:?}"), + } + } + /// CR 604.3 + CR 109.4: opponent-controlled and chosen-player CDA counts. #[test] fn parse_number_of_controlled_type_opponent_and_chosen_player_cda() { diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index cea3b3f49f..90c38fbc28 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -24059,59 +24059,7 @@ fn enchanted_permanent_is_ongoing_grants_supertype() { ); } -/// CR 604.1 + CR 611.3a + CR 613.4c + CR 122.1 + CR 109.4: High Sentinels of -/// Arashin — "~ gets +1/+1 for each other creature you control with a +1/+1 -/// counter on it." The controller scope ("you control") and the counter -/// qualifier ("with a +1/+1 counter on it") both trail the type word; the "for -/// each" grammar previously had no combinator for a controller-scoped count -/// gated on a counter predicate, so this dynamic pump failed to parse and the -/// whole modification was dropped. -#[test] -fn static_self_dynamic_pump_for_each_other_creature_you_control_with_counter() { - let def = parse_static_line( - "~ gets +1/+1 for each other creature you control with a +1/+1 counter on it.", - ) - .expect("High Sentinels of Arashin's dynamic pump must parse"); - assert_eq!(def.mode, StaticMode::Continuous); - assert_eq!(def.affected, Some(TargetFilter::SelfRef)); - let is_expected_count = |value: &QuantityExpr| -> bool { - let QuantityExpr::Ref { - qty: - QuantityRef::ObjectCount { - filter: TargetFilter::Typed(tf), - }, - } = value - else { - return false; - }; - tf.controller == Some(ControllerRef::You) - && tf.type_filters == vec![TypeFilter::Creature] - && tf - .properties - .iter() - .any(|p| matches!(p, FilterProp::Another)) - && tf.properties.iter().any(|p| { - matches!( - p, - FilterProp::Counters { - counters: crate::types::counter::CounterMatch::OfType( - CounterType::Plus1Plus1 - ), - .. - } - ) - }) - }; - - assert!(def - .modifications - .iter() - .any(|m| matches!(m, ContinuousModification::AddDynamicPower { value } if is_expected_count(value)))); - assert!(def - .modifications - .iter() - .any(|m| matches!(m, ContinuousModification::AddDynamicToughness { value } if is_expected_count(value)))); assert!( !def.modifications.iter().any(|m| matches!( m,