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
2 changes: 1 addition & 1 deletion crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4822,7 +4822,7 @@ fn attach_host_enchant_filter(
Some((filter, attachment_id, controller))
}

fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool {
pub(crate) fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool {
if ability.target_choice_timing != TargetChoiceTiming::Stack {
return false;
}
Expand Down
15 changes: 15 additions & 0 deletions crates/engine/src/game/effects/put_on_top.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,21 @@ pub fn resolve(
expected
};

// CR 601.2c + CR 401.4 (issue #6565 / #6836): A per-opponent target fanout
// ("for each opponent, put up to one target ... that player controls ...")
// pre-selects one target PER opponent at stack time — `multi_target.max =
// PlayerCount { Opponent }`, so `collected_targets` already holds every
// chosen permanent (one per opponent). The effect's `count` (`Fixed(1)`) is
// the PER-OPPONENT cap, NOT the total, so it must never gate a further
// "choose `count` of them" prompt over the already-targeted permanents
// (which would loop forever and place at most one). Each pre-chosen target
// is placed into its own owner's library (CR 400.7, routed by the move).
let expected = if crate::game::ability_utils::is_per_opponent_target_fanout(ability) {
collected_targets.len()
} else {
expected
};

if collected_targets.is_empty() {
if expected == 0 {
events.push(GameEvent::EffectResolved {
Expand Down
69 changes: 69 additions & 0 deletions crates/engine/tests/integration/riptide_gearhulk_5994.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,72 @@ fn riptide_selects_one_opponent_target_and_declines_the_other() {
// The declined opponent's permanent is untouched.
outcome.assert_zone(&[p2_perm], Zone::Battlefield);
}

/// Issue #6565: "for each opponent, put up to one target nonland permanent THAT
/// PLAYER controls ..." scopes each per-opponent target to the iterated
/// opponent's permanents — never the caster's own. Offering the caster's own
/// permanent as the sole target intent must leave it untouched (it is not a legal
/// target for the opponent's slot), and the ability still resolves.
#[test]
fn riptide_per_opponent_slot_excludes_the_casters_own_permanents() {
let mut scenario = GameScenario::new();
scenario.at_phase(Phase::PreCombatMain);
scenario.with_mana_pool(P0, riptide_mana());

let my_perm = scenario.add_creature(P0, "My Own Bear", 2, 2).id();
let opp_perm = scenario.add_creature(P1, "Opp Bear", 2, 2).id();
let riptide = scenario
.add_creature_to_hand_from_oracle(P0, "Riptide Gearhulk", 4, 4, RIPTIDE_ORACLE)
.id();

let mut runner = scenario.build();

// Offer ONLY the caster's own permanent as target intent. It is not a legal
// target for the opponent's per-opponent slot, so that slot is declined.
let outcome = runner
.cast(riptide)
.target_players(&[P1])
.target_objects(&[my_perm])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.resolve();

assert!(
matches!(outcome.final_waiting_for(), WaitingFor::Priority { .. }),
"the ETB must resolve to priority, got {:?}",
outcome.final_waiting_for()
);
// The caster's own permanent is not controlled by the opponent, so it can
// never be chosen for the opponent's slot — it stays on the battlefield.
outcome.assert_zone(&[my_perm], Zone::Battlefield);
outcome.assert_zone(&[opp_perm], Zone::Battlefield);
}

/// Issue #6565: each opponent's own permanent is legal only for that opponent's
/// per-opponent slot. Selecting both opponents independently moves both targets
/// to their owners' libraries while the caster's own permanent remains untouched.
#[test]
fn riptide_per_opponent_slots_target_each_opponents_permanent() {
let mut scenario = GameScenario::new_n_player(3, 42);
scenario.at_phase(Phase::PreCombatMain);
scenario.with_mana_pool(P0, riptide_mana());
scenario.add_card_to_library_top(P1, "P1 Library Card");
scenario.add_card_to_library_top(P2, "P2 Library Card");

let my_perm = scenario.add_creature(P0, "My Own Bear", 2, 2).id();
let p1_perm = scenario.add_creature(P1, "Opp1 Bear", 2, 2).id();
let p2_perm = scenario.add_creature(P2, "Opp2 Bear", 2, 2).id();
let riptide = scenario
.add_creature_to_hand_from_oracle(P0, "Riptide Gearhulk", 4, 4, RIPTIDE_ORACLE)
.id();

let mut runner = scenario.build();

let outcome = runner
.cast(riptide)
.target_players(&[P1, P2])
.target_objects(&[p1_perm, p2_perm])
.resolve();

outcome.assert_zone(&[p1_perm, p2_perm], Zone::Library);
outcome.assert_zone(&[my_perm], Zone::Battlefield);
outcome.assert_zone(&[riptide], Zone::Battlefield);
}
Loading