diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index e3f4822908..6ee4030bbf 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -5734,16 +5734,52 @@ fn opaque_single_quoted_span<'a, E: nom::error::ParseError<&'a str>>( /// text; a single-quoted span (the depth-2 nested grant) is consumed via /// [`opaque_single_quoted_span`], whose structural close preserves embedded /// apostrophes. -fn split_choice_list_items(input: &str) -> Option> { +fn parse_choice_list_item(input: &str) -> nom::IResult<&str, &str> { let unit = alt(( recognize((tag("\""), take_until("\""), tag("\""))), opaque_single_quoted_span, recognize(preceded(not(parse_choice_list_separator), anychar)), )); - let item = recognize(many1(unit)); - let (_, items) = all_consuming(separated_list1(parse_choice_list_separator, item)) - .parse(input) + recognize(many1(unit)).parse(input) +} + +fn split_choice_list_items(input: &str) -> Option> { + let (_, items) = all_consuming(separated_list1( + parse_choice_list_separator, + parse_choice_list_item, + )) + .parse(input) + .ok()?; + Some(items) +} + +/// Split a bare counter-choice list only when its final top-level separator is +/// `or`. Unlike an explicit "your choice of" payload, an unmarked `and` +/// conjunction means all listed counters are applied by the ordinary counter +/// parser, not that one branch is chosen. +/// +/// CR 608.2c + CR 608.2d: Normal English determines whether counters apply +/// together or as a disjunctive instruction, and an `or` choice is made while +/// the effect resolves. +fn split_bare_disjunctive_choice_list_items(input: &str) -> Option> { + // Keep the final `, or ` intact for the disjunctive separator below. A + // generic `, ` list separator would otherwise consume its comma before the + // parser can recognize a three-or-more-item choice. + let (rest, mut items) = separated_list1( + terminated( + tag::<_, _, OracleError<'_>>(", "), + not(tag::<_, _, OracleError<'_>>("or ")), + ), + parse_choice_list_item, + ) + .parse(input) + .ok()?; + let (rest, _) = alt((tag::<_, _, OracleError<'_>>(", or "), tag(" or "))) + .parse(rest) .ok()?; + let (rest, last_item) = parse_choice_list_item(rest).ok()?; + eof::<_, OracleError<'_>>(rest).ok()?; + items.push(last_item); Some(items) } @@ -5758,12 +5794,40 @@ fn split_choice_list_items(input: &str) -> Option> { /// - `SharedNoun`: "a X, Y, ..., or Z counter" — one leading article and one /// trailing "counter" shared across a list of bare keyword adjectives; /// synthesized as "a counter". +#[derive(Clone, Copy)] enum ChoiceListShape { Distributed, FromAmong, SharedNoun, } +/// A counter-choice list after its surface grammar and every item have been +/// validated. The original-text branch builder uses `shape` and `items`; the +/// context-free replacement parser consumes `entries` without reparsing. +struct ClassifiedCounterChoiceList<'a> { + shape: ChoiceListShape, + items: Vec<&'a str>, + entries: Vec<(CounterType, QuantityExpr)>, +} + +/// Parse one complete counter noun phrase in a distributed choice list. +/// +/// The counter-type parser intentionally admits open-ended named counters, but +/// a distributed item is valid only when that name is followed by the complete +/// singular or plural counter noun. This keeps bare noun disjunctions from +/// reaching the counter-choice branch builder. +fn parse_full_counter_noun(input: &str) -> Option<(CounterType, QuantityExpr)> { + let (count, rest) = parse_count_expr(input.trim())?; + let (rest, counter_type) = nom_primitives::parse_counter_type_typed(rest.trim_start()).ok()?; + all_consuming(alt(( + tag::<_, _, OracleError<'_>>("counters"), + tag("counter"), + ))) + .parse(rest.trim_start()) + .ok()?; + Some((counter_type, count)) +} + /// CR 122.1b: keyword counters distribute over a single shared noun. Recognize /// the "shared-noun" disjunctive list shape: ONE leading article, a list of /// bare keyword adjectives, and ONE trailing "counter" — e.g. "a menace, @@ -5797,7 +5861,115 @@ fn recognize_shared_noun_counter_list(input: &str) -> Option> { Some(items) } -/// CR 122.1 + CR 608.2d: Context-free classifier for a disjunctive +/// Parse and validate every member of a classified counter-choice list. +/// +/// This is deliberately the sole item-level parser: callers that need the +/// typed entries receive the work performed during classification rather than +/// reparsing the same text with subtly different validation. +fn parse_counter_choice_list_entries( + shape: ChoiceListShape, + items: &[&str], +) -> Option> { + if items.len() < 2 || items.iter().any(|item| item.trim().is_empty()) { + return None; + } + + items + .iter() + .map(|item| match shape { + // CR 122.1: full counter noun phrase ("a +1/+1 counter", "two + // charge counters"). Parse count then counter type from the remainder. + ChoiceListShape::Distributed => parse_full_counter_noun(item.trim()), + // CR 122.1b: bare keyword name ("first strike"); count is one. + ChoiceListShape::FromAmong | ChoiceListShape::SharedNoun => { + let (_rest, counter_type) = + all_consuming(nom_primitives::parse_strict_counter_type) + .parse(item.trim()) + .ok()?; + Some((counter_type, QuantityExpr::Fixed { value: 1 })) + } + }) + .collect() +} + +/// Classify a counter-choice list and parse every member for the classified +/// shape. This is the single authority for the priority order and guards shared +/// by context-free callers and the branch-reparsing parser. +fn classify_counter_choice_list(input: &str) -> Option> { + if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("a counter from among ").parse(input) { + let items = split_choice_list_items(rest)?; + let entries = parse_counter_choice_list_entries(ChoiceListShape::FromAmong, &items)?; + return Some(ClassifiedCounterChoiceList { + shape: ChoiceListShape::FromAmong, + items, + entries, + }); + } + + // A distributed list also begins with "a" and ends with "counter". Only + // retain the shared-noun interpretation when every member is a bare, + // recognized counter type; otherwise try the full-noun distributed grammar. + if let Some(items) = recognize_shared_noun_counter_list(input) { + if let Some(entries) = + parse_counter_choice_list_entries(ChoiceListShape::SharedNoun, &items) + { + return Some(ClassifiedCounterChoiceList { + shape: ChoiceListShape::SharedNoun, + items, + entries, + }); + } + } + + let items = split_choice_list_items(input)?; + let entries = parse_counter_choice_list_entries(ChoiceListShape::Distributed, &items)?; + Some(ClassifiedCounterChoiceList { + shape: ChoiceListShape::Distributed, + items, + entries, + }) +} + +/// Recover the original-case list items after a lowercased list has already +/// been classified and validated. This is deliberately a structural mirror of +/// the accepted grammar: counter-choice recognition and validation remain on +/// `TextPair::lower`, while original text is retained only for branch display +/// text and the existing branch reparse path. +fn original_counter_choice_list_items( + shape: ChoiceListShape, + choices: TextPair<'_>, +) -> Option> { + match shape { + ChoiceListShape::Distributed => split_choice_list_items(choices.original), + ChoiceListShape::FromAmong => { + // allow-noncombinator: mirrors an already-classified lowercase prefix on paired original text. + let after_preamble = choices.strip_prefix("a counter from among ")?; + split_choice_list_items(after_preamble.original) + } + ChoiceListShape::SharedNoun => { + let mut original_items = split_choice_list_items(choices.original)?; + let lower_items = split_choice_list_items(choices.lower)?; + if original_items.len() != lower_items.len() || original_items.len() < 2 { + return None; + } + + let first = TextPair::new(original_items[0], lower_items[0]) + // allow-noncombinator: mirrors an already-classified lowercase article on paired original text. + .strip_prefix("a ")? + .original; + let last_index = original_items.len() - 1; + let last = TextPair::new(original_items[last_index], lower_items[last_index]) + // allow-noncombinator: mirrors an already-classified lowercase counter suffix on paired original text. + .strip_suffix(" counter")? + .original; + original_items[0] = first; + original_items[last_index] = last; + Some(original_items) + } + } +} + +/// CR 122.1 + CR 122.1a + CR 122.1b: Context-free classifier for a disjunctive /// counter-choice list. Given the choices payload (the text BETWEEN /// "your choice of " and " on TARGET"), recognize which of the three list /// shapes it is and parse each item into a typed `(CounterType, QuantityExpr)` @@ -5826,66 +5998,11 @@ fn recognize_shared_noun_counter_list(input: &str) -> Option> { pub(crate) fn classify_and_parse_counter_choice_list( choices_text: &str, ) -> Option> { - let (shape, choice_items) = - match tag::<_, _, OracleError<'_>>("a counter from among ")(choices_text) { - Ok((rest, _)) => (ChoiceListShape::FromAmong, split_choice_list_items(rest)?), - // CR 122.1b: keyword counters distribute over a single noun; only - // classify as SharedNoun when the shape matches AND every item is a - // recognized counter type — otherwise distributed lists and - // non-counter lists leak through. Fall through to Distributed when - // the strict guard fails. - Err(_) => match recognize_shared_noun_counter_list(choices_text) { - Some(items) - if items.len() >= 2 - && items.iter().all(|item| { - all_consuming(nom_primitives::parse_strict_counter_type) - .parse(item.trim()) - .is_ok() - }) => - { - (ChoiceListShape::SharedNoun, items) - } - _ => ( - ChoiceListShape::Distributed, - split_choice_list_items(choices_text)?, - ), - }, - }; - - if choice_items.len() < 2 { - return None; - } - - let mut entries: Vec<(CounterType, QuantityExpr)> = Vec::with_capacity(choice_items.len()); - for item in &choice_items { - let item = item.trim(); - if item.is_empty() { - return None; - } - let entry = match shape { - // CR 122.1: full counter noun phrase ("a +1/+1 counter", "two charge - // counters"). Parse count then counter type from the remainder. - ChoiceListShape::Distributed => { - let (count, rest) = parse_count_expr(item)?; - let (_after, counter_type) = nom_primitives::parse_counter_type_typed(rest).ok()?; - (counter_type, count) - } - // CR 122.1b: bare keyword name ("first strike"); count is one. - ChoiceListShape::FromAmong | ChoiceListShape::SharedNoun => { - let (_rest, counter_type) = - all_consuming(nom_primitives::parse_strict_counter_type) - .parse(item) - .ok()?; - (counter_type, QuantityExpr::Fixed { value: 1 }) - } - }; - entries.push(entry); - } - - Some(entries) + let lower = choices_text.to_lowercase(); + Some(classify_counter_choice_list(&lower)?.entries) } -/// CR 122.1 + CR 608.2d: Parse shared-target counter choices of the form +/// CR 122.1 + CR 122.1a + CR 122.1b: Parse shared-target counter choices of the form /// "put your choice of A counter-pattern, B counter-pattern, or C /// counter-pattern on TARGET" (N-ary branches). /// @@ -5911,86 +6028,54 @@ fn try_parse_put_counter_choice( tp: TextPair<'_>, ctx: &mut ParseContext, ) -> Option { - // CR 122.1b + CR 608.2d: two surface forms select a counter kind. The - // explicit "put your choice of on TARGET" form (Inspirit, Invoke the + // CR 122.1 + CR 122.1a + CR 122.1b: Two surface forms name counter kinds. + // CR 608.2d: Each accepted form presents its controller with a + // resolution-time choice. The explicit "put your choice of on + // TARGET" form (Inspirit, Invoke the // Ancients) and the bare "put a , , or counter on TARGET" form // (Reluctant Role Model: "put a flying, lifelink, or +1/+1 counter on it"). // Both resolve to the same `ChooseOneOf` of `PutCounter` branches — the // controller still picks one kind at resolution. The bare form is allowed - // ONLY for the strictly-validated SharedNoun/FromAmong shapes (every item - // must name a real counter type), so noun-phrase disjunctions like "put a - // creature or a land into play" never misclassify as a counter choice. - let explicit_choice; - let after_choice_original = if let Some(((), rest)) = nom_on_lower(tp.original, tp.lower, |i| { - value((), tag("put your choice of ")).parse(i) - }) { - explicit_choice = true; - rest + // only when every distributed item is a complete counter noun phrase, so + // noun-phrase disjunctions like "put a creature or a land into play" never + // misclassify as a counter choice. + let (explicit_choice, after_choice_original) = if let Some(((), rest)) = + nom_on_lower(tp.original, tp.lower, |i| { + value((), tag("put your choice of ")).parse(i) + }) { + (true, rest) } else { - explicit_choice = false; - nom_on_lower(tp.original, tp.lower, |i| value((), tag("put ")).parse(i))?.1 + ( + false, + nom_on_lower(tp.original, tp.lower, |i| value((), tag("put ")).parse(i))?.1, + ) }; let consumed = tp.original.len() - after_choice_original.len(); let after_choice = TextPair::new(after_choice_original, &tp.lower[consumed..]); let (choices_tp, target_tp) = after_choice.split_around(" on ")?; - // Split the post-"on" choices into individual items via nom combinators. - // Three list shapes (CR 122.1 + CR 608.2d), classified in priority order: - // 1. FromAmong — "a counter from among X, Y, ..., and Z" (bare keywords) - // 2. SharedNoun — "a X, Y, ..., or Z counter" (one leading article + bare - // keyword adjectives + one trailing "counter") - // 3. Distributed — "a A counter, a B counter, or a C counter" / binary - // ("a A counter or a B counter"), each item a full counter noun phrase. - // CR 122.1b: both FromAmong and SharedNoun name bare keywords; each branch - // is later synthesized as "a counter". - let choices_text = choices_tp.original; - let (shape, choice_items) = - match tag::<_, _, OracleError<'_>>("a counter from among ")(choices_text) { - Ok((rest, _)) => (ChoiceListShape::FromAmong, split_choice_list_items(rest)?), - // CR 122.1b: keyword counters distribute over a single noun; CR - // 608.2d: choice made at resolution; CR 601.2c: shared target at - // cast. Only classify as SharedNoun when the shape matches AND every - // item is a recognized counter type — otherwise distributed lists - // ("a +1/+1 counter, ...") and non-counter lists ("a red or blue - // creature") would leak through. Fall through to Distributed when - // the strict guard fails. - Err(_) => match recognize_shared_noun_counter_list(choices_text) { - Some(items) - if items.len() >= 2 - && items.iter().all(|item| { - all_consuming(nom_primitives::parse_strict_counter_type) - .parse(item.trim()) - .is_ok() - }) => - { - (ChoiceListShape::SharedNoun, items) - } - // The bare "put on ..." form has no "your choice of" - // disambiguator, so it must NOT fall through to the permissive - // Distributed shape — that would let arbitrary "A or B" noun - // phrases reach the counter-branch builder. Require the strict - // SharedNoun/FromAmong shapes for the bare form. - _ if !explicit_choice => return None, - _ => ( - ChoiceListShape::Distributed, - split_choice_list_items(choices_text)?, - ), - }, - }; - - // Require at least 2 branches. - if choice_items.len() < 2 { + if !explicit_choice && split_bare_disjunctive_choice_list_items(choices_tp.lower).is_none() { return None; } - let target_text = target_tp.original.trim().trim_end_matches('.'); - if target_text.is_empty() { + // The shared classifier validates FromAmong, SharedNoun, and Distributed + // lists before branch reparsing. The unmarked bare form retains its + // established shared-noun admission and adds only a full counter-noun + // Distributed list (Dwarven Armorer's form); `from among` remains reserved + // for the explicit choice grammar. + let classified = classify_counter_choice_list(choices_tp.lower)?; + let shape = classified.shape; + if !explicit_choice && matches!(shape, ChoiceListShape::FromAmong) { + return None; + } + let choice_items = original_counter_choice_list_items(shape, choices_tp)?; + if choice_items.len() != classified.items.len() { return None; } - // Validate each choice item is non-empty. - if choice_items.iter().any(|item| item.trim().is_empty()) { + let target_text = target_tp.original.trim().trim_end_matches('.'); + if target_text.is_empty() { return None; } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 6f73270ee7..3af338ec0f 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -40891,6 +40891,160 @@ fn choose_one_of_detects_shared_target_counter_choice() { } } +#[test] +fn dwarven_armorer_bare_distributed_counter_choice_preserves_cost_and_branches() { + use crate::types::counter::CounterType; + + let parsed = parse_oracle_text( + "{R}, {T}, Discard a card: Put a +0/+1 counter or a +1/+0 counter on target creature.", + "Dwarven Armorer", + &[], + &["Creature".to_string()], + &["Dwarf".to_string()], + ); + + assert_eq!( + parsed.abilities.len(), + 1, + "Dwarven Armorer must produce exactly one activated ability: {:#?}", + parsed.abilities + ); + let ability = &parsed.abilities[0]; + assert_eq!(ability.kind, AbilityKind::Activated); + + let AbilityCost::Composite { costs } = ability + .cost + .as_ref() + .expect("Dwarven Armorer must retain its activation costs") + else { + panic!( + "expected composite mana, tap, discard cost, got {:?}", + ability.cost + ); + }; + assert_eq!(costs.len(), 3); + assert!(matches!( + &costs[0], + AbilityCost::Mana { + cost: ManaCost::Cost { + shards, + generic: 0, + } + } if shards == &vec![ManaCostShard::Red] + )); + assert!(matches!(&costs[1], AbilityCost::Tap)); + assert!(matches!( + &costs[2], + AbilityCost::Discard { + count: QuantityExpr::Fixed { value: 1 }, + filter: None, + .. + } + )); + + assert!(matches!(&*ability.effect, Effect::TargetOnly { .. })); + let choice = ability + .sub_ability + .as_deref() + .expect("the shared target must lead to the counter-choice sub-ability"); + let Effect::ChooseOneOf { chooser, branches } = &*choice.effect else { + panic!( + "expected ChooseOneOf after shared target, got {:?}", + choice.effect + ); + }; + assert_eq!(*chooser, PlayerFilter::Controller); + assert_eq!(branches.len(), 2); + + let expected = [(0, 1), (1, 0)]; + for (branch, (power, toughness)) in branches.iter().zip(expected) { + assert!( + matches!( + &*branch.effect, + Effect::PutCounter { + counter_type: CounterType::PowerToughness { power: actual_power, toughness: actual_toughness }, + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::ParentTarget, + } if (*actual_power, *actual_toughness) == (power, toughness) + ), + "expected +{power}/+{toughness} ParentTarget counter branch, got {:?}", + branch.effect + ); + } +} + +#[test] +fn bare_shared_noun_counter_choice_keeps_final_comma_or_separator() { + use crate::types::counter::CounterType; + use crate::types::keywords::KeywordKind; + + // Reluctant Role Model's three-way bare choice exercises the final `, or` + // separator. It must remain visible to the disjunctive splitter rather than + // being consumed as a generic comma list separator. + let ability = parse_effect_chain( + "Put a flying, lifelink, or +1/+1 counter on it.", + AbilityKind::Spell, + ); + assert!(matches!( + &*ability.effect, + Effect::TargetOnly { + target: TargetFilter::SelfRef, + } + )); + let choice = ability + .sub_ability + .as_deref() + .expect("the shared self target must lead to the counter choice"); + let Effect::ChooseOneOf { branches, .. } = &*choice.effect else { + panic!( + "expected a three-way counter choice, got {:?}", + choice.effect + ); + }; + assert_eq!(branches.len(), 3); + assert!(matches!( + &*branches[1].effect, + Effect::PutCounter { + counter_type: CounterType::Keyword(KeywordKind::Lifelink), + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::ParentTarget, + } + )); +} + +#[test] +fn mixed_case_counter_choice_validates_lowercase_and_keeps_branch_descriptions() { + let ability = parse_effect_chain( + "Put a +0/+1 Counter or a +1/+0 Counter on Target Creature.", + AbilityKind::Spell, + ); + + let choice = ability + .sub_ability + .as_deref() + .expect("mixed-case counter choice must retain its shared target"); + let Effect::ChooseOneOf { branches, .. } = &*choice.effect else { + panic!("expected mixed-case ChooseOneOf, got {:?}", choice.effect); + }; + assert_eq!(branches.len(), 2); + assert_eq!( + branches[0].description.as_deref(), + Some("put a +0/+1 Counter"), + "original case belongs to generated branch display text, not classification" + ); + assert!(matches!( + &*branches[1].effect, + Effect::PutCounter { + counter_type: CounterType::PowerToughness { + power: 1, + toughness: 0 + }, + target: TargetFilter::ParentTarget, + .. + } + )); +} + #[test] fn choose_one_of_detects_from_among_counter_choice() { use crate::types::counter::CounterType; @@ -41088,6 +41242,11 @@ fn classify_counter_choice_list_all_three_shapes() { ), ] ); + + let mixed_case = + classify_and_parse_counter_choice_list("A +1/+1 Counter OR Two Charge Counters") + .expect("counter-list grammar and validation must run on lowercased text"); + assert_eq!(mixed_case, with_count); } #[test] @@ -41104,8 +41263,102 @@ fn classify_counter_choice_list_rejects_non_counter_and_singletons() { ); } +#[test] +fn bare_distributed_counter_choice_rejects_non_counter_noun_disjunction() { + // Positive reach guard: the bare distributed path is live and fully + // supported before the hostile phrase is checked. + let valid = parse_effect_chain( + "Put a +0/+1 counter or a +1/+0 counter on target creature.", + AbilityKind::Spell, + ); + assert!( + matches!(&*valid.effect, Effect::TargetOnly { .. }) + && valid + .sub_ability + .as_deref() + .is_some_and(|sub| matches!(&*sub.effect, Effect::ChooseOneOf { branches, .. } if branches.len() == 2)), + "a valid bare distributed counter list must reach ChooseOneOf: {valid:?}" + ); + + let hostile = parse_effect_chain( + "Put a red or blue creature on target creature.", + AbilityKind::Spell, + ); + let is_counter_choice = matches!(&*hostile.effect, Effect::TargetOnly { .. }) + && hostile.sub_ability.as_deref().is_some_and(|sub| { + matches!(&*sub.effect, Effect::ChooseOneOf { branches, .. } + if branches.iter().all(|branch| matches!(&*branch.effect, Effect::PutCounter { .. }))) + }); + assert!( + !is_counter_choice, + "bare non-counter noun disjunction must not reach counter branches: {hostile:?}" + ); +} + +#[test] +fn bare_counter_conjunctions_fall_through_to_the_multi_counter_parser() { + use crate::types::counter::CounterType; + use crate::types::keywords::KeywordKind; + + // These are ordinary multi-counter placements, not resolution-time + // choices. They cover Unexpected Fangs (the All Will Be One/Stalwart + // driver) and Abigale's three keyword counters respectively. + let cases = [ + ( + "Put a +1/+1 counter and a lifelink counter on target creature.", + vec![ + CounterType::Plus1Plus1, + CounterType::Keyword(KeywordKind::Lifelink), + ], + ), + ( + "Put a flying counter, a first strike counter, and a lifelink counter on that creature.", + vec![ + CounterType::Keyword(KeywordKind::Flying), + CounterType::Keyword(KeywordKind::FirstStrike), + CounterType::Keyword(KeywordKind::Lifelink), + ], + ), + ]; + + for (text, expected_counter_types) in cases { + let ability = parse_effect_chain(text, AbilityKind::Spell); + let effects = collect_chain_effects(&ability); + assert_eq!( + effects.len(), + expected_counter_types.len(), + "the ordinary multi-counter parser must retain every conjunct: {text}" + ); + for (effect, expected_counter_type) in effects.iter().zip(expected_counter_types) { + assert!( + matches!( + effect, + Effect::PutCounter { + counter_type, + count: QuantityExpr::Fixed { value: 1 }, + .. + } if counter_type == &expected_counter_type + ), + "counter conjunction must fall through instead of becoming ChooseOneOf: {text}; got {effect:?}" + ); + } + } +} + #[test] fn shared_noun_counter_choice_rejects_non_counter_list() { + let valid = parse_effect_chain( + "Put your choice of a flying or haste counter on target creature.", + AbilityKind::Spell, + ); + assert!( + matches!(&*valid.effect, Effect::TargetOnly { .. }) + && valid.sub_ability.as_deref().is_some_and(|sub| { + matches!(&*sub.effect, Effect::ChooseOneOf { branches, .. } if branches.len() == 2) + }), + "positive reach guard: a shared-noun counter list must reach ChooseOneOf: {valid:?}" + ); + // "a red or blue creature" is a noun-phrase disjunction, not a counter // choice — the per-item strict-counter-type guard must reject it so the // shared-noun arm does not produce a ChooseOneOf-of-PutCounter shape. diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index bd63f09603..6a733bdc2d 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -15688,6 +15688,10 @@ mod tests { "Denry Klin enters with your choice of a +1/+1, first strike, or vigilance counter on it.", &expected, ); + assert_choice( + "Denry Klin Enters With Your Choice Of A +1/+1, First Strike, Or Vigilance Counter On It.", + &expected, + ); // Distributed shape. assert_choice( "Denry Klin enters with your choice of a +1/+1 counter, a first strike counter, or a vigilance counter on it.", diff --git a/crates/engine/tests/fixtures/integration_cards.json.gz b/crates/engine/tests/fixtures/integration_cards.json.gz index 5ff7748e97..eec4ef12f7 100644 Binary files a/crates/engine/tests/fixtures/integration_cards.json.gz and b/crates/engine/tests/fixtures/integration_cards.json.gz differ diff --git a/crates/engine/tests/integration/dwarven_armorer_counter_choice.rs b/crates/engine/tests/integration/dwarven_armorer_counter_choice.rs new file mode 100644 index 0000000000..22cb319910 --- /dev/null +++ b/crates/engine/tests/integration/dwarven_armorer_counter_choice.rs @@ -0,0 +1,121 @@ +//! Dwarven Armorer's real printed activation chooses one of two P/T counter +//! branches after its target and costs have been committed. + +use crate::support::shared_card_db; +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::game::scenario_db::GameScenarioDbExt; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const DWARVEN_ARMORER: &str = "Dwarven Armorer"; + +fn counter_count(runner: &GameRunner, object: ObjectId, counter: CounterType) -> u32 { + runner + .state() + .objects + .get(&object) + .and_then(|card| card.counters.get(&counter).copied()) + .unwrap_or(0) +} + +fn activate_armorer_branch(choice_index: usize) -> (GameRunner, ObjectId, ObjectId, ObjectId) { + let db = shared_card_db().expect("integration card fixture must load"); + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let armorer = scenario.add_real_card(P0, DWARVEN_ARMORER, Zone::Battlefield, db); + let discard = scenario.add_real_card(P0, "Mountain", Zone::Hand, db); + let target = scenario.add_real_card(P0, "Grizzly Bears", Zone::Battlefield, db); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![])], + ); + + let mut runner = scenario.build(); + runner + .activate(armorer, 0) + .target_object(target) + .pay_with(&[discard]) + .resolve(); + + match &runner.state().waiting_for { + WaitingFor::ChooseOneOfBranch { branches, .. } => assert_eq!( + branches.len(), + 2, + "Dwarven Armorer must offer both printed counter branches" + ), + other => panic!("expected Dwarven Armorer counter choice, got {other:?}"), + } + runner + .act(GameAction::ChooseBranch { + index: choice_index, + }) + .expect("choosing Dwarven Armorer's counter branch must succeed"); + runner.advance_until_stack_empty(); + + (runner, armorer, discard, target) +} + +#[test] +fn dwarven_armorer_plus_zero_plus_one_branch_pays_costs_and_applies_only_that_counter() { + let (runner, armorer, discard, target) = activate_armorer_branch(0); + + assert_eq!( + counter_count( + &runner, + target, + CounterType::PowerToughness { + power: 0, + toughness: 1, + }, + ), + 1 + ); + assert_eq!( + counter_count( + &runner, + target, + CounterType::PowerToughness { + power: 1, + toughness: 0, + }, + ), + 0 + ); + assert!(runner.state().objects[&armorer].tapped); + assert_eq!(runner.state().objects[&discard].zone, Zone::Graveyard); +} + +#[test] +fn dwarven_armorer_plus_one_plus_zero_branch_pays_costs_and_applies_only_that_counter() { + let (runner, armorer, discard, target) = activate_armorer_branch(1); + + assert_eq!( + counter_count( + &runner, + target, + CounterType::PowerToughness { + power: 1, + toughness: 0, + }, + ), + 1 + ); + assert_eq!( + counter_count( + &runner, + target, + CounterType::PowerToughness { + power: 0, + toughness: 1, + }, + ), + 0 + ); + assert!(runner.state().objects[&armorer].tapped); + assert_eq!(runner.state().objects[&discard].zone, Zone::Graveyard); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 5d3c97f1a5..8687f979c2 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1077,6 +1077,7 @@ mod detectives_phoenix_bestow_graveyard; mod dreadhorde_invasion_amass; mod dromokas_command_spell_prevention; mod duggan_private_detective_punch; +mod dwarven_armorer_counter_choice; mod dynamic_x_cost_reduction; mod each_player_they_control_scope; mod edgar_markov_eminence_command_zone;