test(engine): Uphill Battle WasPlayed filter runtime discrimination#3523
test(engine): Uphill Battle WasPlayed filter runtime discrimination#3523kiannidev wants to merge 4 commits into
Conversation
Adds WasPlayed filter property and Uphill Battle / Contamination class replacement parsing distinct from control-based enter-tapped effects. Co-authored-by: Cursor <cursoragent@cursor.com>
Ensures played-by-opponents enter-tapped replacements match cast or played entries but not tokens put directly onto the battlefield. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the FilterProp::WasPlayed property to identify objects entering the battlefield from being played or cast, along with parser support for replacement effects like 'played by your opponents enter tapped' (e.g., Uphill Battle). Feedback highlights a critical bug where any nontoken card entering the battlefield from another zone is incorrectly matched as WasPlayed. Additionally, the parser implementation violates the repository's style guide by using verbatim string matching instead of modular nom combinators, and the test suite should be expanded to cover nontoken creatures put onto the battlefield without being cast.
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.
| // CR 305.1 + CR 601.2a: zone-change snapshots carry `from_zone` when | ||
| // the object existed in a prior zone (cast/played entry). | ||
| FilterProp::WasPlayed => record.from_zone.is_some(), |
There was a problem hiding this comment.
[HIGH] Incorrectly matches any nontoken card entering the battlefield from another zone as WasPlayed.
Why it matters: Under MTG rules (CR 305.1, CR 601.2a), "played" or "cast" is a specific action. Simply entering the battlefield from another zone (e.g., via reanimation or Elvish Piper) is not playing or casting, but since record.from_zone is Some(...) in those cases, record.from_zone.is_some() will incorrectly evaluate to true.
Suggested fix: Ensure ZoneChangeRecord explicitly tracks whether the transition was a cast or play (e.g., via played_from_zone or cast_from_zone fields) and check those instead of from_zone.is_some().
| if !nom_primitives::scan_contains(norm_lower, "played by your opponents enter") { | ||
| return None; | ||
| } | ||
| let stripped = norm_lower.trim_end_matches('.'); | ||
| // allow-noncombinator: peel fixed played-by entry suffix after scan_contains gate | ||
| let subject = stripped | ||
| .strip_suffix(" played by your opponents enter the battlefield tapped") // allow-noncombinator: fixed suffix after scan_contains gate | ||
| .or_else(|| { | ||
| stripped.strip_suffix(" played by your opponents enter tapped") // allow-noncombinator: fixed suffix after scan_contains gate | ||
| })?; | ||
| let (filter, subject_rest) = parse_type_phrase(subject.trim()); | ||
| if !subject_rest.trim().is_empty() { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
[MEDIUM] Use modular nom combinators for parsing dispatch instead of verbatim string matches or monolithic tags.
Why it matters: Avoid verbatim string equality or monolithic tags 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 to prevent combinatorial explosion and improve maintainability.
let (filter, rest) = parse_type_phrase(norm_lower.trim());
let rest = rest.trim();
let parse_subject = nom::bytes::complete::tag("played by your opponents");
let parse_action = nom::sequence::tuple((
nom::bytes::complete::tag("enter"),
nom::combinator::opt(nom::bytes::complete::tag(" the battlefield")),
));
let parse_state = nom::bytes::complete::tag("tapped");
let parser = nom::sequence::tuple((
parse_subject,
nom::character::complete::space1,
parse_action,
nom::character::complete::space1,
parse_state,
nom::combinator::opt(nom::bytes::complete::tag(".")),
));
if nom::combinator::all_consuming(parser)(rest).is_err() {
return None;
}References
- 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.
| let token_event = ProposedEvent::CreateToken { | ||
| owner: PlayerId(1), | ||
| count: 1, | ||
| spec: Box::new(test_token_spec(PlayerId(1), CoreType::Creature)), | ||
| copy: None, | ||
| enter_tapped: EtbTapState::Unspecified, | ||
| applied: HashSet::new(), | ||
| }; | ||
| let token_matches = find_applicable_replacements(&state, &token_event, ®istry); | ||
| assert!( | ||
| !token_matches | ||
| .iter() | ||
| .any(|rid| rid.source == uphill_id), | ||
| "tokens put directly onto the battlefield must not match WasPlayed filter" | ||
| ); | ||
| } |
There was a problem hiding this comment.
[MEDIUM] Add a test case for nontoken creatures put onto the battlefield without being cast/played.
Why it matters: The current test suite only covers cast creatures and tokens, failing to verify that nontoken creatures put onto the battlefield from hand/graveyard/library (e.g., via Elvish Piper or reanimation) are correctly excluded from the WasPlayed filter.
Suggested fix: Add a third test case to uphill_battle_was_played_filter_matches_cast_creature_not_token that puts a nontoken creature onto the battlefield without setting cast_from_zone or played_from_zone.
let token_event = ProposedEvent::CreateToken {
owner: PlayerId(1),
count: 1,
spec: Box::new(test_token_spec(PlayerId(1), CoreType::Creature)),
copy: None,
enter_tapped: EtbTapState::Unspecified,
applied: HashSet::new(),
};
let token_matches = find_applicable_replacements(&state, &token_event, ®istry);
assert!(
!token_matches
.iter()
.any(|rid| rid.source == uphill_id),
"tokens put directly onto the battlefield must not match WasPlayed filter"
);
let put_creature = ObjectId(30);
let mut put_obj = GameObject::new(
put_creature,
CardId(3),
PlayerId(1),
"Runeclaw Bear".to_string(),
Zone::Hand,
);
put_obj.card_types.core_types.push(CoreType::Creature);
state.objects.insert(put_creature, put_obj);
let put_event = ProposedEvent::ZoneChange {
object_id: put_creature,
from: Zone::Hand,
to: Zone::Battlefield,
cause: None,
attach_to: None,
enter_tapped: EtbTapState::Unspecified,
enter_with_counters: Vec::new(),
controller_override: None,
enter_transformed: false,
face_down_profile: None,
applied: HashSet::new(),
};
let put_matches = find_applicable_replacements(&state, &put_event, ®istry);
assert!(
!put_matches
.iter()
.any(|rid| rid.source == uphill_id),
"nontoken creatures put onto the battlefield without being cast must not match WasPlayed filter"
);
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for this — the WasPlayed filter design and the runtime test are solid. The runtime test uphill_battle_was_played_filter_matches_cast_creature_not_token drives find_applicable_replacements and discriminates correctly (cast creature with cast_from_zone=Some matches; a token put directly onto the battlefield does not). Parser tests cover the creature/land class. CR 305.1 + 601.2a annotations check out.
One blocker before this can merge: cargo fmt --all -- --check is failing (the "Rust lint (fmt, clippy, parser gate)" job). The diffs are in the test code you added:
crates/engine/src/game/replacement.rslines ~10482, ~10514, ~10530crates/engine/src/parser/oracle_replacement.rsline ~9837
Please run cargo fmt --all and push. Note the lint job stops at the fmt step, so clippy and the parser gate haven't actually run yet on this branch — once fmt is fixed, please confirm those pass too (the allow-noncombinator suffix-peel in parse_played_by_opponents_entry is gated behind scan_contains, which should be fine, but the gate needs a clean run to confirm). Re-request review once green.
Co-authored-by: Cursor <cursoragent@cursor.com>
…ed tests - Extend parse_external_entry_suffix/build_external_entry_replacement for Uphill Battle class (DRY with Authority of the Consuls family) - Fix WasPlayed zone-change record matching via cast/play provenance fields - Add runtime discrimination test: cast creature, token, and put-without-cast Addresses review on phase-rs#3520; consolidates phase-rs#3523 runtime coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
- Merge latest main (resolve conflict with Bloodletter test) - Expand runtime test: cast creature, token, and put-without-cast cases - Fix WasPlayed zone-change record matching via cast/play provenance fields - cargo fmt --all Co-authored-by: Cursor <cursoragent@cursor.com>
|
Reopened and updated per feedback:
PR kept open alongside #3520. Ready for re-review. |
|
Closing as superseded — the WasPlayed runtime discrimination test ( |
…3520) * feat(parser): parse played-by-opponents enter tapped replacements Adds WasPlayed filter property and Uphill Battle / Contamination class replacement parsing distinct from control-based enter-tapped effects. Co-authored-by: Cursor <cursoragent@cursor.com> * style(parser): apply rustfmt to played-by-opponents tests Co-authored-by: Cursor <cursoragent@cursor.com> * feat(parser): consolidate played-by-opponents entry + runtime WasPlayed tests - Extend parse_external_entry_suffix/build_external_entry_replacement for Uphill Battle class (DRY with Authority of the Consuls family) - Fix WasPlayed zone-change record matching via cast/play provenance fields - Add runtime discrimination test: cast creature, token, and put-without-cast Addresses review on phase-rs#3520; consolidates phase-rs#3523 runtime coverage. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(engine): CI — ZoneChangeRecord fields + parser gate annotations - Add cast_from_zone/played_from_zone to madame_null integration fixture - Annotate parse_external_entry_suffix strip_suffix peels for parser gate Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Fixes #3518
Note: Stacked on #3520 (parser +
FilterProp::WasPlayed). Rebase to a single commit after #3520 merges.Test plan
cargo test -p engine --lib uphill_battle_was_playedMade with Cursor