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
54 changes: 54 additions & 0 deletions crates/engine/src/parser/oracle_effect/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2970,6 +2974,34 @@ pub(crate) fn extract_bounded_target_multi_target(text: &str) -> Option<MultiTar
None
}

/// CR 115.1d: Recover "up to N target …" from imperative text where the verb
/// precedes the count phrase — "tap up to four target permanents" (Elder
/// Deep-Fiend). The targeted-action parser strips the count via
/// `strip_optional_target_prefix` but does not attach `MultiTargetSpec`.
pub(crate) fn extract_optional_target_multi_target(text: &str) -> Option<MultiTargetSpec> {
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 <filter>" 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<MultiTargetSpec> {
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(
Expand Down Expand Up @@ -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"
Expand Down
109 changes: 109 additions & 0 deletions crates/engine/tests/integration/issue_3274_elder_deep_fiend.rs
Original file line number Diff line number Diff line change
@@ -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<ManaUnit> {
(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:?}"
),
}
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,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;
Expand Down
Loading