Skip to content

feat(engine): enter under an opponent’s control on ETB (CR 110.2a) - #2945

Merged
matthewevans merged 2 commits into
phase-rs:mainfrom
galuis116:feat/enters-under-opponent-control
Jun 11, 2026
Merged

feat(engine): enter under an opponent’s control on ETB (CR 110.2a)#2945
matthewevans merged 2 commits into
phase-rs:mainfrom
galuis116:feat/enters-under-opponent-control

Conversation

@galuis116

Copy link
Copy Markdown
Contributor

Summary

Models the self-ETB replacement " enters under the control of an opponent of your choice" — the permanent enters the battlefield directly under an opponent’s control (CR 110.2a), never under its owner’s first. Previously the clause was dropped (Effect:effect_structure gap) and the permanent entered under its owner’s control.

Unlocks (each was unsupported on this single gap): Xantcha, Sleeper Agent; Captive Audience; Pendant of Prosperity; Abby, Merciless Soldier.

Completes the self-replacement side of the enters_under work whose imperative/engine half landed in #2817 (any-player ControllerRef resolution, CR 110.2a).

Approach (parameterize, don’t proliferate)

  • types/ability.rsReplacementDefinition gains enters_under: Option<ControllerRef> (serde default/skip_serializing_if — card-data back-compatible), parallel to the imperative Effect::ChangeZone.enters_under slot. No new enum variant.
  • game/replacement.rs — surfaced as an EventModifiers field and stamped onto the entering ZoneChange’s controller_override where etb_tap_state is applied — before ETB triggers fire. resolve_self_enters_under_controller resolves Opponent against the entering object’s controller (the canonical resolver returns None for ambiguous Opponent): sole opponent in two-player, first in seat order otherwise.
  • parser/oracle_replacement.rsparse_self_enters_under_opponent emits the Moved/SelfRef/Battlefield/enters_under=Opponent replacement (nom combinators; self-subject gate covers ~ incl. legendary short names, and this <card-type>).

Notes

“of your choice” is deterministic in two-player (the engine’s primary mode / CI). A full multiplayer choice is a follow-up; CR 110.2a holds either way.

Tests

  • self_enters_under_opponent_parses_controller_override_replacement (4-phrasing build-the-class)
  • external_enters_under_opponent_is_not_a_self_replacement (self-subject gate)
  • self_enters_under_opponent_replacement_routes_control_to_opponent (end-to-end: enters under the opponent’s control via the real ChangeZone pipeline)

Parser-combinator gate and rustfmt --check clean.

Fixes #2834

Model the self-ETB replacement "<this permanent> enters under the control
of an opponent of your choice" — Xantcha, Sleeper Agent; Captive Audience;
Pendant of Prosperity; Abby, Merciless Soldier. Previously the clause was
dropped (Effect:effect_structure gap) and the permanent entered under its
owner's control.

- types/ability.rs: ReplacementDefinition gains `enters_under:
  Option<ControllerRef>` (serde default/skip — card-data back-compatible),
  parallel to the imperative Effect::ChangeZone.enters_under slot.
- game/replacement.rs: surface it as an EventModifiers field and stamp the
  entering ZoneChange's controller_override before ETB triggers fire (never
  under the owner first). Opponent resolves to the entering object's
  opponent (sole opponent in two-player; first in seat order otherwise).
- parser/oracle_replacement.rs: emit the Moved/SelfRef/Battlefield/
  enters_under=Opponent replacement for the self-control clause.

Reuses the controller-resolution + entry pipeline generalized in phase-rs#2817.

Fixes phase-rs#2834
@galuis116
galuis116 requested a review from matthewevans as a code owner June 10, 2026 22:56
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

…crates

The new `ReplacementDefinition.enters_under` field broke the explicit
struct literals in mtgish-import (convert/replacement.rs, 9 sites) and
phase-ai (cast_facts.rs, 1 site), which enumerate every field. Add
`enters_under: None` to each (the `..`-spread site in cast_facts is
unaffected). Workspace builds and clippy -D warnings clean.
@matthewevans matthewevans self-assigned this Jun 10, 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.

Approving — this is exactly how to complete a cross-PR mechanic. It slots cleanly into the seams #2817 established and is well-tested at the layer that matters.

Verified:

  • Parameterization is textbook. ReplacementDefinition::enters_under: Option<ControllerRef> (not a bool), serde default/skip_serializing_if so card-data stays back-compatible, with a builder — mirroring the imperative Effect::ChangeZone.enters_under slot from #2817. None = enters under owner (every existing replacement is unaffected).
  • Zone-pipeline integration is correct. The override resolves to a concrete PlayerId and is written onto the ZoneChange's existing controller_override during the replacement step, so the permanent enters under the opponent's control directly — never under its owner first (CR 614.1c, verified; CR 110.2a "unless the effect states otherwise," verified). Reusing the imperative path's controller_override slot rather than inventing a parallel mechanism is the right call.
  • Tests are strong. self_enters_under_opponent_replacement_routes_control_to_opponent drives the real ChangeZone pipeline and asserts controller == opponent (the discriminating runtime check for #2917's class of cards), plus a parser test and a negative control (external_enters_under_opponent_is_not_a_self_replacement) that pins the self-vs-external boundary.
  • Class-level. One shared self-replacement gap unlocks Xantcha, Captive Audience, Pendant of Prosperity, and Abby — all previously entering under the owner's control, fully broken.

On the multiplayer note: resolving ControllerRef::Opponent to the first opponent in seat order is consistent with the rest of the engine — controller_ref_player returns None for Opponent everywhere (filter.rs:659), so "an opponent of your choice" prompting is a genuinely cross-cutting follow-up, not a corner cut here. Two-player is fully correct, and multiplayer still enters under an opponent (satisfying CR 110.2a's core). Worth a tracking issue for the chosen-opponent prompt across the enters_under feature, but it shouldn't hold this. Enqueuing.

@matthewevans matthewevans added the feature Larger-scoped feature label Jun 10, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jun 10, 2026
@matthewevans matthewevans removed their assignment Jun 10, 2026
Merged via the queue into phase-rs:main with commit e4e507f Jun 11, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Larger-scoped feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Model permanents that "enter under the control of an opponent of your choice" (CR 110.2a)

2 participants