Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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: 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,
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: "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: "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,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:?}"
);
}
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