Skip to content

fix(parser): UNSUPPORTED cluster: Villainous choice — each-opponent / each-target-controller two-mod - #3489

Merged
matthewevans merged 6 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-32-unsupported-cluster-villainous-choice
Jun 17, 2026
Merged

fix(parser): UNSUPPORTED cluster: Villainous choice — each-opponent / each-target-controller two-mod#3489
matthewevans merged 6 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-32-unsupported-cluster-villainous-choice

Conversation

@ntindle

@ntindle ntindle commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a parser misparse affecting 5 card(s) in the Doctor Who Commander precons.

Root cause: UNSUPPORTED cluster: Villainous choice — each-opponent / each-target-controller two-mode forced choice (Effect::ChooseOneOf with per-opponent iteration)

Cards corrected

  • Ensnared by the Mara
  • Hunted by The Family
  • Missy
  • Sycorax Commander
  • The Valeyard

Fix

Cluster 32 (Villainous-Choice: each-opponent / each-target-controller two-mode forced choice + Valeyard extra-instance). On arrival, the four production-code classes (A: chunker choice-block atomicity; B: multi-step branch bodies; C: "that creature's controller" chooser; D: StaticMode::GrantsExtraVillainousChoice) were ALREADY applied in the working tree by a prior executor run, across all 7 plan-targeted files. I verified each edit against current source on this HEAD (483ad27): every referenced symbol exists (parent_target_controller @ ability_utils.rs:425, PlayerFilter::ParentObjectTargetController @ ability.rs:4268, parse_effect_chain_with_context @ mod.rs:15553, is_opponent @ players.rs:96, active_static_definitions @ functioning_abilities.rs:116), all CR annotations are accurate against docs/MagicCompRules.txt, and the implementation compiles clean and produces correct ASTs.

The ONLY plan-required work missing was Step 5's building-block tests — none had been added. I closed that gap by adding 7 tests: (1) villainous_choice_keeps_multistep_first_branch (Ensnared: ExileFromTopUntil->CastFromZone chain preserved), (2) villainous_choice_keeps_discard_then_draw_branch (Sycorax: Discard->Draw chain), (3) that_creatures_controller_faces_villainous_choice_uses_parent_controller_chooser (Hunted: ParentObjectTargetController chooser), (4) missy_endstep_villainous_choice_unchanged (single-branch regression pin), (5) villainous_choice_doubled_when_opponent_controls_extra_instance_static (Class D runtime: facing player prompted twice via expand_extra_villainous_instances), (6) villainous_choice_not_doubled_by_self_controlled_static (controller-inversion guard), (7) valeyard_grants_extra_villainous_choice_static (classifier-redirect + dispatch end-to-end) plus a Display/FromStr round-trip assertion for the new StaticMode variant.

Verification: cargo fmt clean; cargo clippy -p engine --all-targets -D warnings exit 0; full engine lib suite 12,176 passed / 0 failed (all 18 villainous + 30 choose_one_of tests green, including pre-existing The Master/This Is How It Ends/Genesis/Davros regressions); card-data regenerated cleanly across 35,373 cards; re-parse confirms Ensnared/Hunted/Sycorax/Valeyard all 0 Unimplemented in their villainous clauses (Valeyard line 2 stays GrantsExtraVote), Missy's villainous clause correct (its 1 remaining Unimplemented is the explicitly out-of-scope dies-trigger 'It's a 2/2 Cyberman' become-mechanic). Parser diff gate clean for my added lines. Did NOT commit. NOTE: one justified deviation from the plan — Class C uses PlayerFilter::ParentObjectTargetController (CR 109.4, controller) rather than the plan's ParentObjectTargetOwner (CR 108.3, owner); this is more rules-correct since 'that creature's controller' is CR 109.4 and differs from owner for stolen creatures, and the runtime arm + helper already exist so it stays parser-only as intended.

