From 01780390f378073812ba411ebe8edf831196d776 Mon Sep 17 00:00:00 2001 From: Jacob Woodson Date: Wed, 5 Aug 2026 01:48:23 -0500 Subject: [PATCH 1/3] Partial: Doom's Time Platform --- .../src/parser/oracle_effect/imperative.rs | 23 ++- crates/engine/src/parser/oracle_effect/mod.rs | 41 ++++++ .../engine/src/parser/oracle_effect/tests.rs | 134 ++++++++++++++++++ ..._time_platform_exile_with_time_counters.rs | 119 ++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/integration/doom_s_time_platform_exile_with_time_counters.rs diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index ea2f7c86be..fa8929f3fd 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -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 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 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 @@ -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 — diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 4c0f1b263f..79ed6c3215 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -34486,6 +34486,47 @@ fn infer_origin_zone(lower: &str) -> Option { } } +/// 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 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, diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 6da5390c94..7c4b947db1 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -35529,6 +35529,140 @@ 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 from your {graveyard,hand,library} with N +/// 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, + 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::Counters { .. })), + "counter clause must not remain a target filter: {:?}", + 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, + 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] diff --git a/crates/engine/tests/integration/doom_s_time_platform_exile_with_time_counters.rs b/crates/engine/tests/integration/doom_s_time_platform_exile_with_time_counters.rs new file mode 100644 index 0000000000..b6c5a1e15c --- /dev/null +++ b/crates/engine/tests/integration/doom_s_time_platform_exile_with_time_counters.rs @@ -0,0 +1,119 @@ +//! Doom's Time Platform: "Whenever you attack, exile target nonland card from +//! your graveyard with two time counters on it. If it doesn't have suspend, it +//! gains suspend." +//! +//! Pre-fix, "with two time counters on it" was consumed by `parse_target` as a +//! `FilterProp::Counters { GE, 2 }` requiring the *graveyard* card to already +//! hold two time counters. Per CR 122.2 a card in a graveyard has no counters, +//! so that filter is vacuous — the trigger had NO legal target and the counter +//! *placement* was dropped entirely. The fix (`split_counterless_enter_counters`) +//! recognizes the counterless origin zone and lifts the clause onto the exile's +//! `enter_with_counters` (the CR 702.62a suspend template), where the resolver +//! stamps the counters as the card enters Exile. +//! +//! This drives the real pipeline (declare attackers → YouAttack trigger → +//! ChangeZone resolution) and discriminates the fix: +//! (a) the graveyard card is exiled (pre-fix it is not even a legal target); +//! (b) it carries two time counters (CR 702.62a); +//! (c) Doom's Time Platform itself stays on the battlefield. + +use engine::game::combat::AttackTarget; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::WaitingFor; +use engine::types::zones::Zone; +use engine::types::Phase; + +const DOOMS_TIME_PLATFORM: &str = "Whenever you attack, exile target nonland card \ + from your graveyard with two time counters on it. If it doesn't have suspend, \ + it gains suspend."; + +/// CR 122.2 + CR 702.62a: the graveyard card selected by Doom's Time Platform's +/// attack trigger must be exiled with two time counters — not filtered out for +/// lacking counters it can never have in a graveyard. +#[test] +fn dooms_time_platform_exiles_graveyard_card_with_two_time_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Doom's Time Platform on P0's battlefield (its type is irrelevant to the + // "you attack" trigger; the fixture uses a noncreature permanent so it is + // not itself a candidate attacker). + let platform = scenario + .add_enchantment_from_oracle(P0, "Doom's Time Platform", DOOMS_TIME_PLATFORM) + .id(); + + // A nonland (creature) card in P0's own graveyard — the trigger's target. + let graveyard_card = scenario + .add_creature_to_graveyard(P0, "Grizzly Bear", 2, 2) + .id(); + + // A separate creature to attack with, firing "Whenever you attack". + let attacker = scenario.add_creature(P0, "Runeclaw Bear", 2, 2).id(); + + let mut runner = scenario.build(); + + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P1))]) + .expect("declare attacker to fire the you-attack trigger"); + + // Drive the YouAttack trigger: choose the (only) legal graveyard target and + // let it resolve. `choose_first_legal_target` panics pre-fix because the + // spurious `Counters GE 2` filter leaves the graveyard card illegal. + for _ in 0..40 { + match runner.state().waiting_for.clone() { + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::TargetSelection { .. } => { + runner.choose_first_legal_target().expect( + "graveyard card must be a legal target once the counter clause is lifted", + ); + } + WaitingFor::Priority { .. } => { + if runner.state().stack.is_empty() { + break; + } + if runner.act(GameAction::PassPriority).is_err() { + break; + } + } + _ => break, + } + } + + let card = runner + .state() + .objects + .get(&graveyard_card) + .expect("graveyard card object must still exist"); + + // (a) The graveyard card was exiled. + assert_eq!( + card.zone, + Zone::Exile, + "CR 400.7: the targeted graveyard card must move to Exile, got {:?}", + card.zone, + ); + assert!( + runner.state().exile.contains(&graveyard_card), + "the exiled card must be in the exile zone", + ); + + // (b) It carries exactly two time counters (CR 702.62a). Pre-fix this is 0 + // (the placement was dropped) — and the card would not even be a legal + // target — so this assertion flips when the fix is reverted. + let time = card.counters.get(&CounterType::Time).copied().unwrap_or(0); + assert_eq!( + time, 2, + "CR 702.62a: the exiled card must enter with two time counters, got {time}", + ); + + // (c) Doom's Time Platform itself is untouched — it stays on the battlefield. + assert!( + runner.state().battlefield.contains(&platform), + "Doom's Time Platform must remain on the battlefield, not exile itself", + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 61c937eb09..3863b91143 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -158,6 +158,7 @@ mod diluvian_primordial_6754; mod disjunctive_state_change_head_coverage_honesty; mod disorder_in_the_court_5955; mod divine_visitation_token_substitution; +mod doom_s_time_platform_exile_with_time_counters; mod doran_attack_block_pump; mod double_strike_first_strike_trigger_removes_attacker; mod dragonstorm_forecaster_named_or_tutor; From 658fe3fa0313a1740c79453a8f7c79a009813133 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 20:40:50 -0700 Subject: [PATCH 2/3] test(PR-7022): complete exile counter origin matrix --- .../engine/src/parser/oracle_effect/tests.rs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 9db4f1fe5a..02808835e8 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -35770,6 +35770,7 @@ fn exile_library_descriptive_target_with_counters_lifts_to_enter_with_counters() ); let Effect::ChangeZone { destination: Zone::Exile, + origin: Some(Zone::Library), target: TargetFilter::Typed(typed), enter_with_counters, .. @@ -35785,6 +35786,16 @@ fn exile_library_descriptive_target_with_counters_lifts_to_enter_with_counters() &[(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 @@ -35795,6 +35806,98 @@ fn exile_library_descriptive_target_with_counters_lifts_to_enter_with_counters() ); } +/// 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 leaves +/// the target text intact for ordinary target filtering. +#[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 + ); + assert!( + typed.properties.iter().any(|p| matches!( + p, + FilterProp::WithKeyword { + value: Keyword::Flying + } + )), + "the non-counter clause must remain a target filter: {:?}", + 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 @@ -35809,6 +35912,7 @@ fn exile_battlefield_target_with_counters_stays_a_target_filter() { ); let Effect::ChangeZone { destination: Zone::Exile, + origin: None, target: TargetFilter::Typed(typed), enter_with_counters, .. From 5aa771cba36a95f4860308e76fd8d888cad3cfa4 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 21:27:29 -0700 Subject: [PATCH 3/3] test(PR-7022): correct non-counter target expectation --- crates/engine/src/parser/oracle_effect/tests.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 02808835e8..858b67f6cc 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -35853,8 +35853,8 @@ fn exile_hand_descriptive_target_with_counters_lifts_to_enter_with_counters() { } /// 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 leaves -/// the target text intact for ordinary target filtering. +/// 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( @@ -35886,16 +35886,6 @@ fn exile_hand_target_with_non_counter_clause_preserves_target_filter() { "target must retain its hand origin constraint: {:?}", typed.properties ); - assert!( - typed.properties.iter().any(|p| matches!( - p, - FilterProp::WithKeyword { - value: Keyword::Flying - } - )), - "the non-counter clause must remain a target filter: {:?}", - typed.properties - ); } /// Negative reach-guard for the counterless-origin lift: a BATTLEFIELD target