From 5fddcdcee12ad6cd838b20fb373945eb9bd0b170 Mon Sep 17 00:00:00 2001 From: Christopher Galpin <52485+CodeOptimist@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:44:29 -0600 Subject: [PATCH 1/2] fix(engine): resolve per-source power in each-X-deals-damage clauses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each-source damage clauses with an own-characteristic amount ("each other Bird you control deals damage equal to its power to target creature an opponent controls" — Bartz and Boko) fell through try_parse_each_source_ deals_damage's Fixed-only amount guard and mis-parsed to a single ability-sourced DealDamage: the "each you control" subject was dropped and Power{Anaphoric} resolved against the trigger source, not each batch member. Introduce ObjectScope::BatchSource (CR 120.1 per-iteration batch source) so QuantityRef::Power { scope: BatchSource } types the per-source amount, thread a QuantityContext.damage_source field + a per-source resolve wrapper (resolve_quantity_with_targets_and_damage_source), and have resolve_each_source_deals_damage resolve the amount per source id (live + LKI fallback, CR 113.7a) inside the existing simultaneous-batch entries loop. The parser guard now rebinds the clause's deferred "its" pronoun to BatchSource via the structural rebind helper instead of rejecting it. 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, The Bears of Littjara. Sources whose riders the filter model cannot express fail closed to Unimplemented (Season's Beatings' "random" recipient; Master of the Wild Hunt's "tapped this way" source restriction) rather than silently degrading. Adds a discriminating runtime cast-pipeline test (0/9 dies to sum 9, uniform/ ability-source 8 survives, Bartz excluded from attribution) plus parser and resolver building-block tests pinning the class. --- crates/engine/src/game/ability_rw.rs | 7 + crates/engine/src/game/ability_scan.rs | 3 + crates/engine/src/game/coverage.rs | 15 + crates/engine/src/game/effects/deal_damage.rs | 68 ++- crates/engine/src/game/effects/mod.rs | 6 +- crates/engine/src/game/layers.rs | 4 +- crates/engine/src/game/quantity.rs | 442 ++++++++++++++++- crates/engine/src/game/replacement.rs | 3 + crates/engine/src/game/restrictions.rs | 1 + .../src/parser/oracle_effect/subject.rs | 444 +++++++++++++++++- crates/engine/src/types/ability.rs | 17 +- .../bartz_and_boko_each_source_damage.rs | 154 ++++++ crates/engine/tests/integration/main.rs | 1 + 13 files changed, 1124 insertions(+), 41 deletions(-) create mode 100644 crates/engine/tests/integration/bartz_and_boko_each_source_damage.rs 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..3e76a5dec8 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 113.7a: 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..5007598516 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 113.7a) 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 113.7a: 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 113.7a: 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 113.7a: 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 113.7a: 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 113.7a: `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..bae0095e3c 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,17 +7198,380 @@ 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", + ); + assert!( + matches!(effect, Effect::Unimplemented { .. }), + "Season's Beatings random-recipient rider must fail closed to Unimplemented, got {effect:?}" + ); + } + + // 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", + ); + assert!( + matches!(effect, Effect::Unimplemented { .. }), + "Master of the Wild Hunt tapped-this-way source rider must fail closed to Unimplemented, got {effect:?}" ); } 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..1c3508ea7d --- /dev/null +++ b/crates/engine/tests/integration/bartz_and_boko_each_source_damage.rs @@ -0,0 +1,154 @@ +//! 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 113.7a: 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_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_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; From 4ab57b97b624d84739005717596735e01188e029 Mon Sep 17 00:00:00 2001 From: Christopher Galpin <52485+CodeOptimist@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:12:56 -0600 Subject: [PATCH 2/2] test(engine): make fail-closed and Bartz exclusion tests discriminating (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the each-source-damage fix (5fddcdcee) addressing code review: - subject.rs: the Season's Beatings ("random" recipient) and Master of the Wild Hunt ("tapped this way") fail-closed tests previously matched only `Effect::Unimplemented { .. }` — the same outcome as the pre-guard fallthrough, so both passed without the guards. Pin the guard-owned key `each_source_unrepresentable_rider` (via a const comparison) so the fail-closed path discriminates; neutralized-guard revert probe fails both. - bartz integration tests: give Bartz its printed Human/Bird subtypes in both fixtures so he genuinely matches the "Bird" filter, making the `FilterProp::Another` "other" exclusion load-bearing (probes: dropping "other" with subtypes present fails both tests; without subtypes both pass regardless). - CR annotations: the batch-member LKI fallback is CR 608.2h (effect reads a specific object no longer in its expected public zone -> last known information), not CR 113.7a (ability-source independence). Corrected the 7 commit-added comment sites and the integration test module doc. --- crates/engine/src/game/effects/deal_damage.rs | 2 +- crates/engine/src/game/quantity.rs | 12 ++++----- .../src/parser/oracle_effect/subject.rs | 26 +++++++++++++------ .../bartz_and_boko_each_source_damage.rs | 4 ++- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index 3e76a5dec8..bea3faad22 100644 --- a/crates/engine/src/game/effects/deal_damage.rs +++ b/crates/engine/src/game/effects/deal_damage.rs @@ -2528,7 +2528,7 @@ pub fn resolve_each_source_deals_damage( // marked). Each source carries its OWN `DamageContext` (CR 120.1 identity). let mut entries: Vec<(ObjectId, DamageContext, TargetRef, u32)> = Vec::new(); for &source_id in &source_ids { - // CR 120.1 + CR 113.7a: each batch member deals its OWN characteristic; + // 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 diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 5007598516..27360103fc 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -2084,7 +2084,7 @@ pub(crate) fn resolve_quantity_with_targets_and_recipient( /// (`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 113.7a) at each characteristic read. +/// (CR 608.2h) at each characteristic read. pub(crate) fn resolve_quantity_with_targets_and_damage_source( state: &GameState, expr: &QuantityExpr, @@ -5482,7 +5482,7 @@ fn resolve_counters_on_scope( } ObjectScope::Source | ObjectScope::Anaphoric - // CR 120.1 + CR 113.7a: the per-iteration batch member's counters are + // 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`). @@ -5989,7 +5989,7 @@ where ObjectScope::OtherRevealedCard => 0, // MV-only referent; no P/T semantics. ObjectScope::OwnedLinkedExileCard => 0, - // CR 120.1 + CR 208.3 + CR 113.7a: the per-iteration damage source of an + // 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 @@ -6254,7 +6254,7 @@ fn resolve_object_mana_value( current_mana_value.unwrap_or(0) } } - // CR 120.1 + CR 202.3 + CR 113.7a: the per-iteration damage source of an + // 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 @@ -15874,7 +15874,7 @@ mod tests { ); } - /// CR 120.1 + CR 208.3 + CR 113.7a: the `ObjectScope::BatchSource` power + /// 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 @@ -15970,7 +15970,7 @@ mod tests { ); } - /// CR 120.1 + CR 202.3 + CR 113.7a: `ObjectManaValue { BatchSource }` reads + /// 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() { diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index bae0095e3c..3d9570ae48 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -7554,10 +7554,15 @@ mod tests { let effect = super::super::parse_effect( "Each creature target player controls deals damage equal to its power to another random creature that player controls", ); - assert!( - matches!(effect, Effect::Unimplemented { .. }), - "Season's Beatings random-recipient rider must fail closed to Unimplemented, got {effect:?}" - ); + 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 @@ -7569,10 +7574,15 @@ mod tests { let effect = super::super::parse_effect( "Each Wolf tapped this way deals damage equal to its power to target creature", ); - assert!( - matches!(effect, Effect::Unimplemented { .. }), - "Master of the Wild Hunt tapped-this-way source rider must fail closed to Unimplemented, got {effect:?}" - ); + 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 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 index 1c3508ea7d..10041a829b 100644 --- a/crates/engine/tests/integration/bartz_and_boko_each_source_damage.rs +++ b/crates/engine/tests/integration/bartz_and_boko_each_source_damage.rs @@ -30,7 +30,7 @@ //! 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 113.7a: LKI fallback for sources that leave the battlefield mid-batch. +//! 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. @@ -78,6 +78,7 @@ fn bartz_each_other_bird_deals_own_power_sum_kills_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(); @@ -133,6 +134,7 @@ fn bartz_no_other_bird_deals_zero_target_survives() { // 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);