Skip to content

fix(engine): scope "target opponent whose turn it is" to the active opponent (#6496) - #6689

Open
mike-theDude wants to merge 6 commits into
phase-rs:mainfrom
mike-theDude:fix/issue-6496-beamtown-bullies-active-player
Open

fix(engine): scope "target opponent whose turn it is" to the active opponent (#6496)#6689
mike-theDude wants to merge 6 commits into
phase-rs:mainfrom
mike-theDude:fix/issue-6496-beamtown-bullies-active-player

Conversation

@mike-theDude

@mike-theDude mike-theDude commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Partially addresses #6496 (The Beamtown Bullies). #6496 should stay open — this fixes one of its three gaps; the card still does not activate.

The bug

{T}: Target opponent whose turn it is puts target nonlegendary creature card from your graveyard onto the battlefield under their control. It gains haste. Goad it. At the beginning of the next end step, exile it.

The whose turn it is relative clause was silently dropped. parse_target_with_syntax matched tag("opponent") and returned immediately, leaving the clause on the remainder, which parse_subject_application then discarded. Any opponent was a legal target, including on the controller's own turn.

The fix — parameterize, don't proliferate

Rather than add a ControllerRef::ActiveOpponent sibling, this parameterizes the existing variant:

ControllerRef::ActivePlayer { relation: PlayerRelation }

Zero new enum variants. This is what the in-code LEGALITY-SCOPE PAIR directive at types/ability.rs prescribes — a third legality-scoped sibling crosses the /add-engine-variant Stage-2 threshold and must be refactored onto the existing PlayerRelation axis instead. All 48 pre-existing sites take relation: PlayerRelation::All, for which matches_relation is unconditionally true, so behaviour is preserved.

An alternative TargetFilter::PlayerMatching { player: Box<PlayerFilter> } bridge was designed and rejected: it delivers the same one card, needs 2 new variants, and introduces ~16 silent matches! classifier sites with no compile error. The parameterization also discharges three correctness risks structurally — reusing the existing candidate loop in find_legal_targets_with_context means no guard re-implementation, no layering inversion, and no unbounded PlayerFilter surface at the targeting boundary.

Behaviour

  • No legal target on the controller's own turn ⇒ activation illegal to announce (CR 601.2c, imported into activation by CR 602.2b; CR 602.2)
  • Exactly the active opponent otherwise; read live, so CR 608.2b re-checks on resolution
  • Team-correct via players::matches_relation (CR 102.3) — an active teammate in Two-Headed Giant is not "the opponent whose turn it is"
  • Rides the existing candidate loop, so phasing / left-the-game / hexproof / shroud / protection guards compose for free

The card is NOT claimed as fixed

Two further gaps block it, both pre-existing and out of scope:

  1. ability_utils::relative_controller_kind classifies the chained ChangeZone's ControllerRef::You ("from your graveyard") as a relative controller, so the graveyard slot is enumerated against the target opponent's graveyard and activation fails with ActionNotAllowed("No legal targets available"). Proven pre-existing: the same text with the relative clause removed fails identically. Pinned by a known-gap regression test.
  2. The "under their control" binding is unparsed. An Effect::unimplemented("enters_under_their_control", …) marker records it, so coverage reports supported: false, gap_count: 1 rather than silently claiming the card.

Without marker 2, fixing gap 1 alone would reanimate the creature under the ability controller's control — inverting the card. The marker node must be deleted in the same change that unblocks activation, since it currently makes the ChangeZone's sub_link a ContinuationStep.

Also corrected

  • CR 800.4aCR 104.5 + CR 102.1 for the left-the-game targeting guard, at both sites in targeting.rs (800.4a governs the departing player's objects, not target legality)
  • Dropped an overstatement of CR 702.26b to players — it is permanent-scoped; the CR has no player-phasing rule

Verification

Run directly in an isolated worktree (not Tilt-watched), with real exit codes:

Check Result
cargo clippy --all-targets -- -D warnings clean
cargo test -p engine --lib 17,773 passed / 0 failed
cargo test -p engine --test integration 4,076 passed / 0 failed
check-parser-combinators.sh HEAD~1 PASS
cargo coverage exit 0

Serde note: ControllerRef is externally tagged, so "ActivePlayer"{"ActivePlayer":{"relation":{"type":"All"}}} is a wire-shape change. tests/fixtures/integration_cards.json is regenerated; no legacy bare-string reader remains. No frontend change — TargetFilter is Record<string, unknown> client-side.

⚠️ FORGE_TEST_FULL_DB=1 ordering_parity_sweep fails with 18 unexplained diffs — pre-existing on main, verified by running it on pristine base commit 7be8d002c with its own generated corpus, where it fails identically. Not caused by this change.

The final re-verification after rebasing onto current main was still running when this was pushed; CI is the check on that.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KTBTG756AfwmkoPwEty1np

Summary by CodeRabbit

  • New Features
    • Added relation-aware “active player” controller scopes (all/opponent/controller) and support for “whose turn it is” turn-ownership targeting.
  • Bug Fixes
    • Updated legality, controller/ownership filtering, and resolution checks to respect the current turn’s active player plus the specified relation, using safer fail-closed behavior.
    • Improved related behavior for copying, sacrifice scope, damage/attachment controller matching, and static ability targeting.
    • Tightened “under their control” handling for puts-to-battlefield.
  • Documentation
    • Refined coverage label wording to reflect relation-scoped active-player phrasing.
  • Tests
    • Added/updated integration and parser/serialization tests, including targeted guards for the structured ActivePlayer form.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR changes ControllerRef::ActivePlayer into a relation-bearing variant, adds live relation-aware legality checks, extends Oracle parsing for “whose turn it is,” handles third-person control gaps, updates classifiers and formatting, and adds compatibility and integration coverage.

Changes

Active-player relation targeting

Layer / File(s) Summary
Relation contract and runtime evaluation
crates/engine/src/types/ability.rs, crates/engine/src/game/{players,targeting,filter,...}.rs
ActivePlayer carries PlayerRelation; runtime targeting, filtering, quantity, sacrifice, static-ability, and ownership checks use the live active player and relation.
Oracle active-player parsing
crates/engine/src/parser/oracle*.rs
Oracle parsing emits structured controller references and recognizes bounded “whose turn it is” clauses.
Variant compatibility and rendering
crates/engine/src/game/{ability_rw,ability_scan,coverage,...}.rs, crates/mtgish-import/...
Classifiers and fail-closed branches accept the structured variant, while coverage labels render relation-specific wording.
Third-person control gap handling
crates/engine/src/parser/oracle_effect/{mod,subject}.rs, crates/engine/tests/integration/beamtown_bullies_active_opponent_target.rs
Battlefield-entry effects containing “under their control” create an enters_under_their_control gap with guarded regression coverage.
Integration and migration regressions
crates/engine/tests/integration/*
Tests cover active-opponent resolution, live turn changes, team semantics, parser fallbacks, strict-failure behavior, and legacy serialization.

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

Sequence Diagram(s)

sequenceDiagram
  participant OracleParser
  participant AbilityTargeting
  participant GameState
  participant PlayerRelation
  OracleParser->>AbilityTargeting: emit ActivePlayer { relation }
  AbilityTargeting->>GameState: read active_player
  AbilityTargeting->>PlayerRelation: validate relation against source controller
  PlayerRelation-->>AbilityTargeting: legal target result
  AbilityTargeting-->>GameState: apply target selection or revalidate on resolution
Loading

Possibly related issues

Suggested labels: quality

Suggested reviewers: matthewevans

🚥 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 clearly and concisely summarizes the main change: scoping “target opponent whose turn it is” to the active opponent.
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

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

@matthewevans matthewevans self-assigned this Jul 27, 2026
@matthewevans matthewevans added the enhancement New feature or request label Jul 27, 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.

Changes requested — the ActivePlayer parameterization changes a persisted enum wire shape without legacy decoding.

🔴 Blocker

[HIGH] Existing bare "ActivePlayer" JSON no longer deserializes. ControllerRef::ActivePlayer is now a struct variant with relation in crates/engine/src/types/ability.rs:3663-3687; its prior unit-variant wire representation was the bare enum tag. Although the intended All semantics are unchanged, persisted card data using the legacy form breaks at the serialization boundary.

Add a compatibility deserializer mapping legacy ActivePlayer to ActivePlayer { relation: PlayerRelation::All }, with a regression that loads the old wire shape.

🟡 Evidence requested

This changes oracle_target and parser-effect code, but the required coverage parse-diff sticky artifact is absent. Please provide a fresh artifact or reconcile the parser-impact evidence before approval.

@matthewevans matthewevans removed their assignment Jul 27, 2026

@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: 1

🤖 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_nom/target.rs`:
- Line 822: Update the terminator guard in the relative-clause parser around
terminated(tag("turn it is"), ...) to accept EOF, whitespace, and explicit
terminal separators comma, semicolon, and period. Add regression cases covering
terminal target phrases ending with each supported separator, including “target
opponent whose turn it is.”
🪄 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: 4038515a-f988-45c4-9925-e7bf938b316a

📥 Commits

Reviewing files that changed from the base of the PR and between 6e0701b and ee87f11.

📒 Files selected for processing (27)
  • 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/coverage.rs
  • crates/engine/src/game/effects/copy_spell.rs
  • crates/engine/src/game/effects/sacrifice.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/game/players.rs
  • crates/engine/src/game/quantity.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/static_abilities.rs
  • crates/engine/src/game/targeting.rs
  • crates/engine/src/parser/oracle.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/subject.rs
  • crates/engine/src/parser/oracle_nom/filter.rs
  • crates/engine/src/parser/oracle_nom/target.rs
  • crates/engine/src/parser/oracle_static/static_helpers.rs
  • crates/engine/src/parser/oracle_target.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/fixtures/integration_cards.json
  • crates/engine/tests/integration/beamtown_bullies_active_opponent_target.rs
  • crates/engine/tests/integration/coerced_attack_punisher.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/nettling_imp_continuity_target.rs
  • crates/mtgish-import/src/convert/player_effect.rs

Comment thread crates/engine/src/parser/oracle_nom/target.rs Outdated
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

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

🟢 Added (1 signature)

  • 1 card · ➕ ability/enters_under_their_control · added: enters_under_their_control
    • Affected (first 3): The Beamtown Bullies

🔴 Removed (1 signature)

  • 1 card · ➖ ability/grant Haste · removed: grant Haste (affects=parent target, grants=grant Haste, target=parent target)
    • Affected (first 3): The Beamtown Bullies

🟡 Modified fields (1 signature)

  • 1 card · 🔄 ability/TargetOnly · changed field target: opponentthe active opponent
    • Affected (first 3): The Beamtown Bullies

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

mike-theDude pushed a commit to mike-theDude/phase that referenced this pull request Jul 27, 2026
…pponent (phase-rs#6496)

The Beamtown Bullies' activated ability reads "Target opponent whose turn it
is puts target nonlegendary creature card from your graveyard onto the
battlefield under their control." The "whose turn it is" relative clause was
silently dropped: `parse_target_with_syntax` matched `tag("opponent")` and
returned immediately, leaving the clause on the remainder, which
`parse_subject_application` then discarded. Any opponent was a legal target,
including on the controller's own turn.

Rather than add a `ControllerRef::ActiveOpponent` sibling, this parameterizes
the existing variant:

    ControllerRef::ActivePlayer { relation: PlayerRelation }

Zero new enum variants. This is what the in-code LEGALITY-SCOPE PAIR directive
at `types/ability.rs` prescribes -- a third legality-scoped sibling crosses the
/add-engine-variant Stage-2 threshold and must be refactored onto the existing
`PlayerRelation` axis instead. All 48 pre-existing sites take
`relation: PlayerRelation::All`, for which `matches_relation` is
unconditionally true, so behaviour is preserved.

Targeting rides the existing candidate loop in `find_legal_targets_with_context`
rather than adding a branch, so the phasing / left-the-game / hexproof / shroud
/ protection guards compose for free. `relation: Opponent` yields no candidate
on the controller's own turn, making the activation illegal to announce
(CR 601.2c, imported into activation by CR 602.2b; CR 602.2), and the read is
live so CR 608.2b re-checks it on resolution. Team correctness routes through
`players::matches_relation` (CR 102.3), so an active teammate in Two-Headed
Giant is not "the opponent whose turn it is".

Parser: new `parse_active_player_turn_clause` combinator in
`oracle_nom/target.rs`, wired into both player head nouns behind a guarded
fallback, so every existing "target opponent"/"target player" card is
byte-identical.

The card remains UNSUPPORTED and is not claimed as fixed. Two further gaps
block it, both pre-existing and out of scope here:
  * `ability_utils::relative_controller_kind` classifies the chained
    ChangeZone's `ControllerRef::You` ("from YOUR graveyard") as a relative
    controller, so the graveyard slot is enumerated against the target
    opponent's graveyard and activation fails with "No legal targets
    available". Pinned by a known-gap regression test.
  * The "under their control" controller binding is unparsed. An
    `Effect::unimplemented("enters_under_their_control", ...)` marker records
    it so coverage reports `supported: false, gap_count: 1` instead of
    silently claiming the card. Without that marker, fixing the first gap
    alone would reanimate the creature under the ABILITY CONTROLLER's control,
    inverting the card.

Also corrects two CR mis-citations touched by this change: `CR 800.4a` ->
`CR 104.5 + CR 102.1` for the left-the-game targeting guard at both sites in
`targeting.rs` (800.4a governs the departing player's objects, not target
legality), and drops an overstatement of `CR 702.26b` to players (it is
permanent-scoped; the CR has no player-phasing rule).

Verified in an isolated worktree (not Tilt-watched): clippy -D warnings clean;
17,773 lib + 4,076 integration tests passing; parser-combinator gate PASS;
cargo coverage exit 0. `ordering_parity_sweep` fails identically on the
pristine base commit and is pre-existing on main.

Review follow-ups (PR phase-rs#6689):

  * Serde back-compat. `ControllerRef::ActivePlayer` used to be a UNIT variant,
    so its externally-tagged wire form was the bare string `"ActivePlayer"`.
    Regenerating in-repo fixtures cannot reach data persisted OUTSIDE the repo
    (saved game states, reconnect snapshots, external card-data.json consumers),
    which still carry that form and would now fail to deserialize outright.
    `ControllerRef` therefore gets a hand-written `Deserialize` that accepts the
    legacy bare tag as `relation: PlayerRelation::All` -- exactly the
    pre-parameterization meaning, since `matches_relation` is unconditionally
    true for `All`. `Serialize` stays DERIVED, so nothing written out changes.
    The canonical path delegates to a derived mirror enum carrying all 13
    variants verbatim; two tripwires keep it from drifting -- a wildcard-free
    exhaustive match that fails to COMPILE when a variant is added, and a
    per-variant serialize/deserialize round trip. Follows the `QuantityExpr`
    legacy bare-integer and `ChoiceType` legacy unit-variant deserializers in
    the same module.

  * Terminal separators. `parse_active_player_turn_clause`'s boundary accepted
    only EOF or a space, so the sentence-final form "target opponent whose turn
    it is." failed the arm and left the relative clause unparsed. The guard is
    now the single `one_of(" ,;.")` char class -- word boundary, comma,
    semicolon, period -- still under `peek`, so every separator byte survives on
    the remainder for the callers' `&text[lower.len() - rest.len()..]` offset
    arithmetic in `oracle_target.rs`. `" whose turn it isn't"` and Oath of
    Ghouls' `" whose graveyard ..."` are still rejected.

Re-verified in the isolated worktree: cargo fmt clean; clippy -D warnings clean
(exit 0); 17,798 lib + 4,097 integration tests passing (exit 0); parser
combinator gate PASS against the base commit. coverage-parse-diff against the
published main baseline attributes exactly 1 card / 3 signatures to this PR
(The Beamtown Bullies), with no other parse change across 35,516 faces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTBTG756AfwmkoPwEty1np
@mike-theDude
mike-theDude force-pushed the fix/issue-6496-beamtown-bullies-active-player branch from ee87f11 to 7e636c2 Compare July 27, 2026 02:57
@mike-theDude

Copy link
Copy Markdown
Collaborator Author

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

🟢 Added (1 signature)

  • 1 card · ➕ ability/enters_under_their_control · added: enters_under_their_control
    • Affected (first 3): The Beamtown Bullies

🔴 Removed (1 signature)

  • 1 card · ➖ ability/grant Haste · removed: grant Haste (affects=parent target, grants=grant Haste, target=parent target)
    • Affected (first 3): The Beamtown Bullies

🟡 Modified fields (1 signature)

  • 1 card · 🔄 ability/TargetOnly · changed field target: opponentthe active opponent
    • Affected (first 3): The Beamtown Bullies

9 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.
New cards in head: 39.


Reading notes

  • 0 support flips. Coverage is byte-identical to the pre-change run: 31564/35516 (88.9%), standard 97.3%, 1019 parse warnings across 3 categories.
  • The 🔴 removed grant Haste signature is expected and is not a lost ability. The deliberate Effect::unimplemented("enters_under_their_control", …) marker is inserted as a rider ahead of the rest of the chain, which makes the ChangeZone's immediate sub_link a ContinuationStep rather than a SequentialSibling — so the haste grant's signature changes shape rather than disappearing. Effect::Unimplemented resolves as a logged no-op returning Ok(()) (game/effects/mod.rs), so chain descent continues; 835 of 35,516 cards already carry an Unimplemented node with a non-None sub_ability. This node must be deleted in the same change that unblocks activation (engine: chained ChangeZone's ControllerRef::You misclassified as relative, blocking activation #6690), which is noted in the code comment and pinned by a test.
  • Two artifacts of local baseline drift, not this PR: oracle_changed: 9 and added_cards: 39 (all LOTR/Hobbit-set cards) come from the published baseline snapshot being older than the MTGJSON set files cached in the gitignored data/mtgjson/. The tool already carves the 9 oracle-text changes out as non-parser. CI, which builds both sides from the same merge commit, would not show either.

Invocation (mirrors .github/workflows/ci.yml):

BASE_HASH=$(./scripts/engine-source-hash.sh 6e0701b38)
curl -fsSL "https://data.phase-rs.dev/parse-baselines/coverage-data-${BASE_HASH}.json" -o coverage-base.json
cargo build --profile tool --features cli --bin coverage-report --bin coverage-parse-diff
./target/tool/coverage-report data/ > coverage-head.json
./target/tool/coverage-parse-diff coverage-base.json coverage-head.json \
  --base-sha 6e0701b384a7f239ae1865d4ac35b206f965ec4a \
  --markdown parse-diff.md --json parse-diff.json

@mike-theDude

Copy link
Copy Markdown
Collaborator Author

@matthewevans — both points addressed in 7e636c251 (force-pushed; the branch is otherwise unchanged).

🔴 Blocker — legacy "ActivePlayer" decoding

You're right, and the earlier approach was insufficient: regenerating the in-repo fixture does nothing for data persisted outside the repo, which is exactly the case you're describing.

ControllerRef now has a hand-written Deserialize (derive dropped from Deserialize only — Serialize stays derived, so nothing about the emitted shape changes). Legacy bare "ActivePlayer" decodes as ActivePlayer { relation: PlayerRelation::All }, which is the semantically identical pre-change meaning.

Structured after the QuantityExpr legacy-bare-integer precedent in the same file (Value sniff → derived mirror enum → explicit match). There's a closer precedent in ChoiceType, which does an enum-level legacy-unit-variant migration via untagged; I went with the QuantityExpr shape because the mirror is a literal copy of the variant list — one place to drift instead of two — and untagged yields unusable error messages.

Because a hand-written impl for a 13-variant enum can silently drop a variant, which would be worse than the bug it fixes, I verified four independent ways:

  1. Mechanical comparison of the real enum block against the Mirror block — both normalize to an identical 13-item, same-order, same-field-type string.
  2. Compile-time tripwire — an exhaustive, wildcard-free match helper, so a future variant fails to compile rather than silently mis-decoding.
  3. Runtime tripwire — round-trips one sample per variant and asserts the sample count equals 13.
  4. Corpus scalecoverage-report decoded the full 97 MB production card-data.json (35,516 faces) through the new impl, exit 0, byte-identical coverage numbers.

Tests added: legacy-bare-tag → All (and that serialization still emits only the new shape); new-shape round-trip across all three PlayerRelations; a unit variant ("TargetOpponent") and a struct variant ({"ChosenPlayer":{"index":2}}) decoded from literal JSON; and an unknown-tag-is-an-error guard.

One behavioral note worth your call: like the QuantityExpr precedent it mirrors, this impl goes through serde_json::Value, so it requires a self-describing format and its errors lose serde's field path (they surface via de::Error::custom). The workspace has no bincode/postcard/ciborium dependency, so nothing today is affected — but if a non-self-describing format is ever planned, say so and I'll restructure.

🟡 Evidence — coverage parse-diff

Posted above as a sticky artifact: 1 card, 3 signatures, 0 support flips, coverage byte-identical at 31564/35516. The comment explains why the 🔴 removed grant Haste signature is a chain-shape change rather than a lost ability, and flags two artifacts of local baseline drift that CI would not reproduce.

CodeRabbit's terminator finding — fixed, but the citation is wrong

Fixed. The guard is now alt((eof, peek(recognize(one_of(" ,;."))))), following the in-repo precedent at oracle_trigger.rs:14464. peek is retained deliberately: the caller's &text[lower.len() - rest.len()..] remainder arithmetic needs the separator to survive on rest. That's asserted rather than argued — parse_target("Target opponent whose turn it is.") returns remainder ".", and the comma/semicolon cases return ", then YOU draw a card" / "; YOU draw a card" with the uppercase YOU intact, which a one-byte offset shift would corrupt. The " whose turn it isn't" and Oath-of-Ghouls rejections are unchanged and still pass.

However, the cited authority does not exist. CodeRabbit wrote "As per path instructions, relative-clause terminal guards must accept end-of-input or explicit separators such as ,, ;, and .". That sentence is not in .coderabbit.yaml, AGENTS.md, CLAUDE.md, GEMINI.md, CONTRIBUTING.md, or .claude/skills/**. The only path instruction scoped to crates/engine/src/parser/** is the variant-coverage mandate at .coderabbit.yaml:116-128. The underlying gap was real, so I fixed it on correctness grounds — flagging the confabulated attribution since it would otherwise read as an established convention.

Verification

Real exit codes, each redirected to a log with $? captured separately (no pipeline masking), run directly in an isolated worktree:

Gate Exit Result
cargo fmt --all -- --check 0 clean
cargo clippy --all-targets -- -D warnings 0 no warnings
cargo test -p engine --lib 0 17798 passed / 0 failed
cargo test -p engine --test integration 0 4097 passed / 0 failed
check-parser-combinators.sh HEAD~1 0 Gate A + Gate G PASS
coverage-report data/ 0 35,516 faces decoded

The integration suite loads tests/fixtures/integration_cards.json, so its passing is direct evidence the fixture still decodes under the new Deserialize.

FORGE_TEST_FULL_DB=1 ordering_parity_sweep remains excluded — it fails identically on pristine base commit 7be8d002c with its own generated corpus, i.e. pre-existing on main.

@matthewevans matthewevans self-assigned this Jul 27, 2026
@matthewevans matthewevans added the ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow label Jul 27, 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.

[HIGH] State-aware player-target matching still rejects every active-player scope. Evidence: filter.rs:6272-6283 sends player_matches_target_filter_in_state through _with, while filter.rs:6324-6337 hard-codes ControllerRef::ActivePlayer { .. } => false; targeting.rs:975-995 uses that supposedly state-aware matcher while checking resolved player targets. Why it matters: an active opponent can be legal at announcement through find_legal_targets, yet fail the later player-target match/revalidation path; the sole target is then removed and the ability fizzles instead of using the live state.active_player as required by CR 608.2b. Suggested fix: make the in-state matcher handle ActivePlayer { relation } at this authority (player_id == state.active_player plus players::active_player_satisfies_relation(state, source_controller, relation)), preserving the state-free wrapper's fail-closed behavior. Add a direct resolution-validation regression: an already-chosen active opponent remains legal and resolves while they are active, then is removed/fizzles after the active player changes; include a direct matcher assertion so this authority cannot silently regress again.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…pponent (phase-rs#6496)

The Beamtown Bullies' activated ability reads "Target opponent whose turn it
is puts target nonlegendary creature card from your graveyard onto the
battlefield under their control." The "whose turn it is" relative clause was
silently dropped: `parse_target_with_syntax` matched `tag("opponent")` and
returned immediately, leaving the clause on the remainder, which
`parse_subject_application` then discarded. Any opponent was a legal target,
including on the controller's own turn.

Rather than add a `ControllerRef::ActiveOpponent` sibling, this parameterizes
the existing variant:

    ControllerRef::ActivePlayer { relation: PlayerRelation }

Zero new enum variants. This is what the in-code LEGALITY-SCOPE PAIR directive
at `types/ability.rs` prescribes -- a third legality-scoped sibling crosses the
/add-engine-variant Stage-2 threshold and must be refactored onto the existing
`PlayerRelation` axis instead. All 48 pre-existing sites take
`relation: PlayerRelation::All`, for which `matches_relation` is
unconditionally true, so behaviour is preserved.

Targeting rides the existing candidate loop in `find_legal_targets_with_context`
rather than adding a branch, so the phasing / left-the-game / hexproof / shroud
/ protection guards compose for free. `relation: Opponent` yields no candidate
on the controller's own turn, making the activation illegal to announce
(CR 601.2c, imported into activation by CR 602.2b; CR 602.2), and the read is
live so CR 608.2b re-checks it on resolution. Team correctness routes through
`players::matches_relation` (CR 102.3), so an active teammate in Two-Headed
Giant is not "the opponent whose turn it is".

Parser: new `parse_active_player_turn_clause` combinator in
`oracle_nom/target.rs`, wired into both player head nouns behind a guarded
fallback, so every existing "target opponent"/"target player" card is
byte-identical.

The card remains UNSUPPORTED and is not claimed as fixed. Two further gaps
block it, both pre-existing and out of scope here:
  * `ability_utils::relative_controller_kind` classifies the chained
    ChangeZone's `ControllerRef::You` ("from YOUR graveyard") as a relative
    controller, so the graveyard slot is enumerated against the target
    opponent's graveyard and activation fails with "No legal targets
    available". Pinned by a known-gap regression test.
  * The "under their control" controller binding is unparsed. An
    `Effect::unimplemented("enters_under_their_control", ...)` marker records
    it so coverage reports `supported: false, gap_count: 1` instead of
    silently claiming the card. Without that marker, fixing the first gap
    alone would reanimate the creature under the ABILITY CONTROLLER's control,
    inverting the card.

Also corrects two CR mis-citations touched by this change: `CR 800.4a` ->
`CR 104.5 + CR 102.1` for the left-the-game targeting guard at both sites in
`targeting.rs` (800.4a governs the departing player's objects, not target
legality), and drops an overstatement of `CR 702.26b` to players (it is
permanent-scoped; the CR has no player-phasing rule).

Verified in an isolated worktree (not Tilt-watched): clippy -D warnings clean;
17,773 lib + 4,076 integration tests passing; parser-combinator gate PASS;
cargo coverage exit 0. `ordering_parity_sweep` fails identically on the
pristine base commit and is pre-existing on main.

Review follow-ups (PR phase-rs#6689):

  * Serde back-compat. `ControllerRef::ActivePlayer` used to be a UNIT variant,
    so its externally-tagged wire form was the bare string `"ActivePlayer"`.
    Regenerating in-repo fixtures cannot reach data persisted OUTSIDE the repo
    (saved game states, reconnect snapshots, external card-data.json consumers),
    which still carry that form and would now fail to deserialize outright.
    `ControllerRef` therefore gets a hand-written `Deserialize` that accepts the
    legacy bare tag as `relation: PlayerRelation::All` -- exactly the
    pre-parameterization meaning, since `matches_relation` is unconditionally
    true for `All`. `Serialize` stays DERIVED, so nothing written out changes.
    The canonical path delegates to a derived mirror enum carrying all 13
    variants verbatim; two tripwires keep it from drifting -- a wildcard-free
    exhaustive match that fails to COMPILE when a variant is added, and a
    per-variant serialize/deserialize round trip. Follows the `QuantityExpr`
    legacy bare-integer and `ChoiceType` legacy unit-variant deserializers in
    the same module.

  * Terminal separators. `parse_active_player_turn_clause`'s boundary accepted
    only EOF or a space, so the sentence-final form "target opponent whose turn
    it is." failed the arm and left the relative clause unparsed. The guard is
    now the single `one_of(" ,;.")` char class -- word boundary, comma,
    semicolon, period -- still under `peek`, so every separator byte survives on
    the remainder for the callers' `&text[lower.len() - rest.len()..]` offset
    arithmetic in `oracle_target.rs`. `" whose turn it isn't"` and Oath of
    Ghouls' `" whose graveyard ..."` are still rejected.

Re-verified in the isolated worktree: cargo fmt clean; clippy -D warnings clean
(exit 0); 17,798 lib + 4,097 integration tests passing (exit 0); parser
combinator gate PASS against the base commit. coverage-parse-diff against the
published main baseline attributes exactly 1 card / 3 signatures to this PR
(The Beamtown Bullies), with no other parse change across 35,516 faces.

  * Player-target revalidation. `filter::player_matches_target_filter_with`
    hard-coded the new `ControllerRef::ActivePlayer { relation }` arm to
    `false`, with a comment claiming no caller reached it. That claim was
    wrong: `targeting::target_ref_matches_resolved_filter` routes every
    announced `TargetRef::Player` back through
    `player_matches_target_filter_in_state` on resolution (CR 608.2b), and
    `stack_spell_entry_matches_filter` (CR 115.9b/c) and the Aura host
    restriction reach it too -- so a still-legal active opponent failed its own
    re-check. The helper now takes a second closure alongside the existing
    `is_opponent` one, `active_player_ok`, which the in-state entry point
    supplies from `state` (candidate == `state.active_player`, narrowed through
    the single authority `players::active_player_satisfies_relation`, CR 102.1 /
    CR 102.3) and the state-free `player_matches_target_filter` supplies as
    `|_, _| false`, preserving its fail-closed contract. The false comment is
    gone. Three new tests: an end-to-end CR 608.2b resolution pin (target stays
    active -> resolves; turn passes -> fizzles), a revert-failing direct
    assertion on both matcher entry points, and a 2HG teammate case -- each
    negative paired with a positive reach-guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTBTG756AfwmkoPwEty1np
@mike-theDude
mike-theDude force-pushed the fix/issue-6496-beamtown-bullies-active-player branch from 7e636c2 to ce336ca Compare July 27, 2026 03:34
@mike-theDude

Copy link
Copy Markdown
Collaborator Author

@matthewevans — confirmed and fixed in ce336ca6e (force-pushed).

You're right, and this one was introduced by this PR: ControllerRef::ActivePlayer as the controller of a bare player-target filter is a new shape, and the _with helper had no way to answer for it. Worse, the arm carried a comment asserting "No caller reaches this arm today" — that claim was false, and target_ref_matches_resolved_filter disproves it. The comment is deleted and replaced with one naming the actual reach paths.

The fix

crates/engine/src/game/filter.rs

player_matches_target_filter_with gains a second closure parameter active_player_ok: &impl Fn(PlayerId, PlayerRelation) -> bool, alongside the existing is_opponent:

  • player_matches_target_filter_in_state supplies candidate == state.active_player && players::active_player_satisfies_relation(state, source_controller, relation) — the same single authority the rest of this PR routes through, no inlined relation logic.
  • player_matches_target_filter (state-free) supplies &|_, _| false, so fail-closed is preserved and now expressed at the call site where it is auditable.
  • The recursive Or/And arms thread the caller's closure, so composition preserves whichever entry point you came in through.

I took your suggested closure shape over threading &GameState into the helper: the helper is deliberately state-free so the no-state entry point can exist, and threading state would force Option<&GameState> and a nullable state read inside every arm. The closure models the problem the same way is_opponent already does.

Callers of the private helper — all three, all in filter.rs: the two public wrappers above, plus its own recursive Or/And arms. Nothing outside the module calls it (phase-ai/src/features/poison.rs and types/game_state.rs only mention it in doc comments, and neither makes a claim this invalidates).

One correction to the finding's blast radius

I revert-checked the tests — restored the => false arm and re-ran. Result worth reporting precisely:

  • the direct-matcher tests FAIL (these are the regression pin for this defect)
  • the end-to-end resolution test PASSES

So the fizzle path itself was already correct: targeting::find_legal_targets resolves the relation independently and is what actually decides CR 608.2b legality. The real defect is a widely-called matcher returning a wrong answer for a class of filters — reached from target_ref_matches_resolved_filter, stack_spell_entry_matches_filter (CR 115.9b/c), the Aura enchant-host restriction, zone_pipeline, quantity, gain_control, casting, trigger_matchers, and phase-ai — rather than a guaranteed fizzle of this one card.

Fixed as prescribed regardless, since a wrong answer from that authority is a real defect. Flagging it because the test doc says the same thing: I did not want the end-to-end test reading as stronger coverage of this fix than it is.

Tests (in the existing integration file, no new top-level binary)

  • active_opponent_filter_matches_only_the_live_active_opponent_in_statethe revert-failing pin you asked for. Active opponent → true; non-active opponent → false; controller → false; controller's own turn → false with a reach-guard flipping active_player back. Also asserts the state-free entry still returns false, with a reach-guard that it still resolves plain ControllerRef::Opponent — so fail-closed isn't masking a broken matcher.
  • active_opponent_target_is_revalidated_against_the_live_active_player — end-to-end through real apply(), using the file's existing class card ({T}: Target opponent whose turn it is loses 1 life.) since The Beamtown Bullies is still unannounceable per engine: chained ChangeZone's ControllerRef::You misclassified as relative, blocking activation #6690. Positive: target stays active, resolves, P2 → 19. Negative: active_player flips between announce and resolution, ability countered, all three life totals stay 20.
  • active_opponent_filter_in_state_excludes_two_headed_giant_teammate — CR 102.3; active teammate → false, reach-guard active opposing-team player → true.

Verification (real exit codes, each redirected to its own log)

Gate Exit Result
cargo fmt --all 0 clean
cargo clippy --all-targets -- -D warnings 0 clean
cargo test -p engine --lib 0 17798 passed / 0 failed
cargo test -p engine --test integration 0 4100 passed / 0 failed
check-parser-combinators.sh HEAD~1 0 Gate A + Gate G PASS

No card-data regeneration needed — this fix touches neither the parser nor any serialized shape, so the posted parse-diff artifact still stands.

@matthewevans matthewevans self-assigned this Jul 27, 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.

Changes requested — shared-team turns are rules-incorrect.

[HIGH] ActivePlayer narrows to the engine's single representative instead of the active team. Evidence: players.rs:204 evaluates relation only against state.active_player; targeting.rs:257 then permits only player.id == state.active_player; and GameState stores that as one PlayerId (game_state.rs:11029). In a shared-team-turn game, however, each member of the active team is an active player (CR 805.4/805.4a), and an ability referring to an active player must select the specific one its controller chooses (CR 805.9). Thus, in 2HG with P2 and P3 on the active opposing team, this PR exposes only the representative P2 and makes the equally legal P3 impossible to target.

Please make the active-player authority enumerate/select the active-team candidates at the target-choice seam and thread that same authority through the resolution-side matcher, instead of treating state.active_player as the complete set. Add a production-pipeline 2HG test proving both active opposing-team players are offered/choosable and their chosen target remains legal on resolution. The current 2HG test only swaps the single representative between a teammate and an opposing player, so it cannot detect this omission.

@matthewevans matthewevans added bug Bug fix and removed enhancement New feature or request labels Jul 27, 2026
@matthewevans matthewevans removed their assignment Jul 27, 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.

Changes requested — manual quality gate remains unmet

The current implementation is at the correct targeting/filter seam, and the earlier state-aware matcher defect is fixed at this head. The parse evidence also matches the claimed scope: one affected card, The Beamtown Bullies, with the three expected signatures.

However, the strict quality gate requires a production-pipeline test that demonstrably fails when the production matcher change is reverted. The existing active_opponent_target_is_revalidated_against_the_live_active_player scenario drives apply(), but the author’s documented revert check confirms it still passes with the matcher arm reverted: target legality there is independently decided by find_legal_targets. The only demonstrated revert-failing coverage is the direct player_matches_target_filter_in_state assertion.

Please add a focused scenario-runner / apply() regression that reaches a production caller whose result actually depends on player_matches_target_filter_in_state, and include the revert result. Until then this is a test-discrimination gap rather than a correctness finding, but it blocks the quality label and merge-queue approval under the maintainer quality bar.

@matthewevans matthewevans self-assigned this Jul 27, 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.

[HIGH] Active-player targeting is rules-incorrect in shared-team-turn games. Evidence: crates/engine/src/game/players.rs:204-214 evaluates the relation only against the singleton state.active_player; crates/engine/src/game/targeting.rs:257-264 and crates/engine/src/game/filter.rs:6309-6319 then require the candidate to equal that one representative. In Two-Headed Giant, CR 805.4a makes the whole team active and CR 805.9 requires the ability controller to choose one specific active player. With P0/P1 versus P2/P3 and state.active_player == P2, P3 is an equally legal active opponent but is never offered and cannot survive revalidation. The current 2HG test at crates/engine/src/game/targeting.rs:3847-3868 tests only the inactive teammate/P2 representative distinction, so it cannot detect P3 being omitted. Why it matters: “target opponent whose turn it is” rejects a legal target and the new ActivePlayer { relation } readers all encode the same singleton assumption. Suggested fix: model active-team candidate membership at the shared authority, enumerate and revalidate every active-team member satisfying the relation, add a real 2HG announcement-and-resolution test choosing each opposing teammate, and audit scalar active-player reads for the CR 805.9 choice/binding requirement.

[MED] The current head still lacks a discriminating production-pipeline regression for the state-aware matcher. Evidence: crates/engine/tests/integration/beamtown_bullies_active_opponent_target.rs:664-672 explicitly says its apply() scenario is not the regression pin for player_matches_target_filter_in_state; the sole current pin is the direct helper test at :759-. Commit cc4e481 reverted the only cast-pipeline test that exercised a production caller of that matcher. Why it matters: the direct test catches the helper in isolation but does not meet the required proof that a real caller fails if its production behavior regresses. Suggested fix: restore or replace it with a focused apply()/scenario regression that enters a real matcher-dependent caller and demonstrably fails when the active-player matcher arm is reverted.

@matthewevans matthewevans removed their assignment Jul 27, 2026
@matthewevans matthewevans self-assigned this Jul 27, 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.

[HIGH] Active-player targeting remains rules-incorrect in shared-team-turn games. Evidence: players.rs:204-214 evaluates only the singleton state.active_player; targeting.rs:257-264 and filter.rs:6309-6319 then require that same singleton. The new 2HG test proves P2 but never tests P3, the active opponent's teammate. Under CR 805.4a, the team whose turn it is is the active team, and CR 805.9 requires the ability controller to choose a specific active player. With P0/P1 versus P2/P3 and state.active_player == P2, P3 is an equally legal active opponent but is neither offered nor able to survive revalidation. Why it matters: this rejects a legal target for the new active-player scope, and the same singleton assumption is threaded through the other ControllerRef::ActivePlayer readers. Suggested fix: centralize active-team membership at the authority, enumerate and revalidate each active-team member that satisfies the relation, bind the CR 805.9 choice where a scalar consumer needs one, and add a real 2HG announcement-and-resolution test selecting P2 and P3.

[MED] The required discriminating production-pipeline matcher test is still absent. Evidence: beamtown_bullies_active_opponent_target.rs:664-672 explicitly says its apply() test is not the regression pin for player_matches_target_filter_in_state; the direct helper assertion remains the only pin. Commit cc4e481 reverted the cast-pipeline test, and this head does not replace it. Why it matters: the manual quality gate requires a runtime production caller whose result fails when the matcher change is reverted. Suggested fix: add a focused scenario / apply() regression through a production caller that depends on the state-aware matcher and include the revert result.

The parse-diff evidence is still unavailable at this head (Baseline pending); the PR already contains one maintainer merge for that churn, so I am not adding another merge-main commit solely to retry it. Security/intake were clean, the final diff has no workflow/instruction changes, and CI's aggregate Rust gate is still in progress; neither changes the blockers above.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…ns (phase-rs#6496)

CR 102.1 + CR 805.4a: under the shared team turns option every member of
the team whose turn it is is an active player, but every
ControllerRef::ActivePlayer { relation } read compared candidates against
the single stored representative (state.active_player). In 2HG with P2/P3
as the active opposing team, "target opponent whose turn it is" offered
only P2 and made the equally legal P3 impossible to target (CR 805.9:
the ability's controller chooses WHICH active player it refers to).

- players.rs: active-player identity is now owned by four single
  authorities: is_active_player (CR 805.4a membership), active_players
  (representative-first enumeration), active_player_candidate_matches
  (candidate + relation, evaluated against the CANDIDATE per CR 805.9;
  replaces active_player_satisfies_relation), and choose_active_player
  (the CR 805.9 deterministic stand-in for scalar reads).
- targeting/filter/static_abilities/ability_utils/quantity: every
  candidate-shaped ActivePlayer read routes through the candidate
  authority; the two scalar reads (filter::controller_ref_player, the
  sacrifice scope) route through choose_active_player.
- tests: new 2HG announcement-and-resolution pipeline test proving both
  active-team members are offered, individually choosable, and resolve
  (fails under the singleton revert); extended the 2HG matcher and
  enumeration tests with membership positives/negatives; restored the
  reverted cast-pipeline cost-reducer regression for
  player_matches_target_filter_in_state with a corrected reach-guard
  (sole-candidate announcement auto-binds, so the stack target is
  asserted instead of a TargetSelection prompt) — it demonstrably fails
  when the matcher arm is reverted to fail-closed.

Re-applies matthewevans' 1ebbf65 (reverted in cc4e481) with the
reach-guard fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125MPN7zw3bbEXdeFsn2sJT
@mike-theDude

Copy link
Copy Markdown
Collaborator Author

@matthewevans — both remaining findings addressed in 573187eb8.

🔴 HIGH — active-player targeting in shared-team-turn games

You're right: every ControllerRef::ActivePlayer { relation } read compared candidates against the singleton state.active_player, which is only the active team's turn REPRESENTATIVE under shared team turns. CR 805.4a makes the whole team active, and CR 805.9 requires the ability's controller to choose which active player the ability refers to — so P3 in your scenario was a legal target the engine could never offer.

The fix — membership at the shared authority

crates/engine/src/game/players.rs now owns active-player identity outright:

  • is_active_player(state, candidate) — CR 102.1 + CR 805.4a membership: scalar equality outside shared team turns, alive-filtered team membership under them.
  • active_player_candidate_matches(state, candidate, controller, relation) — the single authority every candidate-shaped ActivePlayer read routes through (replaces active_player_satisfies_relation). relation is now evaluated against the candidate (each specific active player, per CR 805.9), never against the stored representative.
  • active_players(state) — representative-first enumeration of the active set.
  • choose_active_player(state, controller, relation) — the CR 805.9 stand-in for the two SCALAR read sites (filter::controller_ref_player, the sacrifice scope), which must still name ONE player. The doc spells out where the stand-in is exact vs. choice-shaped: outside shared turns it is exact; under them Controller has at most one satisfying member and Opponent's verdict is team-uniform (CR 102.3 opponency is team-level), so only which member is named — never whether one exists — depends on it.

All eleven candidate read sites (targeting enumeration, both matcher entry points, filter props, static restrictions, ability_utils enumeration, quantity counts) now route through the candidate authority, so the announce seam and the resolution seam cannot drift: the target-choice prompt is the CR 805.9 choice, and revalidation accepts exactly the same set.

Production-pipeline 2HG test

active_opponent_activation_offers_both_active_team_members_in_two_headed_giant (integration): 4-player 2HG, source controlled by P0, active team P2+P3 with P2 stored as representative. For each of P2 and P3 in turn it asserts:

  1. activation is legal and halts on a TargetSelection prompt (two candidates — no silent auto-bind),
  2. the slot's legal_targets are exactly [Player(P2), Player(P3)] (CR 805.4a),
  3. the chosen member — including non-representative P3 — survives resolution and is the only player whose life changes (asserted as per-player deltas; CR 810.4/810.9 shared-life bookkeeping can't invalidate the pin).

The existing narrow 2HG tests were extended rather than replaced: the unit enumeration test now pins [P2, P3], and the in-state matcher test adds the P3-matches / own-team-active negatives.

Revert evidence: with is_active_player reverted to candidate == state.active_player (singleton), active_opponent_activation_offers_both_active_team_members_in_two_headed_giant and active_opponent_filter_in_state_excludes_two_headed_giant_teammate both FAIL; with the fix, the full engine suite is green.

🟡 MED — discriminating production-pipeline regression for the matcher

Your active_opponent_target_reducer_applies_in_cast_pipeline is restored — it was the right test aimed at the right caller (casting.rs's selected-target cost-filter path, CR 601.2f), and the revert in cc4e481 threw away the only discriminating pin. The reason it failed as-written: its reach-guard asserted a WaitingFor::TargetSelection prompt after CastSpell, but with a sole legal candidate the announcement auto-binds the target and the cast runs straight through to Priority. The reach-guard now asserts the stack entry's bound target ([Player(P1)]) instead; the discriminating assertion — the {1}→{0} reduction leaves the land untapped — is unchanged, and the doc comment now records the revert result.

Revert evidence: with the in-state active_player_ok closure replaced by fail-closed false, the restored test FAILS at "the selected-target reducer must see the active opponent and reduce {1} to {0}" (the reduction is skipped and the land taps); with the arm intact it passes. Unlike the CR 608.2b fizzle scenario, no independent find_legal_targets verdict can mask this path.

Verification (worktree, direct cargo — Tilt does not watch it)

  • cargo fmt --all
  • cargo clippy -p engine --all-targets -- -D warnings — clean
  • cargo test -p engine — full suite green (17k+ unit tests + 4k+ integration tests)
  • cargo check -p phase-ai -p mtgish-import -p engine-wasm -p server-core — clean
  • No parser changes in this delta — parse signatures and the coverage diff are unchanged from the previous head.

@matthewevans matthewevans self-assigned this Jul 27, 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.

[HIGH] Scalar ActivePlayer reads replace the required controller choice with a deterministic teammate. Evidence: players::choose_active_player explicitly selects the first qualifying active-team member (crates/engine/src/game/players.rs:256-275), and scalar callers use it for controller resolution and sacrifice scope (crates/engine/src/game/filter.rs:978-989; crates/engine/src/game/effects/sacrifice.rs:111-121). CR 805.9 requires the ability controller to choose which specific active player an ability refers to when its effect is applied. Why it matters: in shared-team turns, an effect that searches a referenced player library, puts a card under that player control, or makes that player sacrifice chooses P2 solely because P2 is the representative or first seat; it must permit choosing P3 when both are active. Suggested fix: model and bind that CR 805.9 player choice at effect application, threading the bound player through scalar consumers, rather than using a deterministic choose_active_player; add a 2HG production-pipeline regression that exercises a scalar consumer with distinct P2/P3 outcomes.

@matthewevans matthewevans removed their assignment Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants