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
23 changes: 22 additions & 1 deletion crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8879,7 +8879,20 @@ pub(super) fn parse_exile_ast(
// bodies ("Whenever an Elf you control dies, exile it") bind to the
// triggering subject via `resolve_pronoun_target`, not the ability source.
// Issue #319: Serpent's Soul-Jar exiled itself instead of the dying Elf.
let (parsed_target, rem) = parse_target_with_ctx(rest_text, ctx);
//
// CR 122.2 + CR 122.1 + CR 702.62a: For an implicit-destination exile whose
// descriptive target is drawn from a COUNTERLESS origin zone (graveyard /
// hand / library), a trailing "with N <type> counters on it" clause is an
// ENTER-WITH-COUNTERS rider (the suspend template "…exile it with N time
// counters on it"), not a vacuous target filter — split it off the target
// text BEFORE parse_target so it never becomes a `FilterProp::Counters`
// (Doom's Time Platform; the descriptive-target sibling of Taigam's
// anaphoric "exile the spell you cast with four time counters"). Exile /
// battlefield origins are excluded (cards there CAN bear counters), so this
// never touches the anaphor recovery, hand arm, or return-to-battlefield
// path below.
let (target_input, pre_lifted_counters) = super::split_counterless_enter_counters(rest_text);
let (parsed_target, rem) = parse_target_with_ctx(target_input, ctx);
// CR 122.1 + CR 702.62: "exile … with N <type> counter(s) on it" lifts the
// counter clause onto the exile ChangeZone's `enter_with_counters` so the
// object enters Exile carrying them (Taigam, Master Opportunist: "exile the
Expand All @@ -8892,6 +8905,14 @@ pub(super) fn parse_exile_ast(
let rem_lower = rem.to_ascii_lowercase();
let (mut enter_with_counters, counters_offset) =
super::parse_with_counters_suffix_spanned(&rem_lower);
// CR 122.2 + CR 702.62a: Adopt the counters lifted off a counterless-origin
// descriptive target above (Doom's Time Platform) when the post-target
// remainder carried none. The origin gate in `split_counterless_enter_counters`
// already excluded exile/battlefield targets, so this only fires for the
// graveyard/hand/library reading where the filter would have been vacuous.
if enter_with_counters.is_empty() && !pre_lifted_counters.is_empty() {
enter_with_counters = pre_lifted_counters;
}
// CR 122.1 + CR 702.62b: An anaphoric exile target ("that card" / "it" /
// "those cards") greedily absorbs the trailing counter instruction —
// `parse_target` returns `ParentTarget`/`SelfRef` with an EMPTY remainder —
Expand Down
41 changes: 41 additions & 0 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34666,6 +34666,47 @@ fn infer_origin_zone(lower: &str) -> Option<Zone> {
}
}

/// CR 122.2 (docs/MagicCompRules.txt): "Counters on an object are not retained
/// if that object moves from one zone to another … they simply cease to exist";
/// CR 400.7 makes the moved object a new object. So a card selected from a
/// COUNTERLESS origin zone (graveyard / hand / library) has no counters, and a
/// trailing "with N <type> counter(s) on it" clause on an exile of such a card
/// cannot be a target FILTER (that reading is vacuous — nothing there ever
/// bears counters). It is instead the ENTER-WITH-COUNTERS rider the object is
/// given as it enters Exile, exactly the suspend template of CR 702.62a
/// ("…exile it with N time counters on it") and CR 122.1: Doom's Time Platform
/// ("exile target nonland card from your graveyard with two time counters on
/// it"); the descriptive-target sibling of Taigam's anaphoric "exile the spell
/// you cast with four time counters on it".
///
/// Returns `(target_text_without_rider, lifted_counters)`. For exile /
/// battlefield / unknown origins — where a card CAN bear counters (suspend time
/// counters; "a creature card in exile with a takeover counter on it") — the
/// clause is returned UNCHANGED so it remains a filter.
///
/// The origin gate (`infer_origin_zone`) and the counter clause detection
/// (`parse_with_counters_suffix_spanned`, a nom `scan_preceded`) are both the
/// parser acting as detector: a non-counter "with …" (e.g. "with flying") fails
/// the counter body and is left in place. `off` indexes the ASCII-lowercase
/// copy; `to_ascii_lowercase` is byte-length preserving, so it is a valid char
/// boundary in `clause` (same offset-slice invariant as `parse_exile_ast`).
pub(super) fn split_counterless_enter_counters(
clause: &str,
) -> (&str, Vec<(CounterType, QuantityExpr)>) {
let lower = clause.to_ascii_lowercase();
if !matches!(
infer_origin_zone(&lower),
Some(Zone::Graveyard | Zone::Hand | Zone::Library)
) {
return (clause, Vec::new());
}
let (counters, offset) = parse_with_counters_suffix_spanned(&lower);
match offset {
Some(off) if !counters.is_empty() => (clause[..off].trim_end(), counters),
_ => (clause, Vec::new()),
}
}

fn add_inferred_origin_constraints_to_target(
target: TargetFilter,
origin: Option<Zone>,
Expand Down
228 changes: 228 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35699,6 +35699,234 @@ fn exile_anaphor_with_time_counters_lifts_to_enter_with_counters() {
);
}

/// CR 122.2 + CR 122.1 + CR 702.62a: "Exile target nonland card from your
/// graveyard with two time counters on it." (Doom's Time Platform). A card in a
/// graveyard bears no counters (CR 122.2 — counters cease to exist on a zone
/// change; CR 400.7 makes the moved object a new object), so "with two time
/// counters on it" is the ENTER-WITH-COUNTERS rider the object receives as it
/// enters Exile (the suspend template of CR 702.62a "…exile it with N time
/// counters on it"), NOT a target filter demanding the graveyard card already
/// hold >=2 time counters (a vacuous, unsatisfiable reading). This is the
/// descriptive-target sibling of the anaphor test above.
///
/// Revert-guard: pre-fix this clause parsed a `FilterProp::Counters { GE, 2 }`
/// on the target and an EMPTY `enter_with_counters` — both assertions below
/// flip if the fix is reverted.
#[test]
fn exile_graveyard_descriptive_target_with_counters_lifts_to_enter_with_counters() {
let def = parse_effect_chain(
"Exile target nonland card from your graveyard with two time counters on it.",
AbilityKind::Spell,
);
let Effect::ChangeZone {
destination: Zone::Exile,
origin: Some(Zone::Graveyard),
target: TargetFilter::Typed(typed),
enter_with_counters,
..
} = &*def.effect
else {
panic!(
"expected ChangeZone->Exile(Typed) from graveyard, got: {:?}",
def.effect
);
};
assert_eq!(
enter_with_counters.as_slice(),
&[(CounterType::Time, QuantityExpr::Fixed { value: 2 })],
"expected (Time, 2) enter_with_counters, got: {enter_with_counters:?}"
);
assert!(
typed.properties.iter().any(|p| matches!(
p,
FilterProp::InZone {
zone: Zone::Graveyard
}
)),
"target must retain its graveyard origin constraint: {:?}",
typed.properties
);
assert!(
!typed
.properties
.iter()
.any(|p| matches!(p, FilterProp::Counters { .. })),
"the counter clause must NOT remain a vacuous target filter: {:?}",
typed.properties
);
}

/// CR 122.2 + CR 702.62a: the counterless-origin lift is class-level, not a
/// Doom's Time Platform special case — a LIBRARY origin ("exile target card
/// from your library with a +1/+1 counter on it") is equally counterless, so
/// the clause is an enter-with-counters rider rather than a filter. Guards the
/// whole "exile <descriptive target> from your {graveyard,hand,library} with N
/// <type> counter(s) on it" class.
#[test]
fn exile_library_descriptive_target_with_counters_lifts_to_enter_with_counters() {
let def = parse_effect_chain(
"Exile target card from your library with a +1/+1 counter on it.",
AbilityKind::Spell,
);
let Effect::ChangeZone {
destination: Zone::Exile,
origin: Some(Zone::Library),
target: TargetFilter::Typed(typed),
enter_with_counters,
..
} = &*def.effect
else {
panic!(
"expected ChangeZone->Exile(Typed) from library, got: {:?}",
def.effect
);
};
assert_eq!(
enter_with_counters.as_slice(),
&[(CounterType::Plus1Plus1, QuantityExpr::Fixed { value: 1 })],
"expected (+1/+1, 1) enter_with_counters, got: {enter_with_counters:?}"
);
assert!(
typed.properties.iter().any(|p| matches!(
p,
FilterProp::InZone {
zone: Zone::Library
}
)),
"target must retain its library origin constraint: {:?}",
typed.properties
);
assert!(
!typed
.properties
.iter()
.any(|p| matches!(p, FilterProp::Counters { .. })),
"counter clause must not remain a target filter: {:?}",
typed.properties
);
}

/// The same trailing counter rider on a card selected from HAND belongs to the
/// destination object. The target must nevertheless retain its hand constraint,
/// so this covers the hand arm of the counterless-origin split independently of
/// the graveyard and library cases.
#[test]
fn exile_hand_descriptive_target_with_counters_lifts_to_enter_with_counters() {
let def = parse_effect_chain(
"Exile target creature card from your hand with a time counter on it.",
AbilityKind::Spell,
);
let Effect::ChangeZone {
destination: Zone::Exile,
origin: Some(Zone::Hand),
target: TargetFilter::Typed(typed),
enter_with_counters,
..
} = &*def.effect
else {
panic!(
"expected ChangeZone->Exile(Typed) from hand, got: {:?}",
def.effect
);
};
assert_eq!(
enter_with_counters.as_slice(),
&[(CounterType::Time, QuantityExpr::Fixed { value: 1 })],
"expected (Time, 1) enter_with_counters, got: {enter_with_counters:?}"
);
assert!(
typed
.properties
.iter()
.any(|p| matches!(p, FilterProp::InZone { zone: Zone::Hand })),
"target must retain its hand origin constraint: {:?}",
typed.properties
);
assert!(
!typed
.properties
.iter()
.any(|p| matches!(p, FilterProp::Counters { .. })),
"counter rider must not remain a target filter: {:?}",
typed.properties
);
}

/// A non-counter `with …` phrase on a hand target is not an enter-with-
/// counters rider. This reach-guard ensures the counter parser's failure
/// preserves the target's hand-origin constraint.
#[test]
fn exile_hand_target_with_non_counter_clause_preserves_target_filter() {
let def = parse_effect_chain(
"Exile target creature card from your hand with flying.",
AbilityKind::Spell,
);
let Effect::ChangeZone {
destination: Zone::Exile,
origin: Some(Zone::Hand),
target: TargetFilter::Typed(typed),
enter_with_counters,
..
} = &*def.effect
else {
panic!(
"expected ChangeZone->Exile(Typed) from hand, got: {:?}",
def.effect
);
};
assert!(
enter_with_counters.is_empty(),
"a non-counter clause must not create enter_with_counters: {enter_with_counters:?}"
);
assert!(
typed
.properties
.iter()
.any(|p| matches!(p, FilterProp::InZone { zone: Zone::Hand })),
"target must retain its hand origin constraint: {:?}",
typed.properties
);
}

/// Negative reach-guard for the counterless-origin lift: a BATTLEFIELD target
/// ("exile target creature with two +1/+1 counters on it") CAN legitimately
/// bear counters (CR 122.1), so the origin gate must NOT fire — the clause stays
/// a `FilterProp::Counters` target filter and `enter_with_counters` stays empty.
/// Pairs with the positive tests to prove the split is origin-scoped, not a
/// blanket rewrite of every "exile … with N counters" clause.
#[test]
fn exile_battlefield_target_with_counters_stays_a_target_filter() {
let def = parse_effect_chain(
"Exile target creature with two +1/+1 counters on it.",
AbilityKind::Spell,
);
let Effect::ChangeZone {
destination: Zone::Exile,
origin: None,
target: TargetFilter::Typed(typed),
enter_with_counters,
..
} = &*def.effect
else {
panic!("expected ChangeZone->Exile(Typed), got: {:?}", def.effect);
};
assert!(
enter_with_counters.is_empty(),
"a battlefield target's counter clause must NOT be lifted: {enter_with_counters:?}"
);
assert!(
typed.properties.iter().any(|p| matches!(
p,
FilterProp::Counters {
comparator: Comparator::GE,
..
}
)),
"battlefield counter clause must remain a target filter: {:?}",
typed.properties
);
}

/// CR 701.20a: Passive form without inline exile — Blessed Reincarnation pattern.
/// "That player reveals cards … until a creature card is revealed."
#[test]
Expand Down
Loading
Loading