Add Ertai's Trickery - #3576
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances the MTG rules parser by adding support for several target-anaphoric conditions, including card-type conversions, combat-history gates ('it attacked this turn'), cost-reduction protections, 'no mana spent' checks, and 'was kicked' suffixes, along with corresponding integration tests. The review feedback focuses on improving parser robustness by extending the newly introduced parsers to support plural and card-based pronoun variants (such as 'they', 'those creatures', 'those spells', and 'were kicked') to ensure comprehensive coverage of Oracle phrasings.
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.
| fn parse_target_attacked_this_turn_condition( | ||
| input: &str, | ||
| ) -> super::super::oracle_nom::error::OracleResult<'_, AbilityCondition> { | ||
| let (rest, _) = parse_target_anaphoric_subject(input)?; | ||
| let (rest, negated) = parse_anaphoric_attacked_tense_polarity(rest)?; | ||
| let (rest, _) = tag(" this turn").parse(rest)?; | ||
| Ok(( | ||
| rest, | ||
| maybe_negate( | ||
| AbilityCondition::TargetMatchesFilter { | ||
| filter: TargetFilter::Typed( | ||
| TypedFilter::creature().properties(vec![FilterProp::AttackedThisTurn]), | ||
| ), | ||
| use_lki: false, | ||
| }, | ||
| negated, | ||
| ), | ||
| )) | ||
| } |
There was a problem hiding this comment.
The parse_target_attacked_this_turn_condition parser currently only matches singular anaphoric subjects (via parse_target_anaphoric_subject). It should be extended to support plural subjects (e.g., they, those creatures, those permanents, those cards) to ensure full coverage of plural targets.
fn parse_target_attacked_this_turn_condition(
input: &str,
) -> super::super::oracle_nom::error::OracleResult<'_, AbilityCondition> {
let (rest, _) = alt((
parse_target_anaphoric_subject,
value(
(),
alt((
tag("they"),
tag("those creatures"),
tag("those permanents"),
tag("those cards"),
)),
),
))
.parse(input)?;
let (rest, negated) = parse_anaphoric_attacked_tense_polarity(rest)?;
let (rest, _) = tag(" this turn").parse(rest)?;
Ok((
rest,
maybe_negate(
AbilityCondition::TargetMatchesFilter {
filter: TargetFilter::Typed(
TypedFilter::creature().properties(vec![FilterProp::AttackedThisTurn]),
),
use_lki: false,
},
negated,
),
))
}References
- If a parser arm or string was extended, ensure plural / possessive / negated / another variants are covered. (link)
- 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.
| fn parse_no_mana_spent_to_cast_target_condition(input: &str) -> OracleResult<'_, AbilityCondition> { | ||
| let (rest, _) = ( | ||
| tag("no mana was spent to cast "), | ||
| alt((tag("it"), tag("that spell"), tag("this spell"), tag("them"))), | ||
| ) | ||
| .parse(input)?; | ||
| Ok(( | ||
| rest, | ||
| AbilityCondition::QuantityCheck { | ||
| lhs: QuantityExpr::Ref { | ||
| qty: QuantityRef::ManaSpentToCast { | ||
| scope: CastManaObjectScope::AbilityTarget, | ||
| metric: CastManaSpentMetric::Total, | ||
| }, | ||
| }, | ||
| comparator: Comparator::EQ, | ||
| rhs: QuantityExpr::Fixed { value: 0 }, | ||
| }, | ||
| )) | ||
| } |
There was a problem hiding this comment.
The parse_no_mana_spent_to_cast_target_condition parser can be extended to support plural and card-based pronouns (e.g., those spells, that card, those cards) to ensure full coverage of various Oracle phrasings.
fn parse_no_mana_spent_to_cast_target_condition(input: &str) -> OracleResult<'_, AbilityCondition> {
let (rest, _) = (
tag("no mana was spent to cast "),
alt((
tag("it"),
tag("that spell"),
tag("this spell"),
tag("them"),
tag("those spells"),
tag("that card"),
tag("those cards"),
)),
)
.parse(input)?;
Ok((
rest,
AbilityCondition::QuantityCheck {
lhs: QuantityExpr::Ref {
qty: QuantityRef::ManaSpentToCast {
scope: CastManaObjectScope::AbilityTarget,
metric: CastManaSpentMetric::Total,
},
},
comparator: Comparator::EQ,
rhs: QuantityExpr::Fixed { value: 0 },
},
))
}References
- If a parser arm or string was extended, ensure plural / possessive / negated / another variants are covered. (link)
- 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.
| fn parse_was_kicked_condition(input: &str) -> OracleResult<'_, AbilityCondition> { | ||
| let (rest, _) = ( | ||
| alt((tag("it"), tag("that spell"), tag("this spell"))), | ||
| tag(" was kicked"), | ||
| ) | ||
| .parse(input)?; | ||
| Ok((rest, AbilityCondition::additional_cost_paid_any())) | ||
| } |
There was a problem hiding this comment.
The parse_was_kicked_condition parser currently only matches singular pronouns and verbs (it was kicked, that spell was kicked, this spell was kicked). It should be extended to support plural variants (e.g., they were kicked, those spells were kicked, those cards were kicked) to ensure full coverage of plural targets.
fn parse_was_kicked_condition(input: &str) -> OracleResult<'_, AbilityCondition> {
let (rest, _) = alt((
(
alt((tag("it"), tag("that spell"), tag("this spell"), tag("that card"))),
tag(" was kicked"),
),
(
alt((tag("they"), tag("those spells"), tag("those cards"), tag("them"))),
tag(" were kicked"),
),
))
.parse(input)?;
Ok((rest, AbilityCondition::additional_cost_paid_any()))
}References
- If a parser arm or string was extended, ensure plural / possessive / negated / another variants are covered. (link)
- 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.
matthewevans
left a comment
There was a problem hiding this comment.
Thanks — and I see you reopened this as a fresh PR (replacing the closed #3572). The parser design is good (counter-if-kicked at the correct suffix-conditional seam, pure nom, CR 702.33d verified, and AdditionalCostPaid is consumed at runtime — not inert). But this new PR carries the same two blockers #3572 had, both mechanical:
1. Still on a stale base — 262 commits behind main, conflicting (DIRTY). This branch's merge-base is cea1a0e08; main is 262 commits ahead, so conditions.rs still re-adds already-merged content (parse_no_mana_spent_to_cast_target_condition + the tests/integration/main.rs block) and won't merge. Opening a new PR off the same old base doesn't change this — the branch itself has to move onto current main:
git fetch origin
git rebase origin/main
# resolve conditions.rs / main.rs by DROPPING the duplicate already-merged
# content, keeping ONLY your new was_kicked parser arm + the ertai test
git push --force-with-leaseThe result should be roughly a +60-line diff (your parse_was_kicked_condition + wiring + test), not +451.
2. The test is still parser-shape only. ertai_trickery_counter_kicked.rs parses the Oracle text and asserts matches!(... Effect::Counter) + the kicked-condition shape — it never resolves the spell, so it passes even if the counter became unconditional. Please add a GameScenario/GameRunner test that casts Ertai's Trickery kicked (assert the target spell is countered) and un-kicked (assert it resolves).
Same applies to your Unravel PR (#3573) — identical stale-base + test situation. Once both branches are genuinely rebased onto current main with a runtime test, re-request review and I'll land them. The parser work is solid; this is purely about landing it cleanly.
|
Still blocked on the same two items — the new commits are three more "Add Ertai's Trickery" commits on the same stale base, not a rebase. The branch is now 267 commits behind git fetch origin
git rebase origin/main # NOT new commits — this MOVES your branch onto current main
# resolve conditions.rs / main.rs by dropping the already-merged duplicate content
git push --force-with-leaseAfter the rebase the diff should be ~+60 lines (just your was_kicked parser arm + test), not +451. The parser work itself is fine — happy to land it once the branch is genuinely rebased and the test resolves the spell (kicked → countered / un-kicked → resolves) instead of only asserting the parsed AST. If |
|
Re-synced Ertai's Trickery parser + integration test on |
3e36ef0 to
b38e348
Compare
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for rebasing — the stale-base/duplicate-helper blocker is fully cleared (even with main, single clean commit, no re-added helper). The parser arm is good: idiomatic nom, reuses the AbilityCondition::additional_cost_paid_any() building block (whose doc comment lists "plain 'if it was kicked'" as an intended consumer), CR 702.33d + 608.2c verified, and AdditionalCostPaid is consumed at runtime across effects/triggers/casting. It also generalizes (the "[that/this] spell was kicked" intervening-if suffix covers Fires of Victory and the broader kicker corpus, not just Ertai's Trickery).
Same remaining blocker as before — the test only asserts parser AST shape. ertai_trickery_counter_kicked.rs is parse_oracle_text(...) + matches!(... AdditionalCostPaid{..}), which would pass even if the runtime path regressed. Per the bar, this needs a runtime test that drives cast→resolve and fails on revert.
Please add a GameScenario/GameRunner integration test exercising both directions and asserting on resolved game state:
- Opponent casts a spell with kicker paid, you cast Ertai's Trickery targeting it → assert the target is countered (ends in graveyard / leaves the stack).
- Opponent casts the same spell without kicker, you cast Ertai's Trickery targeting it → assert the condition fails and the target resolves (counter does nothing).
Keep the parse unit test, but it can't stand alone. Re-request with the runtime test and I'll enqueue. (Optional polish: CR 702.33g — "effect only if kicked" — is the more precise citation for a conditional counter-rider than 702.33d, but not a blocker.)
b38e348 to
4f342c3
Compare
|
Rebased onto latest |
|
Same as the previous review — this push is a rebase (pulls in merged #3452); the PR diff and the test are unchanged. The test is still |
Co-authored-by: Cursor <cursoragent@cursor.com>
4f342c3 to
d2e2d7c
Compare
|
Thanks for this, and for the rebases. Closing for now, same reason as #3573. The parser work was fine, but the discriminating runtime test I asked for across several cycles wasn't added (the test stayed a parse-AST-shape assertion), and Gemini's coverage notes on plural variants also went unaddressed. We require a behavior-level regression test before merging card PRs, so I can't land it as-is. If you want to pick it back up, reopen with a |
phase-rs#1340) (phase-rs#3632) * Add Ertai's Trickery Co-authored-by: Cursor <cursoragent@cursor.com> * test(engine): runtime repro for Ertai's Trickery counter-if-kicked Add a maintainer runtime test that drives Ertai's Trickery through the real cast->resolve pipeline: P0 casts a kickable creature spell (kicker paid / skipped via the real CastSpell -> DecideOptionalCost path, so the target object's kickers_paid is populated authentically), then P1 counters it with Ertai's Trickery. Marked #[ignore]: the parser fix on this branch is necessary but not sufficient. At resolution evaluate_condition reads AdditionalCostPaid against Ertai's OWN ability.context (always empty), never the target spell's kickers_paid — AdditionalCostPaid has no object/target scope axis, so the counter never fires. Verified: a kicked target resolves to the battlefield uncountered. Unblocking needs a target-scoped additional-cost condition (an engine variant gated through add-engine-variant), out of scope for the parser salvage. Remove #[ignore] when that lands and this becomes a discriminating guard. Parser by @jaso0n0818 (salvaged from phase-rs#3576); runtime test added by maintainer. * feat(engine): target-spell-scoped additional-cost condition (Ertai's Trickery phase-rs#1340) * fix(parser): preserve AdditionalCostPaidInstead fold for kicker-instead patterns Jaso's Ertai's Trickery commit (b1824f6) added a generic 'was kicked' recognizer to parse_condition_text. Because build_instead_def calls parse_condition_text, the kicker-'...instead' construction (Rite of Replication, 'if it was kicked, ... instead') began matching there first and produced ConditionInstead { inner: AdditionalCostPaid } instead of the dedicated AdditionalCostPaidInstead fold that strip_additional_cost_conditional emits. Defer in build_instead_def when the condition is a recognized additional-cost-instead fragment, so the established strip_additional_cost_conditional path owns it (restoring origin/main precedence). The non-instead Ertai suffix case ('Counter target spell if it was kicked') is unaffected. --------- Co-authored-by: jaso0n0818 <hirakawatsuneteru@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Rebased branch refresh for Ertai's Trickery card parser support + integration test.