From 6ee8a7beccea4c5f27dd7300c14a6ac08c3052f0 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Fri, 31 Jul 2026 10:19:55 -0300 Subject: [PATCH 1/3] test(engine): Riptide per-opponent target slots are scoped to that opponent's permanents (#6565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-opponent fanout targeting fix (#6565, "that player controls" bound to the iterated opponent) had no runtime regression covering the controller scope. The existing riptide_gearhulk_5994.rs tests cover the optional-count behavior (#5994) but not that each opponent's slot targets THAT opponent's permanents rather than the caster's. Add two runtime tests driving Riptide's real ETB through the cast pipeline: - `riptide_per_opponent_slot_excludes_the_casters_own_permanents` offers only the caster's own permanent as target intent; it is not a legal target for the opponent's per-opponent slot, so the slot is declined and the permanent stays. Non-vacuous: a broken scope that let the opponent slot target the caster's permanent would move it to the library and flip the assertion. - `riptide_per_opponent_slot_targets_that_opponents_permanent` is the positive half — the opponent's own permanent IS legal and moves to its owner's library, while the caster's permanent is untouched. Refs #6565 --- .../integration/riptide_gearhulk_5994.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/engine/tests/integration/riptide_gearhulk_5994.rs b/crates/engine/tests/integration/riptide_gearhulk_5994.rs index 6c77fa06a0..a5f35d651f 100644 --- a/crates/engine/tests/integration/riptide_gearhulk_5994.rs +++ b/crates/engine/tests/integration/riptide_gearhulk_5994.rs @@ -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); } + +/// CR 109.5 (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]) + .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); +} + +/// CR 109.5 (issue #6565): the positive half — the opponent's own permanent IS a +/// legal target for that opponent's per-opponent slot and is moved to its owner's +/// library, while the caster's own permanent is untouched. +#[test] +fn riptide_per_opponent_slot_targets_that_opponents_permanent() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, riptide_mana()); + scenario.add_card_to_library_top(P1, "Opp Library Card"); + + 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(); + + let outcome = runner + .cast(riptide) + .target_players(&[P1]) + .target_objects(&[opp_perm]) + .resolve(); + + // The opponent's permanent went to its owner's library; the caster's own + // permanent was never a candidate and stays put. + outcome.assert_zone(&[opp_perm], Zone::Library); + outcome.assert_zone(&[my_perm], Zone::Battlefield); + outcome.assert_zone(&[riptide], Zone::Battlefield); +} From 32be2fe1b4db72cf8e9096be824ca609bcb13371 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Fri, 31 Jul 2026 07:10:31 -0700 Subject: [PATCH 2/3] test(PR-6836): cover both per-opponent target slots --- .../integration/riptide_gearhulk_5994.rs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/engine/tests/integration/riptide_gearhulk_5994.rs b/crates/engine/tests/integration/riptide_gearhulk_5994.rs index a5f35d651f..a96764d053 100644 --- a/crates/engine/tests/integration/riptide_gearhulk_5994.rs +++ b/crates/engine/tests/integration/riptide_gearhulk_5994.rs @@ -115,11 +115,11 @@ fn riptide_selects_one_opponent_target_and_declines_the_other() { outcome.assert_zone(&[p2_perm], Zone::Battlefield); } -/// CR 109.5 (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. +/// 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(); @@ -153,18 +153,20 @@ fn riptide_per_opponent_slot_excludes_the_casters_own_permanents() { outcome.assert_zone(&[opp_perm], Zone::Battlefield); } -/// CR 109.5 (issue #6565): the positive half — the opponent's own permanent IS a -/// legal target for that opponent's per-opponent slot and is moved to its owner's -/// library, while the caster's own permanent is untouched. +/// 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_slot_targets_that_opponents_permanent() { - let mut scenario = GameScenario::new(); +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, "Opp Library Card"); + 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 opp_perm = scenario.add_creature(P1, "Opp 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(); @@ -173,13 +175,11 @@ fn riptide_per_opponent_slot_targets_that_opponents_permanent() { let outcome = runner .cast(riptide) - .target_players(&[P1]) - .target_objects(&[opp_perm]) + .target_players(&[P1, P2]) + .target_objects(&[p1_perm, p2_perm]) .resolve(); - // The opponent's permanent went to its owner's library; the caster's own - // permanent was never a candidate and stays put. - outcome.assert_zone(&[opp_perm], Zone::Library); + outcome.assert_zone(&[p1_perm, p2_perm], Zone::Library); outcome.assert_zone(&[my_perm], Zone::Battlefield); outcome.assert_zone(&[riptide], Zone::Battlefield); } From c25788f903ae2f7b786b7627e67e29425b821e3d Mon Sep 17 00:00:00 2001 From: galuis116 Date: Fri, 31 Jul 2026 12:24:55 -0300 Subject: [PATCH 3/3] fix(engine): place every per-opponent fanout target at its library position (#6565) A per-opponent target fanout ("for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top") pre-selects one target PER opponent at stack time via multi_target.max = PlayerCount { Opponent }. The PutAtLibraryPosition effect's count field (Fixed(1)) is the PER-OPPONENT cap, not the total, so put_on_top::resolve wrongly treated the two pre-chosen permanents as "more candidates than count" and issued an interactive EffectZoneChoice { count: 1 } over the already-targeted permanents. That prompt looped forever (selecting one never advanced) and, at best, would have placed only one of the two. CR 601.2c: the number of targets is fixed at targeting; each chosen target is placed. CR 401.4: cards put at the same library position are arranged by their owner. For a per-opponent fanout, expected placement count is the number of pre-chosen targets, so each is routed to its own owner's library (CR 400.7). Fixes the runtime regression covered by riptide_per_opponent_slots_target_each_opponents_permanent (#6836). --- crates/engine/src/game/ability_utils.rs | 2 +- crates/engine/src/game/effects/put_on_top.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index b1c85a6b8e..8e3a77df51 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -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; } diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 5d5581631e..a5c81dff23 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -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 {