From 32dba4ef63c73b921d06a32b2b996b094a3ecc99 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Sat, 11 Jul 2026 16:12:46 -0700 Subject: [PATCH] feat(parser): recover trailing subtype/quoted-ability after a for-each dynamic pump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 205.1b + CR 604.1: A dynamic "gets +N/+M for each X" pump can carry a trailing conjunct, but the for-each branch of `parse_continuous_gets_has` only recovered a trailing keyword clause — a trailing type-addition or quoted-ability grant was dropped: - Reaper's Scythe — "Equipped creature gets +1/+1 for each soul counter on this Equipment and is an Assassin in addition to its other types." dropped the Assassin subtype. - Rakdos Riteknife — "Equipped creature gets +1/+0 for each blood counter on this Equipment and has \"{T}, Sacrifice a creature: ...\"" dropped the granted ability. Fix: after the dynamic pump and trailing-keyword recovery, also run the existing `parse_additive_type_clause_modifications` (the same additive-type authority used elsewhere) and `parse_quoted_ability_modifications` over the description. Both scanners no-op when their pattern is absent, so keyword-only for-each pumps (Claws of Valakut, Ethereal Armor, Nyxborn Hydra, Thran Power Suit) are unchanged, and cards whose dynamic pump falls back to a fixed pump elsewhere are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/src/parser/oracle_static/anthem.rs | 10 +++ .../engine/src/parser/oracle_static/tests.rs | 67 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/crates/engine/src/parser/oracle_static/anthem.rs b/crates/engine/src/parser/oracle_static/anthem.rs index a16980612b..bfc83531b4 100644 --- a/crates/engine/src/parser/oracle_static/anthem.rs +++ b/crates/engine/src/parser/oracle_static/anthem.rs @@ -956,6 +956,16 @@ pub(crate) fn parse_continuous_gets_has( ); } } + // CR 205.1b + CR 604.1: also recover a trailing type-addition + // ("and is an Assassin in addition to its other types", + // Reaper's Scythe) or a trailing quoted-ability grant ("and has + // \"{T}, Sacrifice a creature: ...\"", Rakdos Riteknife) after the + // dynamic pump — the keyword path above only recovers trailing + // keywords. Both scanners no-op when their pattern is absent. + if let Some(type_mods) = parse_additive_type_clause_modifications(description) { + modifications.extend(type_mods); + } + modifications.extend(parse_quoted_ability_modifications(description)); return Some( StaticDefinition::continuous() .affected(affected) diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 8142bfdd77..ee32accc24 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -15,6 +15,73 @@ use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; use crate::types::statics::{AdditionalCostTaxAction, CrewAction, CrewContributionKind}; +// CR 205.1b + CR 604.1: a dynamic "gets +N/+M for each X" pump may carry a +// trailing conjunct the for-each path used to drop (it only recovered trailing +// keywords): "and is a in addition to its other types" (Reaper's +// Scythe) or "and has \"\"" (Rakdos Riteknife). +#[test] +fn dynamic_for_each_pump_recovers_trailing_subtype_addition() { + let defs = parse_static_line_multi( + "Equipped creature gets +1/+1 for each soul counter on this Equipment and is an Assassin in addition to its other types.", + ); + assert_eq!(defs.len(), 1); + let mods = &defs[0].modifications; + assert!( + mods.iter() + .any(|m| matches!(m, ContinuousModification::AddDynamicPower { .. })), + "dynamic pump lost: {mods:?}" + ); + assert!( + mods.contains(&ContinuousModification::AddSubtype { + subtype: "Assassin".to_string() + }), + "trailing 'is an Assassin' dropped: {mods:?}" + ); +} + +#[test] +fn dynamic_for_each_pump_recovers_trailing_quoted_ability() { + let defs = parse_static_line_multi( + "Equipped creature gets +1/+0 for each blood counter on this Equipment and has \"{T}, Sacrifice a creature: Put a blood counter on this permanent.\"", + ); + assert_eq!(defs.len(), 1); + let mods = &defs[0].modifications; + assert!( + mods.iter() + .any(|m| matches!(m, ContinuousModification::AddDynamicPower { .. })), + "dynamic pump lost: {mods:?}" + ); + assert!( + mods.iter() + .any(|m| matches!(m, ContinuousModification::GrantAbility { .. })), + "trailing quoted ability dropped: {mods:?}" + ); +} + +// Regression: a for-each pump with only a trailing KEYWORD is unchanged and gains +// no spurious subtype/grant. +#[test] +fn dynamic_for_each_pump_trailing_keyword_unchanged() { + let defs = parse_static_line_multi( + "Enchanted creature gets +1/+0 for each Mountain you control and has first strike.", + ); + assert_eq!(defs.len(), 1); + let mods = &defs[0].modifications; + assert!(mods + .iter() + .any(|m| matches!(m, ContinuousModification::AddDynamicPower { .. }))); + assert!(mods.contains(&ContinuousModification::AddKeyword { + keyword: Keyword::FirstStrike + })); + assert!( + !mods.iter().any(|m| matches!( + m, + ContinuousModification::AddSubtype { .. } | ContinuousModification::GrantAbility { .. } + )), + "keyword-only pump must not gain spurious mods: {mods:?}" + ); +} + /// CR 207.2c: an ability word is italic flavor with no rules meaning. A leading /// ability-word label on a subject-anchored static must be stripped so the static /// still parses; a leading label that is NOT a recognized ability word must be