Skip to content
Merged
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
60 changes: 52 additions & 8 deletions crates/engine/src/parser/oracle_effect/conditions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,18 @@ pub(super) fn strip_additional_cost_conditional(text: &str) -> (Option<AbilityCo
}
}

/// Strip optional punctuation/space between a parsed reflexive clause and its body.
fn strip_reflexive_conditional_body_separator(input: &str) -> &str {
opt(alt((
tag::<_, _, OracleError<'_>>(", "),
tag(". "),
tag(" "),
)))
.parse(input)
.map(|(rest, _)| rest)
.unwrap_or(input)
}

pub(super) fn strip_if_you_do_conditional(text: &str) -> (Option<AbilityCondition>, String) {
let lower = text.to_lowercase();

Expand All @@ -562,23 +574,36 @@ pub(super) fn strip_if_you_do_conditional(text: &str) -> (Option<AbilityConditio
// the multi-word "put onto the battlefield" verb, with subtype filters
// (Aura/Equipment/...) via `parse_type_phrase`. Replaces the prior
// hand-rolled past-tense / single-word / top-level-type-only matcher.
if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("if ").parse(lower.as_str()) {
if let Ok((rest, prefix)) = alt((
value("if ", tag::<_, _, OracleError<'_>>("if ")),
value("when ", tag("when ")),
))
.parse(lower.as_str())
{
if let Ok((after_clause, (filter, _negated))) =
crate::parser::oracle_nom::condition::parse_zone_changed_this_way_clause(rest)
{
// Strip leading punctuation/space between "this way" and the body.
// Possible separators: ", ", ". ", " ".
let body_lower = after_clause
.strip_prefix(", ") // allow-noncombinator: structural separator after parsed clause
.or_else(|| after_clause.strip_prefix(". ")) // allow-noncombinator: structural separator after parsed clause
.or_else(|| after_clause.strip_prefix(' ')) // allow-noncombinator: structural separator after parsed clause
.unwrap_or(after_clause);
let body_lower = strip_reflexive_conditional_body_separator(after_clause);
let offset = text.len() - body_lower.len();
return (
Some(AbilityCondition::ZoneChangedThisWay { filter }),
text[offset..].to_string(),
);
}
if prefix == "when " {
if let Ok((after_clause, (filter, _negated))) =
Comment on lines +593 to +594

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.

medium

Violation of R6: Missing CR annotation. Every rules-touching parser implementation must carry a verified CR annotation. Additionally, avoid verbatim string equality like prefix == "when " for parsing Oracle phrases as it bypasses the robust nom-based parser. Instead, use modular, reusable parsers for constituent parts.

Suggested fix: Replace the verbatim string check with a nom-based parser (e.g., using tag) and add the CR 603.12 annotation.

        // CR 603.12: Parse active-voice reflexive trigger condition ("when you put ... this way")
        let (after_clause, (filter, _negated)) = tag("when")(input)?
References
  1. R6. CR annotations are mandatory and verified. Every rules-touching line of engine code must carry a comment of the form CR : . (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.

crate::parser::oracle_nom::condition::parse_you_put_onto_battlefield_this_way_clause(
rest,
)
{
let body_lower = strip_reflexive_conditional_body_separator(after_clause);
let offset = text.len() - body_lower.len();
return (
Some(AbilityCondition::ZoneChangedThisWay { filter }),
text[offset..].to_string(),
);
}
}
}
(None, text.to_string())
}
Expand Down Expand Up @@ -4686,6 +4711,25 @@ mod tests {
}
}

#[test]
fn strip_if_you_do_conditional_gilgamesh_you_put_equipment_this_way() {
let (condition, body) = strip_if_you_do_conditional(
"when you put one or more equipment onto the battlefield this way, you may attach one of them to a samurai you control",
);
assert_eq!(body, "you may attach one of them to a samurai you control");
let Some(AbilityCondition::ZoneChangedThisWay { filter }) = condition else {
panic!("expected ZoneChangedThisWay condition, got {condition:?}");
};
match filter {
TargetFilter::Typed(TypedFilter { type_filters, .. }) => {
assert!(type_filters.iter().any(
|f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment"))
));
}
other => panic!("expected Typed Equipment filter, got {other:?}"),
}
}

#[test]
fn suffix_outcome_this_way_kaya_creature_card_exiled() {
// Kaya, Orzhov Usurper +1 (PR #2447): "Exile up to two target cards
Expand Down
31 changes: 31 additions & 0 deletions crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9614,6 +9614,37 @@ mod tests {
assert!(matches!(target, TargetFilter::ParentTarget));
}

/// CR 608.2c + CR 301.5b: Gilgamesh attach body — moved Equipment binds on
/// the attachment side; the Samurai recipient stays explicitly typed.
#[test]
fn parse_attach_one_of_them_to_samurai_you_control() {
use crate::types::ability::{TypeFilter, TypedFilter};

let input = "attach one of them to a Samurai you control";
let lower = input.to_lowercase();
let result = parse_utility_imperative_ast(input, &lower, &mut ParseContext::default());
let Some(UtilityImperativeAst::Attach { attachment, target }) = result else {
panic!("{input}: expected Attach, got {result:?}");
};
assert!(
matches!(attachment, TargetFilter::ParentTarget),
"attachment should bind to a chosen moved Equipment, got {attachment:?}"
);
assert!(
matches!(
target,
TargetFilter::Typed(TypedFilter {
controller: Some(ControllerRef::You),
ref type_filters,
..
}) if type_filters.iter().any(
|f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Samurai"))
)
),
"expected Samurai you control attach target, got {target:?}"
);
}

/// CR 608.2k regression — issue #319 sibling.
/// "attach ~ to it" inside a typed-subject trigger ("Whenever a Samurai
/// or Warrior you control attacks alone … attach this Equipment to it"
Expand Down
110 changes: 109 additions & 1 deletion crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10940,6 +10940,23 @@ fn replace_definition_targets_with_parent(def: &mut AbilityDefinition) {
}
}

/// CR 608.2c + CR 115.10a: True when an `Attach` effect names an explicit typed
/// recipient ("attach … to a Samurai you control" — Gilgamesh; "attach it to a
/// creature you control" — Stonehewer Giant). The chunk-loop anaphor rewriter
/// must not collapse such recipients to `ParentTarget` just because the
/// attachment side uses a set anaphor ("one of them").
fn attach_recipient_is_explicitly_typed(target: &TargetFilter) -> bool {
match target {
TargetFilter::Typed(tf) => {
!tf.type_filters.is_empty() || tf.controller.is_some() || !tf.properties.is_empty()
}
TargetFilter::Or { filters } | TargetFilter::And { filters } => {
filters.iter().any(attach_recipient_is_explicitly_typed)
}
_ => false,
}
}

/// Replace the target filter on an effect with ParentTarget.
/// Used for anaphoric "it"/"that creature" references in compound sub-effects.
fn replace_target_with_parent(effect: &mut Effect) {
Expand Down Expand Up @@ -10997,7 +11014,10 @@ fn replace_target_with_parent(effect: &mut Effect) {
{
*target = TargetFilter::ParentTarget;
}
Effect::Attach { target, .. } if !matches!(target, TargetFilter::LastCreated) => {
Effect::Attach { target, .. }
if !matches!(target, TargetFilter::LastCreated)
&& !attach_recipient_is_explicitly_typed(target) =>
{
*target = TargetFilter::ParentTarget;
}
Effect::UnattachAll { target, .. } if !matches!(target, TargetFilter::LastCreated) => {
Expand Down Expand Up @@ -49827,6 +49847,94 @@ mod tests {
}
}

/// Gilgamesh, Master-at-Arms (issue #3286): any-number Equipment dig-from-among
/// plus CR 603.12 active-voice reflexive attach to Samurai.
#[test]
fn attach_just_moved_gilgamesh_any_number_equipment_reflexive_attach() {
use crate::types::ability::{TypeFilter, TypedFilter};

let def = parse_effect_chain(
"Look at the top six cards of your library. You may put any number of Equipment cards from among them onto the battlefield. Put the rest on the bottom of your library in a random order. When you put one or more Equipment onto the battlefield this way, you may attach one of them to a Samurai you control.",
AbilityKind::Spell,
);

let Effect::Dig {
count,
destination,
keep_count,
up_to,
filter,
rest_destination,
..
} = &*def.effect
else {
panic!("expected outer Dig, got {:?}", def.effect);
};
assert_eq!(*count, QuantityExpr::Fixed { value: 6 });
assert_eq!(*destination, Some(Zone::Battlefield));
assert_eq!(*keep_count, Some(u32::MAX), "any number → unbounded keep");
assert!(*up_to);
assert!(
matches!(
filter,
TargetFilter::Typed(TypedFilter { ref type_filters, .. })
if type_filters.iter().any(
|f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment"))
)
),
"expected Equipment filter on Dig, got {filter:?}"
);
assert_eq!(*rest_destination, Some(Zone::Library));
assert!(
def.forward_result,
"Dig parent must forward the just-moved Equipment to the Attach sub_ability"
);

let attach = def
.sub_ability
.as_ref()
.expect("expected Attach sub_ability after Dig");
match &*attach.effect {
Effect::Attach { attachment, target } => {
assert!(
matches!(attachment, TargetFilter::ParentTarget),
"attachment should bind to a chosen moved Equipment, got {attachment:?}"
);
assert!(
matches!(
target,
TargetFilter::Typed(TypedFilter {
controller: Some(ControllerRef::You),
ref type_filters,
..
}) if type_filters.iter().any(
|f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Samurai"))
)
),
"expected Samurai you control attach target, got {target:?}"
);
}
other => panic!("expected Attach sub_ability, got {other:?}"),
}
match &attach.condition {
Some(AbilityCondition::ZoneChangedThisWay { filter }) => match filter {
TargetFilter::Typed(t) => assert!(
t.type_filters.iter().any(
|f| matches!(f, TypeFilter::Subtype(s) if s.eq_ignore_ascii_case("Equipment"))
),
"expected Equipment on ZoneChangedThisWay, got {:?}",
t.type_filters
),
other => panic!("expected Typed Equipment filter, got {other:?}"),
},
other => panic!("expected ZoneChangedThisWay condition on Attach, got {other:?}"),
}
assert!(
attach.optional,
"\"you may attach\" makes the attach step optional"
);
}

/// Quest for the Holy Relic / Stonehewer Giant pattern:
/// SearchLibrary → ChangeZone(destination=Battlefield) → Attach. The
/// rewire detects the ChangeZone-to-battlefield parent and sets
Expand Down
Loading
Loading