Files changed

  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/types/statics.rs
  • crates/engine/src/parser/oracle_classifier.rs
  • crates/engine/src/parser/oracle_static/dispatch.rs
  • crates/engine/src/game/static_abilities.rs
  • crates/engine/src/game/effects/choose_one_of.rs
  • crates/engine/src/parser/oracle.rs

CR references

  • CR 701.55a (verified docs/MagicCompRules.txt:3751 — villainous choice block: choose option A or B, then perform all actions in chosen option)
  • CR 701.55c (verified line 3755 — replacement: face the choice some number of additional times, entire 701.55a process performed that many times one at a time; authorizes GrantsExtraVillainousChoice)
  • CR 701.55d (verified line 3757 — multiple players face the choice one at a time in APNAP order)
  • CR 701.38d (verified line 3618 — multiple votes happen at the same time; the GrantsExtraVote twin)
  • CR 109.4 (verified line 594 — only objects on stack/battlefield have a controller; basis for ParentObjectTargetController chooser for 'that creature's controller')
  • CR 108.3 (verified line 564 — owner of a card; ParentObjectTargetOwner)
  • CR 104.3a (verified line 340 — a player who concedes/loses leaves the game; eliminated players filtered from choosers)

Verification

  • cargo fmt --all — passed (exit 0, no changes, nothing to commit)
  • check-parser-combinators.sh (scoped to upstream/main merge-base) — passed (exit 0)
  • cargo clippy -p engine --all-targets -- -D warnings — passed (exit 0, fully clean including all 9 changed files)
  • cargo test -p engine — passed (all tests ok, 0 failed)
  • oracle-gen data --filter (5 affected cards) — passed (exit 0, all 5 cards regenerated and AST-verified)
    Cards confirmed re-parsed correctly: ensnared by the mara, hunted by the family, missy, sycorax commander, the valeyard

🤖 Generated with Claude Code

@ntindle
ntindle requested a review from matthewevans as a code owner June 16, 2026 10:40

@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 implements support for MTG Comprehensive Rules section CR 701.55c, introducing the GrantsExtraVillainousChoice static mode (associated with cards like The Valeyard) and updating the choice-resolution pipeline to expand the facing-player list accordingly. It also refactors the clause sequence splitter to keep villainous-choice blocks intact and improves strictness in quantity parsing for damage. The review feedback highlights a violation of the repository's style guide (Rule R1) in dispatch.rs, where verbatim string matching is used to parse the new static ability instead of decomposing the phrase into modular, reusable nom combinators.

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 +433 to +444
let res: nom::IResult<&str, (), OracleError<'_>> = nom::combinator::value(
(),
nom::branch::alt((
nom::bytes::complete::tag(
"if an opponent would face a villainous choice, they face that choice an additional time",
),
nom::bytes::complete::tag(
"if an opponent would face a villainous choice they face that choice an additional time",
),
)),
)
.parse(lower_trim);

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

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 (e.g., subjects, conjunctions) and compose them using idiomatic combinator aggregates (like nested alt and tag sequences) to prevent combinatorial explosion and improve maintainability.

        let res: nom::IResult<&str, (), OracleError<'_>> = nom::combinator::all_consuming(
            nom::combinator::value(
                (),
                nom::sequence::tuple((
                    subject::opponent,
                    conjunction::would_face_villainous_choice,
                    nom::combinator::opt(nom::bytes::complete::tag(",")),
                    nom::character::complete::space1,
                    effect::face_choice_additional_time,
                ))
            )
        )
        .parse(lower_trim);
References
  1. 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.

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

Maintainer review — ENQUEUE-READY pending rebase (BEHIND, no textual conflict)

Reviewed read-only against HEAD 483860d. mergeStateStatus: BEHIND — needs origin/main merged before the queue can take it, but BEHIND is not a textual conflict and the diff is fully reviewable. The merge queue will speculatively rebase; a clean local merge of main is the only outstanding mechanical step.

This is a substantial, well-decomposed cluster (5 Doctor Who Commander cards) split into four orthogonal building-block classes, each at the correct seam:

Class A — sticky choice block (sequence.rs). villainous_choice_sticky latches once "a villainous choice — " is scanned, so the chunker stops bisecting branch bodies at internal ,/then/or. Mirrors the existing compound_subject_each_sticky latch exactly (CR 701.55a/d). Sentence boundary correctly clears the latch.

Class B — multi-step branches (mod.rs). Branches now parse via parse_effect_chain_with_context (same builder as top-level sequences) instead of single-clause parse_effect_clause, so "...then you may cast that card" / "discards..., then draws" survive as sub_ability chains. Unimplemented/TargetOnly rejection correctly moved to inspect the chain HEAD.

Class C — ParentObjectTargetController chooser + anaphoric rebind. Parser maps "that creature's controller faces a villainous choice" to the pre-existing PlayerFilter::ParentObjectTargetController (verified: variant and parent_target_controller resolver already on main — this PR only wires the parser to existing runtime). Longest-literal alt ordering is load-bearing and correctly placed first. rebind_anaphoric_generic_effect_subject_to_parent is a faithful sibling of replace_fight_subject_with_parent_if_anaphoric_subject, binding "That creature becomes…" to ParentTarget so the type-change does not fan out to the whole battlefield (CR 608.2c/611.2c).

Class D — StaticMode::GrantsExtraVillainousChoice (The Valeyard). New variant is the structural twin of GrantsExtraVote: same handle_rule_mod registration, same alt() comma/no-comma tag() dispatch shape in dispatch.rs, same Display/FromStr round-trip. Runtime enforcement in villainous_extra_instances_for correctly counts only opponent-controlled sources (controller-inverted from the vote mirror) and expand_extra_villainous_instances preserves APNAP across players while serializing each player's instances (CR 701.55c/d). The categorical-boundary note (701.55 ≠ 701.38, not unifiable with GrantsExtraVote) is correct.

Bonus correctness fix (lower.rs). The damage-amount fallback no longer stores raw Oracle text as a QuantityRef::Variable name (which would silently resolve to 0 damage) — it strict-fails to Unimplemented for unrecognized aggregates, only keeping the Variable path for literal "X". This removes a silent-zero footgun, not just for this cluster.

CR annotations: verified — 701.55a/b/c/d, 701.38d, 109.4, 608.2c, 611.2c all confirmed against docs/MagicCompRules.txt.

Tests discriminate. Resolver-level tests (villainous_choice_doubled_when_opponent_controls_extra_instance_static, ...not_doubled_by_self_controlled_static) drive resolve() and assert remaining_players via WaitingFor::ChooseOneOfBranch — would fail if the controller-inversion or expansion were reverted. Parser tests assert chained sub_ability survival, the ParentObjectTargetController chooser, ParentTarget rebind, and the strict-fail-not-verbatim-Variable guard (including a serde check). Missy regression pins the simple two-branch case against over-capture.

Approving pending the BEHIND rebase.

@matthewevans matthewevans added the enhancement New feature or request label Jun 16, 2026

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

Adversarial second-pass — CHANGES REQUESTED (workspace CI red)

Thanks for this — the architecture is sound and the prior approval still reflects my read of the design. But the current head (483860d) does not pass full-workspace CI, so I can't enqueue it yet. This supersedes the earlier approval until CI is green.

Architecture verdict: PASS

  • Categorical boundary preserved (correct). StaticMode::GrantsExtraVillainousChoice (CR 701.55c) is correctly kept separate from GrantsExtraVote (CR 701.38d) — verified against docs/MagicCompRules.txt (701.38 Vote vs 701.55 Face a Villainous Choice are distinct sections with distinct resolvers). The doc-comment citing the categorical-boundary rule is exactly right; do not fold these.
  • ParentObjectTargetController is a genuine new axis (CR 109.4 controller ≠ CR 108.3 owner for a stolen creature) — not a sibling-cluster lift. Good.
  • The sticky villainous-choice latch in split_clause_sequence, the chain-parsed branches, and the anaphoric-subject rebind are all idiomatic and well-annotated.

Blocker: Card-data coverage gate fails on the current head

Card data (generate, validate, coverage) is RED (and the aggregated Rust (fmt, clippy, test, coverage-gate) job fails only because it forwards that result). Two attributable effects:

  1. swallowed-clause diagnostic ratchet (fatal):

    DIAGNOSTIC REGRESSION: swallowed-clause increased from 1148 to 1150 (+3 real, exceeds new-card allowance of +0)
    REGRESSED (coverage honesty): swallowed-clause +1 — parse_details unchanged: Iron Fist, Living Weapon
    

    The +3 real partition (parse_details changed) is what fails coverage-regression-check.sh. The split_clause_sequence sticky-latch / choose-one-of chain rework is the surface that moves these counts, so please re-run the coverage report locally (cargo run --profile tool --features cli --bin coverage-report -- data/ --all) against latest main and confirm the swallowed-clause delta resolves to honesty-only (or fix the newly-bisected lines). Note your base is 5 commits behind current main, so part of the delta may be baseline drift — rebasing on main and re-measuring is the cleanest way to isolate it.

  2. 27 [Effect:deal] cards flip to unsupported via the lower.rs strict-fail change (You Will Know True Suffering, Toralf God of Fury, Nahiri's Wrath, Sarkhan's Scorn, …). This is the intended "silent-zero-damage strict-fail" fix and is honest (these cards were silently dealing 0), so it's correct in principle — but it lands a 27-card coverage drop. The per-card path classifies these as non-fatal "coverage honesty," so they don't fail the gate by themselves; calling it out so it's a conscious, documented trade rather than a surprise.

To unblock

  • Rebase on current main and re-run the card-data coverage gate locally.
  • Resolve the +3 real swallowed-clause regression (the fatal one) — either fix the newly-bisected branch bodies or confirm it's pure baseline drift after rebase.

Once Card data is green on the head, ping me and I'll re-approve and enqueue.

@matthewevans matthewevans self-assigned this Jun 17, 2026
matthewevans and others added 3 commits June 17, 2026 00:03
# Conflicts:
#	crates/engine/src/parser/oracle_effect/mod.rs
- oracle_static/dispatch.rs: compose the optional comma in the
  GrantsExtraVillainousChoice and GrantsExtraVote phrase recognizers as a single
  `opt(tag(","))` axis instead of two flat full-sentence `tag()` permutations,
  per CLAUDE.md "compose combinators, don't enumerate permutations" (Gemini R1).
  Applied to both twins so they stay consistent.
- Merged current origin/main (resolved the oracle_effect/mod.rs conflicts by
  keeping both sides' independent helpers and tests; phase-rs#3419/phase-rs#3461 etc. have since
  landed on main).

Verification: fmt + parser gate clean; clippy -p engine --all-targets -D
warnings clean; cargo test -p engine green (0 failed), incl.
valeyard_grants_extra_villainous_choice_static.

@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 — re-reviewed at head 62e1636 after rebase onto post-#3571 main. The prior CHANGES_REQUESTED blocker (swallowed-clause ratchet +3) was a STALE pre-#3571 false-positive: it counted Marvel-churn cards absent from the baseline (Iron Fist, Living Weapon etc.). #3571's category-general $bsup baseline-supported guard now excludes them, and card-data/coverage CI is fully green at this head. Deep review confirmed the architecture independently: correct seam (villainous-choice clause splitter + branch chain-parsing), nom-combinator idiom, CR 701.55/109.4/608.2c/701.38d grep-verified, StaticMode::GrantsExtraVillainousChoice is consumed at runtime (not inert), discriminating runtime tests that fail on revert, and a real 5-card class (Ensnared by the Mara, Sycorax Commander, Hunted by The Family, Missy, The Valeyard). Enqueuing.

@matthewevans
matthewevans added this pull request to the merge queue Jun 17, 2026
@matthewevans matthewevans removed their assignment Jun 17, 2026
Merged via the queue into phase-rs:main with commit caaeed8 Jun 17, 2026
10 checks passed
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