-
-
Notifications
You must be signed in to change notification settings - Fork 113
fix(attach): honor protection attachment exemptions (#4964) #5015
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
63b8efb
1b476f3
57d3f1e
cbff90b
a563c7a
37bec17
52b0e30
025ece3
751ecaf
659fd4b
f294011
d9f9c75
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1564,13 +1564,43 @@ pub(crate) fn parse_continuous_modifications(text: &str) -> Vec<ContinuousModifi | |
| } | ||
| } | ||
| } else if let Some(keyword_text) = extract_keyword_clause(&unquoted_text) { | ||
| for part in split_keyword_list(keyword_text.trim().trim_end_matches('.')) { | ||
| // CR 702.16n/p (issue #4964): a trailing SBA-exemption sentence must be | ||
| // peeled from the keyword clause BEFORE `split_keyword_list`, because the | ||
| // "Auras and Equipment you control" form (Benevolent Blessing) carries an | ||
| // inner " and " that would otherwise fracture the sentence into bogus | ||
| // keyword legs. Gate the peel on a *recognized* exemption | ||
| // (`parse_protection_attachment_exemption_trailing`), NOT on the mere | ||
| // presence of "doesn't remove": an UNRECOGNIZED trailing sentence must | ||
| // stay glued so it can't clean an unrelated keyword clause. Each modeled | ||
| // exemption form (this-Aura, controlled-attachments, all-Auras) IS | ||
| // recognized, so its tail peels and the recovered keyword clause lowers | ||
| // normally — for Spectra Ward that means "protection from each color" | ||
| // correctly fans into the five WUBRG grants (the pre-fix baseline emitted | ||
| // the inert `Protection(CardType("each color"))` because the glued tail | ||
| // blocked the fan-out) plus the `ProtectionDoesntRemoveAuras` static. | ||
| let (keyword_only, trailing_exemption) = | ||
| match super::oracle_nom::bridge::split_once_on_lower( | ||
| keyword_text, | ||
| &keyword_text.to_lowercase(), | ||
| ". ", | ||
| ) { | ||
| Some((first, rest)) | ||
| if parse_protection_attachment_exemption_trailing(rest).is_some() => | ||
| { | ||
| (first, Some(rest.trim())) | ||
| } | ||
| _ => (keyword_text, None), | ||
| }; | ||
| for part in split_keyword_list(keyword_only.trim().trim_end_matches('.')) { | ||
| push_grant_clause_modifications( | ||
| &mut modifications, | ||
| part.as_ref(), | ||
| where_x_expression.as_deref(), | ||
| ); | ||
| } | ||
| if let Some(trailing) = trailing_exemption { | ||
| push_protection_attachment_exemption_modifications(&mut modifications, trailing); | ||
| } | ||
| } | ||
|
|
||
| // CR 613.1f: Pre-quote keyword recovery for compound lines like Swashbuckler's | ||
|
|
@@ -1658,11 +1688,12 @@ pub(crate) fn push_grant_clause_modifications( | |
| // parse_quoted_ability_modifications at :798) before extract_keyword_clause | ||
| // runs, so any ". " here can only introduce a trailing inert prose sentence | ||
| // (e.g. Benevolent Blessing's SBA-exemption "This effect doesn't remove ..."). | ||
| // Drop it so the keyword sentence reaches map_keyword clean. | ||
| let part = | ||
| // Strip it from the keyword token, but emit the matching protection-attachment | ||
| // exemption static when recognized. | ||
| let (part, trailing_exemption) = | ||
| match super::oracle_nom::bridge::split_once_on_lower(part, &part.to_lowercase(), ". ") { | ||
| Some((first, _)) => first, | ||
| None => part, | ||
| Some((first, rest)) => (first, Some(rest.trim())), | ||
| None => (part, None), | ||
| }; | ||
|
|
||
| let part_trimmed = part.trim().trim_end_matches('.'); | ||
|
|
@@ -1752,6 +1783,9 @@ pub(crate) fn push_grant_clause_modifications( | |
|
|
||
| if let Some(kw) = map_keyword(part_trimmed) { | ||
| modifications.push(ContinuousModification::AddKeyword { keyword: kw }); | ||
| if let Some(trailing) = trailing_exemption { | ||
| push_protection_attachment_exemption_modifications(modifications, trailing); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -1779,6 +1813,64 @@ pub(crate) fn push_grant_clause_modifications( | |
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| pub(crate) fn parse_protection_attachment_exemption_trailing_for_test( | ||
| text: &str, | ||
| ) -> Option<StaticMode> { | ||
| parse_protection_attachment_exemption_trailing(text) | ||
| } | ||
|
|
||
| fn parse_protection_attachment_exemption_trailing(text: &str) -> Option<StaticMode> { | ||
| let lower = text.trim().to_ascii_lowercase(); | ||
| let mut this_aura = alt(( | ||
| tag::<_, _, OracleError<'_>>("this effect doesn't remove this aura"), | ||
| tag("this effect does not remove this aura"), | ||
| tag("doesn't remove this aura"), | ||
| tag("does not remove this aura"), | ||
| )); | ||
| if this_aura.parse(lower.as_str()).is_ok() { | ||
| return Some(StaticMode::ProtectionDoesntRemoveThisAura); | ||
| } | ||
| let mut controlled = alt(( | ||
| tag::<_, _, OracleError<'_>>("this effect doesn't remove auras and equipment you control"), | ||
| tag("this effect does not remove auras and equipment you control"), | ||
| tag("doesn't remove auras and equipment you control"), | ||
| tag("does not remove auras and equipment you control"), | ||
| )); | ||
| if controlled.parse(lower.as_str()).is_ok() { | ||
| return Some(StaticMode::ProtectionDoesntRemoveControlledAttachments); | ||
| } | ||
| // CR 702.16n (all-Auras form): "This effect doesn't remove Auras" (Spectra | ||
| // Ward) — the granted protection keeps EVERY already-attached Aura of the | ||
| // quality attached, not just the source Aura or controlled ones. This tag is | ||
| // a PREFIX of the controlled form above ("... Auras and Equipment you | ||
| // control") and of Guardian Beast's non-protection "... Auras already | ||
| // attached to those artifacts", so it is matched LAST and ONLY when it is the | ||
| // complete trailing sentence (nothing but end punctuation follows) — that | ||
| // end-anchor is what keeps Guardian Beast (and the controlled form) out. | ||
| let mut all_auras = alt(( | ||
| tag::<_, _, OracleError<'_>>("this effect doesn't remove auras"), | ||
| tag("this effect does not remove auras"), | ||
| tag("doesn't remove auras"), | ||
| tag("does not remove auras"), | ||
| )); | ||
| if let Ok((rest, _)) = all_auras.parse(lower.as_str()) { | ||
| if rest.trim().trim_end_matches('.').trim().is_empty() { | ||
| return Some(StaticMode::ProtectionDoesntRemoveAuras); | ||
| } | ||
| } | ||
| None | ||
| } | ||
|
|
||
| fn push_protection_attachment_exemption_modifications( | ||
| modifications: &mut Vec<ContinuousModification>, | ||
| trailing: &str, | ||
| ) { | ||
| if let Some(mode) = parse_protection_attachment_exemption_trailing(trailing) { | ||
| modifications.push(ContinuousModification::AddStaticMode { mode }); | ||
| } | ||
| } | ||
|
Comment on lines
+1865
to
+1872
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] Verbatim string matching used for parsing Oracle phrases. Why it matters: Using verbatim string matching for compound phrases bypasses the robust nom-based parser and creates fragile matches, violating the repository rule on Oracle phrase parsing. Suggested fix: Decompose compound phrases into modular, reusable parsers for constituent parts (such as the subject, negation, and target) and compose them using idiomatic combinator aggregates (like nested fn parse_protection_exemption(input: &str) -> nom::IResult<&str, StaticMode> {
use nom::{
branch::alt,
bytes::complete::tag_no_case,
combinator::value,
sequence::tuple,
};
let effect_parser = tag_no_case("this effect ");
let negation_parser = alt((tag_no_case("doesn't remove "), tag_no_case("does not remove ")));
let this_aura = value(
StaticMode::ProtectionDoesntRemoveThisAura,
tag_no_case("this aura")
);
let controlled_attachments = value(
StaticMode::ProtectionDoesntRemoveControlledAttachments,
tag_no_case("auras and equipment you control that are already attached to it")
);
let (input, (_, _, mode)) = tuple((effect_parser, negation_parser, alt((this_aura, controlled_attachments))))(input)?;
Ok((input, mode))
}
fn push_protection_attachment_exemption_modifications(
modifications: &mut Vec<ContinuousModification>,
trailing: &str,
) {
if let Ok((_, mode)) = parse_protection_exemption(trailing) {
modifications.push(ContinuousModification::AddStaticMode { mode });
}
}References
|
||
|
|
||
| /// Extract quoted ability text from Oracle text and parse each into a typed AbilityDefinition. | ||
| /// | ||
| /// Quoted abilities like `"{T}: Add two mana of any one color."` are parsed by splitting | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[HIGH] Verbatim string matching and
.contains()used for parsing dispatch.Why it matters: Using
.contains()for parsing dispatch in non-test parser code violates Rule R1 [33] and bypasses the robust nom-based parser, creating fragile matches.Suggested fix: Use a dedicated nom parser to recognize the trailing exemption prose.
References