From 079aa04ab5581a3b673cb8438041abf5bf6e0391 Mon Sep 17 00:00:00 2001 From: andriypolanski Date: Thu, 18 Jun 2026 15:15:16 -0700 Subject: [PATCH 1/2] fix(parser): recover multi-target spec for tap/untap up-to phrases (#3274) --- .../engine/src/parser/oracle_effect/lower.rs | 54 +++++++++ .../issue_3274_elder_deep_fiend.rs | 109 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 3 files changed, 164 insertions(+) create mode 100644 crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index dc3942e36d..f01da3bf75 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -557,6 +557,10 @@ pub(crate) fn lower_effect_chain_ir(ir: &EffectChainIr) -> AbilityDefinition { def = def.multi_target(spec); } else if let Some(spec) = extract_bounded_target_multi_target(&clause_ir.source_text) { def = def.multi_target(spec); + } else if let Some(spec) = extract_optional_target_multi_target(&clause_ir.source_text) { + def = def.multi_target(spec); + } else if let Some(spec) = extract_verb_up_to_multi_target(&clause_ir.source_text) { + def = def.multi_target(spec); } else if let Some(ref spec) = clause_ir.multi_target { def = def.multi_target(spec.clone()); } else if let Some(ref spec) = clause_ir.parsed.multi_target { @@ -2970,6 +2974,34 @@ pub(crate) fn extract_bounded_target_multi_target(text: &str) -> Option Option { + let lower = text.to_lowercase(); + for verb in MULTI_TARGET_VERBS { + let Ok((after_verb, _)) = + terminated(tag::<_, _, OracleError<'_>>(*verb), tag(" ")).parse(lower.as_str()) + else { + continue; + }; + let (_, multi_target) = strip_optional_target_prefix(after_verb); + if multi_target.is_some() { + return multi_target; + } + } + None +} + +/// CR 115.1d: Recover "verb up to N " when the phrase omits the word +/// "target" — "untap up to five lands" (Peregrine Drake). Delegates to +/// `strip_any_number_quantifier`, which is the single authority for that shape. +pub(crate) fn extract_verb_up_to_multi_target(text: &str) -> Option { + let (_, multi_target) = strip_any_number_quantifier(text); + multi_target +} + fn parse_controlled_by_different_players_target_constraint(text: &str) -> bool { let lower = text.to_lowercase(); let mut parser = preceded( @@ -6078,6 +6110,28 @@ mod tests { use crate::types::triggers::TriggerMode; use crate::types::zones::Zone; + #[test] + fn extract_optional_target_multi_target_recovers_tap_up_to_four() { + use crate::types::ability::MultiTargetSpec; + let spec = super::extract_optional_target_multi_target("tap up to four target permanents") + .expect("Elder Deep-Fiend cast trigger shape"); + assert_eq!( + spec, + MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 4 }) + ); + } + + #[test] + fn extract_verb_up_to_multi_target_recovers_untap_lands() { + use crate::types::ability::MultiTargetSpec; + let spec = super::extract_verb_up_to_multi_target("untap up to five lands") + .expect("Peregrine Drake ETB shape"); + assert_eq!( + spec, + MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 5 }) + ); + } + #[test] fn distribute_damage_power_equal_pattern() { // Gap 1: "damage equal to its power" — Pattern B where qty follows "damage equal to" diff --git a/crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs b/crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs new file mode 100644 index 0000000000..33a112a4b6 --- /dev/null +++ b/crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs @@ -0,0 +1,109 @@ +//! Issue #3274 — Elder Deep-Fiend cast trigger must carry multi-target metadata +//! for "tap up to four target permanents" so casting does not stall or crash +//! the client during target selection. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{Effect, EffectScope, MultiTargetSpec, QuantityExpr, TapStateChange}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::triggers::TriggerMode; + +const ELDER_DEEP_FIEND_ORACLE: &str = "Flash\n\ +Emerge {5}{U}{U} (You may cast this spell by sacrificing a creature and paying the emerge cost reduced by that creature's mana value.)\n\ +When you cast this spell, tap up to four target permanents."; + +fn floating_mana(n: usize, ty: ManaType) -> Vec { + (0..n) + .map(|_| ManaUnit::new(ty, ObjectId(0), false, vec![])) + .collect() +} + +#[test] +fn elder_deep_fiend_cast_trigger_parses_up_to_four_multi_target() { + let parsed = parse_oracle_text( + ELDER_DEEP_FIEND_ORACLE, + "Elder Deep-Fiend", + &["Flash".to_string()], + &["Creature".to_string()], + &["Eldrazi".to_string(), "Octopus".to_string()], + ); + let cast_trigger = parsed + .triggers + .iter() + .find(|t| matches!(t.mode, TriggerMode::SpellCast { .. })) + .expect("Elder Deep-Fiend must have a When-you-cast trigger"); + let execute = cast_trigger + .execute + .as_ref() + .expect("cast trigger must have execute ability"); + assert_eq!( + execute.multi_target, + Some(MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 4 })), + "tap up to four target permanents must stamp MultiTargetSpec::up_to(4)" + ); + assert!( + matches!( + &*execute.effect, + Effect::SetTapState { + scope: EffectScope::Single, + state: TapStateChange::Tap, + .. + } + ), + "cast trigger effect must be single-target tap, got {:?}", + execute.effect + ); +} + +#[test] +fn elder_deep_fiend_cast_trigger_surfaces_four_optional_target_slots() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + for i in 0..4 { + scenario.add_creature(P1, &format!("Defender {i}"), 1, 1); + } + + let deep_fiend = scenario + .add_creature_to_hand_from_oracle(P0, "Elder Deep-Fiend", 6, 6, ELDER_DEEP_FIEND_ORACLE) + .id(); + scenario.with_mana_pool( + P0, + [ + floating_mana(6, ManaType::Blue), + floating_mana(5, ManaType::Colorless), + ] + .concat(), + ); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&deep_fiend].card_id; + runner + .act(GameAction::CastSpell { + object_id: deep_fiend, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("Elder Deep-Fiend must be castable from hand"); + + match &runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { target_slots, .. } => { + assert_eq!( + target_slots.len(), + 4, + "tap up to four target permanents must surface four trigger target slots" + ); + assert!( + target_slots.iter().all(|slot| slot.optional), + "all four slots must be optional for an up-to-four trigger" + ); + } + other => panic!( + "expected TriggerTargetSelection for Elder Deep-Fiend cast trigger, got {other:?}" + ), + } +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index dac359cd79..f13f7835ea 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -239,6 +239,7 @@ mod issue_2943_kayas_wrath; mod issue_3127_reveal_otherwise_graveyard; mod issue_3245_abhorrent_oculus_manifest_dread; mod issue_3260_phantasmal_image_persist; +mod issue_3274_elder_deep_fiend; mod issue_3282_consign_to_memory_counter; mod issue_3283_sevinne_reclamation_copy_no_self_copy; mod issue_3285_face_down_public_zone; From ccc45fe470a19d38ea6b5174f61188c8d642fb0a Mon Sep 17 00:00:00 2001 From: andriypolanski Date: Thu, 18 Jun 2026 15:40:35 -0700 Subject: [PATCH 2/2] fix: remove unnecessary {..} code --- crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs b/crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs index 33a112a4b6..732f0e6f78 100644 --- a/crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs +++ b/crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs @@ -34,7 +34,7 @@ fn elder_deep_fiend_cast_trigger_parses_up_to_four_multi_target() { let cast_trigger = parsed .triggers .iter() - .find(|t| matches!(t.mode, TriggerMode::SpellCast { .. })) + .find(|t| matches!(&t.mode, TriggerMode::SpellCast)) .expect("Elder Deep-Fiend must have a When-you-cast trigger"); let execute = cast_trigger .execute