Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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