Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/engine/src/ai_support/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. }
Expand Down
9 changes: 8 additions & 1 deletion crates/engine/src/ai_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/ai_support/shortcut_efficacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3207,6 +3207,7 @@ mod tests {
TriggerMode::Taps,
TriggerMode::TapsForMana,
TriggerMode::ManaAdded,
TriggerMode::ManaAbilityProduced,
TriggerMode::Shuffled,
TriggerMode::SearchedLibrary,
TriggerMode::PlayerPerformedAction,
Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,9 @@ fn trigger_axis(trig: &TriggerDefinition) -> Option<AxisKey> {
// 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 => {
Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
8 changes: 6 additions & 2 deletions crates/engine/src/game/effects/mana.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/exile_links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 21 additions & 1 deletion crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 { .. } => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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` /
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -13817,6 +13836,7 @@ mod characteristic_read_classification_tests {
| FilterProp::Named { .. }
| FilterProp::SameName
| FilterProp::SameNameAsParentTarget
| FilterProp::SameNameAsExiledBySource
| FilterProp::IsCommander
| FilterProp::SharesCreatureTypeWithCommander
| FilterProp::Other { .. } => false,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion crates/engine/src/game/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ fn importance(event: &GameEvent) -> LogImportance {
| GameEvent::ZoneChanged { .. }
| GameEvent::ManaAdded { .. }
| GameEvent::TappedForMana { .. }
| GameEvent::ManaAbilityProduced { .. }
| GameEvent::ManaPoolEmptied { .. }
| GameEvent::ManaRecolored { .. }
| GameEvent::PermanentTapped { .. }
Expand Down Expand Up @@ -283,6 +284,7 @@ fn tone(event: &GameEvent) -> LogTone {
| GameEvent::ZoneChanged { .. }
| GameEvent::ManaAdded { .. }
| GameEvent::TappedForMana { .. }
| GameEvent::ManaAbilityProduced { .. }
| GameEvent::ManaPoolEmptied { .. }
| GameEvent::ManaRecolored { .. }
| GameEvent::PermanentTapped { .. }
Expand Down Expand Up @@ -538,6 +540,7 @@ fn categorize(event: &GameEvent) -> LogCategory {

GameEvent::ManaAdded { .. }
| GameEvent::TappedForMana { .. }
| GameEvent::ManaAbilityProduced { .. }
| GameEvent::ManaPoolEmptied { .. }
| GameEvent::ManaRecolored { .. } => LogCategory::Mana,

Expand Down Expand Up @@ -1724,7 +1727,7 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec<LogSegment> {
// `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![],
}
}

Expand Down
48 changes: 46 additions & 2 deletions crates/engine/src/game/mana_abilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. }
)
)
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions crates/engine/src/game/mana_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};

Expand Down Expand Up @@ -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
Expand All @@ -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)
})
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/public_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading