Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2411,6 +2411,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 @@ -2680,6 +2681,7 @@ fn member_bound_filter_prop(p: &FilterProp) -> bool {
| FilterProp::Named { .. }
| FilterProp::SameName
| FilterProp::SameNameAsParentTarget
| FilterProp::SameNameAsExiledBySource
Comment thread
matthewevans marked this conversation as resolved.
Outdated
| FilterProp::IsCommander
// CR 205.3m: no nested interior to carry a member-bound referent.
| FilterProp::SharesCreatureTypeWithCommander
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 @@ -3776,6 +3776,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 @@ -3335,6 +3335,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
22 changes: 20 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 @@ -726,6 +732,18 @@ fn produce_mana_from_ability(
}
}

// CR 605.1b: Emit one aggregate event for every mana-ability resolution,
// including abilities without a tap cost. Its output vector lets triggered
// mana abilities inspect the whole resolution exactly once.
if !produced_for_tap_event.is_empty() {
events.push(GameEvent::ManaAbilityProduced {
player_id: player,
source_id,
produced: produced_for_tap_event.clone(),
trigger_state: crate::types::events::ManaAbilityTriggerState::Pending,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// 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
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
5 changes: 4 additions & 1 deletion crates/engine/src/game/trigger_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading