Skip to content

Add Ertai's Trickery - #3576

Closed
jason020818 wants to merge 1 commit into
phase-rs:mainfrom
jason020818:card/ertai-trickery
Closed

Add Ertai's Trickery#3576
jason020818 wants to merge 1 commit into
phase-rs:mainfrom
jason020818:card/ertai-trickery

Conversation

@jason020818

Copy link
Copy Markdown
Contributor

Rebased branch refresh for Ertai's Trickery card parser support + integration test.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

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.

Comment on lines +1048 to +1066
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,
),
))
}

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

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
  1. If a parser arm or string was extended, ensure plural / possessive / negated / another variants are covered. (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.

Comment on lines +1836 to +1855
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 },
},
))
}

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

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
  1. If a parser arm or string was extended, ensure plural / possessive / negated / another variants are covered. (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.

Comment on lines +1864 to +1871
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()))
}

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

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
  1. If a parser arm or string was extended, ensure plural / possessive / negated / another variants are covered. (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.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-lease

The 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.

@matthewevans

Copy link
Copy Markdown
Member

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 main (it was 262) and still shows as conflicting (DIRTY), and the test is still parser-shape only. Adding commits on top of an old base doesn't move the base. You need:

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-lease

After 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 git rebase is giving you trouble, say so and I can push the rebased branch for you.

@jason020818

Copy link
Copy Markdown
Contributor Author

Re-synced Ertai's Trickery parser + integration test on card/ertai-trickery. Local: cargo test -p engine --test integration ertai_trickery passes. Same fork-sync caveat as #3573.

@jason020818
jason020818 force-pushed the card/ertai-trickery branch from 3e36ef0 to b38e348 Compare June 17, 2026 16:45

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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).
  2. 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.)

@jason020818
jason020818 force-pushed the card/ertai-trickery branch from b38e348 to 4f342c3 Compare June 17, 2026 17:08
@jason020818

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main after fork merge-upstream. ahead 1 / behind 0. Ready for review.

@matthewevans

Copy link
Copy Markdown
Member

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 parse_oracle_text + matches!(Effect::Counter …) (AST shape only). It needs the runtime GameRunner/GameScenario test from my last comment: cast a kicker spell with kicker → Ertai's Trickery counters it; cast it without kicker → the target resolves (counter does nothing). Re-request with that test and I'll enqueue.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jason020818
jason020818 force-pushed the card/ertai-trickery branch from 4f342c3 to d2e2d7c Compare June 17, 2026 17:51
@matthewevans

Copy link
Copy Markdown
Member

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 GameScenario/GameRunner test that casts a kicked spell, counters it with Ertai's Trickery (assert it's countered), and the not-kicked case (assert it resolves) — I'll gladly re-review then. Appreciate the effort; this is purely about test coverage.

matthewevans added a commit to galuis116/phase that referenced this pull request Jun 17, 2026
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>
@matthewevans matthewevans added the enhancement New feature or request label Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants