Skip to content

fix(engine): repeat Grindstone mill while milled cards share a color (#6091)#6588

Open
andriypolanski wants to merge 1 commit into
phase-rs:mainfrom
andriypolanski:fix/6091-grindstone-repeat-shared-color
Open

fix(engine): repeat Grindstone mill while milled cards share a color (#6091)#6588
andriypolanski wants to merge 1 commit into
phase-rs:mainfrom
andriypolanski:fix/6091-grindstone-repeat-shared-color

Conversation

@andriypolanski

@andriypolanski andriypolanski commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Closes #6091

Shipped

  • TargetFilter::LastZoneChanged — resolution-local ledger filter mirroring LastRevealed, wired through filter/targeting/quantity/coverage/ability_rw paths; object_count_matching_ids short-circuits to last_zone_changed_ids.
  • Repeat-until parserstrip_milled_shared_quality_conditional maps if [N] cards that share a [quality] were milled this way, repeat this process to RepeatContinuation::WhileCondition over ObjectCountBySharedQuality { LastZoneChanged, Max } >= N (no Unimplemented repeat sub-ability).
  • Deferral predicatecondition_depends_on_zone_change_this_way recurses QuantityCheck / QuantityExpr trees for LastZoneChanged filters (same intent as ZoneChangedThisWay deferral).
  • Tests — parser building-block + full Grindstone parse; quantity ledger unit tests; activated integration tests (Painter red combo, unlike-color reach guard, colorless stop-at-two).

Issue scope vs out of scope

In scope (done) Out of scope (unchanged)
Grindstone activated repeat loop Painter's Servant parser reopen
LastZoneChanged building block Frontend / AI / mtgish
Shared-color milled-this-way repeat stripper General this-way verb taxonomy beyond structured milled+quality combinators

Verification

  • cargo fmt --all
  • Tilt: clippy, test-engine, card-data (or direct cargo test fallback)
  • Parser combinator gate + cargo coverage / cargo semantic-audit for parser diff

Summary by CodeRabbit

  • New Features

    • Added support for identifying and targeting cards most recently moved to a public zone.
    • Added support for “cards that share a quality were milled this way” repeat conditions, including color-based checks.
    • Improved resolution, counting, filtering, importing, and display of these targets.
  • Bug Fixes

    • Grindstone-style milling loops now repeat and stop correctly based on the cards milled together, including colorless cards and Painter’s Servant interactions.
    • Improved discard and attachment targeting for recently moved cards.

@superagent-security

Copy link
Copy Markdown

🚨 Contributor flagged. Click here for more info: Superagent Dashboard

@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 24, 2026
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds TargetFilter::LastZoneChanged support across filtering, targeting, quantity evaluation, parser condition handling, engine classification, and Grindstone integration tests.

Changes

Last zone changed support

Layer / File(s) Summary
Filter and target resolution
crates/engine/src/types/ability.rs, crates/engine/src/game/filter.rs, crates/engine/src/game/targeting.rs, crates/engine/src/game/quantity.rs, crates/engine/src/game/trigger_matchers.rs
LastZoneChanged resolves from state.last_zone_changed_ids, matches candidate objects, participates in quantity populations, and supports trigger target matching.
Shared-quality repeat condition
crates/engine/src/game/effects/mod.rs, crates/engine/src/parser/oracle_effect/*
Milled shared-quality conditionals are lowered to ObjectCountBySharedQuality checks over LastZoneChanged and parsed into unbounded repeat conditions.
Engine classification and effect wiring
crates/engine/src/game/ability_rw.rs, crates/engine/src/game/ability_scan.rs, crates/engine/src/game/ability_utils.rs, crates/engine/src/game/cost_payability.rs, crates/engine/src/game/coverage.rs, crates/engine/src/game/effects/*, crates/engine/src/game/layers.rs, crates/engine/src/parser/oracle_static/restriction.rs, crates/engine/src/types/events.rs, crates/mtgish-import/src/convert/condition.rs
Classifier, formatting, discard, cost, layer, restriction, event, and import paths explicitly handle LastZoneChanged.
Grindstone integration coverage
crates/engine/tests/integration/issue_6091_grindstone.rs, crates/engine/tests/integration/main.rs, crates/engine/src/game/quantity.rs
Tests cover repeated milling for matching colors, Painter’s Servant, unlike colors, and colorless cards, including shared-quality quantity results.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested labels: bug

Suggested reviewers: matthewevans

Sequence Diagram(s)

sequenceDiagram
  participant Grindstone
  participant State
  participant Quantity
  participant RepeatProcess
  Grindstone->>State: record last_zone_changed_ids
  State->>Quantity: provide changed object population
  Quantity->>RepeatProcess: evaluate shared-quality count
  RepeatProcess->>Grindstone: continue or stop milling
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing Grindstone’s repeat mill loop when milled cards share a color.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.44.1)
crates/engine/src/parser/oracle_effect/mod.rs

ast-grep timed out on this file


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (2)
crates/engine/src/parser/oracle_effect/conditions.rs (1)

1539-1578: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Recognizer is hardcoded to the "milled" verb even though LastZoneChanged is a general zone-change ledger.

parse_shared_quality and parse_number already generalize the quality/count axes, but tag(" were milled this way") locks this to mill specifically. Since TargetFilter::LastZoneChanged (filter.rs) is verb-agnostic, consider generalizing the verb via alt() (milled/discarded/exiled/revealed) the same way the rest of this file generalizes other "this way" outcome verbs, so the next card with this shared-quality-repeat shape on a different verb doesn't need a new near-duplicate function.

As per coding guidelines, crates/engine/**: "Build reusable card-class building blocks rather than one-card special cases; new enum variants, parsers, effects, and filters must support a category of cards."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_effect/conditions.rs` around lines 1539 -
1578, Generalize strip_milled_shared_quality_conditional so its recognizer
accepts the supported “this way” outcome verbs (milled, discarded, exiled, and
revealed) instead of only matching “were milled this way.” Keep the existing
shared-quality parsing, QuantityCheck construction, LastZoneChanged filter, and
remainder handling unchanged.

Source: Coding guidelines

crates/engine/src/game/quantity.rs (1)

1896-1904: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Special-case only covers bare LastZoneChanged; compound filters silently fall through to the wrong zone.

The early return only fires for the literal TargetFilter::LastZoneChanged. If it's ever composed (e.g. And([LastZoneChanged, Typed(..)]) for "if two lands milled this way share a color"), extract_zones() won't recognize the ledger and defaults to scanning Battlefield instead of intersecting with state.last_zone_changed_ids — the wrong population for milled/discarded/exiled cards. This also silently bypasses the filter_contains_other_than_trigger_object exclusion for any LastZoneChanged-based count. Contrast with filter_contains_last_zone_changed in effects/mod.rs, which does recurse into And/Or/Not — the "detects dependency" side is more general than this "resolves the population" side.

Not a bug for Grindstone's bare-filter usage, but worth hardening before the next card composes this filter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/quantity.rs` around lines 1896 - 1904, Update
object_count_matching_ids to resolve LastZoneChanged recursively within compound
TargetFilter variants such as And, Or, and Not, using
state.last_zone_changed_ids as the candidate population before applying the
remaining filter logic. Preserve filter_contains_other_than_trigger_object
exclusion behavior for composed filters and keep existing handling for filters
without a LastZoneChanged dependency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/game/effects/mod.rs`:
- Around line 2688-2746: Make filter_contains_last_zone_changed and
quantity_ref_depends_on_zone_change_this_way exhaustive over their respective
enums, removing the _ => false fallbacks. Add explicit false arms for variants
that cannot contain a relevant filter, recurse through
TargetFilter::TrackedSetFiltered, and include every QuantityRef variant carrying
a TargetFilter, including the listed turn-based and zone-change variants, so
nested LastZoneChanged dependencies are detected.

In `@crates/engine/tests/integration/issue_6091_grindstone.rs`:
- Around line 164-175: Reorder the setup in the test around
add_colored_library_card so the six blue pad cards are added before the Green
Second and Blue Top pair, making that pair the first cards milled and ensuring
the test exercises Painter’s red effect. Optionally add assertions after
setup_painter_red confirming both top cards have red.

---

Nitpick comments:
In `@crates/engine/src/game/quantity.rs`:
- Around line 1896-1904: Update object_count_matching_ids to resolve
LastZoneChanged recursively within compound TargetFilter variants such as And,
Or, and Not, using state.last_zone_changed_ids as the candidate population
before applying the remaining filter logic. Preserve
filter_contains_other_than_trigger_object exclusion behavior for composed
filters and keep existing handling for filters without a LastZoneChanged
dependency.

In `@crates/engine/src/parser/oracle_effect/conditions.rs`:
- Around line 1539-1578: Generalize strip_milled_shared_quality_conditional so
its recognizer accepts the supported “this way” outcome verbs (milled,
discarded, exiled, and revealed) instead of only matching “were milled this
way.” Keep the existing shared-quality parsing, QuantityCheck construction,
LastZoneChanged filter, and remainder handling unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b17987f-ac1d-4688-923b-209d94464fca

📥 Commits

Reviewing files that changed from the base of the PR and between 22e7716 and 1059ca9.

📒 Files selected for processing (21)
  • crates/engine/src/game/ability_rw.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/cost_payability.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/game/effects/discard.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/game/layers.rs
  • crates/engine/src/game/quantity.rs
  • crates/engine/src/game/targeting.rs
  • crates/engine/src/game/trigger_matchers.rs
  • crates/engine/src/parser/oracle_effect/conditions.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/parser/oracle_static/restriction.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/events.rs
  • crates/engine/tests/integration/issue_6091_grindstone.rs
  • crates/engine/tests/integration/main.rs
  • crates/mtgish-import/src/convert/condition.rs

Comment on lines +2688 to +2746
fn filter_contains_last_zone_changed(filter: &TargetFilter) -> bool {
match filter {
TargetFilter::LastZoneChanged => true,
TargetFilter::And { filters } | TargetFilter::Or { filters } => {
filters.iter().any(filter_contains_last_zone_changed)
}
TargetFilter::Not { filter } => filter_contains_last_zone_changed(filter),
_ => false,
}
}

fn quantity_expr_depends_on_zone_change_this_way(expr: &QuantityExpr) -> bool {
match expr {
QuantityExpr::Ref { qty } => quantity_ref_depends_on_zone_change_this_way(qty),
QuantityExpr::DivideRounded { inner, .. }
| QuantityExpr::Multiply { inner, .. }
| QuantityExpr::ClampMin { inner, .. }
| QuantityExpr::Offset { inner, .. } => {
quantity_expr_depends_on_zone_change_this_way(inner)
}
QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => exprs
.iter()
.any(quantity_expr_depends_on_zone_change_this_way),
QuantityExpr::UpTo { max } => quantity_expr_depends_on_zone_change_this_way(max),
QuantityExpr::Power { exponent, .. } => {
quantity_expr_depends_on_zone_change_this_way(exponent)
}
QuantityExpr::Difference { left, right } => {
quantity_expr_depends_on_zone_change_this_way(left)
|| quantity_expr_depends_on_zone_change_this_way(right)
}
QuantityExpr::Fixed { .. } => false,
}
}

fn quantity_ref_depends_on_zone_change_this_way(qty: &QuantityRef) -> bool {
match qty {
QuantityRef::ObjectCount { filter }
| QuantityRef::ObjectCountDistinct { filter, .. }
| QuantityRef::ObjectCountBySharedQuality { filter, .. }
| QuantityRef::CountersOnObjects { filter, .. }
| QuantityRef::Aggregate { filter, .. }
| QuantityRef::ControlledByEachPlayer { filter, .. }
| QuantityRef::DistinctColorsAmongPermanents { filter }
| QuantityRef::DistinctCounterKindsAmong { filter }
| QuantityRef::EnteredThisTurn { filter }
| QuantityRef::BattlefieldEntriesThisTurn { filter, .. } => {
filter_contains_last_zone_changed(filter)
}
QuantityRef::DistinctCardTypes {
source: crate::types::ability::CardTypeSetSource::Objects { filter },
}
| QuantityRef::DistinctSubtypes {
source: crate::types::ability::CardTypeSetSource::Objects { filter },
..
} => filter_contains_last_zone_changed(filter),
_ => false,
}
}

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Non-exhaustive _ => false wildcards on large enums, contrary to this file's own idiom.

Both new predicates use a catch-all _ => false instead of enumerating every variant, unlike the rest of the codebase's TargetFilter/FilterProp matchers (e.g. affected_filter_uses_object_population in filter.rs), which deliberately list every arm — even the false ones — so a missing variant is a compile error, not a silent gap.

  • filter_contains_last_zone_changed misses TargetFilter::TrackedSetFiltered { filter, .. }, which wraps a nested filter exactly like And/Or/Not.
  • quantity_ref_depends_on_zone_change_this_way only checks 12 of the many filter-carrying QuantityRef variants; siblings like AttackedThisTurn, SacrificedThisTurn, ZoneChangeCountThisTurn, ZoneChangeAggregateThisTurn, TokensCreatedThisTurn, SpellsCastThisTurn, DamageDealtThisTurn all carry a TargetFilter and are unchecked.

Since this predicate drives the new zone-change-deferral logic, a false negative here means a future LastZoneChanged-composed repeat condition silently skips deferral and reads a stale ledger.

As per coding guidelines, crates/engine/**/*.rs: "Use exhaustive matches without wildcard fallbacks for known enums so the compiler detects missing variants."

♻️ Direction for making both matches exhaustive
 fn filter_contains_last_zone_changed(filter: &TargetFilter) -> bool {
     match filter {
         TargetFilter::LastZoneChanged => true,
         TargetFilter::And { filters } | TargetFilter::Or { filters } => {
             filters.iter().any(filter_contains_last_zone_changed)
         }
         TargetFilter::Not { filter } => filter_contains_last_zone_changed(filter),
-        _ => false,
+        TargetFilter::TrackedSetFiltered { filter, .. } => {
+            filter_contains_last_zone_changed(filter)
+        }
+        // ... enumerate every remaining TargetFilter variant explicitly as `=> false`
+        // (mirrors affected_filter_uses_object_population's exhaustive style).
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/effects/mod.rs` around lines 2688 - 2746, Make
filter_contains_last_zone_changed and
quantity_ref_depends_on_zone_change_this_way exhaustive over their respective
enums, removing the _ => false fallbacks. Add explicit false arms for variants
that cannot contain a relevant filter, recurse through
TargetFilter::TrackedSetFiltered, and include every QuantityRef variant carrying
a TargetFilter, including the listed turn-based and zone-change variants, so
nested LastZoneChanged dependencies are detected.

Source: Coding guidelines

Comment on lines +164 to +175
// One blue + one green on top: without Painter they do not share a color;
// with Painter red they both gain red and repeat.
add_colored_library_card(&mut scenario, P1, "Blue Top", ManaCostShard::Blue);
add_colored_library_card(&mut scenario, P1, "Green Second", ManaCostShard::Green);
for idx in 0..6 {
add_colored_library_card(
&mut scenario,
P1,
&format!("Blue Pad {idx}"),
ManaCostShard::Blue,
);
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Put the blue/green pair on top so this actually proves Painter’s effect.

The six blue pads are added last, so they are milled first as three blue-blue pairs. Even without Painter, the final blue-green pair is milled only after those repeats, yielding all eight cards. This test therefore passes if layer evaluation never grants red. Add the pads first, then add Green Second and Blue Top; optionally assert both cards have red after setup_painter_red.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/issue_6091_grindstone.rs` around lines 164 -
175, Reorder the setup in the test around add_colored_library_card so the six
blue pad cards are added before the Green Second and Blue Top pair, making that
pair the first cards milled and ensuring the test exercises Painter’s red
effect. Optionally add assertions after setup_painter_red confirming both top
cards have red.

Source: Path instructions

@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 1 card(s), 1 signature(s) (baseline: main 22e77160dd98)

🔴 Removed (1 signature)

  • 1 card · ➖ ability/repeat · removed: repeat
    • Affected (first 3): Grindstone

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:flagged Contributor flagged for review by trust analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Card Bug] Grindstone: Does not mill library together with Painter's Servant

2 participants