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
102 changes: 100 additions & 2 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3860,6 +3860,13 @@ fn effect_references_tracked_set(effect: &Effect) -> bool {
return true;
}
}
if let Effect::SearchLibrary { filter, .. } = effect {
if filter_references_tracked_set(filter)
|| filter_properties_reference_tracked_quantity(filter)
{
return true;
}
}
if let Effect::GoadAll { target } = effect {
if filter_references_tracked_set(target) {
return true;
Expand Down Expand Up @@ -3972,6 +3979,46 @@ fn filter_references_tracked_set(filter: &TargetFilter) -> bool {
}
}

/// CR 608.2c + CR 202.3: A search filter whose typed properties carry a
/// dynamic quantity (`FilterProp::Cmc { value: Ref(FilteredTrackedSetSize) }`,
/// Hunger Tide Rises chapter IV) consumes the chain tracked set even when the
/// filter itself is not a bare `TrackedSet`/`TrackedSetFiltered` reference.
fn filter_properties_reference_tracked_quantity(filter: &TargetFilter) -> bool {
match filter {
TargetFilter::Typed(tf) => tf
.properties
.iter()
.any(filter_prop_references_tracked_quantity),
TargetFilter::Not { filter } => filter_properties_reference_tracked_quantity(filter),
TargetFilter::Or { filters } | TargetFilter::And { filters } => filters
.iter()
.any(filter_properties_reference_tracked_quantity),
_ => false,
}
}

/// CR 608.2c: Dynamic `Counters`, `Cmc`, and `PtComparison` values are all
/// quantity-bearing search properties. Keep their tracked-set detection at the
/// property boundary so a parent producer publishes its set for every axis.
fn filter_prop_references_tracked_quantity(prop: &crate::types::ability::FilterProp) -> bool {
match prop {
crate::types::ability::FilterProp::Counters { count, .. } => {
quantity_expr_references_tracked_set(count)
}
crate::types::ability::FilterProp::Cmc { value, .. }
| crate::types::ability::FilterProp::PtComparison { value, .. } => {
quantity_expr_references_tracked_set(value)
}
crate::types::ability::FilterProp::AnyOf { props } => {
props.iter().any(filter_prop_references_tracked_quantity)
}
crate::types::ability::FilterProp::Not { prop } => {
filter_prop_references_tracked_quantity(prop)
}
_ => false,
}
}

fn effect_uses_implicit_tracked_set_targets(effect: &Effect) -> bool {
matches!(
effect,
Expand Down Expand Up @@ -6173,7 +6220,18 @@ fn perform_player_scope_sacrifices(
.expect("a game cannot announce more sacrifices than i32 can represent"),
);
if completion.publish_fresh_tracked_set {
publish_fresh_tracked_set(state, completion.sacrificed.clone());
let set_id = publish_fresh_tracked_set(state, completion.sacrificed.clone());
// CR 608.2c + CR 701.21a: an interactive sacrifice that publishes a
// fresh tracked set for a chained "sacrificed this way" consumer must
// stamp the Sacrificed cause on each member — otherwise
// `FilteredTrackedSetSize { caused_by: Sacrificed }` reads 0 (Hunger
// Tide Rises chapter IV, #5977).
if matches!(completion.effect_kind, Some(EffectKind::Sacrifice)) {
let causes = state.tracked_set_member_causes.entry(set_id).or_default();
for id in &completion.sacrificed {
causes.insert(*id, ThisWayCause::Sacrificed);
}
}
}
if completion.propagate_parent_context {
if let Some(snapshot) =
Expand Down Expand Up @@ -10521,7 +10579,7 @@ mod tests {
use crate::types::actions::GameAction;
use crate::types::card::CardFace;
use crate::types::card_type::CoreType;
use crate::types::counter::CounterType;
use crate::types::counter::{CounterMatch, CounterType};
use crate::types::format::FormatConfig;
use crate::types::game_state::{
AutoMayChoice, CastingVariant, ExileLink, ExileLinkKind, LKISnapshot, LinkedExileSnapshot,
Expand All @@ -10536,6 +10594,46 @@ mod tests {
use crate::types::triggers::TriggerMode;
use crate::types::zones::Zone;

#[test]
fn search_filter_dynamic_property_axes_consume_the_tracked_set() {
let tracked = || QuantityExpr::Ref {
qty: QuantityRef::TrackedSetSize,
};
let properties = [
FilterProp::Cmc {
comparator: Comparator::LE,
value: tracked(),
},
FilterProp::Counters {
counters: CounterMatch::Any,
comparator: Comparator::LE,
count: tracked(),
},
FilterProp::PtComparison {
stat: crate::types::ability::PtStat::Power,
scope: crate::types::ability::PtValueScope::Current,
comparator: Comparator::LE,
value: tracked(),
},
];

for property in properties {
let effect = Effect::SearchLibrary {
source_zones: vec![Zone::Library],
filter: TargetFilter::Typed(TypedFilter::creature().properties(vec![property])),
count: QuantityExpr::Fixed { value: 1 },
reveal: false,
target_player: None,
selection_constraint: crate::types::ability::SearchSelectionConstraint::None,
split: None,
};
assert!(
effect_references_tracked_set(&effect),
"dynamic search filter property must make its parent publish the tracked set: {effect:?}"
);
}
}

/// CR 608.2c: a single tapped creature becomes the resolution's anaphoric
/// referent, so a later "that creature's power" (Enlist) reads it.
#[test]
Expand Down
170 changes: 143 additions & 27 deletions crates/engine/src/parser/oracle_effect/sequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,41 @@ fn is_search_result_reveal_clause(lower: &str) -> bool {
)
}

/// CR 701.23a + CR 701.18a: Bare "put it onto the battlefield" restatement
/// after a search compound — library-only (Assassin's Trophy comma-split) or
/// multi-zone (The Hunger Tide Rises: "library and/or graveyard … and put it
/// onto the battlefield"). Composed from independent verb/pronoun/tapped axes
/// so Field-of-Ruin "puts it" and Winds-of-Abandon "those cards tapped" share
/// one grammar instead of N! enumerated sentences.
fn parse_search_result_put_onto_battlefield_restatement(
input: &str,
) -> Result<(&str, ()), nom::Err<OracleError<'_>>> {
let (input, _) = alt((tag::<_, _, OracleError<'_>>("put "), tag("puts "))).parse(input)?;
let (input, _) = alt((
tag("that card "),
tag("it "),
tag("them "),
tag("those cards "),
))
.parse(input)?;
let (input, _) = tag("onto the battlefield").parse(input)?;
let (input, _) = opt(tag(" tapped")).parse(input)?;
Ok((input, ()))
}

/// CR 701.23a + CR 701.18a: Bare "put it onto the battlefield" restatement
/// after a search compound. Shared by the bare-`and` split suppressor and the
/// SearchDestination follow-up absorber.
fn is_search_result_put_onto_battlefield_restatement(lower: &str) -> bool {
let bare = strip_search_result_subject(lower.trim().trim_end_matches('.'));
parse_search_result_put_onto_battlefield_restatement(bare)
.map(|(rest, _)| rest.trim().is_empty())
.unwrap_or(false)
}
Comment on lines +193 to +198

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] Avoid verbatim string matching for Oracle phrases. Evidence: crates/engine/src/parser/oracle_effect/sequence.rs:173. Why it matters: Verbatim string matching on full Oracle phrases bypasses the robust nom-based parser, creating fragile matches that fail on minor punctuation or spacing variations. Suggested fix: Use nom combinators to parse the restatement phrase.

fn is_search_result_put_onto_battlefield_restatement(lower: &str) -> bool {
    let bare = strip_search_result_subject(lower.trim().trim_end_matches('.'));
    (
        alt((tag::<_, _, OracleError<'_>>("put "), tag("puts "))),
        alt((tag("that card"), tag("it"), tag("them"), tag("those cards"))),
        tag(" onto the battlefield"),
        opt(tag(" tapped")),
    )
        .parse(bare)
        .is_ok()
}
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... verbatim string equality on full Oracle phrases is the single most prohibited pattern in the codebase. (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 and compose them using idiomatic combinator aggregates to prevent combinatorial explosion and improve maintainability.


fn has_conditional_search_result_destination(lower: &str) -> bool {
fn parse_clause(input: &str) -> Result<(&str, ()), nom::Err<OracleError<'_>>> {
let (input, _) = alt((
tag::<_, _, OracleError<'_>>("put that card onto the battlefield"),
tag("put it onto the battlefield"),
tag("put them onto the battlefield"),
tag("put those cards onto the battlefield"),
))
.parse(input)?;
let (input, _) = opt(tag(" tapped")).parse(input)?;
let (input, _) = parse_search_result_put_onto_battlefield_restatement(input)?;
let (input, _) = alt((tag(" if it's "), tag(" if it is "))).parse(input)?;
let (input, _) = take_until(" card").parse(input)?;
let (input, _) = tag(" card").parse(input)?;
Expand Down Expand Up @@ -1096,6 +1121,15 @@ pub(super) fn split_clause_sequence(text: &str) -> Vec<ClauseChunk> {
&& tag::<_, _, OracleError<'_>>("exile them")
.parse(remainder_trimmed)
.is_ok();
// CR 701.23a + CR 701.18a: "search [library and/or graveyard]
// for … and put it onto the battlefield" is one search
// compound (The Hunger Tide Rises). Without this guard the
// bare-`and` splitter peels the put-step into a sibling
// ChangeZone that duplicates the SearchDestination
// continuation — and multi-zone puts use `origin: None`,
// so the follow-up absorber's library-only arm misses them.
let search_put_destination = has_search_prefix
&& is_search_result_put_onto_battlefield_restatement(remainder_trimmed);
// CR 707.9: ", except <body> and <body> [and …]" — inside
// a copy-effect except clause, " and " is an internal
// delimiter between recognised body shapes (SetName, P/T,
Expand Down Expand Up @@ -1290,6 +1324,7 @@ pub(super) fn split_clause_sequence(text: &str) -> Vec<ClauseChunk> {
|| targeted_compound_continuation
|| prevent_then_put_continuation
|| search_with_that_name
|| search_put_destination
|| inside_except_clause
|| choice_partition_remainder
|| compound_subject_each
Expand Down Expand Up @@ -6877,27 +6912,11 @@ pub(super) fn parse_followup_continuation_ast(
// prefix-strip on the player-subject so all (subject × pronoun × tapped)
// permutations match without N! enumerated arms.
Effect::ChangeZone {
origin: Some(Zone::Library),
origin,
destination: Zone::Battlefield,
..
} if {
let bare = strip_search_result_subject(lower.trim().trim_end_matches('.'));
matches!(
bare,
"put that card onto the battlefield"
| "put it onto the battlefield"
| "puts that card onto the battlefield"
| "puts it onto the battlefield"
| "put them onto the battlefield"
| "put those cards onto the battlefield"
| "put that card onto the battlefield tapped"
| "put it onto the battlefield tapped"
| "puts that card onto the battlefield tapped"
| "puts it onto the battlefield tapped"
| "put them onto the battlefield tapped"
| "put those cards onto the battlefield tapped"
)
} =>
} if matches!(origin, None | Some(Zone::Library))
&& is_search_result_put_onto_battlefield_restatement(&lower) =>
{
Some(ContinuationAst::SearchResultClauseHandled)
}
Expand Down Expand Up @@ -8156,6 +8175,103 @@ mod tests {
assert_eq!(chunks.len(), 1, "unexpected split: {chunks:?}");
}

#[test]
fn bare_and_keeps_multi_zone_search_put_onto_battlefield_compound() {
// CR 701.23a (issue #5977): The Hunger Tide Rises — "search your library
// and/or graveyard for … and put it onto the battlefield" must stay one
// compound so SearchDestination owns the put-step (origin: None).
let chunks = clause_texts(
"search your library and/or graveyard for a creature card with mana value less than or equal to the number of creatures sacrificed this way and put it onto the battlefield",
);
assert_eq!(chunks.len(), 1, "unexpected split: {chunks:?}");
}

#[test]
fn search_result_put_restatement_covers_verb_pronoun_tapped_axes() {
for phrase in [
"put that card onto the battlefield",
"put it onto the battlefield",
"puts that card onto the battlefield",
"puts it onto the battlefield",
"put them onto the battlefield",
"put those cards onto the battlefield",
"put that card onto the battlefield tapped",
"puts it onto the battlefield tapped",
] {
assert!(
is_search_result_put_onto_battlefield_restatement(phrase),
"expected restatement match for {phrase:?}"
);
}
for phrase in [
"that player put it onto the battlefield",
"those players put those cards onto the battlefield tapped",
"each player puts them onto the battlefield",
] {
assert!(
is_search_result_put_onto_battlefield_restatement(phrase),
"expected subject-stripped restatement match for {phrase:?}"
);
}
for phrase in [
"put it into your hand",
"put that card on top of your library",
"exile it",
] {
assert!(
!is_search_result_put_onto_battlefield_restatement(phrase),
"must not match non-battlefield put restatement {phrase:?}"
);
}
}

#[test]
fn search_result_put_restatement_parses_independent_axes() {
let verbs = ["put ", "puts "];
let pronouns = ["that card ", "it ", "them ", "those cards "];
for verb in verbs {
for pronoun in pronouns {
for tapped in [false, true] {
let mut phrase = format!("{verb}{pronoun}onto the battlefield");
if tapped {
phrase.push_str(" tapped");
}
let (rest, _) = parse_search_result_put_onto_battlefield_restatement(&phrase)
.unwrap_or_else(|_| panic!("failed to parse {phrase:?}"));
assert!(
rest.is_empty(),
"parser must consume full restatement for {phrase:?}, leftover {rest:?}"
);
}
}
}
}

#[test]
fn multi_zone_search_put_restatement_absorbed_after_origin_none_destination() {
let previous = Effect::ChangeZone {
origin: None,
destination: Zone::Battlefield,
target: TargetFilter::Any,
owner_library: false,
enter_transformed: false,
enters_under: None,
enter_tapped: crate::types::zones::EtbTapState::Unspecified,
enters_attacking: false,
up_to: false,
enter_with_counters: vec![],
conditional_enter_with_counters: vec![],
face_down_profile: None,
enters_modified_if: None,
};
let result = parse_followup_continuation_ast(
"put it onto the battlefield",
&previous,
&mut ParseContext::default(),
);
assert_eq!(result, Some(ContinuationAst::SearchResultClauseHandled));
}

#[test]
fn search_exile_them_followup_is_absorbed_after_library_exile_destination() {
let previous = Effect::ChangeZone {
Expand Down
Loading
Loading