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/ai_support/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,7 @@ fn filterprop_reads_only_candidate_fp(p: &FilterProp) -> bool {
| FilterProp::AttackingAlone
| FilterProp::BlockingAlone
| FilterProp::WasDealtDamageThisTurn
| FilterProp::DealtDamageThisTurn
| FilterProp::EnteredThisTurn
// CR 302.6: per-turn control-continuity marker — a turn/history-relative
// predicate like the siblings here (POISON for memoization).
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2359,6 +2359,7 @@ fn legacy_filter_prop(p: &FilterProp) -> bool {
| FilterProp::PowerExceedsBase
| FilterProp::InAnyZone { .. }
| FilterProp::WasDealtDamageThisTurn
| FilterProp::DealtDamageThisTurn
| FilterProp::EnteredThisTurn
| FilterProp::ControlledContinuouslySinceTurnBegan
| FilterProp::ZoneChangedThisTurn { .. }
Expand Down Expand Up @@ -2617,6 +2618,7 @@ fn member_bound_filter_prop(p: &FilterProp) -> bool {
| FilterProp::PowerExceedsBase
| FilterProp::InAnyZone { .. }
| FilterProp::WasDealtDamageThisTurn
| FilterProp::DealtDamageThisTurn
| FilterProp::EnteredThisTurn
| FilterProp::ControlledContinuouslySinceTurnBegan
| FilterProp::ZoneChangedThisTurn { .. }
Expand Down
5 changes: 5 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3246,6 +3246,11 @@ fn scan_filter_prop(x: &FilterProp) -> Axes {
// has `damage_marked == 0` yet a persistent journal record, so gate (1) cannot
// backstop this read — PROVEN projected, fail closed.
FilterProp::WasDealtDamageThisTurn => Axes::CONSERVATIVE,
// CR 120.1: reads `state.damage_dealt_this_turn`, the same append-only
// per-turn journal a loop pumps and `project_out_resources` clears — a
// projected-resource read, PROVEN projected, fail closed (mirrors the
// passive `WasDealtDamageThisTurn` arm above).
FilterProp::DealtDamageThisTurn => Axes::CONSERVATIVE,
// CR 400 / CR 603.6a: runtime eval reads `state.zone_changes_this_turn`, an
// append-only event journal a loop pumps, cleared by `project_out_resources`
// and strict-compared by nothing in gate (1). A flicker/blink loop keeps the
Expand Down
8 changes: 7 additions & 1 deletion crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,13 @@ fn fmt_typed_filter(tf: &TypedFilter) -> String {
};
parts.push(format!("{prefix} {name}{suffix}"));
}
FilterProp::WasDealtDamageThisTurn => parts.push("dealt damage this turn".into()),
// Both damage-role filters share this human coverage label (the AST
// variant carries the source-vs-recipient distinction); keeping the
// passive label unchanged avoids a cosmetic coverage-diff on every
// existing "was dealt damage this turn" card.
FilterProp::WasDealtDamageThisTurn | FilterProp::DealtDamageThisTurn => {
parts.push("dealt damage this turn".into())
}
FilterProp::EnteredThisTurn => parts.push("entered this turn".into()),
FilterProp::ControlledContinuouslySinceTurnBegan => {
parts.push("controlled continuously since turn began".into())
Expand Down
62 changes: 62 additions & 0 deletions crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ fn filter_prop_uses_object_population(prop: &FilterProp) -> bool {
| FilterProp::NotHistoric
| FilterProp::InAnyZone { .. }
| FilterProp::WasDealtDamageThisTurn
| FilterProp::DealtDamageThisTurn
| FilterProp::EnteredThisTurn
| FilterProp::ControlledContinuouslySinceTurnBegan
| FilterProp::ZoneChangedThisTurn { .. }
Expand Down Expand Up @@ -462,6 +463,7 @@ fn entered_object_perturbs_filter_prop(
| FilterProp::NotHistoric
| FilterProp::InAnyZone { .. }
| FilterProp::WasDealtDamageThisTurn
| FilterProp::DealtDamageThisTurn
| FilterProp::EnteredThisTurn
| FilterProp::ControlledContinuouslySinceTurnBegan
| FilterProp::ZoneChangedThisTurn { .. }
Expand Down Expand Up @@ -3329,6 +3331,7 @@ fn spell_record_matches_property(record: &SpellCastRecord, prop: &FilterProp) ->
| FilterProp::DistinctFrom { .. }
| FilterProp::SharesQuality { .. }
| FilterProp::WasDealtDamageThisTurn
| FilterProp::DealtDamageThisTurn
| FilterProp::EnteredThisTurn
| FilterProp::ControlledContinuouslySinceTurnBegan
| FilterProp::ZoneChangedThisTurn { .. }
Expand Down Expand Up @@ -4363,6 +4366,14 @@ fn matches_filter_prop(
.damage_dealt_this_turn
.iter()
.any(|record| matches!(record.target, TargetRef::Object(id) if id == object_id)),
// CR 120.1: active-voice counterpart — this object DEALT damage this turn,
// i.e. it was the source of a damage event (Red Guardian, Super-Soldier:
// "target creature ... that dealt damage this turn"). Reads the same
// per-turn ledger the passive arm above does, keyed by `source_id`.
FilterProp::DealtDamageThisTurn => state
.damage_dealt_this_turn
.iter()
.any(|record| record.source_id == object_id),
// CR 400.7: Object entered the battlefield this turn.
FilterProp::EnteredThisTurn => obj.entered_battlefield_turn == Some(state.turn_number),
// CR 302.6 + CR 508.1a: controlled continuously since the controller's
Expand Down Expand Up @@ -4856,6 +4867,14 @@ fn zone_change_record_matches_property(
.damage_dealt_this_turn
.iter()
.any(|r| matches!(r.target, TargetRef::Object(id) if id == record.object_id)),
// CR 120.1: active-voice look-back — the object DEALT damage this turn.
// The `damage_dealt_this_turn` ledger is keyed by battlefield ObjectId and
// survives the object's zone change, so the LKI snapshot reads it by the
// record's `object_id`, mirroring the passive arm above.
FilterProp::DealtDamageThisTurn => state
.damage_dealt_this_turn
.iter()
.any(|r| r.source_id == record.object_id),
// CR 110.5 + CR 110.5d + CR 608.2h: tap status is battlefield-only — once
// the object has left its public zone it is neither tapped nor untapped, so
// the live object can't answer a look-back "was tapped" rider (Brackish
Expand Down Expand Up @@ -6362,6 +6381,49 @@ mod tests {
));
}

// CR 120.1: `DealtDamageThisTurn` matches the damage SOURCE, not the
// recipient — the active-voice counterpart of `WasDealtDamageThisTurn`
// (Red Guardian, Super-Soldier). The two must not be confused: the same
// damage record makes the source match `DealtDamageThisTurn` and the target
// match `WasDealtDamageThisTurn`, never the reverse.
#[test]
fn dealt_damage_this_turn_matches_source_not_target() {
use crate::types::game_state::DamageRecord;

let mut state = setup();
let dealer = add_creature(&mut state, PlayerId(0), "Goblin Piker");
let victim = add_creature(&mut state, PlayerId(1), "Grizzly Bears");
state.damage_dealt_this_turn.push_back(DamageRecord {
source_id: dealer,
source_controller: PlayerId(0),
target: TargetRef::Object(victim),
target_controller: PlayerId(1),
amount: 2,
is_combat: true,
..Default::default()
});

let dealt = TargetFilter::Typed(
TypedFilter::creature().properties(vec![FilterProp::DealtDamageThisTurn]),
);
// The creature that dealt the damage matches; the one that received it does not.
assert!(
matches_target_filter(&state, dealer, &dealt, dealer),
"the damage source must satisfy DealtDamageThisTurn"
);
assert!(
!matches_target_filter(&state, victim, &dealt, victim),
"the damage recipient must NOT satisfy DealtDamageThisTurn"
);

// Symmetry check against the passive filter: exactly the opposite.
let was_dealt = TargetFilter::Typed(
TypedFilter::creature().properties(vec![FilterProp::WasDealtDamageThisTurn]),
);
assert!(matches_target_filter(&state, victim, &was_dealt, victim));
assert!(!matches_target_filter(&state, dealer, &was_dealt, dealer));
}

#[test]
fn spell_record_matches_qualified_filter() {
let record = SpellCastRecord {
Expand Down
68 changes: 68 additions & 0 deletions crates/engine/src/game/targeting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2410,6 +2410,74 @@ mod tests {
TargetFilter::Typed(TypedFilter::creature())
}

// CR 120.1 (#5615): Red Guardian, Super-Soldier — "destroy target creature an
// opponent controls that dealt damage this turn." This drives the card's
// REAL parsed target filter through the production `find_legal_targets`
// authority: an opponent creature that dealt damage this turn is a legal
// target; an otherwise-identical one that did not is not. Fails if the
// `DealtDamageThisTurn` FilterProp or its parser wiring is reverted (the
// filter would drop back to "any opponent creature" and both would qualify).
#[test]
fn red_guardian_targets_only_a_creature_that_dealt_damage_this_turn() {
use crate::types::ability::Effect;
use crate::types::game_state::DamageRecord;

// Parse the card's actual Oracle text and pull the Destroy target filter.
let parsed = crate::parser::oracle::parse_oracle_text(
"When Red Guardian enters, destroy target creature an opponent controls that dealt damage this turn.",
"Red Guardian, Super-Soldier",
&[],
&["Creature".to_string()],
&[],
);
let filter = parsed
.triggers
.iter()
.find_map(|t| match t.execute.as_deref()?.effect.as_ref() {
Effect::Destroy { target, .. } => Some(target.clone()),
_ => None,
})
.expect("Red Guardian must parse a Destroy-target trigger");

// P0 controls Red Guardian; both candidate creatures are P1's (opponent's).
let (mut state, red_guardian, dealer) = setup_with_creatures();
let bystander = create_object(
&mut state,
CardId(3),
PlayerId(1),
"Bystander".to_string(),
Zone::Battlefield,
);
state
.objects
.get_mut(&bystander)
.unwrap()
.card_types
.core_types
.push(CoreType::Creature);

// `dealer` (P1's Goblin from the helper) dealt damage this turn.
state.damage_dealt_this_turn.push_back(DamageRecord {
source_id: dealer,
source_controller: PlayerId(1),
target: TargetRef::Object(red_guardian),
target_controller: PlayerId(0),
amount: 1,
is_combat: true,
..Default::default()
});

let legal = find_legal_targets(&state, &filter, PlayerId(0), red_guardian);
assert!(
legal.contains(&TargetRef::Object(dealer)),
"the opponent creature that dealt damage this turn must be targetable: {legal:?}"
);
assert!(
!legal.contains(&TargetRef::Object(bystander)),
"an opponent creature that dealt NO damage must not be targetable: {legal:?}"
);
}

#[test]
fn post_replacement_source_controller_resolves_to_event_source_controller() {
// CR 615.5 + CR 609.7: When `state.post_replacement_event_source` is
Expand Down
34 changes: 34 additions & 0 deletions crates/engine/src/parser/oracle_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6485,6 +6485,9 @@ pub(crate) fn parse_that_clause_suffix<'a>(
"was dealt damage this turn",
FilterProp::WasDealtDamageThisTurn,
),
// CR 120.1: active voice — the creature dealt damage (was the source),
// distinct from the passive "was dealt damage" above (Red Guardian).
("dealt damage this turn", FilterProp::DealtDamageThisTurn),
(
"entered the battlefield this turn",
FilterProp::EnteredThisTurn,
Expand Down Expand Up @@ -13983,6 +13986,37 @@ mod tests {
);
}

// CR 120.1: active-voice "that dealt damage this turn" (creature is the damage
// source) must parse to `DealtDamageThisTurn`, NOT the passive
// `WasDealtDamageThisTurn` (creature is the damage recipient). Red Guardian,
// Super-Soldier: "destroy target creature an opponent controls that dealt
// damage this turn."
#[test]
fn that_dealt_damage_this_turn_is_active_voice() {
let (filter, rest) =
parse_target("target creature an opponent controls that dealt damage this turn");
let TargetFilter::Typed(ref tf) = filter else {
panic!("expected Typed filter, got {filter:?}");
};
assert!(tf.type_filters.contains(&TypeFilter::Creature));
assert_eq!(tf.controller, Some(ControllerRef::Opponent));
assert!(
tf.properties
.iter()
.any(|p| matches!(p, FilterProp::DealtDamageThisTurn)),
"Expected DealtDamageThisTurn (active), got: {:?}",
tf.properties
);
assert!(
!tf.properties
.iter()
.any(|p| matches!(p, FilterProp::WasDealtDamageThisTurn)),
"must NOT collapse to the passive WasDealtDamageThisTurn: {:?}",
tf.properties
);
assert!(rest.trim().is_empty(), "expected empty remainder: {rest:?}");
}

#[test]
fn that_entered_this_turn() {
let (filter, rest) = parse_type_phrase("token you control that entered this turn");
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/parser/swallow_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4017,6 +4017,7 @@ fn detect_duration_this_turn(
matches!(
x,
FilterProp::WasDealtDamageThisTurn
| FilterProp::DealtDamageThisTurn
| FilterProp::EnteredThisTurn
| FilterProp::ZoneChangedThisTurn { .. }
| FilterProp::AttackedThisTurn { .. }
Expand Down
12 changes: 12 additions & 0 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3828,6 +3828,18 @@ pub enum FilterProp {
/// CR 510.1: Object was dealt damage during this turn.
/// Checks `damage_marked > 0` (damage persists until cleanup step).
WasDealtDamageThisTurn,
/// CR 120.1: This object *dealt* damage during this turn — i.e. it was the
/// SOURCE of a damage event ("target creature ... that dealt damage this
/// turn", Red Guardian, Super-Soldier). The active-voice counterpart of
/// `WasDealtDamageThisTurn` (which reads the damage recipient). Evaluated by
/// scanning `state.damage_dealt_this_turn` for a record whose `source_id`
/// is this object.
///
/// Parameterization note: these two form a damage-role pair. If a third
/// damage-role filter appears (e.g. "dealt combat damage this turn"), fold
/// the pair into `DamageThisTurn { role: {Dealt, Received}, combat_only }`
/// per the /add-engine-variant sibling-cluster threshold.
DealtDamageThisTurn,
/// CR 400.7: Object entered the battlefield during this turn.
/// Checks `entered_battlefield_turn == Some(current_turn)`.
EnteredThisTurn,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/types/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ impl EventObjectSnapshot {

// ---- embedded per-turn history ----
FilterProp::WasDealtDamageThisTurn
| FilterProp::DealtDamageThisTurn
| FilterProp::EnteredThisTurn
| FilterProp::AttackedThisTurn { .. }
| FilterProp::BlockedThisTurn
Expand Down
Loading