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
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
14 changes: 14 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,11 @@ 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 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 +9053,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 @@ -7457,6 +7457,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 test_token_spec(
owner_controller: PlayerId,
core_type: crate::types::card_type::CoreType,
Expand Down Expand Up @@ -10459,6 +10480,96 @@ mod tests {
);
}

/// 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 + CR 111.1: Halving Season halves opponent token batches.
#[test]
fn halving_season_halves_opponent_token_creation() {
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
Loading
Loading