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
132 changes: 113 additions & 19 deletions crates/engine/src/game/trigger_matchers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1268,8 +1268,8 @@ fn matching_combat_damage_to_player_sources(
source_amounts
.iter()
.filter(|(src, amt)| {
if let Some((cmp, threshold)) = trigger.damage_amount {
if !cmp.evaluate(*amt as i32, threshold as i32) {
if let Some(t) = trigger.damage_amount {
if !t.comparator.evaluate(*amt as i32, t.threshold as i32) {
return false;
}
}
Expand All @@ -1295,7 +1295,7 @@ fn damage_kind_matches(filter: DamageKindFilter, is_combat: bool) -> bool {
fn damage_amount_matches(trigger: &TriggerDefinition, amount: u32) -> bool {
trigger
.damage_amount
.is_none_or(|(cmp, threshold)| cmp.evaluate(amount as i32, threshold as i32))
.is_none_or(|t| t.comparator.evaluate(amount as i32, t.threshold as i32))
}

pub(super) fn match_damage_done(
Expand Down Expand Up @@ -3938,12 +3938,45 @@ pub(super) fn match_damage_received(
trigger: &TriggerDefinition,
source_context: &TriggerSourceContext,
state: &GameState,
) -> bool {
let GameEvent::DamageDealt { amount, .. } = event else {
return false;
};
if !damage_received_filters_match(event, trigger, source_context, state) {
return false;
}
// CR 603.2 + CR 120.1: per-event amount threshold — UNCHANGED for every
// caller, including the delayed-trigger seams (`delayed_trigger_event_with_index`
// in `game/triggers.rs`) that consume this verdict directly with no batch
// fold available. `DamageAmountThreshold::scope` deliberately does NOT relax
// this: a `WholeEvent` threshold reaching a single-event consumer is still
// honored per event. The whole-event relaxation lives solely in
// `game/triggers.rs`, which is the only seam that has the batch to sum.
trigger
.damage_amount
.is_none_or(|t| t.comparator.evaluate(*amount as i32, t.threshold as i32))
}

/// CR 120.1 + CR 120.2a/b + CR 120.3: the non-threshold half of
/// `match_damage_received` — event shape, kind filter, recipient scoping
/// (`TargetRef::Object` vs `Player`), and `valid_source`. Split out so the
/// whole-event aggregation path in `game/triggers.rs` can apply every filter
/// EXCEPT the amount threshold, which for `DamageAmountScope::WholeEvent` is a
/// property of the summed batch and cannot be decided per event (CR 120.4b).
///
/// The signature is deliberately `TriggerMatcher` (`game/triggers.rs`) so it
/// drops into `candidate_passes_batched_filters`'s existing `matcher` slot with
/// no change to that shared helper.
pub(super) fn damage_received_filters_match(
event: &GameEvent,
trigger: &TriggerDefinition,
source_context: &TriggerSourceContext,
state: &GameState,
) -> bool {
let source_id = source_event_subject_id(source_context);
if let GameEvent::DamageDealt {
target,
is_combat,
amount,
source_id: damagesource_id,
..
} = event
Expand All @@ -3954,15 +3987,6 @@ pub(super) fn match_damage_received(
DamageKindFilter::NoncombatOnly if *is_combat => return false,
DamageKindFilter::CombatOnly | DamageKindFilter::NoncombatOnly => {}
}
// CR 603.2 + CR 120.1: Per-event damage-amount threshold. Mirrors
// `match_damage_done` so a "is dealt N or more damage" trigger sets
// `damage_amount` once and the field's semantics is uniform across
// every damage-event matcher.
if let Some((cmp, threshold)) = trigger.damage_amount {
if !cmp.evaluate(*amount as i32, threshold as i32) {
return false;
}
}
match target {
TargetRef::Object(target_id) => {
// CR 120.3: Player-scoped triggers ("you're dealt damage") must not
Expand Down Expand Up @@ -5136,8 +5160,9 @@ mod tests {
use crate::game::zones::create_object;
use crate::parser::oracle_trigger::parse_trigger_line;
use crate::types::ability::{
Comparator, ControllerRef, FilterProp, QuantityExpr, ResolvedAbility, TargetFilter,
TriggerCondition, TriggerDefinition, TypeFilter, TypedFilter,
Comparator, ControllerRef, DamageAmountScope, DamageAmountThreshold, FilterProp,
QuantityExpr, ResolvedAbility, TargetFilter, TriggerCondition, TriggerDefinition,
TypeFilter, TypedFilter,
};
use crate::types::card_type::CoreType;
use crate::types::events::{ClashResult, GameEvent, ManaTapState, PlayerActionKind};
Expand Down Expand Up @@ -13705,7 +13730,11 @@ mod tests {
fn damage_amount_ge_threshold_rejects_below() {
let state = setup();
let mut trigger = make_trigger(TriggerMode::DamageDone);
trigger.damage_amount = Some((Comparator::GE, 5));
trigger.damage_amount = Some(DamageAmountThreshold {
comparator: Comparator::GE,
threshold: 5,
scope: DamageAmountScope::PerSource,
});

let event = GameEvent::DamageDealt {
source_id: ObjectId(1),
Expand All @@ -13726,7 +13755,11 @@ mod tests {
fn damage_amount_ge_threshold_accepts_at_or_above() {
let state = setup();
let mut trigger = make_trigger(TriggerMode::DamageDone);
trigger.damage_amount = Some((Comparator::GE, 5));
trigger.damage_amount = Some(DamageAmountThreshold {
comparator: Comparator::GE,
threshold: 5,
scope: DamageAmountScope::PerSource,
});

for amount in [5, 7, 100] {
let event = GameEvent::DamageDealt {
Expand Down Expand Up @@ -13789,7 +13822,11 @@ mod tests {
Zone::Battlefield,
);
let mut trigger = make_trigger(TriggerMode::DamageReceived);
trigger.damage_amount = Some((Comparator::GE, 3));
trigger.damage_amount = Some(DamageAmountThreshold {
comparator: Comparator::GE,
threshold: 3,
scope: DamageAmountScope::PerSource,
});

for (amount, expect) in [(2u32, false), (3, true), (10, true)] {
let event = GameEvent::DamageDealt {
Expand All @@ -13812,6 +13849,59 @@ mod tests {
}
}

/// V15 — CR 603.2 + CR 120.1: `match_damage_received` stays STRICTLY
/// per-event even for a `WholeEvent` threshold. The whole-event relaxation
/// lives only in `game/triggers.rs`, which is the sole seam holding the
/// simultaneous batch to sum. Every other registry consumer — notably
/// `delayed_trigger_event_with_index`, which calls the matcher per event
/// with no fold available — must keep seeing the threshold honored rather
/// than silently dropped.
///
/// Revert-failing: make the matcher's threshold arm return `true` for
/// `DamageAmountScope::WholeEvent` (deferring the check to the fold) and
/// the 2-damage case returns `true`.
#[test]
fn match_damage_received_whole_event_threshold_stays_per_event() {
let mut state = setup();
let source_id = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Innocent Bystander".to_string(),
Zone::Battlefield,
);
let mut trigger = make_trigger(TriggerMode::DamageReceived);
trigger.valid_card = Some(TargetFilter::SelfRef);
trigger.damage_amount = Some(DamageAmountThreshold {
comparator: Comparator::GE,
threshold: 3,
scope: DamageAmountScope::WholeEvent,
});

// (2, false) is the assertion under test; (3, true) is its paired
// positive — without it, `false` could come from any unrelated filter
// failing and the negative would be vacuous.
for (amount, expect) in [(2u32, false), (3, true)] {
let event = GameEvent::DamageDealt {
source_id: ObjectId(99),
target: TargetRef::Object(source_id),
amount,
is_combat: true,
excess: 0,
};
assert_eq!(
match_damage_received(
&event,
&trigger,
&test_trigger_source_context(&state, source_id),
&state
),
expect,
"WholeEvent threshold must still be evaluated per event: amount={amount} GE 3"
);
}
}

#[test]
fn damage_received_object_target_rejects_damage_to_other_objects() {
let mut state = setup();
Expand Down Expand Up @@ -14244,7 +14334,11 @@ mod tests {
fn damage_amount_eq_threshold_only_matches_exact() {
let state = setup();
let mut trigger = make_trigger(TriggerMode::DamageDone);
trigger.damage_amount = Some((Comparator::EQ, 3));
trigger.damage_amount = Some(DamageAmountThreshold {
comparator: Comparator::EQ,
threshold: 3,
scope: DamageAmountScope::PerSource,
});

for (amount, expect) in [(2, false), (3, true), (4, false)] {
let event = GameEvent::DamageDealt {
Expand Down
Loading
Loading