diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 63b718de97..e24c635e91 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2158,6 +2158,9 @@ fn legacy_object_scope(s: &ObjectScope) -> bool { // Source-persistent exile-pile member read (not one of the retained // legacy refs), mirroring the OtherRevealedCard precedent. | ObjectScope::OwnedLinkedExileCard + // CR 120.1: the per-iteration batch source is resolution-local, not one + // of the retained legacy refs (mirrors EventTarget). + | ObjectScope::BatchSource | ObjectScope::EventTarget => false, } } @@ -3646,6 +3649,10 @@ fn read_object_scope(scope: &ObjectScope, kind: StateKind) -> RwProfile { // `ObjectScope::Recipient` and the `LastRevealed => empty` classification; // contributes no observable `reads_board`/`reads_src`. ObjectScope::OtherRevealedCard => RwProfile::empty(), + // CR 120.1 + CR 208.3: the per-iteration batch source reads the batch + // member's live power — a mutable board characteristic (mirrors + // `Target`/`Anaphoric`/`Demonstrative`). + ObjectScope::BatchSource => reads_board_of(kind), // D5 carrier: `CostPaidObject` is one of the 12 retained refs. ObjectScope::CostPaidObject => legacy_ref(), } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 4191a7fae8..f01d0621aa 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -3115,6 +3115,9 @@ fn scan_object_scope(x: &ObjectScope) -> Axes { // CR 607.2a: source-persistent exile-pile member read — no event/sibling // projected axis (mirrors AmassedArmy). ObjectScope::OwnedLinkedExileCard => Axes::NONE, + // CR 120.1: per-iteration batch source — a resolution-filtered object + // with no event/sibling axis (mirrors Source/Target). + ObjectScope::BatchSource => Axes::NONE, ObjectScope::EventTarget => Axes { event: true, sibling: false, diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 2be6093fb7..53e2bcd81c 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -1357,6 +1357,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { ObjectScope::OtherRevealedCard => "other revealed card", ObjectScope::OwnedLinkedExileCard => "owned linked-exiled card", ObjectScope::AmassedArmy => "amassed Army", + ObjectScope::BatchSource => "batch source", }; match counter_type { Some(ct) => format!("{} counters on {scope_str}", ct.as_str()), @@ -1384,6 +1385,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { ObjectScope::OtherRevealedCard => "other revealed card's power".into(), ObjectScope::OwnedLinkedExileCard => "owned linked-exiled card's power".into(), ObjectScope::AmassedArmy => "amassed Army's power".into(), + ObjectScope::BatchSource => "batch source's power".into(), }, QuantityRef::Toughness { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -1397,6 +1399,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { ObjectScope::OtherRevealedCard => "other revealed card's toughness".into(), ObjectScope::OwnedLinkedExileCard => "owned linked-exiled card's toughness".into(), ObjectScope::AmassedArmy => "amassed Army's toughness".into(), + ObjectScope::BatchSource => "batch source's toughness".into(), }, QuantityRef::ObjectManaValue { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -1410,6 +1413,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { ObjectScope::OtherRevealedCard => "other revealed card's mana value".into(), ObjectScope::OwnedLinkedExileCard => "owned linked-exiled card's mana value".into(), ObjectScope::AmassedArmy => "amassed Army's mana value".into(), + ObjectScope::BatchSource => "batch source's mana value".into(), }, QuantityRef::TargetObjectManaValue { .. } => "target object's mana value".into(), QuantityRef::ObjectColorCount { scope } => match scope { @@ -1424,6 +1428,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { ObjectScope::OtherRevealedCard => "other revealed card's colors".into(), ObjectScope::OwnedLinkedExileCard => "owned linked-exiled card's colors".into(), ObjectScope::AmassedArmy => "amassed Army's colors".into(), + ObjectScope::BatchSource => "batch source's colors".into(), }, QuantityRef::ObjectTypelineComponentCount { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -1439,6 +1444,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { "typeline components on owned linked-exiled card".into() } ObjectScope::AmassedArmy => "typeline components on amassed Army".into(), + ObjectScope::BatchSource => "typeline components on batch source".into(), }, QuantityRef::ObjectNameWordCount { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -1452,6 +1458,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { ObjectScope::OtherRevealedCard => "words in other revealed card's name".into(), ObjectScope::OwnedLinkedExileCard => "words in owned linked-exiled card's name".into(), ObjectScope::AmassedArmy => "words in amassed Army's name".into(), + ObjectScope::BatchSource => "words in batch source's name".into(), }, QuantityRef::ManaSymbolsInManaCost { scope, color } => { let scope_str = match scope { @@ -1464,6 +1471,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { ObjectScope::OtherRevealedCard => "other revealed card", ObjectScope::OwnedLinkedExileCard => "owned linked-exiled card", ObjectScope::AmassedArmy => "amassed Army", + ObjectScope::BatchSource => "batch source", }; match color { Some(c) => format!("{c:?} mana symbols in {scope_str}'s mana cost"), @@ -7945,6 +7953,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ObjectScope::OtherRevealedCard => ("OtherRevealedCardPower", Handled), ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardPower", Handled), ObjectScope::AmassedArmy => ("AmassedArmyPower", Handled), + ObjectScope::BatchSource => ("BatchSourcePower", Handled), }, QuantityRef::Toughness { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -7958,6 +7967,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ObjectScope::OtherRevealedCard => ("OtherRevealedCardToughness", Handled), ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardToughness", Handled), ObjectScope::AmassedArmy => ("AmassedArmyToughness", Handled), + ObjectScope::BatchSource => ("BatchSourceToughness", Handled), }, QuantityRef::ObjectManaValue { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -7971,6 +7981,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ObjectScope::OtherRevealedCard => ("OtherRevealedCardManaValue", Handled), ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardManaValue", Handled), ObjectScope::AmassedArmy => ("AmassedArmyManaValue", Handled), + ObjectScope::BatchSource => ("BatchSourceManaValue", Handled), }, QuantityRef::TargetObjectManaValue { .. } => ("TargetObjectManaValue", Handled), QuantityRef::ObjectColorCount { scope } => match scope { @@ -7985,6 +7996,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ObjectScope::OtherRevealedCard => ("OtherRevealedCardColorCount", Handled), ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardColorCount", Handled), ObjectScope::AmassedArmy => ("AmassedArmyObjectColorCount", Handled), + ObjectScope::BatchSource => ("BatchSourceObjectColorCount", Handled), }, QuantityRef::ObjectNameWordCount { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -7998,6 +8010,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ObjectScope::OtherRevealedCard => ("OtherRevealedCardNameWordCount", Handled), ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardNameWordCount", Handled), ObjectScope::AmassedArmy => ("AmassedArmyObjectNameWordCount", Handled), + ObjectScope::BatchSource => ("BatchSourceObjectNameWordCount", Handled), }, QuantityRef::ObjectTypelineComponentCount { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -8013,6 +8026,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ("OwnedLinkedExileCardTypelineComponentCount", Handled) } ObjectScope::AmassedArmy => ("AmassedArmyObjectTypelineComponentCount", Handled), + ObjectScope::BatchSource => ("BatchSourceObjectTypelineComponentCount", Handled), }, QuantityRef::ManaSymbolsInManaCost { scope, .. } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { @@ -8028,6 +8042,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ("OwnedLinkedExileCardManaSymbolsInManaCost", Handled) } ObjectScope::AmassedArmy => ("AmassedArmyManaSymbolsInManaCost", Handled), + ObjectScope::BatchSource => ("BatchSourceManaSymbolsInManaCost", Handled), }, QuantityRef::SelfManaValue => ("SelfManaValue", Handled), QuantityRef::Aggregate { .. } => ("Aggregate", Handled), diff --git a/crates/engine/src/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index badd621f98..bea3faad22 100644 --- a/crates/engine/src/game/effects/deal_damage.rs +++ b/crates/engine/src/game/effects/deal_damage.rs @@ -2486,8 +2486,19 @@ pub fn resolve_each_source_deals_damage( } }; - // CR 608.2: the amount is uniform across every source — resolve it once. - let amt = resolve_quantity_with_targets(state, amount, ability).max(0) as u32; + // CR 608.2: the amount is uniform across every source — resolve it once — + // UNLESS the amount reads the per-source `BatchSource` scope (CR 120.1: + // "deals damage equal to its power" resolves per source object, never + // against the ability source). + let per_source = crate::game::quantity::quantity_expr_contains_scope( + amount, + crate::types::ability::ObjectScope::BatchSource, + ); + let amt_uniform = if per_source { + 0 + } else { + resolve_quantity_with_targets(state, amount, ability).max(0) as u32 + }; // CR 608.2 + CR 120.1: evaluate the source class against the battlefield at // resolution (mirrors `resolve_all`). Each matching object is an independent @@ -2516,27 +2527,42 @@ pub fn resolve_each_source_deals_damage( // recipient so combined lethal/excess is computed once all sources have // marked). Each source carries its OWN `DamageContext` (CR 120.1 identity). let mut entries: Vec<(ObjectId, DamageContext, TargetRef, u32)> = Vec::new(); - if amt > 0 { - for &source_id in &source_ids { - let ctx = DamageContext::from_source(state, source_id) - .unwrap_or_else(|| DamageContext::fallback(source_id, ability.controller)); - match recipient { - EachDamageRecipient::Shared(_) => { - for recip in &shared_recipients { - entries.push((source_id, ctx, recip.clone(), amt)); - } - } - // CR 109.4 + CR 120.3a: each source deals to the player that - // controls it. - EachDamageRecipient::EachController => { - let controller = state - .objects - .get(&source_id) - .map(|obj| obj.controller) - .unwrap_or(ctx.controller); - entries.push((source_id, ctx, TargetRef::Player(controller), amt)); + for &source_id in &source_ids { + // CR 120.1 + CR 608.2h: each batch member deals its OWN characteristic; + // resolved per source (live object, LKI fallback via the new scope's + // resolve arms — the same instant semantics as the one-time uniform + // resolution, but read against each batch member). Zero/sourceless + // members deal nothing (skip) — entry-set-equivalent to the uniform + // path's `if amt > 0` gate. + let amt = if per_source { + crate::game::quantity::resolve_quantity_with_targets_and_damage_source( + state, amount, ability, source_id, + ) + .max(0) as u32 + } else { + amt_uniform + }; + if amt == 0 { + continue; + } + let ctx = DamageContext::from_source(state, source_id) + .unwrap_or_else(|| DamageContext::fallback(source_id, ability.controller)); + match recipient { + EachDamageRecipient::Shared(_) => { + for recip in &shared_recipients { + entries.push((source_id, ctx, recip.clone(), amt)); } } + // CR 109.4 + CR 120.3a: each source deals to the player that + // controls it. + EachDamageRecipient::EachController => { + let controller = state + .objects + .get(&source_id) + .map(|obj| obj.controller) + .unwrap_or(ctx.controller); + entries.push((source_id, ctx, TargetRef::Player(controller), amt)); + } } } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 2f5aa4e70e..6bdaf1094b 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -11738,7 +11738,8 @@ pub(crate) fn evaluate_condition( | crate::types::ability::ObjectScope::OtherRevealedCard | crate::types::ability::ObjectScope::OwnedLinkedExileCard | crate::types::ability::ObjectScope::EventTarget - | crate::types::ability::ObjectScope::AmassedArmy => false, + | crate::types::ability::ObjectScope::AmassedArmy + | crate::types::ability::ObjectScope::BatchSource => false, }, AbilityCondition::AlternativeManaCostPaid => ability.context.alternative_mana_cost_paid, AbilityCondition::EffectOutcome { @@ -11964,7 +11965,8 @@ pub(crate) fn evaluate_condition( | crate::types::ability::ObjectScope::OtherRevealedCard | crate::types::ability::ObjectScope::OwnedLinkedExileCard | crate::types::ability::ObjectScope::EventTarget - | crate::types::ability::ObjectScope::AmassedArmy => None, + | crate::types::ability::ObjectScope::AmassedArmy + | crate::types::ability::ObjectScope::BatchSource => None, }; object_id .and_then(|id| state.objects.get(&id)) diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index d479f090df..0c5530d980 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -1501,6 +1501,7 @@ fn evaluate_condition_with_context( trigger_source: None, recipient: recipient_id, scoped_player: None, + damage_source: None, }, ) }; @@ -1683,7 +1684,8 @@ fn evaluate_condition_with_context( | crate::types::ability::ObjectScope::OtherRevealedCard | crate::types::ability::ObjectScope::OwnedLinkedExileCard | crate::types::ability::ObjectScope::Demonstrative - | crate::types::ability::ObjectScope::AmassedArmy => false, + | crate::types::ability::ObjectScope::AmassedArmy + | crate::types::ability::ObjectScope::BatchSource => false, }, // CR 702.171b + CR 110.5d: off-battlefield permanents have no saddled designation. StaticCondition::SourceIsSaddled => state.objects.get(&source_id).is_some_and(|obj| { diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index f221faf51e..27360103fc 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -61,6 +61,11 @@ pub struct QuantityContext { /// Current player for an "each player/opponent" resolution pass. Distinct /// from `controller`, which remains the printed ability's controller. pub scoped_player: Option, + /// CR 120.1: The per-iteration damage source of an `EachSourceDealsDamage` + /// batch. Set by the per-source resolver + /// (`resolve_quantity_with_targets_and_damage_source`); `None` in every + /// non-batch context (a null read → 0, fail-closed). + pub damage_source: Option, } impl QuantityContext { @@ -196,6 +201,7 @@ pub fn resolve_quantity( trigger_source: None, recipient: None, scoped_player: None, + damage_source: None, }, ) } @@ -221,6 +227,7 @@ pub fn resolve_quantity_with_recipient( trigger_source: None, recipient: Some(recipient_id), scoped_player: None, + damage_source: None, }, ) } @@ -376,7 +383,11 @@ pub(crate) fn quantity_expr_uses_resolution_only_object_scope(expr: &QuantityExp // links only at resolution time, never as a static CDA read. | ObjectScope::OwnedLinkedExileCard | ObjectScope::Demonstrative - | ObjectScope::AmassedArmy => true, + | ObjectScope::AmassedArmy + // CR 120.1: the per-iteration damage source of an + // `EachSourceDealsDamage` batch is bound per batch member only at + // resolution time, never as a static CDA read. + | ObjectScope::BatchSource => true, } } match expr { @@ -411,6 +422,46 @@ pub(crate) fn quantity_expr_uses_resolution_only_object_scope(expr: &QuantityExp } } +/// CR 120.1 + CR 608.2: True when `expr` references `scope` anywhere — including +/// nested inside any composite wrapper (`Multiply`, `Offset`, `Sum`, +/// `Difference`, …). Structural recursion over the already-parsed `QuantityExpr`; +/// the `QuantityRef` leaf classifies per-object scopes exhaustively so a new +/// object-scoped reference forces a decision here. Used by the parser (does the +/// "each deals damage" amount carry the deferred `Anaphoric` pronoun?) +/// and by the resolver (does the batch amount read the per-source `BatchSource` +/// scope?). +pub(crate) fn quantity_expr_contains_scope(expr: &QuantityExpr, scope: ObjectScope) -> bool { + fn ref_contains_scope(qty: &QuantityRef, scope: ObjectScope) -> bool { + match qty { + QuantityRef::Power { scope: s } + | QuantityRef::Toughness { scope: s } + | QuantityRef::ObjectManaValue { scope: s } + | QuantityRef::ObjectColorCount { scope: s } + | QuantityRef::ObjectNameWordCount { scope: s } + | QuantityRef::ObjectTypelineComponentCount { scope: s } + | QuantityRef::ManaSymbolsInManaCost { scope: s, .. } + | QuantityRef::CountersOn { scope: s, .. } => *s == scope, + _ => false, + } + } + match expr { + QuantityExpr::Fixed { .. } => false, + QuantityExpr::Ref { qty } => ref_contains_scope(qty, scope), + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Multiply { inner, .. } => quantity_expr_contains_scope(inner, scope), + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => exprs + .iter() + .any(|expr| quantity_expr_contains_scope(expr, scope)), + QuantityExpr::UpTo { max } => quantity_expr_contains_scope(max, scope), + QuantityExpr::Power { exponent, .. } => quantity_expr_contains_scope(exponent, scope), + QuantityExpr::Difference { left, right } => { + quantity_expr_contains_scope(left, scope) || quantity_expr_contains_scope(right, scope) + } + } +} + /// CR 607.2a + CR 608.2c: Return the source-linked cards explicitly bound to /// this resolved ability. A nonempty result is the current materialized /// candidate set and is authoritative over the source's persistent exile pile. @@ -491,6 +542,9 @@ fn resolution_only_scope_referent_present( }) } ObjectScope::AmassedArmy => ability.amassed_army_object.is_some(), + // CR 120.1: the per-iteration batch member is bound only while the + // per-source resolver runs; absent everywhere else. + ObjectScope::BatchSource => ctx.damage_source.is_some(), } } @@ -517,6 +571,7 @@ pub(crate) fn quantity_expr_missing_resolution_only_referent( trigger_source: ability.trigger_source.clone(), recipient: None, scoped_player: ability.scoped_player, + damage_source: None, }; !resolution_only_scope_referent_present(state, scope, ctx, &ability.targets, ability) } @@ -1649,6 +1704,7 @@ pub(crate) fn resolve_quantity_for_trigger_check( trigger_source: source_context.cloned(), recipient: None, scoped_player, + damage_source: None, }; // Fast path: when current_trigger_event is already set (resolution-time @@ -1958,6 +2014,7 @@ pub fn resolve_quantity_with_targets( trigger_source: ability.trigger_source.clone(), recipient: None, scoped_player: ability.scoped_player, + damage_source: None, }, &ability.targets, ability.chosen_x, @@ -2010,6 +2067,7 @@ pub(crate) fn resolve_quantity_with_targets_and_recipient( trigger_source: ability.trigger_source.clone(), recipient: Some(recipient_id), scoped_player: ability.scoped_player, + damage_source: None, }, &ability.targets, ability.chosen_x, @@ -2021,6 +2079,43 @@ pub(crate) fn resolve_quantity_with_targets_and_recipient( } } +/// CR 120.1 + CR 608.2: Resolve a `QuantityExpr` that references the +/// per-iteration damage source of an `EachSourceDealsDamage` batch +/// (`ObjectScope::BatchSource`). Threaded through every composite wrapper so +/// "twice its power" / "its power plus its toughness" read the same batch +/// member. Matches the source against its live object, falling back to LKI +/// (CR 608.2h) at each characteristic read. +pub(crate) fn resolve_quantity_with_targets_and_damage_source( + state: &GameState, + expr: &QuantityExpr, + ability: &ResolvedAbility, + damage_source: ObjectId, +) -> i32 { + let controller = ability.original_controller.unwrap_or(ability.controller); + match expr { + QuantityExpr::Fixed { value } => *value, + QuantityExpr::Ref { qty } => resolve_ref( + state, + qty, + controller, + QuantityContext { + entering: None, + source: ability.source_id, + trigger_source: ability.trigger_source.clone(), + recipient: None, + scoped_player: ability.scoped_player, + damage_source: Some(damage_source), + }, + &ability.targets, + ability.chosen_x, + Some(ability), + ), + other => fold_compose(other, |inner| { + resolve_quantity_with_targets_and_damage_source(state, inner, ability, damage_source) + }), + } +} + /// Resolve a QuantityExpr with an explicit target slice but no full /// `ResolvedAbility`. Used by the combat-tax pipeline (CR 118.12a + /// CR 202.3e) to resolve per-attacker `CountersOnTarget`-style scaling @@ -2045,6 +2140,7 @@ pub fn resolve_quantity_with_targets_slice( trigger_source: None, recipient: None, scoped_player: None, + damage_source: None, }, targets, None, @@ -2126,6 +2222,7 @@ pub(crate) fn resolve_quantity_scoped_with_targets( trigger_source: None, recipient: None, scoped_player: Some(scope_player), + damage_source: None, }, targets, None, @@ -5069,6 +5166,9 @@ fn object_for_scope<'a>( | ObjectScope::OwnedLinkedExileCard | ObjectScope::Demonstrative | ObjectScope::AmassedArmy => None, + // CR 120.1: the per-iteration damage source of an `EachSourceDealsDamage` + // batch is bound per batch member by the per-source resolver. + ObjectScope::BatchSource => ctx.damage_source.and_then(|id| state.objects.get(&id)), } } @@ -5136,6 +5236,9 @@ fn object_id_for_scope( | ObjectScope::OwnedLinkedExileCard | ObjectScope::Demonstrative | ObjectScope::AmassedArmy => None, + // CR 120.1: the per-iteration damage source of an `EachSourceDealsDamage` + // batch is bound per batch member by the per-source resolver. + ObjectScope::BatchSource => ctx.damage_source, } } @@ -5377,7 +5480,13 @@ fn resolve_counters_on_scope( } } } - ObjectScope::Source | ObjectScope::Anaphoric => { + ObjectScope::Source + | ObjectScope::Anaphoric + // CR 120.1 + CR 608.2h: the per-iteration batch member's counters are + // read via its live object, falling back to LKI (mirrors the + // `Source`/`Anaphoric` live-with-LKI shape; `object_id_for_scope` + // reads `ctx.damage_source`). + | ObjectScope::BatchSource => { resolve_counters_on_live_or_lki_scope(state, scope, ctx, targets, counter_type) } ObjectScope::CostPaidObject => ability @@ -5880,6 +5989,16 @@ where ObjectScope::OtherRevealedCard => 0, // MV-only referent; no P/T semantics. ObjectScope::OwnedLinkedExileCard => 0, + // CR 120.1 + CR 208.3 + CR 608.2h: the per-iteration damage source of an + // `EachSourceDealsDamage` batch reads its OWN characteristic ("deals + // damage equal to ITS power"). Guarded live-then-LKI read (a batch + // member that leaves the battlefield mid-batch still contributes its + // pre-leave power), mirroring the `Recipient` arm. + ObjectScope::BatchSource => { + object_id_for_scope(state, ObjectScope::BatchSource, ctx, targets) + .and_then(|id| read_object_pt_by_id(state, id, &obj_extract, &lki_extract)) + .unwrap_or(0) + } } } @@ -6135,6 +6254,29 @@ fn resolve_object_mana_value( current_mana_value.unwrap_or(0) } } + // CR 120.1 + CR 202.3 + CR 608.2h: the per-iteration damage source of an + // `EachSourceDealsDamage` batch reads its OWN mana value. Live object + // first, LKI fallback (mirrors the `EventSource` arm), so a batch member + // that leaves the battlefield mid-batch still contributes its pre-leave + // value. + ObjectScope::BatchSource => { + let Some(object_id) = + object_id_for_scope(state, ObjectScope::BatchSource, ctx, targets) + else { + return 0; + }; + state + .objects + .get(&object_id) + .map(|obj| u32_to_i32_saturating(obj.effective_mana_value())) + .or_else(|| { + state + .lki_cache + .get(&object_id) + .map(|lki| u32_to_i32_saturating(lki.mana_value)) + }) + .unwrap_or(0) + } } } @@ -8483,6 +8625,7 @@ mod tests { trigger_source: None, recipient: None, scoped_player: None, + damage_source: None, }, ), 1 @@ -14848,6 +14991,7 @@ mod tests { trigger_source: None, recipient: None, scoped_player: Some(scoped_player), + damage_source: None, }, ), 9, @@ -15730,6 +15874,300 @@ mod tests { ); } + /// CR 120.1 + CR 208.3 + CR 608.2h: the `ObjectScope::BatchSource` power + /// read binding-block — `resolve_quantity_with_targets_and_damage_source` + /// resolves a `Power { BatchSource }` ref to the SUPPLIED batch member's + /// own power (live object first, LKI fallback when the member leaves the + /// battlefield mid-batch). + #[test] + fn resolve_batch_source_power_live_and_lki() { + use crate::types::ability::ResolvedAbility; + let mut state = GameState::new_two_player(42); + let ability_source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Bartz".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&ability_source).unwrap(); + obj.power = Some(4); + obj.toughness = Some(3); + obj.card_types.core_types.push(CoreType::Creature); + } + // A batch member with power 5, distinct from the ability source (4) — + // proves the read follows `damage_source`, not the source. + let member = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Bird".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&member).unwrap(); + obj.power = Some(5); + obj.toughness = Some(5); + obj.card_types.core_types.push(CoreType::Creature); + } + let ability = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 0 }, + player: TargetFilter::Controller, + }, + Vec::new(), + ability_source, + PlayerId(0), + ); + let power = QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource, + }, + }; + assert_eq!( + resolve_quantity_with_targets_and_damage_source(&state, &power, &ability, member), + 5, + "live batch member power (5) must win, never the ability source's 4" + ); + + // LKI fallback: the member left the battlefield with a buffed battlefield + // LKI; the live graveyard card is reverted to base (1), so the buffed LKI + // must win (mirrors the `Source` power LKI read). + let mut state = GameState::new_two_player(42); + let member = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Departed Bird".to_string(), + Zone::Graveyard, + ); + { + let obj = state.objects.get_mut(&member).unwrap(); + obj.base_power = Some(1); + obj.base_toughness = Some(1); + obj.power = Some(1); + obj.toughness = Some(1); + obj.card_types.core_types.push(CoreType::Creature); + } + let mut lki = state.objects[&member].snapshot_public_characteristics(); + lki.power = Some(6); + lki.toughness = Some(6); + state.lki_cache.insert(member, lki); + let ability = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 0 }, + player: TargetFilter::Controller, + }, + Vec::new(), + ability_source, + PlayerId(0), + ); + assert_eq!( + resolve_quantity_with_targets_and_damage_source(&state, &power, &ability, member), + 6, + "buffed battlefield LKI power must win for an off-battlefield batch member" + ); + } + + /// CR 120.1 + CR 202.3 + CR 608.2h: `ObjectManaValue { BatchSource }` reads + /// the batch member's own mana value (live, then LKI). + #[test] + fn resolve_batch_source_mana_value_live_and_lki() { + use crate::types::ability::ResolvedAbility; + let mut state = GameState::new_two_player(42); + let ability_source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let member = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Mana Member".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&member).unwrap().mana_cost = ManaCost::generic(7); + let ability = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 0 }, + player: TargetFilter::Controller, + }, + Vec::new(), + ability_source, + PlayerId(0), + ); + let mv = QuantityExpr::Ref { + qty: QuantityRef::ObjectManaValue { + scope: ObjectScope::BatchSource, + }, + }; + assert_eq!( + resolve_quantity_with_targets_and_damage_source(&state, &mv, &ability, member), + 7, + "live batch member mana value" + ); + + // LKI fallback: the member is GONE entirely (no live object anywhere) — + // only the LKI snapshot carries its mana value (CR 202.3 zone-independent + // read via the cache). + let mut state = GameState::new_two_player(42); + let member = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Departed".to_string(), + Zone::Graveyard, + ); + state.objects.get_mut(&member).unwrap().mana_cost = ManaCost::generic(1); + let mut lki = state.objects[&member].snapshot_public_characteristics(); + lki.mana_value = 3; + state.lki_cache.insert(member, lki); + state.objects.remove(&member); + let ability = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 0 }, + player: TargetFilter::Controller, + }, + Vec::new(), + ability_source, + PlayerId(0), + ); + assert_eq!( + resolve_quantity_with_targets_and_damage_source(&state, &mv, &ability, member), + 3, + "LKI mana value must win when the batch member has no live object" + ); + } + + /// CR 120.1 + CR 608.2: the per-source wrapper threads the batch member + /// through composite wrappers ("twice ..." / "plus" composed) and leaves + /// `Fixed` alone. + #[test] + fn resolve_quantity_with_damage_source_composed_and_fixed() { + use crate::types::ability::ResolvedAbility; + let mut state = GameState::new_two_player(42); + let ability_source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let member = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Bird".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&member).unwrap(); + obj.power = Some(4); + obj.toughness = Some(3); + obj.card_types.core_types.push(CoreType::Creature); + } + let ability = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 0 }, + player: TargetFilter::Controller, + }, + Vec::new(), + ability_source, + PlayerId(0), + ); + // Composed Multiply over the batch power → 2 × 4 = 8. + let composed = QuantityExpr::Multiply { + factor: 2, + inner: Box::new(QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource, + }, + }), + }; + assert_eq!( + resolve_quantity_with_targets_and_damage_source(&state, &composed, &ability, member), + 8, + "composed per-source amount must double the batch member's power" + ); + // Fixed stays constant regardless of the batch member. + let fixed = QuantityExpr::Fixed { value: 9 }; + assert_eq!( + resolve_quantity_with_targets_and_damage_source(&state, &fixed, &ability, member), + 9, + "a Fixed amount must ignore the batch member" + ); + } + + /// CR 120.1 + CR 608.2: `quantity_expr_contains_scope` detects a scope + /// nested anywhere in the composite wrapper tree. Shared by the parser's + /// anaphoric-rebind guard in `try_parse_each_source_deals_damage` + /// (testing for `Anaphoric`) and the resolver's per-source detection in + /// `resolve_each_source_deals_damage` (testing for `BatchSource`). + #[test] + fn quantity_expr_contains_scope_detects_nested() { + let ref_batch = || QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource, + }, + }; + // Not present. + assert!(!quantity_expr_contains_scope( + &ref_batch(), + ObjectScope::Anaphoric + )); + assert!(!quantity_expr_contains_scope( + &QuantityExpr::Fixed { value: 1 }, + ObjectScope::BatchSource + )); + // Direct. + assert!(quantity_expr_contains_scope( + &ref_batch(), + ObjectScope::BatchSource + )); + // Nested in Multiply. + assert!(quantity_expr_contains_scope( + &QuantityExpr::Multiply { + factor: 2, + inner: Box::new(ref_batch()), + }, + ObjectScope::BatchSource + )); + // Nested in Offset. + assert!(quantity_expr_contains_scope( + &QuantityExpr::Offset { + inner: Box::new(ref_batch()), + offset: 1, + }, + ObjectScope::BatchSource + )); + // Nested in Sum (one of two operands). + assert!(quantity_expr_contains_scope( + &QuantityExpr::Sum { + exprs: vec![ + QuantityExpr::Fixed { value: 1 }, + QuantityExpr::Ref { + qty: QuantityRef::Toughness { + scope: ObjectScope::BatchSource, + } + }, + ], + }, + ObjectScope::BatchSource + )); + // Nested in Difference (right operand). + assert!(quantity_expr_contains_scope( + &QuantityExpr::Difference { + left: Box::new(QuantityExpr::Fixed { value: 1 }), + right: Box::new(ref_batch()), + }, + ObjectScope::BatchSource + )); + } + #[test] fn lki_cleared_on_advance_phase() { use crate::types::game_state::LKISnapshot; diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index ff08ad6c75..fa8d226ae1 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -5482,6 +5482,7 @@ fn replacement_condition_quantity_ctx( trigger_source: None, recipient: None, scoped_player, + damage_source: None, } } @@ -7711,6 +7712,7 @@ fn extract_etb_counters_from_effect( trigger_source: None, recipient: None, scoped_player: None, + damage_source: None, }; let n = match count { QuantityExpr::Fixed { value } => (*value).max(0) as u32, @@ -7743,6 +7745,7 @@ fn extract_etb_counters_from_effect( trigger_source: None, recipient: None, scoped_player: None, + damage_source: None, }; let n = crate::game::quantity::resolve_quantity_with_ctx(state, count, controller, ctx) diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index 72aaa40164..b93ce75275 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -1629,6 +1629,7 @@ pub(crate) fn evaluate_condition( trigger_source: None, recipient: None, scoped_player: None, + damage_source: None, }, ) as usize >= *minimum diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index eedbe53139..3d9570ae48 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -17,7 +17,7 @@ use super::{resolve_it_pronoun, ParseContext}; use crate::parser::oracle_ir::ast::*; use crate::types::ability::{ AbilityDefinition, AbilityKind, ChosenSubtypeKind, ColorChangeMode, ContinuousModification, - ControllerRef, Duration, EachDamageRecipient, Effect, FilterProp, MultiTargetSpec, + ControllerRef, Duration, EachDamageRecipient, Effect, FilterProp, MultiTargetSpec, ObjectScope, PlayerFilter, PlayerScope, PtValue, QuantityExpr, QuantityRef, StaticCondition, StaticDefinition, TargetFilter, TypedFilter, }; @@ -6206,6 +6206,33 @@ pub(super) fn try_parse_each_source_deals_damage( // The recipient phrase: everything after the "deals N damage to " marker. let recipient_phrase = damage_recipient_phrase(&predicate_lower); + // CR 120.1 + CR 608.2c (DEFERRED §9): two unrepresentable rider shapes the + // filter model cannot express, which would otherwise SILENTLY DEGRADE to a + // supported-but-wrong `EachSourceDealsDamage`: + // * a damage predicate carrying "random" — "another random creature that + // player controls" (Season's Beatings) degrades to `Typed{Another}` with + // the random selection AND the "that player controls" scope dropped; + // * a source subject ending in "tapped this way" — "Each Wolf tapped this + // way" (Master of the Wild Hunt) carries a per-source tapped-by-this- + // ability rider the source filter cannot hold, degrading to bare `Typed{Wolf}`. + // In both cases fail CLOSED to an honest `Unimplemented` — the same precedent + // as the Aura-Barbs attached-host check below. Detection is structural + // (word-boundary scan / all_consuming end-anchor), never substring dispatch. + // The random scan is on the whole damage predicate (not `damage_recipient_phrase`, + // which only fires on the fixed-"N damage to" form — the own-power recipient is + // introduced by "to " after the amount, so no clean recipient slice exists); the + // own-power each-source grammar is the only predicate kind that reaches here, and + // "random" never appears in a supported damage amount, so a word-boundary match is + // always a random RECIPIENT. + if nom_primitives::scan_contains(&predicate_lower, "random") + || subject_sources_tapped_this_way(&subject.to_lowercase()) + { + return Some(super::parsed_clause(Effect::unimplemented( + "each_source_unrepresentable_rider", + text, + ))); + } + // CR 303.4 (DEFERRED §9): "...to the creature/permanent it's attached to" — a // per-attachment host recipient not yet modeled. Fail CLOSED to an honest // `Unimplemented` BEFORE the subject-parse requirement, so the clause never @@ -6229,7 +6256,7 @@ pub(super) fn try_parse_each_source_deals_damage( // Delegate the predicate to the shared damage parser so the amount and the // recipient anaphora (`ParentTarget`, `TriggeringSource`, `Any`) resolve // identically to the `DealDamage` the misparse produced — no re-implementation. - let (amount, target, damage_source) = + let (mut amount, target, damage_source) = match super::lower::try_parse_damage(&predicate_lower, predicate, ctx)? { Effect::DealDamage { amount, @@ -6243,11 +6270,19 @@ pub(super) fn try_parse_each_source_deals_damage( if damage_source.is_some() { return None; } - // CR 120.1: only a FIXED, source-INDEPENDENT amount. The amount is resolved - // ONCE (uniform across the batch), so a per-source dynamic amount ("equal to - // its power", "equal to its mana value") would be wrong — that filter-source - // own-power class is deferred (see §1/§9). - if !matches!(amount, QuantityExpr::Fixed { .. }) { + // CR 120.1 + CR 608.2: a per-source amount ("deals damage equal to its + // power") reads each source OBJECT's own characteristic. "its power" + // parses to `QuantityExpr::Ref { Power { scope: Anaphoric } }`; the + // "each " clause subject establishes the per-source antecedent + // (CR 120.1: each matching object is the source of its own damage), so + // rebind the deferred pronoun to the per-batch-source scope. Structurally + // detected (recursion, no string matching): a composed amount ("twice its + // power") rebinds through every wrapper via + // `rebind_anaphoric_object_scope`. A uniform dynamic amount (no anaphoric + // pronoun) stays on the prior `None` path — fail-closed unchanged. + if crate::game::quantity::quantity_expr_contains_scope(&amount, ObjectScope::Anaphoric) { + super::rebind_anaphoric_object_scope(&mut amount, ObjectScope::BatchSource); + } else if !matches!(amount, QuantityExpr::Fixed { .. }) { return None; } @@ -6274,6 +6309,24 @@ fn damage_recipient_phrase(predicate_lower: &str) -> Option<&str> { Some(after.trim_end_matches('.').trim()) } +/// CR 120.1 + CR 608.2c (DEFERRED §9): the source subject ends in +/// "tapped this way" ("Each Wolf tapped this way deals damage ..." — Master of +/// the Wild Hunt), a per-source tapped-by-this-ability rider the filter model +/// cannot hold. `parse_subject_application` degrades it to a bare `Typed{Wolf}`, +/// dropping the tapped restriction, so the each-source intercept must fail +/// CLOSED to `Unimplemented`. Pattern 2 (`oracle_nom/PATTERNS.md`): the whole +/// subject is parsed and the trailing phrase consumed LAST via `all_consuming`, +/// anchoring the tag to the END so an interior/non-terminal "tapped this way" +/// is not matched (mirrors `ends_with_of_your_choice`). +fn subject_sources_tapped_this_way(subject_lower: &str) -> bool { + all_consuming(terminated( + take_until::<_, _, OracleError<'_>>("tapped this way"), + tag("tapped this way"), + )) + .parse(subject_lower) + .is_ok() +} + /// CR 109.4 + CR 120.3a: the recipient phrase is exactly "its controller". fn is_its_controller_recipient(recipient_phrase: &str) -> bool { all_consuming(tag::<_, _, OracleError<'_>>("its controller")) @@ -7145,20 +7198,393 @@ mod tests { ); } - // Negative: a per-source DYNAMIC amount ("each creature you control deals damage - // equal to its power") is the deferred filter-source own-power class — the - // single uniform resolve would be wrong, so it must NOT be captured. - #[test] - fn each_source_own_power_amount_is_not_each_source_deals_damage() { + // CR 120.1 + CR 608.2: a per-source OWN-power amount ("each creature you + // control deals damage equal to its power") now parses to + // `EachSourceDealsDamage` with the deferred pronoun rebound to the + // per-batch-source scope — the per-source resolver reads each batch + // member's OWN power (the filter-source own-power class). The guard change + // captures a 10-card class — Bartz and Boko, Judgment of + // Alexander, Kamahl's Will, Master of the Wild Hunt, Moonlight Hunt, Nissa's + // Judgment, Sarkhan the Mad, Season's Beatings, Signature Slam, and The Bears + // of Littjara. Two of those ten (Master of the Wild Hunt's "tapped this way" + // source rider and Season's Beatings' "random" recipient) carry riders the + // filter model cannot express and are pinned to fail CLOSED as `Unimplemented` + // (see `each_master_of_the_wild_hunt_tapped_this_way_fails_closed` / + // `each_seasons_beatings_random_recipient_fails_closed`), leaving EIGHT clean + // BatchSource members; the tests below pin each distinct source-filter shape + // (own-power "any target", "each other", composed amount, union subtype, + // +1/+1-counter property). The "any target" recipient + // stays `Shared(Any)` (pinned by `each_source_deals_damage_any_target_recipient` + // for the fixed-amount form; this flips the own-power form to the same + // shape). + #[test] + fn each_source_own_power_amount_is_each_source_deals_damage() { let effect = super::super::parse_effect( "each creature you control deals damage equal to its power to any target", ); assert!( - !matches!(effect, Effect::EachSourceDealsDamage { .. }), - "per-source dynamic amount wrongly captured: {effect:?}" + matches!( + effect, + Effect::EachSourceDealsDamage { + amount: QuantityExpr::Ref { + qty: QuantityRef::Power { scope: ObjectScope::BatchSource } + }, + .. + } + ), + "own-power amount must now parse as EachSourceDealsDamage with BatchSource scope: {effect:?}" + ); + let Effect::EachSourceDealsDamage { recipient, .. } = effect else { + unreachable!("matched above"); + }; + assert_eq!( + recipient, + EachDamageRecipient::Shared(TargetFilter::Any), + "own-power 'any target' recipient must stay Shared(Any)" + ); + } + + // CR 120.1 + CR 608.2: Bartz and Boko's ETB trigger BODY — verbatim Oracle. + // The "each other Bird you control" subject binds the "its power" pronoun to + // the per-batch-source scope, and the "other" exclusion is preserved on the + // source filter. + #[test] + fn bartz_trigger_each_other_bird_own_power_is_each_source_deals_damage() { + let effect = super::super::parse_effect( + "each other Bird you control deals damage equal to its power to target creature an opponent controls", + ); + let Effect::EachSourceDealsDamage { + sources, + amount, + recipient, + } = effect + else { + panic!("expected EachSourceDealsDamage, got {effect:?}"); + }; + assert!( + matches!( + amount, + QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource + } + } + ), + "Bartz amount must be Ref(Power{{BatchSource}}), got {amount:?}" + ); + let TargetFilter::Typed(filter) = sources else { + panic!("expected a Typed source filter, got {sources:?}"); + }; + assert!( + filter + .properties + .iter() + .any(|p| matches!(p, FilterProp::Another)), + "Bartz 'each other Bird' must carry FilterProp::Another, got {filter:?}" + ); + assert_eq!(filter.controller, Some(ControllerRef::You)); + assert!( + filter + .type_filters + .iter() + .any(|tf| matches!(tf, TypeFilter::Subtype(s) if s == "Bird")), + "expected a Bird subtype, got {filter:?}" + ); + assert_eq!( + recipient, + EachDamageRecipient::Shared(TargetFilter::Typed( + TypedFilter::new(TypeFilter::Creature).controller(ControllerRef::Opponent) + )), + "Bartz recipient is a creature an opponent controls" + ); + } + + // CR 120.1 + CR 608.2: Judgment of Alexander's delayed-trigger BODY — verbatim + // Oracle. "that creature" (the prevented-damage source) resolves to + // `TriggeringSource`, unchanged. + #[test] + fn judgment_of_alexander_each_commander_own_power_is_each_source_deals_damage() { + let effect = super::super::parse_effect( + "each commander creature you control deals damage equal to its power to that creature", + ); + let Effect::EachSourceDealsDamage { + amount, recipient, .. + } = effect + else { + panic!("expected EachSourceDealsDamage, got {effect:?}"); + }; + assert!( + matches!( + amount, + QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource + } + } + ), + "Judgment of Alexander amount must be Ref(Power{{BatchSource}}), got {amount:?}" + ); + assert_eq!( + recipient, + EachDamageRecipient::Shared(TargetFilter::TriggeringSource), + "Judgment of Alexander 'that creature' recipient is TriggeringSource" + ); + } + + // CR 120.1 + CR 608.2: Signature Slam's spell-chain clause — verbatim Oracle. + #[test] + fn signature_slam_each_modified_own_power_is_each_source_deals_damage() { + let effect = super::super::parse_effect( + "each modified creature you control deals damage equal to its power to target creature you don't control", + ); + let Effect::EachSourceDealsDamage { + amount, recipient, .. + } = effect + else { + panic!("expected EachSourceDealsDamage, got {effect:?}"); + }; + assert!( + matches!( + amount, + QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource + } + } + ), + "Signature Slam amount must be Ref(Power{{BatchSource}}), got {amount:?}" + ); + assert_eq!( + recipient, + EachDamageRecipient::Shared(TargetFilter::Typed( + TypedFilter::new(TypeFilter::Creature).controller(ControllerRef::Opponent) + )), + "Signature Slam recipient is a creature you don't control" + ); + } + + // CR 120.1 + CR 608.2: a COMPOSED per-source amount rebinds the pronoun + // through every wrapper — "its power plus its toughness" → + // Sum{Power{BatchSource}, Toughness{BatchSource}} (both leaves rebound). + // + // The composed rebind is exercised with the "plus" sum rather than + // "twice its power": the shared amount parser binds the "its" in "twice + // its power" to `ObjectScope::Source` before the anaphoric guard ever + // runs (pre-existing parser behavior; only DIRECT "its power" / "its + // toughness" and the "plus" sum preserve the deferred `Anaphoric` + // scope). The Sum fixture proves the identical mechanism — rebind + // through composition — with a form that genuinely keeps the pronoun + // deferred. + #[test] + fn composed_per_source_amount_rebinds_through_wrappers() { + let effect = super::super::parse_effect( + "each Bird you control deals damage equal to its power plus its toughness to target creature", + ); + let Effect::EachSourceDealsDamage { amount, .. } = effect else { + panic!("expected EachSourceDealsDamage, got {effect:?}"); + }; + assert_eq!( + amount, + QuantityExpr::Sum { + exprs: vec![ + QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource + } + }, + QuantityExpr::Ref { + qty: QuantityRef::Toughness { + scope: ObjectScope::BatchSource + } + }, + ], + }, + "composed per-source amount must rebind both inner pronouns to BatchSource: {amount:?}" + ); + } + + // Regression: the FIXED-amount form is unchanged. + #[test] + fn each_source_deals_damage_fixed_amount_regression() { + let effect = + super::super::parse_effect("each Dwarf you control deals 1 damage to any target"); + let Effect::EachSourceDealsDamage { amount, .. } = effect else { + panic!("expected EachSourceDealsDamage, got {effect:?}"); + }; + assert_eq!(amount, QuantityExpr::Fixed { value: 1 }); + } + + // Negative + reach-guard pair: a UNIFORM dynamic amount ("equal to the number + // of artifacts you control") has no anaphoric pronoun, so it stays on the + // prior rejection path — NOT captured. The positive control (same input minus + // nothing, with the "its power" pronoun) parses to `EachSourceDealsDamage` in + // the SAME test, so if the guard were wrongly loosened to accept every + // non-Fixed, the positive still parses and the negative fails — the negative + // is not vacuous (the only delta is the pronoun). + #[test] + fn uniform_dynamic_amount_rejected_with_pronoun_positive_reach_guard() { + let negative = super::super::parse_effect( + "each Bird you control deals damage equal to the number of artifacts you control to target creature", + ); + assert!( + !matches!(negative, Effect::EachSourceDealsDamage { .. }), + "uniform dynamic amount must NOT be captured (non-anaphoric): {negative:?}" + ); + let positive = super::super::parse_effect( + "each Bird you control deals damage equal to its power to target creature", + ); + assert!( + matches!( + positive, + Effect::EachSourceDealsDamage { + amount: QuantityExpr::Ref { + qty: QuantityRef::Power { scope: ObjectScope::BatchSource } + }, + .. + } + ), + "reach-guard: the pronoun form must still parse as EachSourceDealsDamage(BatchSource): {positive:?}" ); } + // CR 120.1 + CR 608.2: Moonlight Hunt — verbatim Oracle clause. The source + // filter carries the Wolf-or-Werewolf subtype UNION (TypeFilter::AnyOf) with + // controller You, the own-power amount is Ref(Power{BatchSource}), and the + // "that creature" recipient (the Werewolf that transformed and caused the + // trigger) resolves to TriggeringSource. Distinct source-filter shape from + // Bartz's "other Bird" and the composed-amount tests. + #[test] + fn moonlight_hunt_union_subtype_own_power_is_each_source_deals_damage() { + let effect = super::super::parse_effect( + "Each creature you control that's a Wolf or a Werewolf deals damage equal to its power to that creature", + ); + let Effect::EachSourceDealsDamage { + sources, + amount, + recipient, + } = effect + else { + panic!("expected EachSourceDealsDamage, got {effect:?}"); + }; + assert_eq!( + sources, + TargetFilter::Typed(TypedFilter { + type_filters: vec![ + TypeFilter::AnyOf(vec![ + TypeFilter::Subtype("Wolf".to_string()), + TypeFilter::Subtype("Werewolf".to_string()), + ]), + TypeFilter::Creature, + ], + controller: Some(ControllerRef::You), + ..Default::default() + }), + "Moonlight Hunt source must carry the Wolf-or-Werewolf union, got {sources:?}" + ); + assert_eq!( + amount, + QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource + } + }, + "Moonlight Hunt amount must be Ref(Power{{BatchSource}}), got {amount:?}" + ); + assert_eq!( + recipient, + EachDamageRecipient::Shared(TargetFilter::TriggeringSource), + "Moonlight Hunt 'that creature' recipient is TriggeringSource" + ); + } + + // CR 120.1 + CR 608.2: Nissa's Judgment — verbatim Oracle clause. The source + // filter carries the +1/+1-counter property (`FilterProp::Counters { OfType(P1P1), + // GE, 1 }`) — a distinct source-filter shape from the union-subtype and + // "other"-property pins above. + #[test] + fn nissas_judgment_counter_property_own_power_is_each_source_deals_damage() { + use crate::types::ability::{Comparator, FilterProp}; + use crate::types::counter::{CounterMatch, CounterType}; + let effect = super::super::parse_effect( + "Each creature you control with a +1/+1 counter on it deals damage equal to its power to that creature", + ); + let Effect::EachSourceDealsDamage { + sources, + amount, + recipient, + } = effect + else { + panic!("expected EachSourceDealsDamage, got {effect:?}"); + }; + assert_eq!( + sources, + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: Some(ControllerRef::You), + properties: vec![FilterProp::Counters { + counters: CounterMatch::OfType(CounterType::Plus1Plus1), + comparator: Comparator::GE, + count: QuantityExpr::Fixed { value: 1 }, + }], + }), + "Nissa's Judgment source must carry the +1/+1-counter property, got {sources:?}" + ); + assert_eq!( + amount, + QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::BatchSource + } + }, + "Nissa's Judgment amount must be Ref(Power{{BatchSource}}), got {amount:?}" + ); + assert_eq!( + recipient, + EachDamageRecipient::Shared(TargetFilter::TriggeringSource), + "Nissa's Judgment 'that creature' recipient is TriggeringSource" + ); + } + + // CR 120.1 + CR 608.2c (DEFERRED §9): Season's Beatings' "random" recipient + // ("another random creature that player controls") is an unmodeled random + // selection — fail CLOSED to `Unimplemented` rather than degrade the recipient + // to `Typed{Another}` (which drops both "random" and the controller scope). + #[test] + fn each_seasons_beatings_random_recipient_fails_closed() { + let effect = super::super::parse_effect( + "Each creature target player controls deals damage equal to its power to another random creature that player controls", + ); + const RIDER_KEY: &str = "each_source_unrepresentable_rider"; + match &effect { + Effect::Unimplemented { name, .. } if name.as_str() == RIDER_KEY => {} + other => panic!( + "Season's Beatings random-recipient rider must fail closed to \ + each_source_unrepresentable_rider (random recipient is an unmodeled \ + per-source rider, not a degradation to Typed{{Another}}), got {other:?}" + ), + } + } + + // CR 120.1 + CR 608.2c (DEFERRED §9): Master of the Wild Hunt's source rider + // ("Each Wolf tapped this way") is a per-source tapped-by-this-ability + // constraint the source filter cannot hold — fail CLOSED to `Unimplemented` + // rather than degrade the sources to bare `Typed{Wolf}`. + #[test] + fn each_master_of_the_wild_hunt_tapped_this_way_fails_closed() { + let effect = super::super::parse_effect( + "Each Wolf tapped this way deals damage equal to its power to target creature", + ); + const RIDER_KEY: &str = "each_source_unrepresentable_rider"; + match &effect { + Effect::Unimplemented { name, .. } if name.as_str() == RIDER_KEY => {} + other => panic!( + "Master of the Wild Hunt tapped-this-way source rider must fail closed to \ + each_source_unrepresentable_rider (per-source tapped-by-this-ability is \ + unmodeled, not a degradation to bare Typed{{Wolf}}), got {other:?}" + ), + } + } + // Negative: the targeted own-power team-up shape still routes to // `EachDealsDamageEqualToPower`, never `EachSourceDealsDamage`. #[test] diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index e1172cb0fd..5f26190ab0 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -5773,6 +5773,18 @@ pub enum ObjectScope { /// Mana-value-only referent today. NOTE: a THIRD set-member ObjectScope should /// trigger a `SetMember { set, selector }` parameterization round. OwnedLinkedExileCard, + /// CR 120.1: The per-iteration damage SOURCE of an + /// [`Effect::EachSourceDealsDamage`] batch — "each you control + /// deals damage equal to its power to …". Each matching object is the + /// source of its own damage; "its power" reads THAT object, not the ability + /// source (which is exempted by the "other" FilterProp). Bound per batch + /// member at resolution by a per-source resolver (`damage_source` field on + /// `QuantityContext`). Distinct from [`ObjectScope::Source`] (the ability + /// source) and `EventTarget` (the damage RECIPIENT); the filter-evaluated + /// source set is unrelated to any trigger event. No runtime fallback: when + /// the per-iteration id is absent (a condition/layer read) it reads null → + /// 0, mirroring [`ObjectScope::Target`]'s fail-closed null read. + BatchSource, } /// Source set for counting distinct card types. @@ -10897,7 +10909,10 @@ pub enum Effect { /// subjects route to `DamageEachPlayer`. sources: TargetFilter, /// CR 120.1: Damage dealt by every source. Uniform across the batch - /// (resolved once, CR 608.2). + /// (resolved once, CR 608.2) UNLESS the amount reads the per-source + /// `ObjectScope::BatchSource` scope ("deals damage equal to its power"), + /// in which case it is resolved per batch member (each source is the + /// source of its own damage, CR 120.1). amount: QuantityExpr, /// CR 120.3: The recipient resolution strategy (shared target vs /// per-source controller). diff --git a/crates/engine/tests/integration/bartz_and_boko_each_source_damage.rs b/crates/engine/tests/integration/bartz_and_boko_each_source_damage.rs new file mode 100644 index 0000000000..10041a829b --- /dev/null +++ b/crates/engine/tests/integration/bartz_and_boko_each_source_damage.rs @@ -0,0 +1,156 @@ +//! Runtime discriminator: the filter-source own-power damage class — "each other +//! you control deals damage equal to its power to " — parsed to +//! `Effect::EachSourceDealsDamage` with a per-batch-source `Power { BatchSource }` +//! amount, resolved PER SOURCE (each member is the source of its own damage, +//! CR 120.1), NOT resolved once against the ability source. +//! +//! Card under test: Bartz and Boko's ETB trigger: +//! "When Bartz and Boko enters, each other Bird you control deals damage equal +//! to its power to target creature an opponent controls." +//! +//! The board is sized so the assertions DISCRIMINATE every failure mode: +//! - Correct per-source resolution: 4/4 + 5/5 = 9 damage ≥ 9 toughness kills the +//! opponent's 0/9 target (CR 120.6 damage marking; CR 704.5g lethal-damage +//! state-based action). +//! - A resolver reading the amount once against the ability source (Bartz, 4) +//! and applying it per source deals 4 + 4 = 8 < 9 → target survives. +//! - A resolver reading once against the FIRST batch member (the 4/4, placed +//! first so the batch iterates 4/4 → 5/5) deals 4 + 4 = 8 < 9 → survives. +//! - The pre-fix parser misparse (single `DealDamage` at Bartz's own 4 once) +//! deals 4 < 9 → survives. +//! - A fail-closed resolver (`damage_source` unset → 0) deals 0 → survives. +//! +//! The correct arm is the ONLY one that kills the 0/9. +//! +//! `objects_that_dealt_damage` is keyed per source (deal_damage.rs), so the +//! attribution assertion (BOTH Birds marked, Bartz NOT) pairs with — but does not +//! substitute for — the creature-dies threshold (a uniform-amount resolver still +//! keys per source and would satisfy attribution alone). +//! +//! CR 120.1: the object that deals damage is the source of that damage. +//! CR 120.6: lethal damage is total marked damage ≥ toughness (definition). +//! CR 704.5g: a creature with lethal damage marked on it is destroyed (SBA). +//! CR 608.2h: LKI fallback for sources that leave the battlefield mid-batch. +//! CR 208.1 + CR 608.2: a creature's power is a modifiable characteristic read at +//! resolution. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +/// Verbatim Oracle text (Scryfall) — Bartz and Boko, {2}{U}{R} Legendary Creature — +/// Human Bird. Affinity for Birds reduces the cost; the cast below pays a zeroed +/// cost so the affinity line is irrelevant to the mechanic under test. +const BARTZ_AND_BOKO: &str = "Affinity for Birds (This spell costs {1} less to cast for each \ + Bird you control.)\nWhen Bartz and Boko enters, each other Bird you control deals damage \ + equal to its power to target creature an opponent controls."; + +/// Place a creature on the battlefield under `player` that matches "each other +/// Bird you control" (a Bird subtype is the whole filter — the parser emitted +/// `TypeFilter::Subtype("Bird")` without a redundant Creature type). +fn add_bird( + scenario: &mut GameScenario, + player: engine::types::PlayerId, + name: &str, + power: i32, + toughness: i32, +) -> engine::types::identifiers::ObjectId { + scenario + .add_creature(player, name, power, toughness) + .with_subtypes(vec!["Bird"]) + .id() +} + +/// The 4/4 enters on the battlefield FIRST (before Bartz is cast), so a +/// resolve-once-against-the-first-batch-member bug reads the 4/4 → 4 + 4 = 8 < 9 +/// (survives). The correct per-source read 4 + 5 = 9 kills the 0/9. +#[test] +fn bartz_each_other_bird_deals_own_power_sum_kills_0_9() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + + // 4/4 on the battlefield first, then the 5/5 (battlefield is insertion-ordered). + let bird4 = add_bird(&mut scenario, P0, "Gwaihir the Windlord", 4, 4); + let bird5 = add_bird(&mut scenario, P0, "Dragonhawk", 5, 5); + // The opponent's target: 9 toughness survives 8 (any wrong arm) but dies to 9. + let recipient = scenario.add_vanilla(P1, 0, 9); + + let bartz = scenario + .add_creature_to_hand_from_oracle(P0, "Bartz and Boko", 4, 3, BARTZ_AND_BOKO) + .with_subtypes(vec!["Human", "Bird"]) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + // Cast Bartz; the harness drives the ETB trigger's TriggerTargetSelection from + // the declared `recipient` intent (CR 603.3d). + let outcome = runner.cast(bartz).target_object(recipient).resolve(); + let state = outcome.state(); + + // CR 120.1 + CR 208.1 + CR 608.2: each of the two OTHER Birds deals its own + // power — 4 + 5 = 9 marked. 9 ≥ 9 toughness is lethal under CR 120.6, so the + // 0/9 is destroyed via the CR 704.5g state-based action. + assert_eq!( + outcome.zone_of(recipient), + Zone::Graveyard, + "0/9 must be dealt 4 + 5 = 9 (own-power per source) and die; \ + uniform-against-Bartz 8, first-source 8, pre-fix single 4, and fail-closed 0 \ + all leave it alive — got {:?}", + outcome.zone_of(recipient) + ); + + // Per-source attribution (CR 120.1): BOTH Birds are recorded as damage + // sources; Bartz (excluded by "other" — he is himself a Bird per the Human + // Bird power 4 typeline) contributes 0 and must NOT be in the dealt set. + assert!( + state.objects_that_dealt_damage.contains(&bird4), + "the 4/4 Bird must be a recorded damage source" + ); + assert!( + state.objects_that_dealt_damage.contains(&bird5), + "the 5/5 Bird must be a recorded damage source" + ); + assert!( + !state.objects_that_dealt_damage.contains(&bartz), + "Bartz must NOT deal damage — the 'other Bird' exclusion binds per source; \ + got dealt set {:?}", + state.objects_that_dealt_damage + ); +} + +/// Negative paired with the positive above (reach-guard): with NO other Birds on +/// the battlefield, the "each OTHER Bird you control" filter has zero members, so +/// the trigger deals 0 to the 0/9 (it survives) and `objects_that_dealt_damage` is +/// empty (Bartz — himself a Bird — is excluded). This proves the `FilterProp::Another` +/// exclusion and the zero-member per-source skip are load-bearing, not vacuous: +/// the positive two-Bird test would fail if the "other" filter were dropped +/// (Bartz would wrongly deal 4 and appear in the dealt set). +#[test] +fn bartz_no_other_bird_deals_zero_target_survives() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + + // Only Bartz — he is a Human Bird, but "other" excludes him from his own filter. + let bartz = scenario + .add_creature_to_hand_from_oracle(P0, "Bartz and Boko", 4, 3, BARTZ_AND_BOKO) + .with_subtypes(vec!["Human", "Bird"]) + .with_mana_cost(ManaCost::zero()) + .id(); + let recipient = scenario.add_vanilla(P1, 0, 9); + + let mut runner = scenario.build(); + let outcome = runner.cast(bartz).target_object(recipient).resolve(); + let state = outcome.state(); + + assert_eq!( + outcome.zone_of(recipient), + Zone::Battlefield, + "with no other Birds, the trigger must deal 0 and the 0/9 survives" + ); + assert!( + state.objects_that_dealt_damage.is_empty(), + "no Bird source dealt damage (Bartz excluded as 'other'), got {:?}", + state.objects_that_dealt_damage + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 9969115fe2..412a6d4487 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1058,6 +1058,7 @@ mod ashling_delayed_sacrifice_unless_pay; mod atarkas_command_sequential_mode_after_decline; mod aurora_awakener_reveal_until_n_permanents; mod baron_helmut_zemo_boast; +mod bartz_and_boko_each_source_damage; mod base_pt_dynamic_set_std_base_pt; mod become_color_set_std_batch; mod behold_chosen_object_power_damage;