Skip to content
Closed
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
1 change: 1 addition & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ fn fmt_typed_filter(tf: &TypedFilter) -> String {
match prop {
FilterProp::Token => parts.push("token".into()),
FilterProp::NonToken => parts.push("nontoken".into()),
FilterProp::WasPlayed => parts.push("was played".into()),
FilterProp::Attacking { defender } => match defender {
None => parts.push("attacking".into()),
Some(ControllerRef::You) => parts.push("attacking you".into()),
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/derived_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,8 @@ mod tests {
controller: PlayerId(1),
owner: PlayerId(1),
from_zone: Some(Zone::Library),
cast_from_zone: None,
played_from_zone: None,
to_zone: Zone::Hand,
attachments: Vec::new(),
linked_exile_snapshot: Vec::new(),
Expand Down
16 changes: 16 additions & 0 deletions crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ fn filter_prop_uses_object_population(prop: &FilterProp) -> bool {
| FilterProp::ColorCount { .. }
| FilterProp::Token
| FilterProp::NonToken
| FilterProp::WasPlayed
| FilterProp::Attacking { .. }
| FilterProp::Blocking
| FilterProp::BlockingSource
Expand Down Expand Up @@ -347,6 +348,7 @@ fn entered_object_perturbs_filter_prop(
| FilterProp::ColorCount { .. }
| FilterProp::Token
| FilterProp::NonToken
| FilterProp::WasPlayed
| FilterProp::Attacking { .. }
| FilterProp::Blocking
| FilterProp::BlockingSource
Expand Down Expand Up @@ -1220,6 +1222,8 @@ pub fn matches_target_filter_on_lki_snapshot(
controller: lki.controller,
owner: lki.owner,
from_zone: None,
cast_from_zone: None,
played_from_zone: None,
to_zone: Zone::Battlefield,
attachments: vec![],
linked_exile_snapshot: vec![],
Expand Down Expand Up @@ -2580,6 +2584,7 @@ fn spell_record_matches_property(record: &SpellCastRecord, prop: &FilterProp) ->
// for this snapshot shape.
FilterProp::Token => false,
FilterProp::NonToken => true,
FilterProp::WasPlayed => true,
FilterProp::InZone { zone: required } => record.from_zone == *required,
// CR 400.1 + CR 601.2a: cast-origin membership — the record's captured
// from_zone (populated when the spell was put on the stack from where it
Expand Down Expand Up @@ -2888,6 +2893,8 @@ fn matches_filter_prop(
FilterProp::Token => obj.is_token,
// CR 111.1: Nontoken identity of the matched object or event-time snapshot.
FilterProp::NonToken => !obj.is_token,
// CR 305.1 + CR 601.2a: "played by" entry replacements (Uphill Battle).
FilterProp::WasPlayed => obj.played_from_zone.is_some() || obj.cast_from_zone.is_some(),
// CR 508.1b: Attacking creatures may be scoped by defending player
// relation ("attacking", "attacking you", "attacking your opponents").
FilterProp::Attacking { defender } => state.combat.as_ref().is_some_and(|combat| {
Expand Down Expand Up @@ -3630,6 +3637,13 @@ fn zone_change_record_matches_property(
FilterProp::Token => record.is_token,
// CR 111.1 + CR 603.6a: Nontoken identity as of the zone change.
FilterProp::NonToken => !record.is_token,
// CR 305.1 + CR 601.2a: zone-change snapshots carry `from_zone` when
// the object existed in a prior zone (cast/played entry).
// CR 305.1 + CR 601.2a: zone-change snapshots carry cast/play provenance
// when the object was cast or played — not mere zone moves (reanimate).
FilterProp::WasPlayed => {
record.played_from_zone.is_some() || record.cast_from_zone.is_some()
}

// -------- Group 2: source/event relational --------
// CR 109.1 "another": same-object check against the triggering source.
Expand Down Expand Up @@ -9041,6 +9055,8 @@ mod tests {
controller: PlayerId(0),
owner: PlayerId(0),
from_zone: Some(Zone::Battlefield),
cast_from_zone: None,
played_from_zone: None,
to_zone: Zone::Graveyard,
attachments: vec![],
linked_exile_snapshot: vec![],
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/game_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,8 @@ impl GameObject {
controller: self.controller,
owner: self.owner,
from_zone: from,
cast_from_zone: self.cast_from_zone,
played_from_zone: self.played_from_zone,
to_zone: to,
attachments: Vec::new(),
linked_exile_snapshot: Vec::new(),
Expand Down
111 changes: 111 additions & 0 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7439,6 +7439,27 @@ mod tests {
.destination_zone(Zone::Battlefield)
}

fn uphill_battle_replacement() -> ReplacementDefinition {
use crate::types::ability::{
AbilityKind, ControllerRef, FilterProp, TargetFilter, TypedFilter,
};
ReplacementDefinition::new(ReplacementEvent::ChangeZone)
.execute(AbilityDefinition::new(
AbilityKind::Spell,
Effect::SetTapState {
target: TargetFilter::SelfRef,
scope: EffectScope::Single,
state: TapStateChange::Tap,
},
))
.valid_card(TargetFilter::Typed(
TypedFilter::creature()
.controller(ControllerRef::Opponent)
.properties(vec![FilterProp::WasPlayed]),
))
.destination_zone(Zone::Battlefield)
}

fn spelunking_replacement() -> ReplacementDefinition {
use crate::types::ability::{AbilityKind, ControllerRef, TargetFilter, TypedFilter};
ReplacementDefinition::new(ReplacementEvent::ChangeZone)
Expand Down Expand Up @@ -10524,6 +10545,96 @@ mod tests {
assert_eq!(count, 2, "five tokens halved (rounded down) → two");
}

/// CR 305.1 + CR 601.2a: Uphill Battle WasPlayed filter discriminates cast
/// creatures from tokens and from nontokens put onto the battlefield.
#[test]
fn uphill_battle_was_played_filter_matches_cast_creature_not_token() {
use crate::types::card_type::CoreType;

let uphill_id = ObjectId(10);
let mut state = test_state_with_object(
uphill_id,
Zone::Battlefield,
vec![uphill_battle_replacement()],
);
let registry = build_replacement_registry();

let cast_creature = ObjectId(20);
let mut creature = GameObject::new(
cast_creature,
CardId(2),
PlayerId(1),
"Grizzly Bears".to_string(),
Zone::Hand,
);
creature.card_types.core_types.push(CoreType::Creature);
creature.cast_from_zone = Some(Zone::Hand);
state.objects.insert(cast_creature, creature);

let cast_event = ProposedEvent::ZoneChange {
object_id: cast_creature,
from: Zone::Hand,
to: Zone::Battlefield,
cause: None,
attach_to: None,
enter_tapped: EtbTapState::Unspecified,
enter_with_counters: Vec::new(),
controller_override: None,
enter_transformed: false,
face_down_profile: None,
applied: HashSet::new(),
};
let cast_matches = find_applicable_replacements(&state, &cast_event, &registry);
assert!(
cast_matches.iter().any(|rid| rid.source == uphill_id),
"cast creature must match Uphill Battle WasPlayed filter"
);

let token_event = ProposedEvent::CreateToken {
owner: PlayerId(1),
count: 1,
spec: Box::new(test_token_spec(PlayerId(1), CoreType::Creature)),
copy: None,
enter_tapped: EtbTapState::Unspecified,
applied: HashSet::new(),
};
let token_matches = find_applicable_replacements(&state, &token_event, &registry);
assert!(
!token_matches.iter().any(|rid| rid.source == uphill_id),
"tokens put directly onto the battlefield must not match WasPlayed filter"
);

let put_creature = ObjectId(30);
let mut put_obj = GameObject::new(
put_creature,
CardId(3),
PlayerId(1),
"Runeclaw Bear".to_string(),
Zone::Hand,
);
put_obj.card_types.core_types.push(CoreType::Creature);
state.objects.insert(put_creature, put_obj);

let put_event = ProposedEvent::ZoneChange {
object_id: put_creature,
from: Zone::Hand,
to: Zone::Battlefield,
cause: None,
attach_to: None,
enter_tapped: EtbTapState::Unspecified,
enter_with_counters: Vec::new(),
controller_override: None,
enter_transformed: false,
face_down_profile: None,
applied: HashSet::new(),
};
let put_matches = find_applicable_replacements(&state, &put_event, &registry);
assert!(
!put_matches.iter().any(|rid| rid.source == uphill_id),
"nontoken creatures put onto the battlefield without being cast must not match WasPlayed filter"
);
}

/// CR 614.1a: Bloodletter of Aclazotz doubles opponent life loss on the
/// source controller's turn via the LoseLife replacement pipeline.
#[test]
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1710,6 +1710,8 @@ fn zone_change_record_from_spec(
controller: spec.controller,
owner: spec.controller,
from_zone: None,
cast_from_zone: None,
played_from_zone: None,
to_zone: Zone::Battlefield,
attachments: Vec::new(),
linked_exile_snapshot: Vec::new(),
Expand Down
99 changes: 99 additions & 0 deletions crates/engine/src/parser/oracle_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option<Replaceme
return Some(def);
}

// --- "[Type] played by your opponents enter tapped" (Uphill Battle class) ---
if let Some(def) = parse_played_by_opponents_entry(&norm_lower, &text) {
return Some(def);
}

// --- "~ enters under the control of an opponent of your choice." ---
// CR 110.2a: A self-ETB controller-override replacement — the permanent
// enters the battlefield directly under an opponent's control (Xantcha,
Expand Down Expand Up @@ -3094,6 +3099,52 @@ fn parse_external_enters_tapped(
build_external_entry_replacement(subject, original_text, true)
}

/// Parse "[Type] played by your opponents enter [the battlefield] tapped."
/// Uphill Battle class — distinct from "your opponents control enter tapped"
/// (Authority of the Consuls): only applies to cast/played entries, not tokens
/// put directly onto the battlefield.
fn parse_played_by_opponents_entry(
norm_lower: &str,
original_text: &str,
) -> Option<ReplacementDefinition> {
if !nom_primitives::scan_contains(norm_lower, "played by your opponents enter") {
return None;
}
let stripped = norm_lower.trim_end_matches('.');
// allow-noncombinator: peel fixed played-by entry suffix after scan_contains gate
let subject = stripped
.strip_suffix(" played by your opponents enter the battlefield tapped") // allow-noncombinator: fixed suffix after scan_contains gate
.or_else(|| {
stripped.strip_suffix(" played by your opponents enter tapped") // allow-noncombinator: fixed suffix after scan_contains gate
})?;
let (filter, subject_rest) = parse_type_phrase(subject.trim());
if !subject_rest.trim().is_empty() {
return None;
}
Comment on lines +3110 to +3123

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.

medium

[MEDIUM] Use modular nom combinators for parsing dispatch instead of verbatim string matches or monolithic tags.

Why it matters: Avoid verbatim string equality or monolithic tags for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts (e.g., subjects, conjunctions) and compose them using idiomatic combinator aggregates to prevent combinatorial explosion and improve maintainability.

    let (filter, rest) = parse_type_phrase(norm_lower.trim());
    let rest = rest.trim();
    let parse_subject = nom::bytes::complete::tag("played by your opponents");
    let parse_action = nom::sequence::tuple((
        nom::bytes::complete::tag("enter"),
        nom::combinator::opt(nom::bytes::complete::tag(" the battlefield")),
    ));
    let parse_state = nom::bytes::complete::tag("tapped");
    let parser = nom::sequence::tuple((
        parse_subject,
        nom::character::complete::space1,
        parse_action,
        nom::character::complete::space1,
        parse_state,
        nom::combinator::opt(nom::bytes::complete::tag(".")),
    ));
    if nom::combinator::all_consuming(parser)(rest).is_err() {
        return None;
    }
References
  1. Avoid verbatim string equality for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts and compose them using idiomatic combinator aggregates.

let mut typed = match filter {
TargetFilter::Typed(tf) => tf,
TargetFilter::Or { filters } if filters.len() == 1 => match &filters[0] {
TargetFilter::Typed(tf) => tf.clone(),
_ => return None,
},
_ => return None,
};
typed.controller = Some(ControllerRef::Opponent);
typed.properties.push(FilterProp::WasPlayed);
let effect = Effect::SetTapState {
target: TargetFilter::SelfRef,
scope: EffectScope::Single,
state: TapStateChange::Tap,
};
Some(
ReplacementDefinition::new(ReplacementEvent::ChangeZone)
.execute(AbilityDefinition::new(AbilityKind::Spell, effect))
.valid_card(TargetFilter::Typed(typed))
.destination_zone(Zone::Battlefield)
.description(original_text.to_string()),
)
}

/// CR 614.1a: Parse "If [filter] would die, …instead…" replacement effects.
/// Handles non-self creature filters like "another creature", "a nontoken
/// creature an opponent controls", "a creature an opponent controls", and
Expand Down Expand Up @@ -9807,6 +9858,54 @@ mod tests {
}
}

#[test]
fn uphill_battle_played_by_opponents_enter_tapped() {
let text = "Creatures played by your opponents enter the battlefield tapped.";
assert!(
parse_played_by_opponents_entry(&text.to_lowercase(), text).is_some(),
"direct played-by parser must match Uphill Battle"
);
let def =
parse_replacement_line(text, "Uphill Battle").expect("Uphill Battle played-by entry");
assert_eq!(def.event, ReplacementEvent::ChangeZone);
assert_eq!(def.destination_zone, Some(Zone::Battlefield));
match &def.valid_card {
Some(TargetFilter::Typed(tf)) => {
assert!(tf.type_filters.contains(&TypeFilter::Creature));
assert_eq!(tf.controller, Some(ControllerRef::Opponent));
assert!(tf.properties.contains(&FilterProp::WasPlayed));
}
other => panic!("Expected Typed filter with WasPlayed, got {other:?}"),
}
}

#[test]
fn played_by_opponents_entry_covers_creature_and_land() {
for (text, card, type_filter) in [
(
"Creatures played by your opponents enter the battlefield tapped.",
"Uphill Battle",
TypeFilter::Creature,
),
(
"Lands played by your opponents enter tapped.",
"Contamination",
TypeFilter::Land,
),
] {
let def = parse_replacement_line(text, card)
.unwrap_or_else(|| panic!("failed to parse {text}"));
assert_eq!(def.event, ReplacementEvent::ChangeZone);
match &def.valid_card {
Some(TargetFilter::Typed(tf)) => {
assert!(tf.type_filters.contains(&type_filter));
assert!(tf.properties.contains(&FilterProp::WasPlayed));
}
other => panic!("expected Typed filter, got {other:?}"),
}
}
}

#[test]
fn blind_obedience_compound_or_filter() {
let def = parse_replacement_line(
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2222,6 +2222,10 @@ pub enum FilterProp {
Token,
/// CR 111.1: Matches objects that are not tokens.
NonToken,
/// CR 305.1 + CR 601.2a: Matches objects entering from being played
/// (land play) or cast (spell), excluding tokens put directly onto the
/// battlefield without a prior zone.
WasPlayed,
/// CR 508.1b: Matches attacking creatures, optionally scoped by which player
/// the creature is attacking.
Attacking {
Expand Down
9 changes: 9 additions & 0 deletions crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,13 @@ pub struct ZoneChangeRecord {
/// on the battlefield, emblem creation in the command zone). For normal
/// zone moves this carries the origin zone.
pub from_zone: Option<Zone>,
/// CR 601.2a: Cast origin as of the zone change — distinct from `from_zone`
/// for objects put onto the battlefield without being cast (reanimate, etc.).
#[serde(default)]
pub cast_from_zone: Option<Zone>,
/// CR 305.1: Land-play provenance as of the zone change.
#[serde(default)]
pub played_from_zone: Option<Zone>,
pub to_zone: Zone,
/// CR 603.10a + CR 603.6e: Snapshot of attachments on the object at the moment
/// of the zone change. Required by look-back triggers of the form
Expand Down Expand Up @@ -530,6 +537,8 @@ impl ZoneChangeRecord {
controller: PlayerId(0),
owner: PlayerId(0),
from_zone: from,
cast_from_zone: None,
played_from_zone: None,
to_zone: to,
attachments: Vec::new(),
linked_exile_snapshot: Vec::new(),
Expand Down
Loading