From b71eec325dc0c7a1a46dbcbd4cf9a1ba5be5973f Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 12:30:49 -0500 Subject: [PATCH 1/5] fix(engine): parse chosen-player protection --- crates/engine/src/game/keywords.rs | 5 ++ crates/engine/src/game/static_abilities.rs | 1 + crates/engine/src/types/keywords.rs | 22 +++++++ ...5941_true_name_chosen_player_protection.rs | 63 +++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 92 insertions(+) create mode 100644 crates/engine/tests/integration/issue_5941_true_name_chosen_player_protection.rs diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 233d804535..6964f258d4 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -542,6 +542,11 @@ 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.16 + CR 109.4: the source is protected against objects + // controlled by the player chosen for the protected permanent. + ProtectionTarget::ChosenPlayer => protected + .chosen_player() + .is_some_and(|player| source.controller == 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/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..6cd0b41a4e 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -502,6 +502,9 @@ 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.16 + CR 109.4: "Protection from the chosen player" — resolved + /// at runtime from the protected permanent's persisted player choice. + 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 +2886,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 + CR 109.4: "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 +4110,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..9caa9c0530 --- /dev/null +++ b/crates/engine/tests/integration/issue_5941_true_name_chosen_player_protection.rs @@ -0,0 +1,63 @@ +//! Issue #5941: True-Name Nemesis must not be targetable by objects controlled +//! by the player chosen as it entered the battlefield. +//! +//! The regression drives the parsed ETB 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::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::player::PlayerId; + +const P1: PlayerId = PlayerId(1); +const TRUE_NAME_ORACLE: &str = "As True-Name Nemesis enters, 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_from_oracle(P0, "True-Name Nemesis", 3, 1, TRUE_NAME_ORACLE) + .id(); + 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 mut runner = scenario.build(); + + let source = crate::support::exact_named_choice_source(runner.state(), true_name); + runner.state_mut().waiting_for = WaitingFor::NamedChoice { + player: P0, + choice_type: ChoiceType::player(), + options: vec![P0.0.to_string(), P1.0.to_string()], + source: Some(source), + persist_player: None, + }; + 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:?}" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 0cde5467e9..bb86496dc0 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; From 3c6294a50bfdc307b981ba70ebce890c6a2d2191 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 10 Aug 2026 11:07:34 -0700 Subject: [PATCH 2/5] fix(PR-7213): exercise True-Name entry pipeline --- crates/engine/src/game/keywords.rs | 4 +- crates/engine/src/types/keywords.rs | 4 +- ...5941_true_name_chosen_player_protection.rs | 44 +++++++++++++------ 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 6964f258d4..2b0211ae3c 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -542,8 +542,8 @@ 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.16 + CR 109.4: the source is protected against objects - // controlled by the player chosen for the protected permanent. + // CR 702.16: resolve "the chosen player" from the protected + // permanent's persisted choice and match that player's source. ProtectionTarget::ChosenPlayer => protected .chosen_player() .is_some_and(|player| source.controller == player), diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 6cd0b41a4e..4e8c871a43 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -502,7 +502,7 @@ 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.16 + CR 109.4: "Protection from the chosen player" — resolved + /// CR 702.16: "Protection from the chosen player" — resolved /// at runtime from the protected permanent's persisted player choice. ChosenPlayer, /// CR 702.16j: "Protection from everything" — protection from each object @@ -2886,7 +2886,7 @@ 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 + CR 109.4: "the chosen player" resolves from the + // 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 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 index 9caa9c0530..73de0326c3 100644 --- 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 @@ -1,21 +1,22 @@ //! Issue #5941: True-Name Nemesis must not be targetable by objects controlled //! by the player chosen as it entered the battlefield. //! -//! The regression drives the parsed ETB choice through `ChooseOption`, then -//! checks the production target-legality predicate with sources controlled by -//! both players. +//! 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::WaitingFor; +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, 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.)"; +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() @@ -25,20 +26,37 @@ fn add_source(scenario: &mut GameScenario, player: PlayerId, name: &str) -> Obje 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_from_oracle(P0, "True-Name Nemesis", 3, 1, TRUE_NAME_ORACLE) + .add_creature_to_hand_from_oracle(P0, "True-Name Nemesis", 3, 1, TRUE_NAME_ORACLE) + .with_mana_cost(ManaCost::generic(0)) .id(); 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 mut runner = scenario.build(); - let source = crate::support::exact_named_choice_source(runner.state(), true_name); - runner.state_mut().waiting_for = WaitingFor::NamedChoice { - player: P0, - choice_type: ChoiceType::player(), - options: vec![P0.0.to_string(), P1.0.to_string()], - source: Some(source), - persist_player: None, + 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(), From 67d9d4506154098ead179d8638099c5b29b5ffe0 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 11 Aug 2026 07:22:55 -0700 Subject: [PATCH 3/5] test(PR-7213): cast True-Name in main phase --- .../issue_5941_true_name_chosen_player_protection.rs | 2 ++ 1 file changed, 2 insertions(+) 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 index 73de0326c3..58af906f6b 100644 --- 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 @@ -33,6 +33,8 @@ fn true_name_protection_uses_the_protected_objects_chosen_player() { let other_player_source = add_source(&mut scenario, P0, "Friendly Spell"); let mut runner = scenario.build(); + runner.auto_advance_to_main_phase(); + let card_id = runner.state().objects[&true_name].card_id; runner .act(GameAction::CastSpell { From 2ba1c0ebaca57cb2b53589396e5c741bb7a94080 Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 10:25:08 -0500 Subject: [PATCH 4/5] fix(engine): honor chosen-player source ownership --- crates/engine/src/game/game_object.rs | 11 +++++ crates/engine/src/game/keywords.rs | 8 ++-- crates/engine/src/game/replacement.rs | 5 +-- crates/engine/src/types/keywords.rs | 6 ++- ...5941_true_name_chosen_player_protection.rs | 44 +++++++++++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 94b18aa3dd..988ee9e935 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -1456,6 +1456,17 @@ 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. Command-zone objects retain the engine's + /// explicit controller for the CR 109.4 exceptions modeled there. + pub(crate) fn controller_or_owner(&self) -> PlayerId { + match self.zone { + Zone::Battlefield | Zone::Stack | Zone::Command => self.controller, + 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 2b0211ae3c..6f1d97197a 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -542,11 +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.16: resolve "the chosen player" from the protected - // permanent's persisted choice and match that player's source. + // 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 == 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/types/keywords.rs b/crates/engine/src/types/keywords.rs index 4e8c871a43..35e1538046 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -502,8 +502,10 @@ 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.16: "Protection from the chosen player" — resolved - /// at runtime from the protected permanent's persisted player choice. + /// 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 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 index 58af906f6b..868c3b9f87 100644 --- 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 @@ -29,10 +29,30 @@ fn true_name_protection_uses_the_protected_objects_chosen_player() { .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 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.auto_advance_to_main_phase(); let card_id = runner.state().objects[&true_name].card_id; @@ -80,4 +100,28 @@ fn true_name_protection_uses_the_protected_objects_chosen_player() { 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:?}" + ); } From 981c479eb8e8308902a843a131551973f6d36a3e Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 13:18:59 -0500 Subject: [PATCH 5/5] fix(engine): resolve command-zone source ownership --- crates/engine/src/game/game_object.rs | 8 ++- ...5941_true_name_chosen_player_protection.rs | 66 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 988ee9e935..e6f1d0dcf8 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -1458,11 +1458,13 @@ 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. Command-zone objects retain the engine's - /// explicit controller for the CR 109.4 exceptions modeled there. + /// 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 | Zone::Command => self.controller, + 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, } } 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 index 868c3b9f87..49bf5f459c 100644 --- 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 @@ -38,6 +38,14 @@ fn true_name_protection_uses_the_protected_objects_chosen_player() { 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 @@ -52,6 +60,32 @@ fn true_name_protection_uses_the_protected_objects_chosen_player() { .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(); @@ -124,4 +158,36 @@ fn true_name_protection_uses_the_protected_objects_chosen_player() { .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:?}" + ); }