fix(attach): honor protection attachment exemptions (#4964)#5015
fix(attach): honor protection attachment exemptions (#4964)#5015bohdansolovie wants to merge 11 commits into
Conversation
Emit and enforce CR 702.16p/n statics for Benevolent Blessing-style protection grants so choosing a matching color does not detach your already-attached Auras and Equipment. Co-authored-by: Cursor <[email protected]>
There was a problem hiding this comment.
Code Review
This pull request implements CR 702.16n and CR 702.16p to exempt already-attached Auras and Equipment from protection-based detachment when the host carries a matching exemption static. While the implementation successfully adds the necessary static modes, attachment legality checks, and parser updates, several critical issues must be addressed. First, the parser changes in keyword_grant.rs violate Rule R1 by using verbatim string matching and .contains() instead of nom-based combinators. Second, the controller and source checks in attach.rs do not strictly adhere to the MTG Comprehensive Rules, as they incorrectly reference the host's controller instead of the protection-granting source's controller. Finally, a mandatory CR annotation comment is missing in attach.rs per Rule R6.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let (keyword_only, trailing_exemption) = | ||
| match super::oracle_nom::bridge::split_once_on_lower( | ||
| keyword_text, | ||
| &keyword_text.to_lowercase(), | ||
| ". ", | ||
| ) { | ||
| Some((first, rest)) | ||
| if rest.to_ascii_lowercase().contains("doesn't remove") | ||
| || rest.to_ascii_lowercase().contains("does not remove") => | ||
| { | ||
| (first, Some(rest.trim())) | ||
| } | ||
| _ => (keyword_text, None), | ||
| }; |
There was a problem hiding this comment.
[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.
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_exemption(rest.trim()).is_ok() => {
(first, Some(rest.trim()))
}
_ => (keyword_text, None),
};References
- R1. Nom combinators on the first pass — no exceptions. Every new parser dispatch under crates/engine/src/parser/ must use nom 8.0 combinators or delegate to existing helpers. (link)
| fn push_protection_attachment_exemption_modifications( | ||
| modifications: &mut Vec<ContinuousModification>, | ||
| trailing: &str, | ||
| ) { | ||
| let lower = trailing.to_lowercase(); | ||
| if lower.contains("doesn't remove this aura") || lower.contains("does not remove this aura") { | ||
| modifications.push(ContinuousModification::AddStaticMode { | ||
| mode: StaticMode::ProtectionDoesntRemoveThisAura, | ||
| }); | ||
| } else if lower.contains("doesn't remove auras and equipment you control") | ||
| || lower.contains("does not remove auras and equipment you control") | ||
| { | ||
| modifications.push(ContinuousModification::AddStaticMode { | ||
| mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
[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 alt and tag sequences) to prevent combinatorial explosion and improve maintainability.
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
- R1. Nom combinators on the first pass — no exceptions. Every new parser dispatch under crates/engine/src/parser/ must use nom 8.0 combinators or delegate to existing helpers. (link)
- Avoid verbatim string equality for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts (e.g., subjects, conjunctions) and compose them using idiomatic combinator aggregates (like nested
altandtagsequences) to prevent combinatorial explosion and improve maintainability.
| crate::game::functioning_abilities::active_static_definitions(state, host).any(|def| match def | ||
| .mode | ||
| { | ||
| crate::types::statics::StaticMode::ProtectionDoesntRemoveThisAura => { | ||
| attacher_is_aura && attachment.controller == host.controller | ||
| } | ||
| crate::types::statics::StaticMode::ProtectionDoesntRemoveControlledAttachments => { | ||
| (attacher_is_aura || attacher_is_equipment) && attachment.controller == host.controller | ||
| } | ||
| _ => false, | ||
| }) | ||
| } |
There was a problem hiding this comment.
[HIGH] Incorrect controller and source checks for protection attachment exemptions.
Why it matters: Under CR 702.16n, ProtectionDoesntRemoveThisAura only protects the specific Aura that is the source of the protection effect (not any Aura controlled by the host's controller). Under CR 702.16p, ProtectionDoesntRemoveControlledAttachments protects attachments controlled by the controller of the protection-granting Aura (def.controller or def.source_id's controller), not the host's controller (host.controller).
Suggested fix: Update the checks to correctly reference the static definition's source or controller.
References
- Strict fidelity to the MTG Comprehensive Rules (CR) — every game rule, validation, and computed value matches the CR exactly. (link)
| if protection_doesnt_remove_attached_exemption( | ||
| state, | ||
| host_id, | ||
| attachment_id, | ||
| attacher_is_aura, | ||
| attacher_is_equipment, | ||
| ) { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
[MEDIUM] Missing CR annotation comment.
Why it matters: Every rules-touching line of engine code must carry a comment of the form CR <number>: <description> [108] to ensure rules verification and traceability.
Suggested fix: Add a CR comment directly above the exemption check.
| if protection_doesnt_remove_attached_exemption( | |
| state, | |
| host_id, | |
| attachment_id, | |
| attacher_is_aura, | |
| attacher_is_equipment, | |
| ) { | |
| return None; | |
| } | |
| // CR 702.16n / CR 702.16p: Exempt already-attached Auras/Equipment if the host has the matching exemption static. | |
| if protection_doesnt_remove_attached_exemption( | |
| state, | |
| host_id, | |
| attachment_id, | |
| attacher_is_aura, | |
| attacher_is_equipment, | |
| ) { | |
| return None; | |
| } |
References
- R6. CR annotations are mandatory and verified. Every rules-touching line of engine code must carry a comment of the form CR : . (link)
matthewevans
left a comment
There was a problem hiding this comment.
The ProtectionDoesntRemoveThisAura runtime check is too broad. In protection_doesnt_remove_attached_exemption, the ProtectionDoesntRemoveThisAura arm accepts any already-attached Aura controlled by the host controller once the host has that static. CR 702.16n says the exemption is for that specific Aura; another controlled Aura attached to the same creature should still be affected by protection.
Please make the “this Aura” exemption source/attachment-specific, or model it at the attachment/source level instead of as a host-wide static, and add a regression with two controlled Auras attached to the same protected host proving only the source Aura is exempt.
Parse changes introduced by this PR · 2 card(s), 2 signature(s) (baseline: main
|
…4964) Replace forbidden .contains() dispatch with nom/scan_contains helpers, correct exemption controller checks against the granting aura, and add CR 702.16c–702.16p commentary on the protection attach gate. Co-authored-by: Cursor <[email protected]>
matthewevans
left a comment
There was a problem hiding this comment.
Requesting changes on the current head. The source-specific ProtectionDoesntRemoveThisAura issue is closer, but the current parser diff still shows an unrelated regression.
[MED] The current coverage-parse-diff sticky changes Spectra Ward from power +2, toughness +2, grant Protection to power +2, toughness +2, grant Protection, grant Protection, grant Protection, grant Protection, grant Protection. That is outside the Benevolent Blessing / attachment-exemption fix and means the keyword-grant trailing-prose splitter is still changing sibling protection text. Please keep the exemption parsing from duplicating Spectra Ward's protection grants, and add coverage that would fail on this parse-diff regression.
The existing runtime test is useful for the attachment exemption path, but it does not exercise the production Oracle parse shape that regressed here, so CI can still false-green on the parser blast radius.
…on (phase-rs#4964) Add a Spectra Ward regression proving the exemption splitter never duplicates sibling protection grants, and a two-Aura attach regression proving CR 702.16n's "this Aura" exemption protects only the granting Aura. Co-authored-by: Cursor <[email protected]>
matthewevans
left a comment
There was a problem hiding this comment.
Requesting changes on current head 57d3f1e02d7a2505f1057fba00b68563b2bab639.
[MED] The current coverage-parse-diff still shows parser blast radius outside the Benevolent Blessing attachment-exemption scope. Evidence: the sticky updated for this head still changes Spectra Ward from power +2, toughness +2, grant Protection to power +2, toughness +2, grant Protection, grant Protection, grant Protection, grant Protection, grant Protection, and also changes unrelated signatures such as ReduceAbilityCost affects fields, static_structure, orphaned_copy_retarget, Ravenous Trap, Teyo, and neighbor-attack cards. Why it matters: this PR is supposed to add CR 702.16n/p attachment exemptions, not rewrite unrelated parser output; the added Spectra Ward unit test calls parse_continuous_modifications on a shortened text fragment, so it does not cover the production card parse path that the sticky says is still regressing. Suggested fix: constrain the exemption splitter so the full card-data parse for Spectra Ward and the unrelated cards is unchanged, then add a production parse/card-data regression that would fail on the duplicated-protection sticky diff.
…a Ward parse (phase-rs#4964) The pre-split exemption peel fired on any trailing "doesn't remove" sentence, which cleaned Spectra Ward's "protection from each color. This effect doesn't remove Auras." and let split_keyword_list fan the leg out into multiple protection grants — an unrelated change from origin/main. Gate the peel on a recognized attachment exemption (parse_protection_attachment_exemption_trailing) so unrecognized tails stay glued and the protection parse is unchanged. Replace the fragment-based unit test with production-line regressions (Spectra Ward, Benevolent Blessing, Ward of Lights) parsed through parse_static_line_multi, mirroring the coverage-parse-diff card-data path. Co-authored-by: Cursor <[email protected]>
matthewevans
left a comment
There was a problem hiding this comment.
[MED] The current head still has parser blast radius outside the Benevolent Blessing / protection-attachment exemption scope. Evidence: the current coverage-parse-diff sticky updated at 2026-07-03T17:08:31Z still changes Spectra Ward from power +2, toughness +2, grant Protection to power +2, toughness +2, grant Protection, grant Protection, grant Protection, grant Protection, grant Protection, and also reports unrelated signature churn such as Mystic Barrier/Pramikon static-structure changes and multiple ReduceAbilityCost affects drops. Why it matters: this PR is supposed to model CR 702.16n/p attachment exemptions, not alter sibling protection parses or unrelated card signatures; merging it would make coverage less honest outside the claimed issue. Suggested fix: constrain the exemption splitter so only the recognized this Aura / Auras and Equipment you control that are already attached forms are peeled, then get the sticky down to the intended Benevolent Blessing / Ward-class deltas only.
|
Thanks @matthewevans — fixed the Spectra Ward blast radius on the new head ( Root cause. The pre-split exemption peel fired on any trailing "doesn't remove" sentence. Spectra Ward's production line is Fix. The peel is now gated on a recognized exemption ( Production coverage (via
The earlier fragment test ( |
…lessing-protection-exemption
…ind (phase-rs#4964) Merging main brought in the StaticModePresence index (phase-rs#5022), whose wildcard-free StaticMode::kind() match is exhaustive over StaticMode. The two exemption variants added by this PR (ProtectionDoesntRemoveThisAura / ProtectionDoesntRemoveControlledAttachments) were not covered, breaking the PR-merge build (E0004). Add matching StaticModeKind discriminants and their kind() arms. Co-authored-by: Cursor <[email protected]>
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Protection attachment exemptions incorrectly override unrelated protection instances. Evidence: crates/engine/src/game/effects/attach.rs:606. Why it matters: CR 702.16n/p explicitly says other instances of protection from the same quality affect attached permanents normally, but this code first asks whether any protection keyword prevents the attachment and then exempts via a separate host/static scan at crates/engine/src/game/effects/attach.rs:607, while crates/engine/src/game/keywords.rs:384 has no source/provenance for the protection instance that matched. Suggested fix: bind the exemption to the specific protection-granting continuous effect/source/quality, and add a regression where Benevolent Blessing or Ward grants an exemption but another same-quality protection instance still detaches the Aura/Equipment.
…ect (phase-rs#4964) CR 702.16n/p: the "doesn't remove this Aura / Auras and Equipment you control" exemption only neutralizes the protection granted by that SAME continuous effect. The prior check asked separately whether the host had ANY protection (baked keyword, no provenance) and whether the host had ANY exemption static, so an unrelated same-quality protection instance was wrongly exempted too. Scan the protection-granting static definitions WITH provenance (each def exposes both its AddKeyword(Protection) and any sibling AddStaticMode(ProtectionDoesntRemove*)): an attachment stays only if every protection instance matching its quality comes from an effect that also exempts THIS attachment. Protection from another effect, the host's intrinsic keywords, or a transient grant still detaches it (fail-closed on unattributed protection). Rework the exemption unit tests to model protection as a granting effect (not a bare baked keyword), and add a regression proving a second, unrelated protection-from-white instance still detaches an otherwise exempt Benevolent Blessing. Co-authored-by: Cursor <[email protected]>
|
Thanks @matthewevans — fixed the exemption-provenance issue on the new head ( Root cause. Fix — bind the exemption to the specific granting effect. The exemption and the protection grant live on the same
An attachment stays only if every matching protection instance comes from an effect that exempts it. Any non-exempting instance — a different effect, the host's intrinsic (printed) Coverage. The two prior exemption tests modeled protection as a bare baked keyword + a separate exemption static (exactly the no-provenance shape this review flagged); they now model protection as a granting continuous effect. New regression Scope. Runtime-only change ( |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Transient same-quality protection grants can still be masked by the exempting protection grant. Evidence: crates/engine/src/game/effects/attach.rs:689 reconstructs protection provenance only from battlefield_active_statics, then crates/engine/src/game/effects/attach.rs:730 treats the baked host.keywords protection quality as covered if any exempting battlefield static granted that same ProtectionTarget. But transient continuous effects also feed the layer pipeline (crates/engine/src/game/layers.rs:3518), and identical protection keywords from different sources coalesce into one keyword entry (crates/engine/src/game/layers.rs:4673; existing test at crates/engine/src/game/layers.rs:14185). Why it matters: Benevolent Blessing plus a later transient "gains protection from white until end of turn" can still leave the Aura attached because the unexempted transient instance has no surviving provenance in host.keywords, contrary to CR 702.16n/p's "other instances" rule. Please make attachment_exempt_from_protection enumerate every matching protection instance from the same authorities the layer pipeline uses, including transient continuous effects, and require each source/effect instance to carry its own matching exemption.
The current parse-diff scope is clean from this pass; this blocker is the remaining runtime provenance issue.
phase-rs#4964) The exemption fail-closed check iterated `host.keywords` directly, which the engine-authority gate (check-engine-authorities.sh) rejects: raw keyword queries silently miss off-zone grants. Add `keywords::protection_targets_matching` as the object-scoped authority for enumerating an object's matching protection instances, and consume it from the attach exemption path instead of poking `host.keywords`. No behavior change; keeps the CR 702.16n/p exemption fix intact. Co-authored-by: Cursor <[email protected]>
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Transient same-quality protection grants can still be masked by the exempting protection grant. Evidence: crates/engine/src/game/effects/attach.rs:689 reconstructs protection provenance only from battlefield_active_statics, while crates/engine/src/game/layers.rs:3518 feeds transient continuous effects into the same layer pipeline and crates/engine/src/game/layers.rs:4673 deduplicates identical Protection(Color(_)) values into one baked keyword; then crates/engine/src/game/effects/attach.rs:730 checks only whether the baked protection target is present in the exempted set. Why it matters: Benevolent Blessing plus a later transient “gains protection from white until end of turn” can still leave the Aura attached because the transient same-quality protection instance has no surviving provenance in host.keywords, contrary to CR 702.16n/p’s “other instances” rule. Suggested fix: enumerate active transient protection grants alongside the active static grants, or otherwise preserve per-effect protection provenance through the exemption check, and add a regression with an exempting Aura plus a same-quality transient protection grant.
…ption (phase-rs#4964) CR 702.16n/p: identical Protection(Color(_)) grants from different effects deduplicate into one baked keyword, so an exempting static Aura (Benevolent Blessing) could mask a later same-quality transient grant ("gains protection from white until end of turn") — leaving the Aura attached, contrary to the "other instances" rule. Scan active TRANSIENT continuous grants alongside the static grants, preserving per-effect provenance, so an unexempted same-quality transient instance still detaches the otherwise-exempt attachment. Adds a `for_each_transient_protection_grant` authority in layers.rs (same duration/condition/affected gating as `gather_transient_continuous_effects`) and a regression test. Co-authored-by: Cursor <[email protected]>
|
Thanks — fixed in Fix — enumerate transient protection grants alongside the static grants, with per-effect provenance:
So Benevolent Blessing + a later "gains protection from white until end of turn" now correctly detaches the Aura per CR 702.16n/p's "other instances" rule. The baked-keyword Regression: Local: |
matthewevans
left a comment
There was a problem hiding this comment.
[MED] CR 702.16n’s “all Auras” exemption is still explicitly left unmodeled. Evidence: docs/MagicCompRules.txt:4067 says protection-granting Auras can say the effect does not remove either that specific Aura or all Auras, but crates/engine/src/parser/oracle_static/tests.rs:22239 asserts Spectra Ward’s “This effect doesn’t remove Auras” emits no exemption static at all. Why it matters: Spectra Ward-class protection still detaches matching Auras even though this PR is claiming CR 702.16n/p attachment-exemption support. Suggested fix: model the exemption scope explicitly, e.g. ThisAura, AllAuras, and the already-attached controlled Aura/Equipment scope, then add parser and runtime regressions for Spectra Ward.
…on (phase-rs#4964) Spectra Ward's "This effect doesn't remove Auras" was the one modeled-gap in the CR 702.16n/p attachment-exemption family: the trailing sentence was left unrecognized, so the line lowered to a single inert Protection(CardType("each color")) grant with no exemption at all — meaning Spectra Ward granted no functional color protection and still detached every matching Aura. Add the all-Auras exemption scope explicitly: - New StaticMode::ProtectionDoesntRemoveAuras (+ StaticModeKind, Display, FromStr, as_keyword, static-abilities registry). - Recognize the terminal "This effect doesn't remove Auras" sentence in parse_protection_attachment_exemption_trailing. It is matched LAST (it is a prefix of the controlled-attachments form) and ONLY as a complete trailing sentence, so Guardian Beast's "...Auras already attached to those artifacts" and the controlled form are excluded. Recognizing it lets the peel fire, so the recovered "protection from each color" correctly fans into the five WUBRG grants (CR 105.2) instead of the inert CardType grant. - Enforce it in attach.rs: the all-Auras form exempts ANY already-attached Aura of the granted quality (not just the source Aura or controlled ones), while Equipment of that quality is still detached (the printed form names Auras). Regressions: Spectra Ward production-line parse (5 grants + exemption static), a direct trailing-form parser test incl. the Guardian Beast guard, and a runtime test proving a foreign-controlled Aura stays attached while Equipment is still removed. Co-authored-by: Cursor <[email protected]>
…lessing-protection-exemption
|
Thanks @matthewevans — modeled the CR 702.16n all-Auras exemption on the new head ( Root cause. Spectra Ward's Fix — the scope is now modeled explicitly:
Parse-diff is clean. I merged the latest Guardian Beast (the only other Coverage (production-path + direct + runtime):
Local: |
|
Processed current head Holding this revision for current parser/card-data evidence before approval or enqueue. The head changed after the last |
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for pushing the CR 702.16n / 702.16p coverage forward. The parser/test direction and the provenance-aware attachment check are the right surface, but I don't think this should land with the new static modes shaped as three sibling variants.
The patch adds ProtectionDoesntRemoveThisAura, ProtectionDoesntRemoveControlledAttachments, and ProtectionDoesntRemoveAuras, then branches over those siblings in attachment_exempt_from_protection. Those are not separate rule concepts; they are one protection-attachment exemption with a scope/subject parameter. Please collapse this into a single parameterized StaticMode shape, with a typed scope enum for the three covered forms, and have the parser map each Oracle sentence to that scope. That keeps the CR 702.16n/p surface extensible without multiplying enum variants and exhaustive registry/display/from-str arms for each textual form.
Once that shape is parameterized, the existing discriminating tests for this-Aura, controlled attachments, all-Auras, unrelated same-quality protection, transient protection, and parse-diff cards look like the right proof set to keep.
Summary
ProtectionDoesntRemoveControlledAttachments/ProtectionDoesntRemoveThisAurastatics from protection-grant trailing text (CR 702.16p/n)attachment_illegalitymap_keywordFixes #4964
Test plan
cargo test -p engine --lib protection_chosen_color_drops_trailingcargo test -p engine --lib attachment_illegality_protection_exemption