From f7e53118f4d25aa74c74780509d333a6d1670534 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 21 May 2026 03:36:08 +0000 Subject: [PATCH 1/4] feat(parser): support 'can block an additional creature this turn' as effect CR 509.1a + CR 509.1b: Add try_parse_can_block_additional handler in the subject-predicate effect parser to recognize activated/triggered ability text of the form '[subject] can block an additional creature this turn' and '[subject] can block any number of creatures this turn'. Previously, this pattern was only recognized as a permanent static ability (in oracle_static.rs). When it appeared as an activated ability effect (e.g. '{2}: ~ can block an additional creature this turn.'), the effect parser could not produce the ExtraBlockers static mode, leaving it as an unimplemented gap. The new handler mirrors try_parse_can_attack_with_defender: it intercepts the text before the generic continuous-clause parser, extracts the subject, and emits a GenericEffect carrying: - StaticMode::ExtraBlockers { count: Some(1) } (or count: None for 'any number') - ContinuousModification::AddStaticMode with the same mode - Duration::UntilEndOfTurn This unlocks Luminous Guardian, Coastline Chimera, Anurid Swarmsnapper, and 13+ other cards whose only gap was this effect pattern. Includes two unit tests validating both the 'additional' and 'any number' variants. --- .../src/parser/oracle_effect/subject.rs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index cd18822cf6..74c8c3f5cf 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -49,6 +49,22 @@ pub(super) fn try_parse_subject_predicate_ast( )); } + // CR 509.1a + CR 509.1b: "can block an additional creature [this turn]" — + // must intercept before continuous clause parsing which cannot produce the + // ExtraBlockers static mode from the predicate text. + if let Some(clause) = try_parse_can_block_additional(text, ctx) { + return Some(subject_predicate_ast_from_clause( + text, + clause, + |effect, duration, _sub_ability| PredicateAst::Continuous { + effect, + duration, + sub_ability: None, + }, + ctx, + )); + } + if let Some(clause) = try_parse_subject_additive_type_clause(text, ctx) { return Some(clause); } @@ -463,6 +479,54 @@ fn try_parse_can_attack_with_defender( }) } +/// CR 509.1a + CR 509.1b: "[subject] can block an additional creature [this turn]" +/// Produces a GenericEffect with ExtraBlockers { count: Some(1) } static mode. +/// Mirrors the static-ability parser in `oracle_static.rs` but for activated/triggered +/// effect text where the grant is transient (until end of turn). +fn try_parse_can_block_additional( + text: &str, + ctx: &mut ParseContext, +) -> Option { + let lower = text.to_lowercase(); + let tp = TextPair::new(text, &lower); + let pos = tp.find(" can block a")?; + // Must contain the "additional" pattern to distinguish from other "can block" text. + if !nom_primitives::scan_contains(&lower, "can block an additional") + && !nom_primitives::scan_contains(&lower, "can block any number") + { + return None; + } + let subject = text[..pos].trim(); + let application = parse_subject_application(subject, ctx)?; + let duration = if lower.contains("this turn") || lower.contains("this combat") { + Some(Duration::UntilEndOfTurn) + } else { + None + }; + let mode = if nom_primitives::scan_contains(&lower, "can block any number") { + StaticMode::ExtraBlockers { count: None } + } else { + StaticMode::ExtraBlockers { count: Some(1) } + }; + let affected = static_affected_for_application(&application); + Some(ParsedEffectClause { + effect: Effect::GenericEffect { + static_abilities: vec![StaticDefinition::new(mode.clone()) + .affected(affected) + .modifications(vec![ContinuousModification::AddStaticMode { mode }])], + duration: duration.clone(), + target: application.target, + }, + duration, + sub_ability: None, + distribute: None, + multi_target: None, + condition: None, + optional: false, + unless_pay: None, + }) +} + pub(super) fn parse_subject_application( subject: &str, ctx: &mut ParseContext, @@ -3090,4 +3154,86 @@ mod tests { ]) ); } + + /// CR 509.1a + CR 509.1b: Activated ability "~ can block an additional creature + /// this turn" produces a transient GenericEffect granting ExtraBlockers { count: Some(1) } + /// via AddStaticMode. Validates the `try_parse_can_block_additional` handler. + #[test] + fn can_block_additional_creature_this_turn_effect() { + let mut ctx = ParseContext { + card_name: Some("Luminous Guardian".to_string()), + ..Default::default() + }; + let ability = crate::parser::oracle_effect::parse_effect_chain_with_context( + "~ can block an additional creature this turn.", + AbilityKind::Activated, + &mut ctx, + ); + match &*ability.effect { + Effect::GenericEffect { + static_abilities, + duration, + .. + } => { + assert_eq!( + duration, + &Some(Duration::UntilEndOfTurn), + "duration must be UntilEndOfTurn" + ); + assert_eq!(static_abilities.len(), 1); + let sd = &static_abilities[0]; + assert_eq!( + sd.mode, + StaticMode::ExtraBlockers { count: Some(1) }, + "mode must be ExtraBlockers(1)" + ); + assert!( + sd.modifications.iter().any(|m| matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ExtraBlockers { count: Some(1) } + } + )), + "must have AddStaticMode(ExtraBlockers(1)) modification" + ); + } + other => panic!("expected GenericEffect, got {other:?}"), + } + } + + /// CR 509.1a: "~ can block any number of creatures this turn" produces + /// ExtraBlockers { count: None } via the same handler. + #[test] + fn can_block_any_number_this_turn_effect() { + let mut ctx = ParseContext { + card_name: Some("Test Card".to_string()), + ..Default::default() + }; + let ability = crate::parser::oracle_effect::parse_effect_chain_with_context( + "~ can block any number of creatures this turn.", + AbilityKind::Activated, + &mut ctx, + ); + match &*ability.effect { + Effect::GenericEffect { + static_abilities, + duration, + .. + } => { + assert_eq!( + duration, + &Some(Duration::UntilEndOfTurn), + "duration must be UntilEndOfTurn" + ); + assert_eq!(static_abilities.len(), 1); + let sd = &static_abilities[0]; + assert_eq!( + sd.mode, + StaticMode::ExtraBlockers { count: None }, + "mode must be ExtraBlockers(None)" + ); + } + other => panic!("expected GenericEffect, got {other:?}"), + } + } } From 44b968dd81d7476a79763a9ec984f39c4a857354 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 21 May 2026 10:20:03 +0000 Subject: [PATCH 2/4] fix: map 'this combat' to Duration::UntilEndOfCombat (CR 514.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review correctly identified that 'can block an additional creature this combat' was mapped to UntilEndOfTurn. Per CR 514.2, 'this combat' means until end of the current combat phase, not end of turn. Split the duration check into two branches: - 'this turn' → Duration::UntilEndOfTurn - 'this combat' → Duration::UntilEndOfCombat --- crates/engine/src/parser/oracle_effect/subject.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 74c8c3f5cf..a433b4ba9e 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -498,8 +498,10 @@ fn try_parse_can_block_additional( } let subject = text[..pos].trim(); let application = parse_subject_application(subject, ctx)?; - let duration = if lower.contains("this turn") || lower.contains("this combat") { + let duration = if lower.contains("this turn") { Some(Duration::UntilEndOfTurn) + } else if lower.contains("this combat") { + Some(Duration::UntilEndOfCombat) } else { None }; From 934f461206c99fc3eb83f90508fbcecd3409126a Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 21 May 2026 12:21:36 +0000 Subject: [PATCH 3/4] fix: use nom combinators for can_block_additional parser Replaced string-based dispatch (.find(), .contains()) with modular nom combinators (alt, tag, opt, preceded) to comply with the repository's architectural rule (R1) for robust Oracle phrase parsing. --- .../src/parser/oracle_effect/subject.rs | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index a433b4ba9e..cd98a042bd 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -487,29 +487,35 @@ fn try_parse_can_block_additional( text: &str, ctx: &mut ParseContext, ) -> Option { + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::combinator::{eof, opt, value}; + use nom::sequence::preceded; + let lower = text.to_lowercase(); - let tp = TextPair::new(text, &lower); - let pos = tp.find(" can block a")?; - // Must contain the "additional" pattern to distinguish from other "can block" text. - if !nom_primitives::scan_contains(&lower, "can block an additional") - && !nom_primitives::scan_contains(&lower, "can block any number") - { - return None; - } - let subject = text[..pos].trim(); - let application = parse_subject_application(subject, ctx)?; - let duration = if lower.contains("this turn") { - Some(Duration::UntilEndOfTurn) - } else if lower.contains("this combat") { - Some(Duration::UntilEndOfCombat) - } else { - None - }; - let mode = if nom_primitives::scan_contains(&lower, "can block any number") { - StaticMode::ExtraBlockers { count: None } - } else { - StaticMode::ExtraBlockers { count: Some(1) } - }; + let (subject_lower, predicate_lower) = + nom_primitives::scan_split_at_phrase(&lower, |i| tag("can block ").parse(i))?; + let subject_text = &text[..subject_lower.len()]; + let application = parse_subject_application(subject_text.trim(), ctx)?; + + use nom::sequence::tuple; + type VE<'a> = OracleError<'a>; + let (_rest, (count, duration, _, _)) = tuple(( + preceded( + tag::<_, _, VE>("can block "), + alt(( + value(Some(1), tag::<_, _, VE>("an additional creature")), + value(None, tag::<_, _, VE>("any number of creatures")), + )), + ), + opt(alt(( + value(Duration::UntilEndOfTurn, tag::<_, _, VE>(" this turn")), + value(Duration::UntilEndOfCombat, tag::<_, _, VE>(" this combat")), + ))), + opt(tag::<_, _, VE>(".")), + eof::<_, VE>, + )).parse(predicate_lower).ok()?; + let mode = StaticMode::ExtraBlockers { count }; let affected = static_affected_for_application(&application); Some(ParsedEffectClause { effect: Effect::GenericEffect { From 114ee25bbc8d8d6fc55ac91ff13263e75088aecf Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 21 May 2026 13:35:12 -0700 Subject: [PATCH 4/4] fix(PR-662): parse extra blocker grants structurally --- .../src/parser/oracle_effect/subject.rs | 115 +++++++++++++----- 1 file changed, 87 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index cd98a042bd..a65f99e4f2 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -1,7 +1,7 @@ use crate::parser::oracle_nom::error::OracleError; use nom::branch::alt; use nom::bytes::complete::{tag, take_till}; -use nom::combinator::{all_consuming, opt, value, verify}; +use nom::combinator::{all_consuming, map, opt, value, verify}; use nom::sequence::preceded; use nom::Parser; @@ -56,11 +56,7 @@ pub(super) fn try_parse_subject_predicate_ast( return Some(subject_predicate_ast_from_clause( text, clause, - |effect, duration, _sub_ability| PredicateAst::Continuous { - effect, - duration, - sub_ability: None, - }, + |effect, duration, _sub_ability| PredicateAst::Restriction { effect, duration }, ctx, )); } @@ -487,34 +483,23 @@ fn try_parse_can_block_additional( text: &str, ctx: &mut ParseContext, ) -> Option { - use nom::branch::alt; - use nom::bytes::complete::tag; - use nom::combinator::{eof, opt, value}; - use nom::sequence::preceded; - let lower = text.to_lowercase(); let (subject_lower, predicate_lower) = nom_primitives::scan_split_at_phrase(&lower, |i| tag("can block ").parse(i))?; let subject_text = &text[..subject_lower.len()]; let application = parse_subject_application(subject_text.trim(), ctx)?; - use nom::sequence::tuple; - type VE<'a> = OracleError<'a>; - let (_rest, (count, duration, _, _)) = tuple(( - preceded( - tag::<_, _, VE>("can block "), - alt(( - value(Some(1), tag::<_, _, VE>("an additional creature")), - value(None, tag::<_, _, VE>("any number of creatures")), - )), - ), - opt(alt(( - value(Duration::UntilEndOfTurn, tag::<_, _, VE>(" this turn")), - value(Duration::UntilEndOfCombat, tag::<_, _, VE>(" this combat")), - ))), - opt(tag::<_, _, VE>(".")), - eof::<_, VE>, - )).parse(predicate_lower).ok()?; + let (_rest, (_, _, _, _, count, duration, _)) = all_consuming(( + tag("can"), + tag(" "), + tag("block"), + tag(" "), + parse_extra_blockers_count, + parse_block_grant_duration, + opt(tag(".")), + )) + .parse(predicate_lower) + .ok()?; let mode = StaticMode::ExtraBlockers { count }; let affected = static_affected_for_application(&application); Some(ParsedEffectClause { @@ -535,6 +520,43 @@ fn try_parse_can_block_additional( }) } +fn parse_extra_blockers_count(input: &str) -> OracleResult<'_, Option> { + alt(( + map( + ( + nom_primitives::parse_number, + tag(" additional creature"), + opt(tag("s")), + ), + |(count, _, _)| Some(count), + ), + value( + None, + ( + tag("any"), + tag(" "), + tag("number"), + tag(" "), + tag("of"), + tag(" "), + tag("creatures"), + ), + ), + )) + .parse(input) +} + +fn parse_block_grant_duration(input: &str) -> OracleResult<'_, Option> { + opt(preceded( + tag(" this "), + alt(( + value(Duration::UntilEndOfTurn, tag("turn")), + value(Duration::UntilEndOfCombat, tag("combat")), + )), + )) + .parse(input) +} + pub(super) fn parse_subject_application( subject: &str, ctx: &mut ParseContext, @@ -3244,4 +3266,41 @@ mod tests { other => panic!("expected GenericEffect, got {other:?}"), } } + + /// CR 509.1a + CR 509.1b: combat-scoped blocking permissions expire at + /// end of combat, and numeric counts are parsed through the shared number + /// combinator rather than a one-card string branch. + #[test] + fn can_block_two_additional_creatures_this_combat_effect() { + let mut ctx = ParseContext { + card_name: Some("Test Card".to_string()), + ..Default::default() + }; + let ability = crate::parser::oracle_effect::parse_effect_chain_with_context( + "~ can block two additional creatures this combat.", + AbilityKind::Activated, + &mut ctx, + ); + match &*ability.effect { + Effect::GenericEffect { + static_abilities, + duration, + .. + } => { + assert_eq!( + duration, + &Some(Duration::UntilEndOfCombat), + "duration must be UntilEndOfCombat" + ); + assert_eq!(static_abilities.len(), 1); + let sd = &static_abilities[0]; + assert_eq!( + sd.mode, + StaticMode::ExtraBlockers { count: Some(2) }, + "mode must be ExtraBlockers(2)" + ); + } + other => panic!("expected GenericEffect, got {other:?}"), + } + } }