Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
531 changes: 529 additions & 2 deletions crates/engine/src/game/effects/attach.rs

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions crates/engine/src/game/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,29 @@ pub fn protection_prevents_from(target: &GameObject, source: &GameObject) -> boo
false
}

/// CR 702.16: Collect every protection target on `protected` (its post-layer
/// keyword set) whose quality matches `source` — i.e. the protection instances
/// that would prevent `source` from being attached to / interacting with it.
///
/// This is the object-scoped authority for reading an object's protection
/// instances; callers must not iterate `protected.keywords` themselves.
pub fn protection_targets_matching(
protected: &GameObject,
source: &GameObject,
) -> Vec<ProtectionTarget> {
// allow-raw-authority: this IS the object-scoped protection-instance authority
protected
.keywords
.iter()
.filter_map(|kw| match kw {
Keyword::Protection(pt) if source_matches_protection_target(pt, protected, source) => {
Some(pt.clone())
}
_ => None,
})
.collect()
}

pub fn source_matches_protection_target(
protection: &ProtectionTarget,
protected: &GameObject,
Expand Down
66 changes: 66 additions & 0 deletions crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3683,6 +3683,72 @@ fn transient_duration_holds(state: &GameState, tce: &TransientContinuousEffect)
}
}

/// CR 702.16n/p provenance helper: visit every currently-active TRANSIENT
/// continuous effect that grants a `Protection` keyword to `affected_id`. The
/// visitor receives the granting source object plus that effect's FULL sibling
/// modification list, so callers can reconstruct per-effect protection
/// provenance that the deduplicated baked `keywords` set discards — identical
/// `Protection(Color(_))` grants from different effects collapse to one keyword,
/// so a same-quality transient grant would otherwise be masked by an exempting
/// static grant.
///
/// Applies the same duration / source-condition / recipient-condition / affected
/// gating as `gather_transient_continuous_effects`, so only grants that actually
/// bake onto `affected_id` are surfaced.
pub(crate) fn for_each_transient_protection_grant(
state: &GameState,
affected_id: ObjectId,
mut visit: impl FnMut(&crate::game::game_object::GameObject, &[ContinuousModification]),
) {
for tce in &state.transient_continuous_effects {
if tce.duration == Duration::UntilHostLeavesPlay
&& !state
.objects
.get(&tce.source_id)
.is_some_and(|obj| obj.zone == crate::types::zones::Zone::Battlefield)
{
continue;
}
if !transient_duration_holds(state, tce) {
continue;
}
if let Some(condition) = &tce.condition {
if !source_condition_gate_passes(state, condition, tce.controller, tce.source_id) {
continue;
}
if condition_uses_recipient_context(condition)
&& !evaluate_condition_with_recipient(
state,
condition,
tce.controller,
tce.source_id,
affected_id,
)
{
continue;
}
}
let ctx = FilterContext::from_source(state, tce.source_id);
if !matches_target_filter(state, affected_id, &tce.affected, &ctx) {
continue;
}
let grants_protection = tce.modifications.iter().any(|modification| {
matches!(
modification,
ContinuousModification::AddKeyword {
keyword: crate::types::keywords::Keyword::Protection(_)
}
)
});
if !grants_protection {
continue;
}
if let Some(source_obj) = state.objects.get(&tce.source_id) {
visit(source_obj, &tce.modifications);
}
}
}

#[allow(clippy::ptr_arg)]
fn push_effect(
effects: &mut Vec<(Layer, Vec<ActiveContinuousEffect>)>,
Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/game/static_abilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,14 @@ pub fn build_static_registry() -> HashMap<StaticMode, StaticAbilityHandler> {
// CR 704.5j: LegendRuleDoesntApply — affected permanents are excluded from
// the legend-rule SBA. Runtime enforcement is in sba.rs::legend_rule_exempt().
registry.insert(StaticMode::LegendRuleDoesntApply, handle_rule_mod);
// CR 702.16n / CR 702.16p: protection attachment exemptions — enforced in
// effects/attach.rs::attachment_illegality via active static scan.
registry.insert(StaticMode::ProtectionDoesntRemoveThisAura, handle_rule_mod);
registry.insert(
StaticMode::ProtectionDoesntRemoveControlledAttachments,
handle_rule_mod,
);
registry.insert(StaticMode::ProtectionDoesntRemoveAuras, handle_rule_mod);
// CR 702.179e: Card-specific rule modification allowing speed to exceed 4.
registry.insert(StaticMode::SpeedCanIncreaseBeyondFour, handle_rule_mod);
// CR 609.4b: "You may spend mana as though it were mana of any color."
Expand Down
102 changes: 97 additions & 5 deletions crates/engine/src/parser/oracle_static/keyword_grant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Comment on lines +1581 to +1593

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[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
  1. 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)

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
Expand Down Expand Up @@ -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('.');
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[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
  1. 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)
  2. 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 alt and tag sequences) to prevent combinatorial explosion and improve maintainability.


/// 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
Expand Down
Loading
Loading