Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/engine/src/game/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Implement the ownership leg of protection from the chosen player.

CR 702.16k covers objects controlled by the chosen player and objects owned by that player that another player does not control. The current comparison only checks source.controller, so nonbattlefield sources owned by the chosen player can bypass protection. CR 109.4 also states that objects outside the stack and battlefield have no controller. (media.wizards.com)

  • crates/engine/src/game/keywords.rs#L545-L549: Match the chosen player against the source owner when the source has no controller, while retaining controller matching for controlled sources.
  • crates/engine/src/types/keywords.rs#L505-L507: Replace the CR 109.4 citation with the applicable CR 702.16k behavior and document the ownership case.

As per path instructions, player-scoped queries on nonbattlefield zones must filter by obj.owner, not controller.

📍 Affects 2 files
  • crates/engine/src/game/keywords.rs#L545-L549 (this comment)
  • crates/engine/src/types/keywords.rs#L505-L507
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/keywords.rs` around lines 545 - 549, Update
ProtectionTarget::ChosenPlayer in crates/engine/src/game/keywords.rs:545-549 to
match the chosen player against source.owner when source.controller is absent,
while retaining controller matching for controlled sources and using owner for
nonbattlefield player-scoped queries. Update the documentation in
crates/engine/src/types/keywords.rs:505-507 to cite CR 702.16k and describe both
controller and ownership protection behavior.

Sources: Path instructions, MCP tools

// CR 702.16j: "Protection from everything" — protection from each object
// regardless of the source's characteristic values.
ProtectionTarget::Everything => true,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/static_abilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(_)
Expand Down
22 changes: 22 additions & 0 deletions crates/engine/src/types/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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:?}"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading