Skip to content
Merged
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
13 changes: 13 additions & 0 deletions crates/engine/src/game/game_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1456,6 +1456,19 @@ pub(crate) fn chosen_card_type_of(attrs: &[ChosenAttribute]) -> Option<CoreType>
}

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. 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 => self.controller,
Zone::Command if self.is_emblem => self.controller,
Zone::Command => self.owner,
Zone::Library | Zone::Hand | Zone::Graveyard | Zone::Exile => self.owner,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

const fn initial_trigger_base_set_instance() -> TriggerBaseSetInstanceRef {
TriggerBaseSetInstanceRef::INITIAL
}
Expand Down
7 changes: 7 additions & 0 deletions crates/engine/src/game/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +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.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_or_owner() == player),
// CR 702.16j: "Protection from everything" — protection from each object
// regardless of the source's characteristic values.
ProtectionTarget::Everything => true,
Expand Down
5 changes: 1 addition & 4 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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
24 changes: 24 additions & 0 deletions crates/engine/src/types/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,11 @@ 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.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
/// in `source_matches_protection_target`.
Expand Down Expand Up @@ -2883,6 +2888,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 +4112,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,193 @@
//! 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();
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 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
.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
.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();

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:?}"
);

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:?}"
);

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:?}"
);
}
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