feat(engine): enter under an opponent’s control on ETB (CR 110.2a) - #2945
Conversation
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
|
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
left a comment
There was a problem hiding this comment.
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), serdedefault/skip_serializing_ifso card-data stays back-compatible, with a builder — mirroring the imperativeEffect::ChangeZone.enters_underslot from #2817.None= enters under owner (every existing replacement is unaffected). - Zone-pipeline integration is correct. The override resolves to a concrete
PlayerIdand is written onto theZoneChange's existingcontroller_overrideduring 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'scontroller_overrideslot rather than inventing a parallel mechanism is the right call. - Tests are strong.
self_enters_under_opponent_replacement_routes_control_to_opponentdrives the real ChangeZone pipeline and assertscontroller == 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.
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_structuregap) 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_underwork whose imperative/engine half landed in #2817 (any-playerControllerRefresolution, CR 110.2a).Approach (parameterize, don’t proliferate)
ReplacementDefinitiongainsenters_under: Option<ControllerRef>(serdedefault/skip_serializing_if— card-data back-compatible), parallel to the imperativeEffect::ChangeZone.enters_underslot. No new enum variant.EventModifiersfield and stamped onto the enteringZoneChange’scontroller_overridewhereetb_tap_stateis applied — before ETB triggers fire.resolve_self_enters_under_controllerresolvesOpponentagainst the entering object’s controller (the canonical resolver returnsNonefor ambiguousOpponent): sole opponent in two-player, first in seat order otherwise.parse_self_enters_under_opponentemits theMoved/SelfRef/Battlefield/enters_under=Opponentreplacement (nom combinators; self-subject gate covers~incl. legendary short names, andthis <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 --checkclean.Fixes #2834