Skip to content

Fix Wheel and Deal#6571

Merged
matthewevans merged 2 commits into
phase-rs:mainfrom
invalidCards:card/wheel-and-deal
Jul 24, 2026
Merged

Fix Wheel and Deal#6571
matthewevans merged 2 commits into
phase-rs:mainfrom
invalidCards:card/wheel-and-deal

Conversation

@invalidCards

@invalidCards invalidCards commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes Wheel and Deal so its selected opponents discard their hands and draw seven cards, while the controller performs the final draw exactly once. Removes the card from the parser misparse backlog.

Files changed

  • Parse bare continuations under multi-target opponent subjects as the selected opponent.
  • Fan out opponent-targeted effects while preserving sequential controller-only tails.
  • Extend the scenario cast driver to retain distinct declared multi-target players.
  • Add parser, runtime, zero-target, and pause/resume regressions.

CR references

  • CR 601.2c — target declaration.
  • CR 608.2c — resolving instructions and continuations.
  • CR 701.9 — discard.
  • CR 120 — draw.

Implementation method (required)

Method: /engine-implementer

Track

Developer

LLM

Model: GPT-5 Codex
Thinking: high
Tier: Frontier

Verification

  • cargo fmt --all
  • git diff --check
  • Focused parser, integration, and pause/resume runtime regressions
  • ./scripts/check-parser-combinators.sh — PASS
  • Fresh post-commit implementation review — PASS

Gate A

Gate A PASS head=483bd981a65b3555950b3c210afe7977a11609b9 base=21a53d50cbcb8c21e6d250ae2c1f914fd6b0de15

Final review-impl

PASS: fresh review of 21a53d50c..483bd981a65b3555950b3c210afe7977a11609b9 found no remaining issues.

Claimed parse impact

  • Wheel and Deal

Validation failures

  • cargo clippy-strict could not complete because this environment lacks the system OpenSSL development package required by openssl-sys; engine-focused clippy was additionally blocked behind the shared Cargo target-directory lock.
  • Card-data generation, coverage, and semantic-audit were not run because that same shared Cargo lock did not clear during this session.

CI failures

None observed locally.

Summary by CodeRabbit

  • Bug Fixes

    • Corrected multi-target effects so each selected opponent or player resolves their instructions properly before follow-up effects occur.
    • Ensured final controller instructions resolve exactly once, including when no targets are selected.
    • Fixed continuation text so card draws correctly apply to previously selected targets.
  • Tests

    • Added regression coverage for multi-target resolution, paused choices, and “Wheel and Deal” behavior.
  • Documentation

    • Removed “Wheel and Deal” from the parser misparse backlog.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This change binds implicit continuations after multi-target player clauses to selected targets, consumes declared players correctly during multi-target casts, and separates per-target resolution from detached sequential tails. Parser, unit, and integration regressions cover opponent fan-out, pauses, explicit controller subjects, and empty selections.

Changes

Multi-target continuation handling

Layer / File(s) Summary
Oracle continuation target binding
crates/engine/src/parser/oracle_effect/assembly.rs, crates/engine/src/parser/oracle_effect/mod.rs
Recognizes multi-target player subjects and rewrites implicit continuation draws to target the selected parent players.
Multi-target cast target consumption
crates/engine/src/game/scenario.rs
Consumes each declared player once during multi-target casts while preserving reusable targeting for other callers.
Fan-out and sequential tail resolution
crates/engine/src/game/effects/mod.rs
Splits per-target instructions from sequential tails, narrows each target-specific ability, and resolves the detached tail only at the correct waiting-state boundary.
Regression coverage
crates/engine/src/parser/oracle_effect/tests.rs, crates/engine/tests/integration/*, crates/engine/src/game/effects/mod.rs, docs/parser-misparse-backlog.md
Adds parser, unit, and integration coverage for multi-opponent resolution, pauses, explicit controller subjects, and empty selections; removes the resolved backlog entry.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SpellCast
  participant TargetSelection
  participant MultiTargetResolver
  participant PlayerChoice
  participant SequentialTail
  SpellCast->>TargetSelection: Consume declared multi-target players
  TargetSelection->>MultiTargetResolver: Provide selected opponents
  MultiTargetResolver->>PlayerChoice: Resolve each opponent instruction
  PlayerChoice-->>MultiTargetResolver: Resume after choices
  MultiTargetResolver->>SequentialTail: Resolve detached tail once
Loading

Possibly related PRs

  • phase-rs/phase#6533: Both changes modify sequential sibling continuation handling and oracle effect assembly.

Suggested labels: bug

Suggested reviewers: matthewevans, andriypolanski

🚥 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 matches the main change by describing the Wheel and Deal rules fix.
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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/engine/src/game/scenario.rs (1)

2894-2906: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

AbilityActivation and resolution-time TargetSelection still reuse multi-target player declarations.

Both sites drive the identical WaitingFor::TargetSelection shape that SpellCast::try_commit now feeds remaining_multi_target_players into, but they hard-code None, falling back to the old reusable-declared_players behavior. A future test scenario driving an activated ability (or a resolution-time target selection) with multi_target: Some(...) and two distinct declared players would silently reuse the first declared player for every slot instead of consuming them in order — the same bug class this PR fixes for cast-time SpellCast.

Also applies to: 3141-3159

🤖 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/scenario.rs` around lines 2894 - 2906, Update the
`WaitingFor::TargetSelection` branches for both `AbilityActivation` and
resolution-time target selection to maintain and pass a mutable
`remaining_multi_target_players` collection into `pick_slot_target`, matching
the `SpellCast::try_commit` behavior. Replace the hard-coded `None` while
preserving single-target behavior and consuming declared multi-target players in
order across slots.
🧹 Nitpick comments (1)
crates/engine/src/game/effects/mod.rs (1)

3583-3599: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shared root cause: multi-target-player-subject classification is duplicated between parser and runtime. Both sites independently re-implement the same TargetFilter shape test that decides whether a head is an "any number of target players/opponents" subject; keeping them as one predicate removes the risk of the parser's continuation rewrite and the runtime's fan-out gate silently drifting apart when a new Oracle lowering is added.

  • crates/engine/src/game/effects/mod.rs#L3583-L3599: extract the Player | Opponent | Typed{opponent-only} match into a shared helper on TargetFilter (e.g. in crate::types::ability) that both this file and the parser can call.
  • crates/engine/src/parser/oracle_effect/assembly.rs#L82-L98: replace is_multi_target_player_subject_definition's inline match with a call to the same shared helper.
🤖 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 3583 - 3599, The
multi-target player classification is duplicated between runtime and parser
code. Extract the `TargetFilter::Player | Opponent | Typed` opponent-only shape
check from `is_multi_target_player_filter` into a shared `TargetFilter` helper,
then update `crates/engine/src/game/effects/mod.rs` lines 3583-3599 and
`crates/engine/src/parser/oracle_effect/assembly.rs` lines 82-98 so both
`is_multi_target_player_filter` and `is_multi_target_player_subject_definition`
call it.
🤖 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/parser/oracle_effect/tests.rs`:
- Around line 11691-11710: Update
multi_target_player_subject_does_not_capture_explicit_controller_draw to first
assert def.multi_target is present and verify the multi-target head targets
players, then retain the existing controller-only draw assertion for the
continuation. Use the parsed head symbols already exposed by the definition
rather than relying solely on sub_ability.

In `@crates/engine/tests/integration/wheel_and_deal.rs`:
- Around line 114-115: Update the comment above this test to cite CR 115.6 as
the rule permitting a targeted spell to have zero targets, while retaining CR
608.2c for the independent trailing draw instruction; remove the incorrect CR
601.2c reference.
- Around line 85-112: Strengthen the integration test around the multi-player
resolution flow after the existing hand assertions so it verifies ordering, not
just final graveyard sizes: assert that both P1 and p2 discard their hands
before either player receives the seven-card draw. Use the production pipeline’s
observable state or event sequence, while preserving the existing terminal-zone
assertions.

---

Outside diff comments:
In `@crates/engine/src/game/scenario.rs`:
- Around line 2894-2906: Update the `WaitingFor::TargetSelection` branches for
both `AbilityActivation` and resolution-time target selection to maintain and
pass a mutable `remaining_multi_target_players` collection into
`pick_slot_target`, matching the `SpellCast::try_commit` behavior. Replace the
hard-coded `None` while preserving single-target behavior and consuming declared
multi-target players in order across slots.

---

Nitpick comments:
In `@crates/engine/src/game/effects/mod.rs`:
- Around line 3583-3599: The multi-target player classification is duplicated
between runtime and parser code. Extract the `TargetFilter::Player | Opponent |
Typed` opponent-only shape check from `is_multi_target_player_filter` into a
shared `TargetFilter` helper, then update
`crates/engine/src/game/effects/mod.rs` lines 3583-3599 and
`crates/engine/src/parser/oracle_effect/assembly.rs` lines 82-98 so both
`is_multi_target_player_filter` and `is_multi_target_player_subject_definition`
call it.
🪄 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: 6334928f-8a77-4242-a20c-dab37ddd6871

📥 Commits

Reviewing files that changed from the base of the PR and between 21a53d5 and 483bd98.

📒 Files selected for processing (8)
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/scenario.rs
  • crates/engine/src/parser/oracle_effect/assembly.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/wheel_and_deal.rs
  • docs/parser-misparse-backlog.md
💤 Files with no reviewable changes (1)
  • docs/parser-misparse-backlog.md

Comment on lines +11691 to +11710
/// CR 608.2c: the lexical conjugation is load-bearing. A fresh imperative
/// subject (`you`) after the same multi-target head must remain controller-only.
#[test]
fn multi_target_player_subject_does_not_capture_explicit_controller_draw() {
let def = parse_effect_chain(
"Any number of target opponents each discard their hands, then you draw a card.",
AbilityKind::Spell,
);
let controller_draw = def
.sub_ability
.as_ref()
.expect("the explicit controller draw must remain a continuation");
assert!(matches!(
&*controller_draw.effect,
Effect::Draw {
count: QuantityExpr::Fixed { value: 1 },
target: TargetFilter::Controller,
}
));
}

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

Prove the multi-target parser path was reached.

This negative assertion passes if the multi-target head fails to parse but an unrelated continuation still yields you draw. Assert def.multi_target and that the head targets players before asserting the controller-only tail.

🤖 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/tests.rs` around lines 11691 - 11710,
Update multi_target_player_subject_does_not_capture_explicit_controller_draw to
first assert def.multi_target is present and verify the multi-target head
targets players, then retain the existing controller-only draw assertion for the
continuation. Use the parsed head symbols already exposed by the definition
rather than relying solely on sub_ability.

Source: Path instructions

Comment on lines +85 to +112
outcome.assert_hand_drawn(P0, 1);
outcome.assert_hand_drawn(P1, 5);
outcome.assert_hand_drawn(p2, 5);
assert_eq!(
outcome
.state()
.players
.iter()
.find(|player| player.id == P1)
.expect("P1 exists")
.graveyard
.len(),
2,
"P1's whole hand must be discarded before drawing seven"
);
assert_eq!(
outcome
.state()
.players
.iter()
.find(|player| player.id == p2)
.expect("P2 exists")
.graveyard
.len(),
2,
"P2's whole hand must be discarded before drawing seven"
);
}

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

Cover simultaneous multi-player actions.

These terminal-zone assertions also pass if resolution processes P1’s discard/draw before p2’s. CR 608.2e requires each multi-player clause to be processed simultaneously, so add a production-pipeline assertion that proves all discards occur before any seven-card draw. (media.wizards.com)

🤖 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/wheel_and_deal.rs` around lines 85 - 112,
Strengthen the integration test around the multi-player resolution flow after
the existing hand assertions so it verifies ordering, not just final graveyard
sizes: assert that both P1 and p2 discard their hands before either player
receives the seven-card draw. Use the production pipeline’s observable state or
event sequence, while preserving the existing terminal-zone assertions.

Source: Path instructions

Comment on lines +114 to +115
/// CR 601.2c + CR 608.2c: "Any number" permits no targets. That skips the
/// opponent-qualified instruction while preserving the independent final draw.

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

Use the CR rule that permits zero targets.

CR 115.6—not CR 601.2c—establishes that a targeted spell may allow zero targets. Keep CR 608.2c for the independent trailing instruction, but cite CR 115.6 for this claim. (media.wizards.com)

🤖 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/wheel_and_deal.rs` around lines 114 - 115,
Update the comment above this test to cite CR 115.6 as the rule permitting a
targeted spell to have zero targets, while retaining CR 608.2c for the
independent trailing draw instruction; remove the incorrect CR 601.2c reference.

Source: Path instructions

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 23, 2026
@matthewevans

Copy link
Copy Markdown
Member

This is a well-built fix — the split between the per-target chain and the controller-only tail is the right decomposition, and the tests discriminate on exactly the property that matters. Nothing blocking. Three items, one of which is a class-level question I'd like answered before it lands.

Reviewed at head 483bd981.

🟡 Non-blocking

1. The fan-out iterates target-declaration order, not APNAP — and that matters for the class, not for this card. resolve_multi_target_player_chain walks ability.targets in the order they were announced. For Wheel and Deal this is unobservable: "discard their hands" takes the whole hand and "draw seven cards" involves no choice, so no ordering is detectable. But the abstraction you've built is "any number of target players/opponents each <verb>", and the moment that verb involves a choice — "each discards a card", "each sacrifices a creature" — CR 101.4 (docs/MagicCompRules.txt:235) requires the active player to choose first, then each nonactive player in turn order.

This is not hypothetical: #6545 is fixing precisely this bug in the per-owner library-order fan-out, and landed on crate::game::players::apnap_order(state) as the engine's authority for it. Since your fan-out is the more general primitive of the two, it would be good for it to reach for the same authority rather than acquire the same defect independently. If you have a reason declaration order is correct here that I'm missing, say so and I'll drop it — but as written the code doesn't explain the choice, and the natural reading is that APNAP simply wasn't considered.

2. CR 120 — draw in the PR body is the wrong rule. CR 120 is Damage (:1085); drawing a card is CR 121 (:35). Your in-code annotations are all correct and all verified — CR 601.2c (:2461), CR 608.2c (:2793), CR 603.3d (:2590), CR 701.9 Discard (:3327) — so this is confined to the PR description's reference list and does not affect the code. Worth correcting anyway, since PR bodies get read as provenance for why a change is right.

3. Heads-up on #6572, which touches the same class. #6572 adds a post-lowering pass attaching TargetSelectionConstraint::DifferentTargetPlayers to every non-modal ability whose multi_target targets Player/Opponent. Wheel and Deal matches that gate, so once both land, its opponent selection will additionally be constrained to distinct players. That is correct under CR 601.2c — "any number of target opponents" is one instance of the word "target", so the same opponent can't fill two slots — and the two changes compose rather than conflict (one governs selection, the other resolution). No action needed; flagging it so neither of you is surprised by the other's behavior change, and so whichever lands second doesn't read the other's effect as a regression.

✅ Clean

  • The decomposition is the right one. split_multi_target_player_chain / detach_after_multi_target_player_local_chain separate "what each selected player does" from "what resolves once afterward", which is the actual structure of CR 608.2c's instruction ordering rather than a Wheel-and-Deal-shaped special case. The recursive detach handles a tail of arbitrary length instead of assuming exactly one trailing instruction.
  • is_multi_target_player_filter covers the class, not the card. It accepts TargetFilter::Opponent, a type-less TypedFilter constrained solely by ControllerRef::Opponent, and TargetFilter::Player — three lowerings that all mean "players chosen at announcement". Enumerating them in one predicate with a comment explaining why all three exist is much better than matching whichever one Wheel and Deal happens to produce.
  • The controller-draw test is a real discriminator. assert_hand_drawn(P0, 1) fails the moment the trailing "Draw a card." is swept into the fan-out, because the controller would draw once per selected opponent. Stacking eight named cards on the controller's library ("Controller draw 1""Controller draw 8") means an over-draw is caught by identity, not just by count. The per-opponent assertions pair a draw count with a graveyard length of 2, so "discarded the hand" and "drew seven" are checked independently rather than collapsing into one net number.
  • The zero-target case is covered and is the right edge. "Any number" permits zero, and wheel_and_deal_with_no_targets_draws_only_for_its_controller asserts the opponent-qualified instruction is skipped while the independent final draw still happens — that's the specific way this class breaks if the tail is treated as part of the fan-out rather than beside it.
  • The cast pipeline is checked, not assumed. Asserting ability.targets == [Player(P1), Player(p2)] on the stack before resolving proves the scenario driver change actually retains both declared players, so a regression there fails loudly instead of silently degrading to one target.
  • Backlog hygiene. Removing the entry from docs/parser-misparse-backlog.md in the same change is correct — that file tracks live misparses, and leaving a fixed card listed makes the backlog lie about the parser's real state.
  • Proof package is complete, which is rarer than it should be: Files changed, Track, LLM, Verification, Implementation method, a Gate A PASS with both head and base pinned, and a fresh post-commit review-impl. ./scripts/check-parser-combinators.sh PASS is the one I most wanted to see given assembly.rs is in the diff.
  • Tauri compile check red is ours, not yours — the v0.35.2 release left client/src-tauri/Cargo.lock stale against --locked. Fixed on main in fix(ci): sync client/src-tauri/Cargo.lock with the v0.35.2 version bump #6568, permanently prevented in fix(release): keep client/src-tauri/Cargo.lock in sync on version bumps #6574; it will clear on rebase. It is the only failing job here.

Recommendation: answer the APNAP question in #1 and fix the CR 121 reference; I'm happy to approve on either "here's why declaration order is right" or a switch to players::apnap_order. The rest of this is ready — the chain split and the test design are both better than the card required, which is the point.

@matthewevans

Copy link
Copy Markdown
Member

Addendum to my review above — a second pass found one blocker I missed, and corrected one of my own CR references.

🔴 Blocker — vacuous negative test

crates/engine/src/parser/oracle_effect/tests.rsmulti_target_player_subject_does_not_capture_explicit_controller_draw asserts only that the sub-ability's effect is Draw { count: 1, target: Controller }.

That passes for the wrong reason. If the "Any number of target opponents each" head failed to parse as a multi-target player subject at all, "then you draw a card" still lowers to a Controller draw and the assertion holds — so the test cannot distinguish "the guard correctly declined to capture the controller draw" from "the feature never engaged." It's the only one of your five tests without a reach guard; its sibling wheel_and_deal_implicit_draw_inherits_each_targeted_opponent has two before its matches!.

Add the same guard:

assert!(def.multi_target.is_some());
assert!(def.effect.target_filter().is_some_and(target_filter_can_target_player));

@coderabbitai raised this as 🟠 Major and it's unaddressed — that one was right and I should have confirmed it in my first pass rather than leaving it open.

Correcting my own CR reference

In my comment above I cited CR 101.4 for the ordering concern. The sharper rule is CR 608.2e (docs/MagicCompRules.txt:2798): choices for the first action are made in APNAP order, then that action is processed simultaneously, then the second action. effects/mod.rs:7666-7675 resolves each opponent's full local chain before starting the next, so with two targets P1 draws seven before P2 discards.

Two things follow. This is pre-existing fan-out architecture, not a regression you introduced — your change improves granularity and doesn't make it worse, so I'm downgrading it from "please answer this" to a follow-up item. And separately, my zero-target citation was wrong:

CR 115.6 (:859) — "A spell or ability that requires targets may allow zero targets to be chosen" — is the rule for the zero-target permission at effects/mod.rs:7645 and tests/integration/wheel_and_deal.rs:114, where you currently cite CR 601.2c. CR 601.2c covers announcing a variable number of targets, which is a different claim. Keep CR 608.2c for the independent tail; it's correct.

Two more non-blocking

  • The Effect::Draw-only gate leaves a named sibling unfixed. parser/oracle_effect/assembly.rs:2091 fires the ParentTarget rewrite only for Effect::Draw. Turtle Tracks"Any number of target players may each search their library for a basic land card, put it onto the battlefield under their control, then shuffle" — is the same shape (multi-target player head + bare ContinuationStep) and is still listed in docs/parser-misparse-backlog.md, the file this PR edits. Either widen to any continuation whose target is Controller/Player/ParentTargetController, or say in the comment why Draw is the only currently-safe rewrite.
  • Three TargetFilter::Opponent arms appear to be dead. effects/mod.rs:3583 asserts that Oracle's target opponents lowering "can use the dedicated TargetFilter::Opponent player role", but its only non-test construction in the engine is ctx.target_chooser at parser/oracle_target.rs:3321. The parse-diff corroborates this: widening target_filter_can_target_player moved zero cards despite that predicate having 14 parser consumers. Either drop the arms at effects/mod.rs:3590, assembly.rs:89, parser/oracle_effect/mod.rs:19643, or point at the lowering site that produces them.
  • Duplicated predicate. is_multi_target_player_subject_definition (assembly.rs:84) and is_multi_target_player_filter (effects/mod.rs:3588) are byte-identical match bodies in two layers. One inherent method on TargetFilter — say selects_players_at_announcement() — would keep them from drifting.

Reinforcing what's good

The parse-diff is an exact match: 1 card, 1 signature, Wheel and Deal, ability/Draw target: ∅ → parent target, zero added/removed. Claim and measurement agree precisely, which is rare and worth saying.

And the sentence-boundary reasoning is stronger than I gave it credit for: SubAbilityLink has exactly two variants (types/ability.rs:16696-16711), so "keep ContinuationStep, detach the first non-ContinuationStep" is CR 608.2c's sentence boundary rather than an approximation of it. Draw single authority is respected end to end — Effect::Draw still routes through draw::resolvestart_draw_sequence_with_replacement_applied with replacement_applied threaded per CR 614.5 (:3073), and CR 121.2 (:1144) per-card sequencing is preserved. Four of your five tests fail on revert, two of them in two independent ways.


Revised recommendation: add the reach-guard to the negative test, fix the CR 115.6 citation, and answer the Turtle Tracks question. The APNAP/CR 608.2e item is a follow-up, not yours to fix here.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/engine/src/parser/oracle_effect/tests.rs \
  --items all --type function --match 'multi_target_player_subject_does_not_capture_explicit_controller_draw|wheel_and_deal_implicit_draw_inherits_each_targeted_opponent'

ast-grep outline crates/engine/src/parser/oracle_effect/assembly.rs \
  --items all --type function --match 'is_multi_target_player_subject_definition|assemble_effect_chain'

rg -n -C 12 \
  'multi_target_player_subject_does_not_capture_explicit_controller_draw|wheel_and_deal_implicit_draw_inherits_each_targeted_opponent|is_multi_target_player_subject_definition|Effect::Draw|Turtle Tracks|CR 601\.2c|CR 115\.6' \
  crates/engine/src/parser/oracle_effect/tests.rs \
  crates/engine/src/parser/oracle_effect/assembly.rs \
  crates/engine/src/game/effects/mod.rs \
  crates/engine/tests/integration/wheel_and_deal.rs \
  docs/parser-misparse-backlog.md

rg -n -C 5 \
  'fn is_multi_target_player_filter|TargetFilter::Opponent|is_multi_target_player_subject_definition' \
  crates/engine/src/game/effects/mod.rs \
  crates/engine/src/parser/oracle_effect/assembly.rs \
  crates/engine/src/parser/oracle_effect/mod.rs

Length of output: 201631


@matthewevans Confirmed on the PR branch: the negative test at Lines 11694-11710 lacks the reach guard, while its positive sibling has it. The test can therefore pass without establishing that the multi-target opponent head parsed successfully. The two assertions you proposed are required to make it discriminating.

Also confirmed that the rewrite in crates/engine/src/parser/oracle_effect/assembly.rs is explicitly Effect::Draw-only. That leaves the same bare-continuation shape in Turtle Tracks outside this fix, so this PR should either widen the supported recipient rewrite with targeted regression coverage or document why the narrower draw-only scope is intentional and safe.

Thank you for the CR corrections as well: CR 115.6 is the relevant zero-target permission, and the CR 608.2e/APNAP behavior is pre-existing fan-out architecture rather than a regression introduced here. The duplicated multi-target-player predicate is a worthwhile cleanup, but can remain non-blocking.

🐇🔎


✏️ Learnings added
Learnt from: matthewevans
URL: https://github.com/phase-rs/phase/pull/6571

Timestamp: 2026-07-23T23:23:53.494Z
Learning: In the Rust engine’s multi-target player/opponent resolution, CR 608.2e requires choices for each action in APNAP order and simultaneous processing of that action. The current per-target local-chain fan-out resolves one player’s complete chain before the next player, which is pre-existing architecture and should be treated as follow-up work rather than attributed to the Wheel and Deal fix.

Learnt from: matthewevans
URL: https://github.com/phase-rs/phase/pull/6571

Timestamp: 2026-07-23T23:23:53.494Z
Learning: For multi-target player-subject parser regressions in `crates/engine/src/parser/oracle_effect/tests.rs`, a negative continuation test must assert that the preceding definition has `multi_target.is_some()` and that its effect target filter can target a player. Otherwise an assertion that a following explicit-controller effect remains controller-relative can pass without exercising the multi-target feature.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

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

🟡 Modified fields (1 signature)

  • 1 card · 🔄 ability/Draw · changed field target: parent target
    • Affected (first 3): Wheel and Deal

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans

Copy link
Copy Markdown
Member

Approving. The seam is right, the fix generalizes further than the title suggests, and the tests are discriminating. Reviewed at head 1653d3346.

Verified against the card, not the description

Pulled the Oracle text from Scryfall rather than trusting the PR body:

Wheel and Deal — {3}{U} Instant
Any number of target opponents each discard their hands, then draw seven cards.
Draw a card.

It matches the fixture at tests/integration/wheel_and_deal.rs:9-10 byte-for-byte including the newline. The subtlety the fix turns on is real: "then draw seven cards" belongs to the targeted opponents (same sentence), while "Draw a card." is a separate sentence for the controller.

I pushed hard on "one card or a class?" — and the measurement answered it

A single-card title is the usual smell, so I checked both halves.

The runtime half is already class-shaped. is_multi_target_player_filter (effects/mod.rs:3588) widens the fan-out guard from == TargetFilter::Player to Player | Opponent | Typed{empty, controller: Opponent}. Against card-data.json that newly fans out Communal Brewing and Hollow Marauder, both of which previously resolved for only the first chosen opponent. That's an undisclosed multi-card bug fix — worth naming in the PR body.

The parser half looks card-shaped (if let Effect::Draw at assembly.rs:2091), so rather than assert, I measured the population: all 35,477 cards scanned for multi_target + player-ish head filter + a ContinuationStep sub whose target is a context-ref. Exactly one hit — Wheel and Deal. The obvious candidate siblings (Will of the Abzan, Tinybones Joins Up, Priest of Forgotten Gods, Turtle Tracks) all turn out not to need it: their continuations carry non-context-ref targets or None, which already inherit ability.targets at life.rs:517-522. So the narrowing leaves zero cards behind today. Not a special case.

Parse-diff sticky confirms it independently: 1 card, 1 signature, ability/Draw field target going ∅ → parent target, Wheel and Deal only. Claimed impact equals measured impact. CR annotations grep-verified — 601.2c, 608.2c, 603.3d, 101.4, 701.9 all real and all describing the code they sit on.

The integration test genuinely flips on revert: restoring Draw{Controller} gives P1 −2 / P0 +8 instead of +5/+1.

🟡 Non-blocking — worth a follow-up, not another round

The exactly-one-target path is production-reachable and has no fixture. effects/mod.rs:7639 gates the whole fan-out — and therefore split_multi_target_player_chain — behind chosen_players.len() != 1. Your two tests use 2 targets and 0 targets. Casting this at a single opponent is the common real-world case and takes a structurally different path. I traced it and it is correct (ParentTarget is non-context-ref, so mod.rs:5948-5954 returns the first TargetRef::Player; the trailing Controller draw is a context-ref and falls to the controller) — so this is a coverage gap rather than a bug. A third case target_players(&[P1]) asserting P1 +5 / P0 +1 would close it.

has_implicit_player_subject_continuation is fail-open. assembly.rs:103-129 treats any fragment not matching one of 12 hardcoded subject prefixes as an implicit continuation of the targeted-opponent subject. Unlisted real prefixes — "For ", "If ", "Put ", "Each of ", "Their " — would silently rebind a controller instruction to the opponents. Hollow Marauder is the near-miss: "For each of those opponents … draw a card" is a Draw under a Typed{Opponent} multi-target head, and the only things saving it are the sub_link == ContinuationStep gate and the Effect::Draw arm. Blast radius today is empirically zero, but the guard inverts from safe to dangerous the moment either narrowing is relaxed — which is exactly what the next card in this shape will need. Inverting it to an allowlist of bare-conjugated-verb continuation shapes would be the durable form.

[LOW] The three-way player-filter predicate is duplicated across the parser/runtime boundaryassembly.rs:84-97 and effects/mod.rs:3588-3600 encode the identical shape. They must agree for the ParentTarget rewrite and the fan-out to stay in lockstep, and adding a variant to one silently desynchronizes them. Hoisting one predicate onto TargetFilter (something like selects_players_at_announcement()) and calling it from both would make that structural.

[LOW] The PR body cites "CR 120 — draw". CR 120 is Damage; drawing is CR 121 (MagicCompRules.txt:1087 vs :1142). Body-only — every in-code annotation is correct — but worth fixing since we grep-verify these.

📋 Checked and clean

scenario.rs's pick_slot_target now consumes player declarations when multi_target.is_some() (:2578-2586) and degrades gracefully to the reusable branch once exhausted, so Kozilek's-Command-style modal reuse is preserved. Both new parser tests assert positive shapes, and multi_target_player_subject_does_not_capture_explicit_controller_draw is a real discriminating negative for the "you " prefix. Removing Wheel and Deal from the backlog is justified — it now both parses and resolves correctly.


Approving and enqueueing. The two MED items are follow-up material; I'd particularly like the fail-open guard inverted before anyone extends this pattern, but it shouldn't hold up a correct fix.

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

Approved — see the review comment above. Findings are non-blocking follow-ups.

@matthewevans matthewevans added the quality For high-quality minimal to no-churn PRs label Jul 24, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
Merged via the queue into phase-rs:main with commit ad0ded6 Jul 24, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) quality For high-quality minimal to no-churn PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants