diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 233d804535..2b0211ae3c 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: 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), // 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..4e8c871a43 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: "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: "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..58af906f6b --- /dev/null +++ b/crates/engine/tests/integration/issue_5941_true_name_chosen_player_protection.rs @@ -0,0 +1,83 @@ +//! 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(); + 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(); + + 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:?}" + ); +} 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;