Skip to content
Open
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 @@ -34486,6 +34486,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
134 changes: 134 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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,
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
);
}
Comment on lines +35589 to +35664

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Complete the parser regression matrix.

The library test does not assert origin: Some(Zone::Library) or FilterProp::InZone { Zone::Library }. An origin-loss regression can still pass while applying counters to the wrong target class.

Add parser cases for Zone::Hand and a non-counter with … clause. The helper has explicit behavior for both cases.

As per path instructions, add parser tests for graveyard, hand, library, battlefield, and non-counter variants.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_effect/tests.rs` around lines 35589 - 35664,
Complete the regression matrix around the existing exile counter tests, covering
graveyard, hand, library, battlefield, and a non-counter “with …” clause. In
each origin-sensitive case, assert the parsed target preserves the expected
origin via origin or FilterProp::InZone, while confirming only
graveyard/hand/library lift counters to enter_with_counters and battlefield
retains FilterProp::Counters. Use the existing parse_effect_chain and target
pattern in the named tests.

Source: Path instructions


/// CR 701.20a: Passive form without inline exile — Blessed Reincarnation pattern.
/// "That player reveals cards … until a creature card is revealed."
#[test]
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading