Fix Grave Sifter per-player creature-type graveyard return (#5279) - #5567
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements support for Grave Sifter (issue #5279) by allowing players to choose a creature type and return any number of matching cards from their graveyard to their hand. It updates the effect resolution logic to treat the choice and the zone change as a single per-player instruction, clears redundant player scopes on co-scoped continuations, parses the 'any number of' quantifier, and correctly resolves 'cards of that type' to the chosen creature subtype. The review feedback suggests optimizing the parser in imperative.rs by using nom::bytes::complete::tag_no_case to avoid unnecessary string allocations on every target parsing pass.
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.
| let target_lower = target_text.to_ascii_lowercase(); | ||
| let any_number_prefix = tag::<_, _, OracleError<'_>>("any number of ") | ||
| .parse(target_lower.as_str()) | ||
| .ok() | ||
| .map(|(rest, _)| target_lower.len() - rest.len()); | ||
| let (target_text, return_up_to) = match any_number_prefix { | ||
| Some(consumed) => (&target_text[consumed..], true), | ||
| None => (target_text, false), | ||
| }; |
There was a problem hiding this comment.
Avoid allocating a new String via to_ascii_lowercase() on every target parsing pass. Since "any number of" is always lowercase in this context (or can be matched case-insensitively), you can use nom::bytes::complete::tag_no_case directly on target_text to avoid unnecessary allocations on a hot parser path.
let any_number_prefix = nom::bytes::complete::tag_no_case::<_, _, OracleError<'_>>("any number of ")
.parse(target_text)
.ok()
.map(|(rest, _)| target_text.len() - rest.len());
let (target_text, return_up_to) = match any_number_prefix {
Some(consumed) => (&target_text[consumed..], true),
None => (target_text, false),
};
matthewevans
left a comment
There was a problem hiding this comment.
Verdict: architecturally sound — three real bugs, all fixed at the right seam. Holding only for CI (Rust gates still pending, branch is BEHIND); approve as bug on settled green.
I verified the premise against the printed card before reviewing the design. Grave Sifter's Oracle text (Scryfall, verbatim):
"When this creature enters, each player chooses a creature type and returns any number of cards of that type from their graveyard to their hand."
Each of the three bugs in the description maps to a clause of that sentence, so this is a genuine defect trio, not a speculative refactor.
✅ Clean
1. Chosen-attribute axis (oracle_target.rs:2986). "cards of that type" has a card-typed head noun, so the old code unconditionally emitted IsChosenCardType — reading the wrong ChosenAttribute axis, matching nothing, and silently skipping the return. Keying the override on ctx.pending_choice_type == Some(ChoiceType::CreatureType) is the right discriminator: it resolves the anaphor against what was actually chosen rather than against the head noun. IsChosenCreatureType is a pre-existing FilterProp (ability.rs:3573) — no new variant.
2. up_to threading (imperative.rs:1778, ast.rs:971). Effect::ChangeZone.up_to already existed and was hardcoded false at the lowering site; the parser simply never carried the quantifier. Stripping "any number of " with tag() and threading the flag onto the AST node fixes the classic parsed-but-not-consumed shape. CR 107.1c grep-verified verbatim: "If a rule or ability instructs a player to choose 'any number,' that player may choose any positive number or zero." — exactly the zero-or-more semantics up_to: true encodes.
3. Player-scope chain split (effects/mod.rs:2478). The load-bearing fix, and it's at the correct seam: a new arm on the existing is_player_scope_local_continuation rather than a bespoke branch in the driver. Keying on the effect shape (Choose { persist: true } → ChangeZone { origin: Some(_) }) rather than on a card or a specific zone makes this cover the whole per-player choose-then-move class, not just Grave Sifter. CR 101.4 (APNAP) grep-verified — each player's return must run inside their own iteration, not after all choices resolve.
Tests are discriminating and registered. mod issue_5279_grave_sifter is wired into tests/integration/main.rs (not inert), and it lives under tests/integration/ rather than as a top-level binary. grave_sifter_each_player_creature_type_return_from_graveyard drives the real runtime path end-to-end (P0 chooses Bear → EffectZoneChoice → P1 chooses Giant → bear returns to hand), which is what actually discriminates bug #1 — a parse-only assertion would not have caught the ChosenAttribute axis mismatch. The unit test asserting the kept ChangeZone's player_scope is cleared pins bug #3 precisely.
🟡 Non-blocking
Redundant disjunct in the new guard (effects/mod.rs:2660). next_is_co_scoped_anaphoric_consumer already clears next.player_scope unconditionally at effects/mod.rs:2634-2636, before the outer if is even evaluated. Re-testing it inside the new inner if is a no-op. The condition can collapse to just:
if is_player_scope_local_continuation(&node.effect, &next.effect) {
next.player_scope = None;
}Same behavior, and it makes the new clearing rule read as the single thing it actually is.
CR 205.2a is the imprecise citation (oracle_target.rs:2986). 205.2a enumerates card types (artifact, creature, land, …). The rule that actually justifies the override is CR 205.3m — creature types are subtypes, which is precisely why a chosen "Bear" cannot be a card type and the head noun must not drive the axis. Suggest swapping the annotation to CR 205.3m (+ 608.2c for the anaphor), so a future grep lands on the rule the code depends on.
Widened origin relative to its siblings (effects/mod.rs:2481). Every existing ChangeZone arm in this matcher pins origin: Some(Zone::Library); the new arm accepts origin: Some(_). I think that's correct — Choose { persist: true } is the load-bearing guard, and a persisting per-player choice followed by a zone move that consumes it is per-player by construction regardless of origin zone — but it is the broadest arm in the function, so it's worth being deliberate that this is intended to cover the class (graveyard, hand, exile returns alike) rather than an unintended widening.
Recommendation
Approve as bug once the Rust gates settle green and the branch is rebased (currently BEHIND, all four Rust checks still pending). The two annotation/simplification notes above are non-blocking and can ride along or land separately — neither changes behavior.
Parse changes introduced by this PR · 1 card(s), 2 signature(s) (baseline: main
|
Collapse redundant player_scope clear (co-scoped path already clears earlier) and fix CR annotation to 205.3m for creature subtype axis. Co-authored-by: Cursor <cursoragent@cursor.com>
9076ed8 to
5ecd259
Compare
matthewevans
left a comment
There was a problem hiding this comment.
Approving — the two nits are cleared and all Rust gates are green on this head.
✅ Clean
- Chosen-attribute axis (
oracle_target.rs:2984) — keying onctx.pending_choice_typeresolves "cards of that type" against the chosen creature type rather than the card-typed head noun, reusing the pre-existingIsChosenCreatureType. CR 205.3m (creature types are subtypes) is now the correct citation. up_tothreading —Effect::ChangeZone.up_towas a parsed-but-not-consumed field hardcodedfalseat the lowering site; "returns any number of cards" now honors CR 107.1c.- Player-scope chain split (
effects/mod.rs:2478) — new arm on the existingis_player_scope_local_continuation, keyed on effect shape (Choose{persist} → ChangeZone{origin}) rather than on a card. CR 101.4 (APNAP) verified. - Redundant disjunct removal (
effects/mod.rs:2661) — verified safe:next_is_co_scoped_anaphoric_consumeralready clearsnext.player_scopeupstream, and the outer retention disjunction still reads the flag, so retention behavior is preserved. mod issue_5279_grave_sifteris registered intests/integration/main.rsand drives the real runtime path end-to-end — a parse-only assertion would not have caught the axis mismatch.
Approved and enqueuing as bug.
Closes #5279
Summary
Discord report title "Grace Sifter" maps to Grave Sifter (C14). Oracle: each player chooses a creature type, then returns any number of matching cards from their graveyard to their hand.
Three bugs blocked the ETB:
CreatureTypechoice,"cards of that type"parsed asIsChosenCardType(card-typed base). Runtime stores the choice onChosenAttribute::CreatureType, so no graveyard cards matched and the return step was skipped."returns any number of"did not setup_to: trueon the graveyardChangeZone.player_scope: Allon bothChooseandChangeZone. Keeping the return inside the scoped template without clearing the child's redundantplayer_scopere-entered the fan-out driver mid-instruction, skipping P0's graveyard return and jumping to P1's creature-type choice.Files changed
crates/engine/src/parser/oracle_target.rs—IsChosenCreatureTypewhen pending choice isCreatureTypecrates/engine/src/parser/oracle_effect/imperative.rs— strip"any number of ", threadup_tocrates/engine/src/parser/oracle_ir/ast.rs—up_toonReturnToZonecrates/engine/src/parser/oracle_effect/mod.rs—ReturnToZonelowering sitescrates/engine/src/game/effects/mod.rs—is_player_scope_local_continuation+ clear redundant childplayer_scopecrates/engine/tests/integration/issue_5279_grave_sifter.rs— regression testscrates/engine/tests/integration/main.rs— mod registrationTest plan
grave_sifter_etb_parses_choose_creature_type_per_player—Choose(CreatureType)+player_scope: All, subChangeZonewithIsChosenCreatureType,origin: Graveyard,up_to: truegrave_sifter_etb_prompts_creature_type_choice— APNAPNamedChoicewith Bear in optionsgrave_sifter_each_player_creature_type_return_from_graveyard— P0 chooses Bear →EffectZoneChoicefor Grizzly Bears → P1 chooses Giant → bear returns to handsplit_player_scope_keeps_reveal_anaphora_chain_but_detaches_aggregate— Choose → ChangeZone stays inside scoped template; childplayer_scopeclearedVerification
cargo fmt --allcargo test -p engine --lib split_player_scope_keeps_revealcargo test -p engine issue_5279 -- --nocapturetilt logs clippy/tilt logs test-engine(when Tilt is up)