diff --git a/crates/engine/src/ai_support/filter.rs b/crates/engine/src/ai_support/filter.rs index 0e349722b1..acefea928b 100644 --- a/crates/engine/src/ai_support/filter.rs +++ b/crates/engine/src/ai_support/filter.rs @@ -969,6 +969,7 @@ fn filterprop_reads_only_candidate_fp(p: &FilterProp) -> bool { | FilterProp::HasAdventure | FilterProp::SameName | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource | FilterProp::NameMatchesAnyPermanent { .. } | FilterProp::DifferentNameFrom { .. } | FilterProp::DistinctFrom { .. } diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index ac1be947b4..2000561586 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -1013,6 +1013,12 @@ fn resolve_mana_option_for_trigger_probe( produced: vec![option.mana_type], tap_state: ManaTapState::FromTap, }); + events.push(GameEvent::ManaAbilityProduced { + player_id: player, + source_id: option.object_id, + produced: vec![option.mana_type], + trigger_state: crate::types::events::ManaAbilityTriggerState::Pending, + }); } triggers::events_would_queue_non_mana_trigger(&mut probe, &events) @@ -1770,7 +1776,8 @@ fn beneficial_mana_tap_trigger_hold( // same predicates the trigger resolver uses. `taps_for_mana_card_matches` // ignores `taps_for_mana_produced`, so a produced-mana filter is // treated as matching (over-approx, err-to-hold). - crate::types::triggers::TriggerMode::TapsForMana => { + crate::types::triggers::TriggerMode::TapsForMana + | crate::types::triggers::TriggerMode::ManaAbilityProduced => { crate::game::trigger_matchers::taps_for_mana_card_matches( trigger, state, diff --git a/crates/engine/src/ai_support/shortcut_efficacy.rs b/crates/engine/src/ai_support/shortcut_efficacy.rs index 73031c4249..f7a6caff94 100644 --- a/crates/engine/src/ai_support/shortcut_efficacy.rs +++ b/crates/engine/src/ai_support/shortcut_efficacy.rs @@ -1207,7 +1207,7 @@ fn trigger_event_unreachable_by_confined_action( // Directly produced by casting or activating: // `SpellCast` CR 601.2i — announcing the spell IS the event // `AbilityActivated` CR 602.2b — likewise for an activation - // `Taps` / `TapsForMana` / `ManaAdded` — `AbilityCost::Tap` and the mana + // `Taps` / `TapsForMana` / `ManaAdded` / `ManaAbilityProduced` — `AbilityCost::Tap` and the mana // the actor spends // `PlayerPerformedAction` — `game::search_library` emits it // `SearchedLibrary` — `Effect::SearchLibrary` itself @@ -3207,6 +3207,7 @@ mod tests { TriggerMode::Taps, TriggerMode::TapsForMana, TriggerMode::ManaAdded, + TriggerMode::ManaAbilityProduced, TriggerMode::Shuffled, TriggerMode::SearchedLibrary, TriggerMode::PlayerPerformedAction, diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index c553402e97..800e9de790 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -1071,7 +1071,9 @@ fn trigger_axis(trig: &TriggerDefinition) -> Option { // CR 701.26a: "becomes tapped" requires untapped state to consume. TriggerMode::Taps | TriggerMode::TapAll => Some(AxisKey::Tap), // CR 106.1: mana-added / tap-for-mana triggers consume the mana axis. - TriggerMode::TapsForMana | TriggerMode::ManaAdded => Some(AxisKey::Mana), + TriggerMode::TapsForMana | TriggerMode::ManaAdded | TriggerMode::ManaAbilityProduced => { + Some(AxisKey::Mana) + } // CR 603.6a / 700.4 / 603.6c: zone-change triggers consume the ETB / dies / // LTB event axis, disambiguated by the definition's destination/origin. TriggerMode::ChangesZone | TriggerMode::ChangesZoneAll => { diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index bd097b6ead..63b718de97 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2412,6 +2412,7 @@ fn legacy_filter_prop(p: &FilterProp) -> bool { | FilterProp::Named { .. } | FilterProp::SameName | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource | FilterProp::IsCommander // CR 205.3m: a unit variant with no nested TargetFilter/QuantityExpr/ // ControllerRef interior — nothing to descend, so no legacy referent. @@ -2602,6 +2603,9 @@ fn member_bound_filter_prop(p: &FilterProp) -> bool { // CR 607.2d / CR 607.2m (by analogy): this reads durable per-player anchor // state keyed by controller, not per-source member-bound storage. FilterProp::ControllerChoseLabel { .. } => false, + // CR 607.2a: this consults the resolving source's linked-exile set, so + // normalized siblings with different sources are not one shared function. + FilterProp::SameNameAsExiledBySource => true, // CR 608.2i: reads live per-turn history keyed by the object's controller, // not per-source member-bound storage. Mirrors ControllerChoseLabel. FilterProp::ControllerMatches { .. } => false, @@ -6896,6 +6900,10 @@ mod tests { TargetFilter::TriggeringSourceController, TargetFilter::OriginalController, typed_ctrl(ControllerRef::SourceChosenPlayer), + TargetFilter::Typed(TypedFilter { + properties: vec![FilterProp::SameNameAsExiledBySource], + ..TypedFilter::creature() + }), ] { assert!( member_bound_target_filter(&f), diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 431bee215c..4191a7fae8 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -3792,6 +3792,7 @@ fn scan_filter_prop(x: &FilterProp, mode: ScanMode) -> Axes { | FilterProp::Named { .. } | FilterProp::SameName | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource | FilterProp::IsCommander // CR 205.3m + CR 903.3: reads commander designation + the candidate's own // creature types — a board/object read, no player resource. diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 8c9a9f056a..82ca0ed971 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -11439,6 +11439,12 @@ fn auto_tap_mana_sources_inner( produced: vec![option.mana_type], tap_state: ManaTapState::FromTap, }); + events.push(GameEvent::ManaAbilityProduced { + player_id: player, + source_id: option.object_id, + produced: vec![option.mana_type], + trigger_state: crate::types::events::ManaAbilityTriggerState::Pending, + }); }); } } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index a331e9022a..2be6093fb7 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -760,6 +760,7 @@ fn fmt_typed_filter(tf: &TypedFilter) -> String { } FilterProp::SameName => parts.push("same name".into()), FilterProp::SameNameAsParentTarget => parts.push("same name as parent target".into()), + FilterProp::SameNameAsExiledBySource => parts.push("same name as exiled card".into()), FilterProp::NameMatchesAnyPermanent { controller } => match controller { Some(c) => parts.push(format!("name matches {} permanent", fmt_controller(c))), None => parts.push("name matches any permanent".into()), diff --git a/crates/engine/src/game/effects/mana.rs b/crates/engine/src/game/effects/mana.rs index ad9f8309c0..14a8747b81 100644 --- a/crates/engine/src/game/effects/mana.rs +++ b/crates/engine/src/game/effects/mana.rs @@ -270,7 +270,8 @@ pub fn resolve( .current_trigger_event .as_ref() .and_then(|event| match event { - GameEvent::TappedForMana { player_id, .. } => Some(*player_id), + GameEvent::TappedForMana { player_id, .. } + | GameEvent::ManaAbilityProduced { player_id, .. } => Some(*player_id), _ => None, }) .unwrap_or(ability.controller), @@ -867,7 +868,10 @@ fn resolve_mana_types_impl( ManaProduction::TriggerEventManaType => { use crate::types::events::GameEvent; match &state.current_trigger_event { - Some(GameEvent::TappedForMana { produced, .. }) => { + Some( + GameEvent::TappedForMana { produced, .. } + | GameEvent::ManaAbilityProduced { produced, .. }, + ) => { let distinct: std::collections::HashSet<_> = produced.iter().copied().collect(); distinct.into_iter().collect() } diff --git a/crates/engine/src/game/exile_links.rs b/crates/engine/src/game/exile_links.rs index e65eb16122..bd3617ba6a 100644 --- a/crates/engine/src/game/exile_links.rs +++ b/crates/engine/src/game/exile_links.rs @@ -10,6 +10,7 @@ const LINKED_EXILE_CONSUMER_TAGS: &[&str] = &[ "OwnersOfCardsExiledBySource", "ChoiceAmongExiledColors", "TargetSharesNameWithOtherExiledThisWay", + "SameNameAsExiledBySource", // CR 700.3: PileSource::ExiledThisWay — the pile-separation effect // consumes cards exiled earlier in the same resolution chain. "ExiledThisWay", diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 3800c93736..fa6597cb02 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -349,6 +349,7 @@ fn filter_prop_uses_object_population(prop: &FilterProp) -> bool { | FilterProp::Named { .. } | FilterProp::SameName | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource | FilterProp::IsCommander // CR 205.3m: reads the controller's COMMANDER, not whole-board population; // another object entering or leaving cannot change the commander's types. @@ -612,7 +613,9 @@ fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> Charact } // ---- CR 613.1c (layer 3): name reads. ---- - FilterProp::SameName | FilterProp::SameNameAsParentTarget => CharacteristicKinds::NAME_TEXT, + FilterProp::SameName + | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource => CharacteristicKinds::NAME_TEXT, // CR 201.2 + CR 613.1f: `Named` also matches through the live // `StaticMode::CountsAsNamed` aliases, which are layer-6 statics. FilterProp::Named { .. } => { @@ -1003,6 +1006,7 @@ fn entered_object_perturbs_filter_prop( | FilterProp::Named { .. } | FilterProp::SameName | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource | FilterProp::IsCommander // CR 205.3m: an entering object cannot perturb this — the commander's // creature types come from the deck-pool registration, not the board. @@ -4463,6 +4467,7 @@ fn spell_record_matches_property(record: &SpellCastRecord, prop: &FilterProp) -> // `FilterProp::Named { name }` is handled above against the snapshot. | FilterProp::SameName | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource | FilterProp::NameMatchesAnyPermanent { .. } // CR 903.3d: Commander designation is meaningful for permanents on the // battlefield. The spell-cast record path is not currently plumbed with @@ -5175,6 +5180,13 @@ fn matches_filter_prop( // (e.g., the seed was just exiled by the preceding effect). FilterProp::SameNameAsParentTarget => parent_target_name(state, source.ability) .is_some_and(|name| obj.name.eq_ignore_ascii_case(&name)), + FilterProp::SameNameAsExiledBySource => state.exile_links.iter().any(|link| { + link.source_id == source.id + && state + .objects + .get(&link.exiled_id) + .is_some_and(|exiled| obj.name.eq_ignore_ascii_case(&exiled.name)) + }), // CR 201.2 + CR 201.2a: Matches if `obj.name` equals the name of any // permanent on the battlefield (optionally narrowed by controller). // Name comparison is case-insensitive per `FilterProp::Named` / @@ -6048,6 +6060,13 @@ fn zone_change_record_matches_property( // target (parent target). Mirrors the live-object evaluator. FilterProp::SameNameAsParentTarget => parent_target_name(state, source.ability) .is_some_and(|name| record.name.eq_ignore_ascii_case(&name)), + FilterProp::SameNameAsExiledBySource => state.exile_links.iter().any(|link| { + link.source_id == source.id + && state + .objects + .get(&link.exiled_id) + .is_some_and(|exiled| record.name.eq_ignore_ascii_case(&exiled.name)) + }), // -------- Group 3: combat snapshot state -------- // CR 508.1k / CR 509.1g / CR 509.1h: Combat state as of the zone change. @@ -13817,6 +13836,7 @@ mod characteristic_read_classification_tests { | FilterProp::Named { .. } | FilterProp::SameName | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource | FilterProp::IsCommander | FilterProp::SharesCreatureTypeWithCommander | FilterProp::Other { .. } => false, diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 066fa25849..d479f090df 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3337,6 +3337,7 @@ fn filter_prop_reads_life(prop: &FilterProp) -> bool { | FilterProp::Named { .. } | FilterProp::SameName | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource | FilterProp::NameMatchesAnyPermanent { .. } | FilterProp::IsCommander | FilterProp::SharesCreatureTypeWithCommander diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs index 1b01527c28..94a43e8d6b 100644 --- a/crates/engine/src/game/log.rs +++ b/crates/engine/src/game/log.rs @@ -134,6 +134,7 @@ fn importance(event: &GameEvent) -> LogImportance { | GameEvent::ZoneChanged { .. } | GameEvent::ManaAdded { .. } | GameEvent::TappedForMana { .. } + | GameEvent::ManaAbilityProduced { .. } | GameEvent::ManaPoolEmptied { .. } | GameEvent::ManaRecolored { .. } | GameEvent::PermanentTapped { .. } @@ -283,6 +284,7 @@ fn tone(event: &GameEvent) -> LogTone { | GameEvent::ZoneChanged { .. } | GameEvent::ManaAdded { .. } | GameEvent::TappedForMana { .. } + | GameEvent::ManaAbilityProduced { .. } | GameEvent::ManaPoolEmptied { .. } | GameEvent::ManaRecolored { .. } | GameEvent::PermanentTapped { .. } @@ -538,6 +540,7 @@ fn categorize(event: &GameEvent) -> LogCategory { GameEvent::ManaAdded { .. } | GameEvent::TappedForMana { .. } + | GameEvent::ManaAbilityProduced { .. } | GameEvent::ManaPoolEmptied { .. } | GameEvent::ManaRecolored { .. } => LogCategory::Mana, @@ -1724,7 +1727,7 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec { // `TapsForMana` matchers. The per-unit `ManaAdded` events already // produce the user-facing "adds X mana" log lines, so this event is // internal plumbing and emits no segments of its own. - GameEvent::TappedForMana { .. } => vec![], + GameEvent::TappedForMana { .. } | GameEvent::ManaAbilityProduced { .. } => vec![], } } diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index eb4d96c201..e70cfa12af 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -314,7 +314,11 @@ pub fn is_triggered_mana_ability( // above for the deliberately-not-yet-widened `AbilityActivated` axis. matches!( trigger_event, - Some(GameEvent::TappedForMana { .. } | GameEvent::ManaAdded { .. }) + Some( + GameEvent::TappedForMana { .. } + | GameEvent::ManaAbilityProduced { .. } + | GameEvent::ManaAdded { .. } + ) ) } @@ -441,7 +445,9 @@ pub fn resolve_triggered_mana_ability_inline( let node = source.map(|source| { let caused_by = match trigger_event { Some( - GameEvent::ManaAdded { source_id, .. } | GameEvent::TappedForMana { source_id, .. }, + GameEvent::ManaAdded { source_id, .. } + | GameEvent::TappedForMana { source_id, .. } + | GameEvent::ManaAbilityProduced { source_id, .. }, ) => state .resolved_rules_journal .latest_mana_producer_for_source(*source_id), @@ -641,6 +647,7 @@ fn produce_mana_from_ability( }, ); let mut produced_for_tap_event = Vec::new(); + let mut produced_for_ability_events = Vec::new(); for recipient in recipients { let mut scoped = resolved_for_quantity.clone(); scoped.set_original_controller_recursive(player); @@ -689,6 +696,9 @@ fn produce_mana_from_ability( // resolution. Its payload is the full aggregate produced by the // ability, including scoped recipients that exclude the activator. produced_for_tap_event.extend(produced_mana.iter().copied()); + if !produced_mana.is_empty() { + produced_for_ability_events.push((recipient, produced_mana.clone())); + } for &mana_type in &produced_mana { mana_payment::produce_mana_with_attributes_from_source_quality( state, @@ -726,6 +736,19 @@ fn produce_mana_from_ability( } } + // CR 605.1b: Emit one aggregate event per receiving player for every + // mana-ability resolution, including abilities without a tap cost. Its + // output vector lets triggered mana abilities inspect each player's share + // of a multi-recipient resolution exactly once. + for (recipient, produced) in produced_for_ability_events { + events.push(GameEvent::ManaAbilityProduced { + player_id: recipient, + source_id, + produced, + trigger_state: crate::types::events::ManaAbilityTriggerState::Pending, + }); + } + // CR 106.12a: an "is tapped for mana" trigger fires once per resolution of // a `{T}`-cost mana ability that produces mana — not once per mana unit. // Emit a single `TappedForMana` here so the `TapsForMana` matcher fires @@ -4637,6 +4660,27 @@ mod tests { } if *source_id == source && *produced == recipient_colors ))); + let mut recipient_events: Vec<_> = events + .iter() + .filter_map(|event| match event { + GameEvent::ManaAbilityProduced { + player_id, + source_id, + produced, + .. + } if *source_id == source => Some((*player_id, produced.clone())), + _ => None, + }) + .collect(); + recipient_events.sort_by_key(|(player, _)| *player); + assert_eq!( + recipient_events, + vec![ + (PlayerId(1), vec![recipient_colors[0]]), + (PlayerId(2), vec![recipient_colors[1]]), + ], + "each recipient receives one distinct aggregate ManaAbilityProduced event" + ); } fn gemstone_caverns_mana_ability() -> AbilityDefinition { diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index 189bd2666d..405053282c 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -969,6 +969,12 @@ pub(crate) fn activate_mana_source_option_with_output( produced, tap_state: ManaTapState::FromTap, }); + events.push(GameEvent::ManaAbilityProduced { + player_id: player, + source_id: option.object_id, + produced: vec![option.mana_type], + trigger_state: crate::types::events::ManaAbilityTriggerState::Pending, + }); mana_abilities::resume_waiting_for(player, resume) }; @@ -1409,7 +1415,7 @@ pub(crate) fn trigger_chain_benefits_controller(trigger: &TriggerDefinition) -> } /// CR 605.1b (+ CR 603.3): True when `trigger` is a *non-mana* tap-triggered -/// ability — mode `TapsForMana` or `ManaAdded` whose `execute` chain contains +/// ability — mode `TapsForMana`, `ManaAbilityProduced`, or `ManaAdded` whose `execute` chain contains /// any effect other than mana production. /// /// Such a trigger FAILS CR 605.1b's mana-ability criteria (it does not "add mana @@ -1423,7 +1429,7 @@ pub(crate) fn trigger_chain_benefits_controller(trigger: &TriggerDefinition) -> pub(crate) fn is_non_mana_tap_trigger(trigger: &TriggerDefinition) -> bool { matches!( trigger.mode, - TriggerMode::TapsForMana | TriggerMode::ManaAdded + TriggerMode::TapsForMana | TriggerMode::ManaAbilityProduced | TriggerMode::ManaAdded ) && trigger.execute.as_deref().is_some_and(|execute| { !matches!(*execute.effect, Effect::Mana { .. }) || chain_has_non_mana_effect(execute) }) diff --git a/crates/engine/src/game/public_state.rs b/crates/engine/src/game/public_state.rs index 3a11525f24..a4b590a4f1 100644 --- a/crates/engine/src/game/public_state.rs +++ b/crates/engine/src/game/public_state.rs @@ -267,6 +267,7 @@ pub fn mark_public_state_from_events(state: &mut GameState, events: &[GameEvent] mark_public_state_object_dirty(state, *source_id); mark_mana_display_dirty(state); } + GameEvent::ManaAbilityProduced { .. } => {} GameEvent::ManaExpended { player_id, .. } => { mark_public_state_player_dirty(state, *player_id); mark_mana_display_dirty(state); diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index a6cc959bb2..2a268d2794 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -340,7 +340,9 @@ pub(crate) fn keys_from_trigger_def(def: &TriggerDefinition) -> (Keys, bool) { TriggerMode::LosesGame => push(TriggerEventKey::PlayerLost), // --- Mana --- - TriggerMode::ManaAdded => push(TriggerEventKey::ManaProduced), + TriggerMode::ManaAdded | TriggerMode::ManaAbilityProduced => { + push(TriggerEventKey::ManaProduced) + } TriggerMode::ManaExpend => push(TriggerEventKey::ManaSpent), // --- Land --- @@ -584,6 +586,7 @@ pub(crate) fn keys_from_event(event: &GameEvent, state: &GameState) -> Keys { GameEvent::LifeChanged { .. } => push(TriggerEventKey::LifeChanged), GameEvent::ControllerChanged { .. } => push(TriggerEventKey::ChangesController), GameEvent::ManaAdded { .. } => push(TriggerEventKey::ManaProduced), + GameEvent::ManaAbilityProduced { .. } => push(TriggerEventKey::ManaProduced), GameEvent::TappedForMana { .. } => { push(TriggerEventKey::ManaProduced); push(TriggerEventKey::TapsForMana); diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 968fcc177b..65aaf1535a 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -3,8 +3,8 @@ use std::sync::LazyLock; use crate::types::ability::{ AbilityTag, CoinFlipResult, ControllerRef, DamageKindFilter, DestinationConstraint, - DieResultFilter, EffectKind, OriginConstraint, TargetFilter, TargetRef, TriggerDefinition, - TypedFilter, + DieResultFilter, EffectKind, ManaAbilityProducedFilter, OriginConstraint, TargetFilter, + TargetRef, TriggerDefinition, TypedFilter, }; use crate::types::events::{GameEvent, PlayerActionKind}; use crate::types::game_state::{GameState, TriggerSourceContext}; @@ -74,6 +74,7 @@ pub fn trigger_matcher(mode: TriggerMode) -> Option { TriggerMode::LandPlayed => match_land_played, TriggerMode::PlayCard => match_play_card, TriggerMode::ManaAdded => match_mana_added, + TriggerMode::ManaAbilityProduced => match_mana_ability_produced, TriggerMode::SearchedLibrary | TriggerMode::Scry | TriggerMode::Surveil @@ -291,6 +292,10 @@ pub fn build_trigger_registry() -> HashMap { r.insert(TriggerMode::PlayCard, match_play_card); r.insert(TriggerMode::SpellCopy, match_spell_cast); r.insert(TriggerMode::ManaAdded, match_mana_added); + r.insert( + TriggerMode::ManaAbilityProduced, + match_mana_ability_produced, + ); r.insert(TriggerMode::SearchedLibrary, match_player_action); r.insert(TriggerMode::Scry, match_player_action); r.insert(TriggerMode::Surveil, match_player_action); @@ -978,6 +983,7 @@ fn count_matching_trigger_event_subjects( | GameEvent::LifeChanged { .. } | GameEvent::ManaAdded { .. } | GameEvent::TappedForMana { .. } + | GameEvent::ManaAbilityProduced { .. } | GameEvent::ManaPoolEmptied { .. } | GameEvent::ManaRecolored { .. } | GameEvent::PlayerLost { .. } @@ -2904,6 +2910,41 @@ pub(super) fn match_mana_added( matches!(event, GameEvent::ManaAdded { .. }) } +/// CR 605.1b: Matches one aggregate production event from an activated mana +/// ability. This deliberately does not consume `ManaAdded`, whose per-unit +/// accounting would fire a multi-mana ability's trigger more than once. +pub(super) fn match_mana_ability_produced( + event: &GameEvent, + trigger: &TriggerDefinition, + source_context: &TriggerSourceContext, + state: &GameState, +) -> bool { + let GameEvent::ManaAbilityProduced { + player_id, + source_id, + produced, + .. + } = event + else { + return false; + }; + if !taps_for_mana_card_matches(trigger, state, *source_id, source_context) + || !valid_player_matches(trigger, state, *player_id, source_context) + { + return false; + } + match trigger.mana_ability_produced.as_ref() { + Some(ManaAbilityProducedFilter::SourceChosenColor) => state + .objects + .get(&source_event_subject_id(source_context)) + .and_then(|source| source.chosen_color()) + .is_some_and(|color| { + produced.contains(&crate::game::mana_sources::mana_color_to_type(&color)) + }), + None => true, + } +} + // --------------------------------------------------------------------------- // Promoted Trigger Matchers // --------------------------------------------------------------------------- diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index ed0a975edd..9eb4d755ad 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -2199,15 +2199,23 @@ fn collect_matching_triggers_inner( // marker so manual `TapLandForMana` taps (still `FromTap`) still // fire, and on `is_triggered_mana_ability` so non-mana `TapsForMana` // triggers still fire (CR 603.3). - if matches!(trig_def.mode, TriggerMode::TapsForMana) - && matches!( - event, + if matches!( + (trig_def.mode.clone(), event), + ( + TriggerMode::TapsForMana, GameEvent::TappedForMana { tap_state: ManaTapState::FromTapTriggersResolved, .. } + ) | ( + TriggerMode::ManaAbilityProduced, + GameEvent::ManaAbilityProduced { + trigger_state: + crate::types::events::ManaAbilityTriggerState::InlineResolved, + .. + } ) - && super::mana_abilities::is_triggered_mana_ability(&ability, Some(event)) + ) && super::mana_abilities::is_triggered_mana_ability(&ability, Some(event)) { continue; } @@ -2822,15 +2830,30 @@ fn inline_tap_mana_trigger_abilities( for active in super::functioning_abilities::active_trigger_definitions(state, object) { let definition_ref = active.definition_ref.clone(); let trigger_definition = active.definition; - if !matches!(trigger_definition.mode, TriggerMode::TapsForMana) { + if !matches!( + trigger_definition.mode, + TriggerMode::TapsForMana | TriggerMode::ManaAbilityProduced + ) { continue; } - if !super::trigger_matchers::match_taps_for_mana( - tap_event, - trigger_definition, - &source_context, - state, - ) { + let matches_event = match trigger_definition.mode { + TriggerMode::TapsForMana => super::trigger_matchers::match_taps_for_mana( + tap_event, + trigger_definition, + &source_context, + state, + ), + TriggerMode::ManaAbilityProduced => { + super::trigger_matchers::match_mana_ability_produced( + tap_event, + trigger_definition, + &source_context, + state, + ) + } + _ => false, + }; + if !matches_event { continue; } let mut ability = build_triggered_ability_from_context( @@ -2889,6 +2912,12 @@ pub(super) fn resolve_tap_mana_triggers_inline( .. }, ) => ev.clone(), + Some( + ev @ GameEvent::ManaAbilityProduced { + trigger_state: crate::types::events::ManaAbilityTriggerState::Pending, + .. + }, + ) => ev.clone(), _ => continue, }; // CR 605.1b: Collect every `TapsForMana` triggered mana ability coupled @@ -2965,6 +2994,9 @@ pub(super) fn resolve_tap_mana_triggers_inline( *tap_state = ManaTapState::FromTapTriggersResolved; } } + if let GameEvent::ManaAbilityProduced { trigger_state, .. } = ev { + *trigger_state = crate::types::events::ManaAbilityTriggerState::InlineResolved; + } } // The aura color overrides stored by `auto_tap_mana_sources_inner` were diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 7254b6b29f..4661b39023 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -51,12 +51,12 @@ use crate::types::ability::{ AdditionalCostOrigin, AdditionalCostPaymentSource, AggregateFunction, AttachmentKind, AttackersDeclaredCountSubject, CastManaObjectScope, CastManaSpentMetric, CastVariantPaid, CoinFlipResult, Comparator, ControllerRef, CountScope, CounterTriggerFilter, DamageKindFilter, - DestinationConstraint, DieResultFilter, Effect, FilterProp, ObjectScope, OriginConstraint, - ParsedCondition, PlayerFilter, PlayerScope, PtStat, PtValueScope, QuantityExpr, QuantityRef, - RenownSubject, SacrificeAggregateStat, SacrificeCost, SacrificeRequirement, SharedQuality, - StaticCondition, SubAbilityLink, TapCreaturesRequirement, TargetFilter, TriggerCondition, - TriggerConstraint, TriggerDefinition, TypeFilter, TypedFilter, UnlessPayModifier, - ZoneChangeClause, + DestinationConstraint, DieResultFilter, Effect, FilterProp, ManaAbilityProducedFilter, + ObjectScope, OriginConstraint, ParsedCondition, PlayerFilter, PlayerScope, PtStat, + PtValueScope, QuantityExpr, QuantityRef, RenownSubject, SacrificeAggregateStat, SacrificeCost, + SacrificeRequirement, SharedQuality, StaticCondition, SubAbilityLink, TapCreaturesRequirement, + TargetFilter, TriggerCondition, TriggerConstraint, TriggerDefinition, TypeFilter, TypedFilter, + UnlessPayModifier, ZoneChangeClause, }; use crate::types::card_type::{is_land_subtype, CoreType}; use crate::types::counter::CounterType; @@ -10745,6 +10745,32 @@ fn parse_taps_for_mana_actor_line( Ok(("", (actor_controller, subject_text, produced_filter))) } +/// CR 605.1b: Parse a passive "a land ... is tapped for mana" trigger subject. +/// This is structurally distinct from the actor-led forms above because it +/// constrains the mana source, not who performed the tap. +fn parse_passive_taps_for_mana_line(i: &str) -> OracleResult<'_, String> { + let (rest, _) = opt(alt((tag("whenever "), tag("when ")))).parse(i)?; + let (rest, subject) = terminated( + take_until(" is tapped for mana"), + tag(" is tapped for mana"), + ) + .parse(rest)?; + Ok((rest, subject.to_string())) +} + +/// CR 605.1b: Recognizes the aggregate mana-ability condition whose produced +/// type is the trigger source's chosen color (Caged Sun class). +fn parse_land_ability_adds_chosen_color(i: &str) -> OracleResult<'_, ()> { + all_consuming(preceded( + alt((tag("whenever "), tag("when "))), + preceded( + tag("a land's ability causes you to add one or more mana of the "), + value((), tag("chosen color")), + ), + )) + .parse(i) +} + /// CR 603.2 + CR 605.1a: Returns true when `cond_lower` is a taps-for-mana trigger /// condition ("Whenever [you / an opponent / a player] taps … for mana"). fn condition_matches_taps_for_mana_event(cond_lower: &str) -> bool { @@ -14992,6 +15018,35 @@ fn try_parse_player_trigger(lower: &str) -> Option<(TriggerMode, TriggerDefiniti } } + if parse_land_ability_adds_chosen_color(lower).is_ok() { + let mut def = make_base(); + def.mode = TriggerMode::ManaAbilityProduced; + def.valid_card = Some(TargetFilter::Typed(TypedFilter::land())); + def.valid_target = Some(TargetFilter::Controller); + def.mana_ability_produced = Some(ManaAbilityProducedFilter::SourceChosenColor); + return Some((TriggerMode::ManaAbilityProduced, def)); + } + + if let Ok((_, subject_text)) = parse_passive_taps_for_mana_line(lower) { + let suffix = " with the same name as the exiled card"; + if let Ok((_, subject)) = + terminated(take_until::<_, _, OracleError<'_>>(suffix), tag(suffix)) + .parse(subject_text.as_str()) + { + if all_consuming(tag::<_, _, OracleError<'_>>("a land")) + .parse(subject) + .is_ok() + { + let mut def = make_base(); + def.mode = TriggerMode::TapsForMana; + def.valid_card = Some(TargetFilter::Typed( + TypedFilter::land().properties(vec![FilterProp::SameNameAsExiledBySource]), + )); + return Some((TriggerMode::TapsForMana, def)); + } + } + } + for prefix in ["whenever you tap ", "when you tap "] { let Ok((rest, ())) = value((), tag::<_, _, OracleError<'_>>(prefix)).parse(lower) else { continue; diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 3c806504f0..7dda29cfc1 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -10633,6 +10633,58 @@ fn trigger_you_tap_a_land_for_colorless_mana() { )); } +#[test] +fn caged_sun_uses_aggregate_land_mana_production() { + let def = parse_trigger_line( + "Whenever a land's ability causes you to add one or more mana of the chosen color, add an additional one mana of that color.", + "Caged Sun", + ); + assert_eq!(def.mode, TriggerMode::ManaAbilityProduced); + assert_eq!( + def.valid_card, + Some(TargetFilter::Typed(TypedFilter::land())) + ); + assert_eq!(def.valid_target, Some(TargetFilter::Controller)); + assert!(matches!( + def.mana_ability_produced, + Some(crate::types::ability::ManaAbilityProducedFilter::SourceChosenColor) + )); + assert!(matches!( + def.execute + .as_deref() + .map(|ability| ability.effect.as_ref()), + Some(Effect::Mana { + produced: ManaProduction::ChosenColor { .. }, + .. + }) + )); +} + +#[test] +fn extraplanar_lens_uses_source_linked_name_filter() { + let def = parse_trigger_line( + "Whenever a land with the same name as the exiled card is tapped for mana, its controller adds one mana of any type that land produced.", + "Extraplanar Lens", + ); + assert_eq!(def.mode, TriggerMode::TapsForMana); + let Some(TargetFilter::Typed(filter)) = def.valid_card else { + panic!("expected a typed land filter"); + }; + assert_eq!(filter.type_filters, vec![TypeFilter::Land]); + assert!(filter + .properties + .contains(&FilterProp::SameNameAsExiledBySource)); + assert!(matches!( + def.execute + .as_deref() + .map(|ability| ability.effect.as_ref()), + Some(Effect::Mana { + produced: ManaProduction::TriggerEventManaType, + .. + }) + )); +} + #[test] fn trigger_forbidden_orchard_targets_opponent_token_owner() { let def = parse_trigger_line( diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 6b1f35b1d3..803a46a6e9 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -4719,6 +4719,9 @@ pub enum FilterProp { /// looking up the name from `state.objects` (or `lki_cache` if the target /// has already left its zone). SameNameAsParentTarget, + /// CR 607.2a + CR 201.2: Matches an object whose name equals a card durably + /// exiled by this ability's source. Used by the Extraplanar Lens class. + SameNameAsExiledBySource, /// CR 201.2 + CR 201.2a: Matches objects whose name equals the name of any /// permanent currently on the battlefield. `controller` optionally narrows /// the pool of permanents whose names are considered (None = any controller, @@ -21821,6 +21824,12 @@ pub struct TriggerDefinition { /// mana type (the "for mana" form). Ignored by other trigger modes. #[serde(default, skip_serializing_if = "Option::is_none")] pub taps_for_mana_produced: Option>, + /// CR 605.1b: Aggregate mana-ability production predicate for + /// `TriggerMode::ManaAbilityProduced`. Kept separate from + /// `taps_for_mana_produced`: the latter describes the distinct CR 106.12a + /// "is tapped for mana" event family. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mana_ability_produced: Option, /// CR 701.30d + CR 603.4: Required clash outcome for "Whenever you clash and /// win" triggers (Sylvan Echoes). `Some(ClashResult::Won)` narrows the trigger /// so it MATCHES only when the ability's controller WON the clash — the win @@ -21834,6 +21843,14 @@ pub struct TriggerDefinition { pub clash_result: Option, } +/// CR 605.1b: Which aggregate mana output a mana-ability trigger requires. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ManaAbilityProducedFilter { + /// At least one output unit has the source permanent's chosen color. + SourceChosenColor, +} + /// Monotonic identity for one intentionally-installed ordered base trigger set. /// /// The number is local to its recipient object. [`TriggerDefinitionRef`] also @@ -22299,6 +22316,7 @@ impl TriggerDefinition { coin_flip_result: None, die_result: None, taps_for_mana_produced: None, + mana_ability_produced: None, clash_result: None, } } @@ -26970,6 +26988,7 @@ mod tests { coin_flip_result: None, die_result: None, taps_for_mana_produced: None, + mana_ability_produced: None, clash_result: None, }; let json = serde_json::to_string(&trigger).unwrap(); diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index f6955d8fe0..3e57190b21 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -85,6 +85,23 @@ pub enum ManaTapState { FromTapTriggersResolved, } +/// CR 605.4a: Records whether the triggered mana abilities coupled to one +/// aggregate mana-ability production event have already resolved inline. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ManaAbilityTriggerState { + /// Coupled triggered mana abilities have not yet resolved. + #[default] + Pending, + /// Coupled triggered mana abilities resolved inline during a payment. + InlineResolved, +} + +impl ManaAbilityTriggerState { + pub fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } +} + /// CR 602.2 + CR 606.2: Discriminates how an activated ability was activated so /// that "Whenever you activate a loyalty ability" triggers (CR 606.2) can be told /// apart from ordinary activated abilities (CR 602.2) while both share the single @@ -701,6 +718,9 @@ impl EventObjectSnapshot { | FilterProp::ManaSymbolCount { .. } | FilterProp::Foretold | FilterProp::HasAdventure + // CR 607.2a: This compares against an object linked in the live + // exile-link side table, which event snapshots deliberately omit. + | FilterProp::SameNameAsExiledBySource | FilterProp::AttachedToSource | FilterProp::AttachedToRecipient | FilterProp::Unpaired @@ -868,6 +888,16 @@ pub enum GameEvent { #[serde(default, skip_serializing_if = "ManaTapState::is_not_from_tap")] tap_state: ManaTapState, }, + /// CR 605.1b: An activated mana ability resolved and produced mana. Unlike + /// `ManaAdded`, this is one aggregate event per ability resolution; unlike + /// `TappedForMana`, it also covers mana abilities without a tap cost. + ManaAbilityProduced { + player_id: PlayerId, + source_id: ObjectId, + produced: Vec, + #[serde(default, skip_serializing_if = "ManaAbilityTriggerState::is_pending")] + trigger_state: ManaAbilityTriggerState, + }, /// CR 500.5 + CR 703.4q: A single mana unit was emptied from a player's /// pool during the step-end empty event after the CR 616.1 replacement /// pipeline resolved. `source_id` is the unit's original producer diff --git a/crates/engine/src/types/triggers.rs b/crates/engine/src/types/triggers.rs index f9b917114a..5771777017 100644 --- a/crates/engine/src/types/triggers.rs +++ b/crates/engine/src/types/triggers.rs @@ -458,6 +458,9 @@ pub enum TriggerMode { // Mana /// CR 106.4: Triggers when mana is added to a player's mana pool. ManaAdded, + /// CR 605.1b: Triggers once when a qualifying activated mana ability + /// resolves and produces mana, including non-tap mana abilities. + ManaAbilityProduced, ManaExpend, // Land @@ -749,6 +752,7 @@ impl FromStr for TriggerMode { "LifeLostAll" => TriggerMode::LifeLostAll, "LosesGame" => TriggerMode::LosesGame, "ManaAdded" => TriggerMode::ManaAdded, + "ManaAbilityProduced" => TriggerMode::ManaAbilityProduced, "ManaExpend" => TriggerMode::ManaExpend, "ManifestDread" => TriggerMode::ManifestDread, "Mentored" => TriggerMode::Mentored, @@ -1089,6 +1093,7 @@ mod tests { "LosesGame", "LoyaltyAbilityActivated", "ManaAdded", + "ManaAbilityProduced", "ManaExpend", "ManifestDread", "Mentored", diff --git a/crates/engine/tests/integration/issue_6979_land_mana_amplification.rs b/crates/engine/tests/integration/issue_6979_land_mana_amplification.rs new file mode 100644 index 0000000000..8b5293da0d --- /dev/null +++ b/crates/engine/tests/integration/issue_6979_land_mana_amplification.rs @@ -0,0 +1,116 @@ +//! Regression for issue #6979: land-mana amplification triggers must observe a +//! mana ability's aggregate output once, with the printed source restrictions. + +use engine::game::mana_abilities::activate_mana_ability; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::triggers::process_triggers; +use engine::game::zones::create_object; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, ChosenAttribute, Effect, ManaContribution, ManaProduction, +}; +use engine::types::game_state::{ExileLink, ExileLinkKind, ManaAbilityResume}; +use engine::types::identifiers::CardId; +use engine::types::mana::{ManaColor, ManaType}; +use engine::types::zones::Zone; + +const CAGED_SUN_ORACLE: &str = "As this artifact enters, choose a color.\nCreatures you control of the chosen color get +1/+1.\nWhenever a land's ability causes you to add one or more mana of the chosen color, add an additional one mana of that color."; +const EXTRAPLANAR_LENS_ORACLE: &str = "Imprint — When this artifact enters, you may exile target land you control.\nWhenever a land with the same name as the exiled card is tapped for mana, its controller adds one mana of any type that land produced."; + +#[test] +fn caged_sun_triggers_once_from_a_non_tap_land_mana_ability() { + let mut scenario = GameScenario::new(); + let sun = scenario + .add_creature(P0, "Caged Sun", 0, 0) + .as_artifact() + .from_oracle_text(CAGED_SUN_ORACLE) + .id(); + let land = scenario + .add_creature(P0, "Red Land", 0, 0) + .as_land() + .with_ability_definition(AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Red], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + )) + .id(); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&sun) + .expect("Caged Sun") + .chosen_attributes + .push(ChosenAttribute::Color(ManaColor::Red)); + + let ability = runner.state().objects[&land].abilities[0].clone(); + let mut events = Vec::new(); + activate_mana_ability( + runner.state_mut(), + land, + P0, + 0, + &ability, + &mut events, + ManaAbilityResume::Priority, + None, + ) + .expect("non-tap land mana ability must resolve"); + process_triggers(runner.state_mut(), &events); + + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Red), + 2, + "Caged Sun adds exactly one chosen-color mana beyond the land's produced mana" + ); +} + +#[test] +fn extraplanar_lens_matches_the_name_of_its_imprinted_land() { + let mut scenario = GameScenario::new(); + let lens = scenario + .add_creature(P0, "Extraplanar Lens", 0, 0) + .as_artifact() + .from_oracle_text(EXTRAPLANAR_LENS_ORACLE) + .id(); + let land = scenario.add_basic_land(P1, ManaColor::Green); + let mut runner = scenario.build(); + let state = runner.state_mut(); + let imprinted = create_object(state, CardId(6980), P0, "Forest".to_string(), Zone::Exile); + state.exile_links.push(ExileLink { + source_id: lens, + exiled_id: imprinted, + kind: ExileLinkKind::TrackedBySource, + }); + let ability = runner.state().objects[&land].abilities[0].clone(); + let mut events = Vec::new(); + activate_mana_ability( + runner.state_mut(), + land, + P1, + 0, + &ability, + &mut events, + ManaAbilityResume::Priority, + None, + ) + .expect("Forest mana ability must resolve"); + process_triggers(runner.state_mut(), &events); + + assert_eq!( + runner.state().players[P1.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 2, + "Lens adds one mana of the imprinted land's produced type to the land's controller" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 58ddb743f7..cff8015dff 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -689,6 +689,7 @@ mod issue_688_mind_into_matter; mod issue_689_resonating_lute_hand_size; mod issue_691_sheoldred_saga_lore; mod issue_6943_faerie_slumber_party; +mod issue_6979_land_mana_amplification; mod issue_7063_library_reorder; mod issue_7087_recruit_discard_provenance; mod issue_709_regression;