diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 94b18aa3dd..e6f1d0dcf8 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -1456,6 +1456,19 @@ pub(crate) fn chosen_card_type_of(attrs: &[ChosenAttribute]) -> Option } impl GameObject { + /// CR 109.4 + CR 108.4a: Objects on the stack or battlefield have a + /// controller; when an effect asks for the controller of a card that has + /// none, use its owner instead. CR 109.4c: emblems are the explicitly + /// modeled command-zone exception that retains their controller. + pub(crate) fn controller_or_owner(&self) -> PlayerId { + match self.zone { + Zone::Battlefield | Zone::Stack => self.controller, + Zone::Command if self.is_emblem => self.controller, + Zone::Command => self.owner, + Zone::Library | Zone::Hand | Zone::Graveyard | Zone::Exile => self.owner, + } + } + const fn initial_trigger_base_set_instance() -> TriggerBaseSetInstanceRef { TriggerBaseSetInstanceRef::INITIAL } diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 233d804535..6f1d97197a 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -542,6 +542,13 @@ pub fn source_matches_protection_target( .chosen_card_type() .and_then(|ct| ct.protection_quality_str()) .is_some_and(|quality| source_matches_card_type(source, quality)), + // CR 702.16k: Resolve "the chosen player" from the protected + // permanent's persisted choice. Protection covers objects that player + // controls and objects they own that no other player controls; CR + // 109.4 + CR 108.4a make controller-or-owner the shared authority. + ProtectionTarget::ChosenPlayer => protected + .chosen_player() + .is_some_and(|player| source.controller_or_owner() == player), // CR 702.16j: "Protection from everything" — protection from each object // regardless of the source's characteristic values. ProtectionTarget::Everything => true, diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index ff08ad6c75..0ff8278dd5 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -102,10 +102,7 @@ const GRANTED_BLOODTHIRST_INDEX: usize = usize::MAX - 8; /// controller; if an effect asks for a card's controller, use its owner /// instead. Command-zone emblems keep their controller under CR 109.4c. pub(crate) fn replacement_source_player(obj: &GameObject) -> PlayerId { - match obj.zone { - Zone::Battlefield | Zone::Stack | Zone::Command => obj.controller, - Zone::Library | Zone::Hand | Zone::Graveyard | Zone::Exile => obj.owner, - } + obj.controller_or_owner() } fn compleated_replacement_id(object_id: ObjectId) -> ReplacementId { diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index a1af2ff4eb..8e9ca7d58c 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -1654,6 +1654,7 @@ pub fn player_protection_from( // a player; object-level grants of these qualities flow through the // `AddKeyword(Protection)` continuous path, not `PlayerProtection`. ProtectionTarget::ChosenColor + | ProtectionTarget::ChosenPlayer | ProtectionTarget::Color(_) | ProtectionTarget::Multicolored | ProtectionTarget::Quality(_) diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 3ac674989e..35e1538046 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -502,6 +502,11 @@ pub enum ProtectionTarget { /// resolved at runtime from the source permanent's `chosen_attributes` /// (the `CardType` chosen as the permanent entered). Parallels `ChosenColor`. ChosenCardType, + /// CR 702.16k: "Protection from the chosen player" — resolved at runtime + /// from the protected permanent's persisted player choice. Covers objects + /// the chosen player controls and objects they own that no other player + /// controls. + ChosenPlayer, /// CR 702.16j: "Protection from everything" — protection from each object /// regardless of that object's characteristic values. Matches every source /// in `source_matches_protection_target`. @@ -2883,6 +2888,9 @@ pub(crate) fn parse_protection_target(s: &str) -> ProtectionTarget { // CR 702.16 + CR 205.2: "the chosen card type" resolves at // runtime from the source permanent's chosen `CardType` attribute. "the chosen card type" | "chosen card type" => ProtectionTarget::ChosenCardType, + // CR 702.16: "the chosen player" resolves from the + // protected permanent's persisted `ChosenAttribute::Player`. + "the chosen player" | "chosen player" => ProtectionTarget::ChosenPlayer, // CR 702.16j: "protection from everything" — typed variant, not stringly-typed "everything" => ProtectionTarget::Everything, // CR 702.16k: "protection from each of your opponents" (Figure of @@ -4104,6 +4112,22 @@ mod tests { ); } + #[test] + fn parse_protection_target_chosen_player() { + assert_eq!( + parse_protection_target("the chosen player"), + ProtectionTarget::ChosenPlayer + ); + assert_eq!( + parse_protection_target("chosen player"), + ProtectionTarget::ChosenPlayer + ); + assert_eq!( + Keyword::from_str("Protection:the chosen player").unwrap(), + Keyword::Protection(ProtectionTarget::ChosenPlayer) + ); + } + /// CR 702.16a + CR 202.3: "mana value N or less/greater" parses to /// `ProtectionTarget::Filter` with a `Cmc` property. #[test] diff --git a/crates/engine/tests/integration/issue_5941_true_name_chosen_player_protection.rs b/crates/engine/tests/integration/issue_5941_true_name_chosen_player_protection.rs new file mode 100644 index 0000000000..49bf5f459c --- /dev/null +++ b/crates/engine/tests/integration/issue_5941_true_name_chosen_player_protection.rs @@ -0,0 +1,193 @@ +//! Issue #5941: True-Name Nemesis must not be targetable by objects controlled +//! by the player chosen as it entered the battlefield. +//! +//! The regression casts the parsed card through its as-enters replacement, +//! answers that production choice through `ChooseOption`, then checks the +//! production target-legality predicate with sources controlled by both players. + +use engine::game::scenario::{GameScenario, P0}; +use engine::game::targeting::find_legal_targets; +use engine::types::ability::ChoiceType; +use engine::types::ability::TargetFilter; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaCost; +use engine::types::player::PlayerId; + +const P1: PlayerId = PlayerId(1); +const TRUE_NAME_ORACLE: &str = "As True-Name Nemesis enters the battlefield, choose a player.\nTrue-Name Nemesis has protection from the chosen player. (This creature can't be blocked, targeted, dealt damage by, or enchanted by anything controlled by that player.)"; + +fn add_source(scenario: &mut GameScenario, player: PlayerId, name: &str) -> ObjectId { + scenario.add_creature(player, name, 2, 2).id() +} + +#[test] +fn true_name_protection_uses_the_protected_objects_chosen_player() { + let mut scenario = GameScenario::new_n_player(2, 5941); + let true_name = scenario + .add_creature_to_hand_from_oracle(P0, "True-Name Nemesis", 3, 1, TRUE_NAME_ORACLE) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.add_card_to_library_top(P0, "Draw Step Filler"); + let chosen_player_source = add_source(&mut scenario, P1, "Song of the Dryads"); + let other_player_source = add_source(&mut scenario, P0, "Friendly Spell"); + let chosen_player_owned_source = scenario + .add_creature_to_graveyard(P1, "Chosen Player's Corpse", 2, 2) + .id(); + let other_player_owned_source = scenario + .add_creature_to_graveyard(P0, "Other Player's Corpse", 2, 2) + .id(); + let chosen_player_owned_command_source = scenario + .add_creature_to_graveyard(P1, "Chosen Player's Commander", 2, 2) + .id(); + scenario.with_commander(chosen_player_owned_command_source); + let other_player_owned_command_source = scenario + .add_creature_to_graveyard(P0, "Other Player's Commander", 2, 2) + .id(); + scenario.with_commander(other_player_owned_command_source); + let mut runner = scenario.build(); + + runner + .state_mut() + .objects + .get_mut(&chosen_player_owned_source) + .unwrap() + .controller = P0; + runner + .state_mut() + .objects + .get_mut(&other_player_owned_source) + .unwrap() + .controller = P1; + runner + .state_mut() + .objects + .get_mut(&chosen_player_owned_command_source) + .unwrap() + .controller = P0; + runner + .state_mut() + .objects + .get_mut(&other_player_owned_command_source) + .unwrap() + .controller = P1; + + let emblem_source = engine::game::effects::create_emblem::grant_emblem( + runner.state_mut(), + P1, + vec![], + vec![], + vec![], + ); + runner + .state_mut() + .objects + .get_mut(&emblem_source) + .unwrap() + .controller = P0; + + runner.auto_advance_to_main_phase(); + + let card_id = runner.state().objects[&true_name].card_id; + runner + .act(GameAction::CastSpell { + object_id: true_name, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting True-Name Nemesis must succeed"); + runner.advance_until_stack_empty(); + + let WaitingFor::NamedChoice { + choice_type, + options, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "True-Name's as-enters replacement must produce a player choice, got {}", + runner.waiting_for_kind() + ); + }; + assert!(matches!(choice_type, ChoiceType::Player { .. })); + assert_eq!(options, vec![P0.0.to_string(), P1.0.to_string()]); + runner + .act(GameAction::ChooseOption { + choice: P1.0.to_string(), + }) + .expect("choosing the player must succeed"); + + assert_eq!(runner.state().objects[&true_name].chosen_player(), Some(P1)); + + let targets_from_chosen_player = + find_legal_targets(runner.state(), &TargetFilter::Any, P1, chosen_player_source); + assert!( + !targets_from_chosen_player.contains(&engine::types::ability::TargetRef::Object(true_name)), + "True-Name must not be targetable by the chosen player's source, got {targets_from_chosen_player:?}" + ); + + let targets_from_other_player = + find_legal_targets(runner.state(), &TargetFilter::Any, P0, other_player_source); + assert!( + targets_from_other_player.contains(&engine::types::ability::TargetRef::Object(true_name)), + "True-Name must remain targetable by another player's source, got {targets_from_other_player:?}" + ); + + let targets_from_chosen_player_owned_source = find_legal_targets( + runner.state(), + &TargetFilter::Any, + P0, + chosen_player_owned_source, + ); + assert!( + !targets_from_chosen_player_owned_source + .contains(&engine::types::ability::TargetRef::Object(true_name)), + "True-Name must not be targetable by an uncontrolled source the chosen player owns, got {targets_from_chosen_player_owned_source:?}" + ); + + let targets_from_other_player_owned_source = find_legal_targets( + runner.state(), + &TargetFilter::Any, + P1, + other_player_owned_source, + ); + assert!( + targets_from_other_player_owned_source + .contains(&engine::types::ability::TargetRef::Object(true_name)), + "a stale controller must not make another player's uncontrolled source match, got {targets_from_other_player_owned_source:?}" + ); + + let targets_from_chosen_player_owned_command_source = find_legal_targets( + runner.state(), + &TargetFilter::Any, + P1, + chosen_player_owned_command_source, + ); + assert!( + !targets_from_chosen_player_owned_command_source + .contains(&engine::types::ability::TargetRef::Object(true_name)), + "True-Name must not be targetable by an ordinary command-zone card the chosen player owns, got {targets_from_chosen_player_owned_command_source:?}" + ); + + let targets_from_other_player_owned_command_source = find_legal_targets( + runner.state(), + &TargetFilter::Any, + P0, + other_player_owned_command_source, + ); + assert!( + targets_from_other_player_owned_command_source + .contains(&engine::types::ability::TargetRef::Object(true_name)), + "a stale command-zone controller must not make another player's card match, got {targets_from_other_player_owned_command_source:?}" + ); + + let targets_from_emblem_source = + find_legal_targets(runner.state(), &TargetFilter::Any, P0, emblem_source); + assert!( + targets_from_emblem_source + .contains(&engine::types::ability::TargetRef::Object(true_name)), + "an emblem's explicit controller must remain authoritative in the command zone, got {targets_from_emblem_source:?}" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 951a41254b..b4f9df6b01 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -635,6 +635,7 @@ mod issue_5900_conjurers_mantle; mod issue_5901_depthshaker_titan; mod issue_5902_heart_shaped_herb; mod issue_5910_kitchen_finks_persist; +mod issue_5941_true_name_chosen_player_protection; mod issue_5945_kellan_the_kid; mod issue_5946_pest_infestation_bogwater_softlock; mod issue_5963_scavengers_talent_food_sacrifice;