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
53 changes: 34 additions & 19 deletions crates/engine/src/parser/oracle_effect/subject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3333,7 +3333,8 @@ fn try_split_pump_compound(

// CR 608.2d: a pump compounded with a modal keyword grant --
// "gets +1/+1 and gains your choice of deathtouch or lifelink" (Alchemist's
// Gift) -- has a grant half that is a two-branch player choice, so it cannot
// Gift) -- has a grant half that is an N-branch player choice (two or more
// options, e.g. Golem Artisan's "flying, trample, or haste"), so it cannot
// collapse into a single `ContinuousModification` the way a fixed "and gains
// trample" does (which the guard above routes to `build_continuous_clause`'s
// coalescing path). Route the choice through the same
Expand Down Expand Up @@ -3368,7 +3369,7 @@ fn try_split_pump_compound(
}

/// CR 608.2d: build the modal keyword-grant half of a pump
/// compound ("gets +1/+1 AND gains your choice of X or Y") as a `ChooseOneOf`
/// compound ("gets +1/+1 AND gains your choice of X, Y, or Z") as a `ChooseOneOf`
/// sub_ability. Reuses the same `parse_keyword_choice_grant` /
/// `keyword_choice_branch` builders as the standalone `build_keyword_choice_clause`,
/// keyed to the pumped creature via `static_affected_for_application`
Expand All @@ -3384,13 +3385,13 @@ fn build_keyword_choice_sub_ability(
// "gains your choice of ...". `parse_keyword_choice_grant` anchors on the
// bare "gain ..." form, so deconjugate the remainder here first.
let normalized = deconjugate_verb(remainder);
let (first, second, duration) = parse_keyword_choice_grant(&normalized)?;
let (keywords, duration) = parse_keyword_choice_grant(&normalized)?;
let affected = static_affected_for_application(application);
let choice_duration = duration.clone();
let branches = vec![
keyword_choice_branch(first, affected.clone(), None, duration.clone()),
keyword_choice_branch(second, affected, None, duration),
];
let branches = keywords
.into_iter()
.map(|kw| keyword_choice_branch(kw, affected.clone(), None, duration.clone()))
.collect();
Some((
AbilityDefinition::new(
AbilityKind::Spell,
Expand All @@ -3403,18 +3404,29 @@ fn build_keyword_choice_sub_ability(
))
}

fn parse_keyword_choice_grant(predicate: &str) -> Option<(Keyword, Keyword, Option<Duration>)> {
fn parse_keyword_choice_grant(predicate: &str) -> Option<(Vec<Keyword>, Option<Duration>)> {
let lower = predicate.to_lowercase();

// Shape 1: "gain your choice of X or Y" — an explicit keyword-grant menu.
// Shape 1: "gain your choice of X, Y, or Z" — an explicit keyword-grant menu
// of two OR MORE options (Golem Artisan: "flying, trample, or haste"). Reuse
// the nom-based `split_choice_list_items` splitter (shared with the counter-
// choice and "from among" paths) so an Oxford-comma N-ary list parses without
// manual byte slicing.
if let Ok((choice_text, _)) =
tag::<_, _, OracleError<'_>>("gain your choice of ").parse(lower.as_str())
{
let (keyword_text, duration) = super::strip_trailing_duration(choice_text);
let (_, (left, right)) = nom_primitives::split_once_on(keyword_text.trim(), " or ").ok()?;
let first = parse_granted_keyword_fragment(left.trim())?;
let second = parse_granted_keyword_fragment(right.trim())?;
return Some((first, second, duration.or(Some(Duration::UntilEndOfTurn))));
let items = super::split_choice_list_items(keyword_text.trim())?;
// `separated_list1` succeeds on a single item when there is no separator
// at all; require ≥2 so a lone keyword is not mistaken for a "choice".
if items.len() < 2 {
return None;
}
let keywords: Vec<Keyword> = items
.iter()
.map(|item| parse_granted_keyword_fragment(item.trim()))
.collect::<Option<Vec<Keyword>>>()?;
return Some((keywords, duration.or(Some(Duration::UntilEndOfTurn))));
}

// Shape 2: "gain/have protection from X or from the color of your choice"
Expand Down Expand Up @@ -3446,7 +3458,10 @@ fn parse_keyword_choice_grant(predicate: &str) -> Option<(Keyword, Keyword, Opti
let second = Keyword::Protection(crate::types::keywords::parse_protection_target(
right.trim(),
));
Some((first, second, duration.or(Some(Duration::UntilEndOfTurn))))
Some((
vec![first, second],
duration.or(Some(Duration::UntilEndOfTurn)),
))
}

fn keyword_choice_branch(
Expand Down Expand Up @@ -3476,12 +3491,12 @@ fn build_keyword_choice_clause(
application: &SubjectApplication,
predicate: &str,
) -> Option<ParsedEffectClause> {
let (first, second, duration) = parse_keyword_choice_grant(predicate)?;
let (keywords, duration) = parse_keyword_choice_grant(predicate)?;
let affected = static_affected_for_application(application);
let branches = vec![
keyword_choice_branch(first, affected.clone(), None, duration.clone()),
keyword_choice_branch(second, affected, None, duration),
];
let branches = keywords
.into_iter()
.map(|kw| keyword_choice_branch(kw, affected.clone(), None, duration.clone()))
.collect();

let choose_effect = Effect::ChooseOneOf {
chooser: PlayerFilter::Controller,
Expand Down
107 changes: 107 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13582,6 +13582,113 @@ fn targeted_keyword_choice_grant_uses_target_only_then_choose_one_of() {
}
}

/// Issue #5992: Golem Artisan's "{2}: Target artifact creature gains your choice
/// of flying, trample, or haste until end of turn." A 3-way Oxford-comma choice
/// list must yield a `ChooseOneOf` of THREE branches — the old binary
/// `split_once_on(text, " or ")` path returned `None` here (falling through to
/// `Unimplemented`). Also asserts `target.controller == None`: the real card has
/// NO "you control" restriction on its target (the issue's paraphrase was wrong).
#[test]
fn golem_artisan_three_way_keyword_choice_grant_has_no_controller_restriction() {
let def = parse_effect_chain(
"Target artifact creature gains your choice of flying, trample, or haste until end of turn",
AbilityKind::Spell,
);

let Effect::TargetOnly {
target: TargetFilter::Typed(target),
} = &*def.effect
else {
panic!("expected TargetOnly head, got {:?}", def.effect);
};
// GAP-FIX: positively assert the target carries NO controller restriction —
// Golem Artisan targets ANY artifact creature, not just ones you control.
assert_eq!(target.controller, None);

let choice = def
.sub_ability
.as_deref()
.expect("targeted keyword choice must chain a choice prompt");
let Effect::ChooseOneOf { chooser, branches } = &*choice.effect else {
panic!("expected ChooseOneOf sub-ability, got {:?}", choice.effect);
};
assert_eq!(*chooser, PlayerFilter::Controller);
assert_eq!(branches.len(), 3);

for (branch, keyword) in [
(&branches[0], Keyword::Flying),
(&branches[1], Keyword::Trample),
(&branches[2], Keyword::Haste),
] {
let Effect::GenericEffect {
static_abilities,
duration,
target,
} = &*branch.effect
else {
panic!(
"expected keyword GenericEffect branch, got {:?}",
branch.effect
);
};
assert_eq!(*duration, Some(Duration::UntilEndOfTurn));
assert_eq!(*target, None);
assert_eq!(
static_abilities[0].affected,
Some(TargetFilter::ParentTarget)
);
assert!(static_abilities[0]
.modifications
.contains(&ContinuousModification::AddKeyword { keyword }));
}
}

/// Issue #5992 generality guard: a DIFFERENT real card with the same N-ary
/// "your choice of" keyword-grant shape — Assassin Initiate,
/// "{1}: This creature gains your choice of flying, deathtouch, or lifelink until
/// end of turn." (verbatim Scryfall Oracle text). Self-referential (no target),
/// so the `ChooseOneOf` is the head effect, but must still expand to 3 branches.
#[test]
fn assassin_initiate_three_way_keyword_choice_grant_is_generic() {
let def = parse_effect_chain(
"This creature gains your choice of flying, deathtouch, or lifelink until end of turn",
AbilityKind::Spell,
);

// No target restriction ("This creature") — the ChooseOneOf is the head.
let branches = match &*def.effect {
Effect::ChooseOneOf { chooser, branches } => {
assert_eq!(*chooser, PlayerFilter::Controller);
branches.clone()
}
other => panic!("expected ChooseOneOf head, got {other:?}"),
};
assert_eq!(branches.len(), 3);

let mut granted: Vec<Keyword> = branches
.iter()
.map(|branch| {
let Effect::GenericEffect {
static_abilities, ..
} = &*branch.effect
else {
panic!(
"expected keyword GenericEffect branch, got {:?}",
branch.effect
);
};
match &static_abilities[0].modifications[0] {
ContinuousModification::AddKeyword { keyword } => keyword.clone(),
other => panic!("expected AddKeyword modification, got {other:?}"),
}
})
.collect();
granted.sort_by_key(|k| format!("{k}"));
let mut expected = vec![Keyword::Flying, Keyword::Deathtouch, Keyword::Lifelink];
expected.sort_by_key(|k| format!("{k}"));
assert_eq!(granted, expected);
}

#[test]
fn shared_target_tap_or_untap_uses_resolution_choice() {
let def = parse_effect_chain("Tap or untap target permanent", AbilityKind::Spell);
Expand Down
Loading
Loading