From 7dd1350e7eee5cde2f2f37c7e9df565fa2e63f1e Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 23 Jul 2026 16:24:29 -0500 Subject: [PATCH 1/7] fix(parser): bind "that player controls" to the iterated opponent for per-opponent repeat clauses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Riptide Gearhulk's ETB ("for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top.") asked the caster to pick a permanent they controlled, instead of a permanent controlled by each opponent. `parse_for_each_opponent_target_fanout_clause` only bound the "that player controls" anaphor to `ControllerRef::TargetPlayer` inside its own scoped clone of the parse context, used to decide whether the clause should fold `repeat_for` into a `MultiTargetSpec`. Effects whose shape doesn't qualify for that fold — including any triggered ability's execute body, which has no multi-target slot at all — fell through to a plain re-parse with the original, unscoped context, silently defaulting "that player controls" to `ControllerRef::You`. Set the scope for the whole re-parse whenever `repeat_for` resolves to "for each opponent", regardless of which downstream arm ends up producing the clause, so the anaphor stays bound to the opponent being iterated even when the fanout declines. Fixes #5994 Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/parser/oracle_effect/mod.rs | 28 ++++++++++ .../engine/src/parser/oracle_effect/tests.rs | 52 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 7edf095edd..f1b4fc7857 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -28952,6 +28952,31 @@ pub(crate) fn parse_effect_chain_ir( None } }); + // CR 109.4 + CR 608.2c: "for each opponent, ... that player + // ..." binds the per-iteration anaphor to the opponent being + // processed by this repetition. Previously only + // `parse_for_each_opponent_target_fanout_clause`'s own scoped clone + // carried this scope, so it was lost whenever that fanout declined + // (e.g. any effect whose surrounding shape has no `MultiTargetSpec` + // slot to fold `repeat_for` into, such as a triggered ability's + // execute body — Riptide Gearhulk's "for each opponent, put up to + // one target nonland permanent that player controls into its + // owner's library third from the top." kept `repeat_for` and fell + // back to resolving "that player controls" as `You`, #5994). + // Setting it here, for the whole re-parse below, keeps the anaphor + // bound to the iterated opponent regardless of which arm below ends + // up producing the clause. + let is_for_each_opponent_repeat = matches!( + repeat_for, + Some(QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent + } + }) + ); + let prior_relative_player_scope = is_for_each_opponent_repeat + .then(|| ctx.relative_player_scope.replace(ControllerRef::TargetPlayer)) + .flatten(); let (clause, repeat_for) = if let Some(draw) = difference_draw { (draw, repeat_for) } else if let Some(lose) = difference_lose { @@ -29014,6 +29039,9 @@ pub(crate) fn parse_effect_chain_ir( (parse_effect_clause(&text_no_qty, ctx), repeat_for) } }; + if is_for_each_opponent_repeat { + ctx.relative_player_scope = prior_relative_player_scope; + } // CR 608.2c + CR 109.4: After a `Choose(Player)` clause is finalized, // advance the chain's chosen-player counter exactly once. The index is diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 581c9a6c63..278c17d816 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -32074,6 +32074,58 @@ fn effect_for_each_opponent_gain_control_uses_per_opponent_target_fanout() { } } +/// #5994: Riptide Gearhulk's ETB — "for each opponent, put up to one target +/// nonland permanent that player controls into its owner's library third +/// from the top." `PutAtLibraryPosition` has no per-opponent target-fanout +/// arm (it isn't a single-object-target shape the +/// `parse_for_each_opponent_target_fanout_clause` gate folds into a +/// `MultiTargetSpec` — and a triggered ability's execute body has no +/// multi-target slot to fold into regardless), so `repeat_for` must stay +/// intact to drive the per-opponent repetition. Previously the "that player +/// controls" anaphor only resolved to `TargetPlayer` inside that fanout's own +/// scoped re-parse; once the fanout declined, the plain re-parse fell back to +/// `ControllerRef::You`, asking the caster (not each opponent) for a target. +#[test] +fn effect_for_each_opponent_put_at_library_position_keeps_repeat_for_and_target_player() { + let def = parse_effect_chain( + "for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top.", + AbilityKind::Spell, + ); + + assert_eq!( + def.repeat_for, + Some(QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent, + }, + }), + "repeat_for must stay intact — PutAtLibraryPosition has no multi-target slot to fold into" + ); + assert!(def.optional_targeting); + assert!(def.multi_target.is_none()); + match &*def.effect { + Effect::PutAtLibraryPosition { + target: TargetFilter::Typed(tf), + position: LibraryPosition::NthFromTop { n }, + .. + } => { + assert_eq!( + tf.controller, + Some(ControllerRef::TargetPlayer), + "target must be scoped to the iterated opponent, not the caster" + ); + assert!(tf + .type_filters + .iter() + .any(|filter| matches!(filter, TypeFilter::Permanent))); + assert_eq!(*n, 3); + } + other => panic!( + "expected PutAtLibraryPosition TargetPlayer nonland permanent, got {other:?}" + ), + } +} + #[test] fn choose_two_target_creatures_controlled_by_different_players_sets_target_constraints() { let def = parse_effect_chain( From 3ad7d71f7645add21f46e77f706f9f19ffd82953 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 23 Jul 2026 16:42:35 -0500 Subject: [PATCH 2/7] style: apply cargo fmt Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/parser/oracle_effect/mod.rs | 5 ++++- crates/engine/src/parser/oracle_effect/tests.rs | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index f1b4fc7857..20e979400b 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -28975,7 +28975,10 @@ pub(crate) fn parse_effect_chain_ir( }) ); let prior_relative_player_scope = is_for_each_opponent_repeat - .then(|| ctx.relative_player_scope.replace(ControllerRef::TargetPlayer)) + .then(|| { + ctx.relative_player_scope + .replace(ControllerRef::TargetPlayer) + }) .flatten(); let (clause, repeat_for) = if let Some(draw) = difference_draw { (draw, repeat_for) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 278c17d816..ea7b16e957 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -32120,9 +32120,9 @@ fn effect_for_each_opponent_put_at_library_position_keeps_repeat_for_and_target_ .any(|filter| matches!(filter, TypeFilter::Permanent))); assert_eq!(*n, 3); } - other => panic!( - "expected PutAtLibraryPosition TargetPlayer nonland permanent, got {other:?}" - ), + other => { + panic!("expected PutAtLibraryPosition TargetPlayer nonland permanent, got {other:?}") + } } } From b2b804fab0069d6688dfa4b74e578fadb7edc5ca Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 23 Jul 2026 17:42:56 -0500 Subject: [PATCH 3/7] Revert "fix(parser): bind \"that player controls\" to the iterated opponent for per-opponent repeat clauses" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (see PR discussion) correctly identifies that this patched the wrong layer: ControllerRef::TargetPlayer has no per-iteration referent under a bare repeat_for at runtime (game/effects/mod.rs's repeat_for resolution is a plain count with no per-iteration player rebinding, and game/filter.rs's TargetPlayer resolution reads a single companion player slot), so this just changed the bug's shape (aliasing one opponent's permanent across all iterations) without fixing it, and left the fizzle half of #5994 untouched. The real fix belongs in the existing, tested parse_for_each_opponent_target_fanout_clause / MultiTargetSpec fanout path (same mechanism Bronzebeak Foragers' identical "for each opponent, exile up to one target nonland permanent that player controls" clause already uses successfully) — reworking that in a follow-up commit. Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/parser/oracle_effect/mod.rs | 31 ----------- .../engine/src/parser/oracle_effect/tests.rs | 52 ------------------- 2 files changed, 83 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 20e979400b..7edf095edd 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -28952,34 +28952,6 @@ pub(crate) fn parse_effect_chain_ir( None } }); - // CR 109.4 + CR 608.2c: "for each opponent, ... that player - // ..." binds the per-iteration anaphor to the opponent being - // processed by this repetition. Previously only - // `parse_for_each_opponent_target_fanout_clause`'s own scoped clone - // carried this scope, so it was lost whenever that fanout declined - // (e.g. any effect whose surrounding shape has no `MultiTargetSpec` - // slot to fold `repeat_for` into, such as a triggered ability's - // execute body — Riptide Gearhulk's "for each opponent, put up to - // one target nonland permanent that player controls into its - // owner's library third from the top." kept `repeat_for` and fell - // back to resolving "that player controls" as `You`, #5994). - // Setting it here, for the whole re-parse below, keeps the anaphor - // bound to the iterated opponent regardless of which arm below ends - // up producing the clause. - let is_for_each_opponent_repeat = matches!( - repeat_for, - Some(QuantityExpr::Ref { - qty: QuantityRef::PlayerCount { - filter: PlayerFilter::Opponent - } - }) - ); - let prior_relative_player_scope = is_for_each_opponent_repeat - .then(|| { - ctx.relative_player_scope - .replace(ControllerRef::TargetPlayer) - }) - .flatten(); let (clause, repeat_for) = if let Some(draw) = difference_draw { (draw, repeat_for) } else if let Some(lose) = difference_lose { @@ -29042,9 +29014,6 @@ pub(crate) fn parse_effect_chain_ir( (parse_effect_clause(&text_no_qty, ctx), repeat_for) } }; - if is_for_each_opponent_repeat { - ctx.relative_player_scope = prior_relative_player_scope; - } // CR 608.2c + CR 109.4: After a `Choose(Player)` clause is finalized, // advance the chain's chosen-player counter exactly once. The index is diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index ea7b16e957..581c9a6c63 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -32074,58 +32074,6 @@ fn effect_for_each_opponent_gain_control_uses_per_opponent_target_fanout() { } } -/// #5994: Riptide Gearhulk's ETB — "for each opponent, put up to one target -/// nonland permanent that player controls into its owner's library third -/// from the top." `PutAtLibraryPosition` has no per-opponent target-fanout -/// arm (it isn't a single-object-target shape the -/// `parse_for_each_opponent_target_fanout_clause` gate folds into a -/// `MultiTargetSpec` — and a triggered ability's execute body has no -/// multi-target slot to fold into regardless), so `repeat_for` must stay -/// intact to drive the per-opponent repetition. Previously the "that player -/// controls" anaphor only resolved to `TargetPlayer` inside that fanout's own -/// scoped re-parse; once the fanout declined, the plain re-parse fell back to -/// `ControllerRef::You`, asking the caster (not each opponent) for a target. -#[test] -fn effect_for_each_opponent_put_at_library_position_keeps_repeat_for_and_target_player() { - let def = parse_effect_chain( - "for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top.", - AbilityKind::Spell, - ); - - assert_eq!( - def.repeat_for, - Some(QuantityExpr::Ref { - qty: QuantityRef::PlayerCount { - filter: PlayerFilter::Opponent, - }, - }), - "repeat_for must stay intact — PutAtLibraryPosition has no multi-target slot to fold into" - ); - assert!(def.optional_targeting); - assert!(def.multi_target.is_none()); - match &*def.effect { - Effect::PutAtLibraryPosition { - target: TargetFilter::Typed(tf), - position: LibraryPosition::NthFromTop { n }, - .. - } => { - assert_eq!( - tf.controller, - Some(ControllerRef::TargetPlayer), - "target must be scoped to the iterated opponent, not the caster" - ); - assert!(tf - .type_filters - .iter() - .any(|filter| matches!(filter, TypeFilter::Permanent))); - assert_eq!(*n, 3); - } - other => { - panic!("expected PutAtLibraryPosition TargetPlayer nonland permanent, got {other:?}") - } - } -} - #[test] fn choose_two_target_creatures_controlled_by_different_players_sets_target_constraints() { let def = parse_effect_chain( From 542c0bf436d15cc17fb2d7cdc36687794e813afb Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 23 Jul 2026 17:51:48 -0500 Subject: [PATCH 4/7] wip: diagnostic trace test (to be reverted) --- crates/engine/src/parser/oracle_effect/tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 581c9a6c63..6814a83230 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -48716,3 +48716,12 @@ fn unless_extraction_offsets_survive_unicode_case_fold() { the boundary, and the mask must not eat the quote)" ); } + +#[test] +fn scratch_riptide_gearhulk_trace() { + let def = parse_effect_chain( + "for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top", + AbilityKind::Spell, + ); + panic!("{:#?}", def); +} From 87df23da028ed21166a71b6c14e0523c818b8600 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 23 Jul 2026 18:24:42 -0500 Subject: [PATCH 5/7] Drop the diagnostic scratch test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It served its purpose (confirming the per-opponent fanout already fires correctly for PutAtLibraryPosition and produces multi_target with controller: TargetPlayer — the prior fix's premise was wrong) but its panic! would red the suite for everyone if left in. Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/parser/oracle_effect/tests.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 6814a83230..581c9a6c63 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -48716,12 +48716,3 @@ fn unless_extraction_offsets_survive_unicode_case_fold() { the boundary, and the mask must not eat the quote)" ); } - -#[test] -fn scratch_riptide_gearhulk_trace() { - let def = parse_effect_chain( - "for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top", - AbilityKind::Spell, - ); - panic!("{:#?}", def); -} From fe6509fef87db6f78487baf90ba950e4dff1cdc5 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 23 Jul 2026 19:05:16 -0500 Subject: [PATCH 6/7] fix(parser): generalize the per-opponent target-fanout min-0 detector beyond "gain control of" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #5994: the caster-vs-opponent aliasing half of the bug was already fixed upstream — Effect::PutAtLibraryPosition is wired into Effect::target_filter(), so the existing per-opponent target fanout (parse_for_each_opponent_target_fanout_clause) already binds Riptide Gearhulk's "that player controls" to ControllerRef::TargetPlayer correctly, the same way it does for the working GainControl/ChangeZone precedents. What survived is the fizzle half: per_opponent_target_fanout_min only recognized the "up to N" min-0 shape after a literal "gain control of " prefix. Every other per-opponent-fanout verb (put, exile, ...) fell back to min: 1, forcing a target from every opponent even though "up to one" should allow skipping. Generalize the detector to scan at word boundaries for an "up to N target "/"any number of target " quantifier anywhere in the clause, reusing strip_optional_target_prefix (not the bare strip_leading_quantifier MULTI_TARGET_VERBS uses) so it can't misfire on a resource-count quantifier that happens to precede the object noun (e.g. "put up to three +1/+1 counters on target creature" — the "target " guard inside strip_optional_target_prefix declines that). Verified with cargo check + cargo clippy -D warnings for the engine crate via a manually-provisioned nightly-x86_64-pc-windows-gnu toolchain (this sandbox's default MSVC toolchain has no linker installed, and full test linking additionally needs a working GNU assembler this sandbox doesn't have either — see PR discussion). Both passed clean. Full test execution (including the new regression test) still needs CI. Co-Authored-By: Claude Sonnet 5 --- .../engine/src/parser/oracle_effect/lower.rs | 33 ++++++++--- .../engine/src/parser/oracle_effect/tests.rs | 56 +++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index b7e2581a49..ce5447ea6b 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -3588,15 +3588,34 @@ pub(crate) fn target_filter_is_single_object_target(filter: &TargetFilter) -> bo } } +/// #5994: whether the per-opponent fanout slot is optional (min 0) or +/// mandatory (min 1). Scans at word boundaries for an "up to N target …" / +/// "any number of target …" quantifier anywhere in the clause — not just +/// after "gain control of" — so every verb in the per-opponent-target-fanout +/// class ("gain control of up to one target …", "exile up to one target …", +/// "put up to one target … into its owner's library …") shares one min-0 +/// detector instead of each verb needing its own hardcoded prefix. Reusing +/// `strip_optional_target_prefix` (rather than the bare `strip_leading_quantifier` +/// used by `MULTI_TARGET_VERBS`) is the safety property this relies on: it only +/// accepts a quantifier immediately followed by "target "/"other target "/ +/// "another target ", so it can't misfire on a resource-count quantifier that +/// happens to precede the object noun (e.g. "put up to three +1/+1 counters on +/// target creature" — the quantity there modifies the counters, not the +/// target, and the "target " guard declines it). fn per_opponent_target_fanout_min(text: &str) -> usize { let lower = text.to_ascii_lowercase(); - let Some((_, rest)) = nom_on_lower(text, &lower, |input| { - value((), tag("gain control of ")).parse(input) - }) else { - return 1; - }; - let (_, spec) = strip_optional_target_prefix(rest); - if spec.is_some_and(|spec| spec.min_is_fixed_zero()) { + let found_optional_target_slot = + nom_primitives::scan_at_word_boundaries(lower.as_str(), |input| { + match strip_optional_target_prefix(input) { + (rest, Some(spec)) if spec.min_is_fixed_zero() => Ok((rest, ())), + _ => Err(nom::Err::Error(OracleError::new( + input, + nom::error::ErrorKind::Fail, + ))), + } + }) + .is_some(); + if found_optional_target_slot { 0 } else { 1 diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 581c9a6c63..bc762ac35e 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -32074,6 +32074,62 @@ fn effect_for_each_opponent_gain_control_uses_per_opponent_target_fanout() { } } +/// #5994: Riptide Gearhulk's ETB — "for each opponent, put up to one target +/// nonland permanent that player controls into its owner's library third +/// from the top." The per-opponent fanout already binds the target's +/// controller to `TargetPlayer` correctly here (`Effect::PutAtLibraryPosition` +/// is wired into `Effect::target_filter()`, and the noun phrase is structurally +/// identical to the working `GainControl`/`ChangeZone` fanout precedents), so +/// the caster-vs-opponent aliasing half of the bug was already fixed upstream. +/// What survived was `MultiTargetSpec.min`: `per_opponent_target_fanout_min` +/// only recognized the min-0 ("up to") shape after a literal "gain control of " +/// prefix, so every other per-opponent-fanout verb ("put", "exile", …) fell +/// back to `min: 1` — forcing a target from every opponent's permanents even +/// though "up to one" should allow skipping — which is the "fizzles if +/// skipped" half of the report. +#[test] +fn effect_for_each_opponent_put_at_library_position_uses_optional_per_opponent_fanout() { + let def = parse_effect_chain( + "for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top.", + AbilityKind::Spell, + ); + + assert!(def.repeat_for.is_none()); + assert_eq!( + def.multi_target, + Some(MultiTargetSpec::bounded( + 0, + QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent, + }, + }, + )), + "an \"up to one\" per-opponent slot must be optional (min 0), not mandatory" + ); + match &*def.effect { + Effect::PutAtLibraryPosition { + target: TargetFilter::Typed(tf), + position: LibraryPosition::NthFromTop { n }, + .. + } => { + assert_eq!( + tf.controller, + Some(ControllerRef::TargetPlayer), + "target must be scoped to the iterated opponent, not the caster" + ); + assert!(tf + .type_filters + .iter() + .any(|filter| matches!(filter, TypeFilter::Permanent))); + assert_eq!(*n, 3); + } + other => { + panic!("expected PutAtLibraryPosition TargetPlayer nonland permanent, got {other:?}") + } + } +} + #[test] fn choose_two_target_creatures_controlled_by_different_players_sets_target_constraints() { let def = parse_effect_chain( From 17372095a50557740545dff8e5e47d8b41b6d640 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Fri, 24 Jul 2026 06:08:08 -0500 Subject: [PATCH 7/7] fix(parser): narrow per_opponent_target_fanout_min's doc comment to what it actually covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the comment claimed the detector recognizes "any number of target …" in addition to "up to N target …", but it calls strip_optional_target_prefix, which only matches "up to " — the "any number of" arm lives in strip_leading_quantifier, which this function doesn't call. No card in the per-opponent-fanout class uses that form today, so state the gap explicitly instead of overclaiming coverage. Also correct the verb-class description: a MULTI_TARGET_VERBS verb like "exile" takes its min from stripped_multi_target upstream and never reaches this function — only verbs outside that list ("put", "gain control of") fall through to this detector. Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/parser/oracle_effect/lower.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index ce5447ea6b..5abbd9072d 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -3589,12 +3589,17 @@ pub(crate) fn target_filter_is_single_object_target(filter: &TargetFilter) -> bo } /// #5994: whether the per-opponent fanout slot is optional (min 0) or -/// mandatory (min 1). Scans at word boundaries for an "up to N target …" / -/// "any number of target …" quantifier anywhere in the clause — not just -/// after "gain control of" — so every verb in the per-opponent-target-fanout -/// class ("gain control of up to one target …", "exile up to one target …", -/// "put up to one target … into its owner's library …") shares one min-0 -/// detector instead of each verb needing its own hardcoded prefix. Reusing +/// mandatory (min 1), for verbs that fall through to this detector because +/// they aren't in `MULTI_TARGET_VERBS` (e.g. "put", "gain control of") — a +/// `MULTI_TARGET_VERBS` verb like "exile" takes its min from +/// `stripped_multi_target` upstream and never reaches this function. Scans at +/// word boundaries for an "up to N target …" quantifier anywhere in the +/// clause, not just immediately after the verb, so one detector covers every +/// non-`MULTI_TARGET_VERBS` verb instead of each needing its own hardcoded +/// prefix (the prior version only recognized "gain control of "). This does +/// NOT recognize "any number of target …" — that arm lives in +/// `strip_leading_quantifier`, which this function doesn't call; no card in +/// the per-opponent-fanout class currently uses that form. Reusing /// `strip_optional_target_prefix` (rather than the bare `strip_leading_quantifier` /// used by `MULTI_TARGET_VERBS`) is the safety property this relies on: it only /// accepts a quantifier immediately followed by "target "/"other target "/