From e2405ccc6bc73d61ee59ae56c1fd5729db666065 Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:19:16 +0200 Subject: [PATCH 1/8] Implement Throne of Eldraine mana restrictions --- crates/engine/src/ai_support/candidates.rs | 9 +- crates/engine/src/ai_support/mod.rs | 2 +- crates/engine/src/database/synthesis.rs | 44 +-- crates/engine/src/game/ability_rw.rs | 2 + crates/engine/src/game/ability_scan.rs | 2 + crates/engine/src/game/casting.rs | 269 ++++++++++-------- crates/engine/src/game/casting_costs.rs | 44 +-- crates/engine/src/game/casting_tests.rs | 31 +- crates/engine/src/game/cost_payability.rs | 6 +- crates/engine/src/game/costs.rs | 33 ++- crates/engine/src/game/effects/mana.rs | 23 +- crates/engine/src/game/engine.rs | 32 +-- crates/engine/src/game/engine_tests.rs | 1 + crates/engine/src/game/keywords.rs | 18 +- crates/engine/src/game/mana_abilities.rs | 56 ++-- crates/engine/src/game/mana_payment.rs | 84 +++++- crates/engine/src/game/planeswalker.rs | 11 +- crates/engine/src/game/replacement.rs | 32 ++- crates/engine/src/parser/oracle.rs | 41 ++- .../engine/src/parser/oracle_effect/mana.rs | 160 ++++++++--- .../src/parser/oracle_effect/sequence.rs | 18 +- crates/engine/src/parser/oracle_ir/ast.rs | 2 +- crates/engine/src/parser/oracle_tests.rs | 166 ++++++++--- crates/engine/src/types/ability.rs | 35 +++ crates/engine/src/types/mana.rs | 221 +++++++++++++- .../integration/companion_special_action.rs | 1 + .../integration/issue_2862_teferi_loyalty.rs | 2 +- .../issue_4220_agatha_soul_cauldron.rs | 2 +- crates/engine/tests/integration/main.rs | 1 + .../restricted_mana_face_down_and_face_up.rs | 2 + .../integration/restricted_mana_mv_or_x.rs | 1 + .../restricted_mana_x_cost_only.rs | 1 + .../throne_of_eldraine_mana_riders.rs | 226 +++++++++++++++ .../phase-ai/src/policies/land_animation.rs | 7 +- .../src/game_action_payload_guard.rs | 2 + 35 files changed, 1207 insertions(+), 380 deletions(-) create mode 100644 crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index b62fab22ae..f9b3e03b64 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -2127,7 +2127,7 @@ pub fn candidate_actions_broad_with_probe( *player, pending_cast.object_id, cost, - pending_cast.ability.context.ability_tag, + pending_cast.activation_ability_index.unwrap_or(usize::MAX), ) }) .map(|(i, _)| { @@ -4041,6 +4041,13 @@ pub(crate) fn priority_actions_with_probe( state, player, *ninjutsu_object_id, + casting::activated_ability_definitions(state, *ninjutsu_object_id) + .into_iter() + .find_map(|(index, ability)| { + crate::game::keywords::is_ninjutsu_family_marker_ability(&ability) + .then_some(index) + }) + .unwrap_or(usize::MAX), cost, ); if !can_afford { diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index d31a8f03c0..164f6f0364 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -286,7 +286,7 @@ fn cheap_reject_candidate(state: &GameState, action: &GameAction) -> bool { *player, pending_cast.object_id, cost, - pending_cast.ability.context.ability_tag, + pending_cast.activation_ability_index.unwrap_or(usize::MAX), ) }), ( diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 4aa4d1b94c..5024e795a1 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -718,29 +718,35 @@ pub fn synthesize_ninjutsu_family(face: &mut CardFace) { let abilities: Vec = face .keywords .iter() - .filter_map(|kw| { - let (variant, cost) = match kw { - Keyword::Ninjutsu(c) => (NinjutsuVariant::Ninjutsu, c), - Keyword::CommanderNinjutsu(c) => (NinjutsuVariant::CommanderNinjutsu, c), - _ => return None, - }; - Some( - AbilityDefinition::new( - AbilityKind::Activated, - Effect::RuntimeHandled { - handler: RuntimeHandler::NinjutsuFamily, - }, - ) - .cost(AbilityCost::NinjutsuFamily { - variant, - mana_cost: cost.clone(), - }), - ) - }) + .filter_map(ninjutsu_family_marker_ability_for_keyword) .collect(); face.abilities.extend(abilities); } +/// CR 702.49: Build the marker activated ability for one Ninjutsu-family +/// keyword. The runtime ability gather uses this too, so a keyword provided by +/// a scenario or a continuous effect has the same activation identity as one +/// synthesized while card data is loaded. +pub fn ninjutsu_family_marker_ability_for_keyword(keyword: &Keyword) -> Option { + let (variant, cost) = match keyword { + Keyword::Ninjutsu(cost) => (NinjutsuVariant::Ninjutsu, cost), + Keyword::CommanderNinjutsu(cost) => (NinjutsuVariant::CommanderNinjutsu, cost), + _ => return None, + }; + Some( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::RuntimeHandled { + handler: RuntimeHandler::NinjutsuFamily, + }, + ) + .cost(AbilityCost::NinjutsuFamily { + variant, + mana_cost: cost.clone(), + }), + ) +} + // Warp is handled at runtime via Keyword::Warp(ManaCost): // - `prepare_spell_cast` overrides the mana cost when cast from hand // - `stack.rs::resolve_top` creates a delayed exile trigger on resolution diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index d348a44c90..ddeafffc16 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -3822,6 +3822,8 @@ fn walk_definition( description: _, target_prompt: _, activation_restrictions: _, + // Payment-time only; it cannot create a resolution-time dependency. + activation_mana_payment_restriction: _, activator_filter: _, activation_zone: _, ability_tag: _, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index e9a4af942e..2cddcb28d2 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -4252,6 +4252,8 @@ fn ability_definition_axes(def: &AbilityDefinition, mode: ScanMode) -> Axes { description: _, target_prompt: _, activation_restrictions: _, + // Payment-time only; it cannot create a resolution-time dependency. + activation_mana_payment_restriction: _, activator_filter: _, activation_zone: _, ability_tag: _, diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 6bb4ff8a6f..a944ad35e1 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -1,11 +1,12 @@ use crate::types::ability::{ is_variable_remove_counter_cost_count, AbilityBlockKind, AbilityBlockReason, AbilityCondition, - AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, AdditionalCost, CardPlayMode, - CardSelectionMode, CastTimingPermission, CastingPermission, ChoiceType, ContinuousModification, - CostObjectCount, CostPaidObjectSnapshot, CounterCostSelection, Duration, Effect, FilterProp, - GameRestriction, ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, - ProhibitedActivity, QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, - RestrictionPlayerScope, StaticCondition, StaticDefinition, SubAbilityLink, + AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, ActivationManaPaymentRestriction, + AdditionalCost, CardPlayMode, CardSelectionMode, CastTimingPermission, CastingPermission, + ChoiceType, ContinuousModification, CostObjectCount, CostPaidObjectSnapshot, + CounterCostSelection, Duration, Effect, FilterProp, GameRestriction, ModalSelectionCondition, + ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity, QuantityExpr, QuantityRef, + ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, StaticCondition, + StaticDefinition, SubAbilityLink, TapCreaturesRequirement, TargetFilter, TargetRef, }; use crate::types::actions::AlternativeCastDecision; @@ -21,7 +22,8 @@ use crate::types::game_state::{ use crate::types::identifiers::{CardId, ObjectId, TrackedSetId}; use crate::types::keywords::{FlashbackCost, Keyword, KeywordKind}; use crate::types::mana::{ - ManaColor, ManaCost, ManaCostShard, ManaSpellGrant, PaymentContext, SpecialAction, SpellMeta, + ActivationManaColorConstraint, ManaColor, ManaCost, ManaCostShard, ManaSpellGrant, + PaymentContext, SpecialAction, SpellMeta, }; use crate::types::player::PlayerId; use crate::types::resolved_commands::ManaPaymentRecipient; @@ -234,6 +236,41 @@ fn runtime_granted_top_of_library_plot_abilities( )] } +/// CR 702.49: Ninjutsu-family keywords function from Hand (and commander +/// ninjutsu from Command). Card loading normally synthesizes their marker +/// ability, but runtime keyword sets and scenario objects need the identical +/// effective definition so activation payment can resolve the exact index. +fn runtime_ninjutsu_family_marker_abilities( + state: &GameState, + source_id: ObjectId, +) -> Vec { + let Some(obj) = state.objects.get(&source_id) else { + return Vec::new(); + }; + if !matches!(obj.zone, Zone::Hand | Zone::Command) { + return Vec::new(); + } + + // The off-zone collector is authoritative for printed and granted + // characteristics. Include the object's current keyword set as well: test + // scenarios and runtime-only objects can deliberately provide a synthesized + // Ninjutsu keyword without mirroring it into `base_keywords`. + let mut keywords = + crate::game::off_zone_characteristics::effective_off_zone_keywords(state, source_id); + for keyword in &obj.keywords { + if !keywords.contains(keyword) { + keywords.push(keyword.clone()); + } + } + keywords + .into_iter() + .filter_map(|keyword| { + crate::database::synthesis::ninjutsu_family_marker_ability_for_keyword(&keyword) + }) + .filter(|candidate| !obj.abilities.iter().any(|printed| printed == candidate)) + .collect() +} + pub fn activated_ability_definitions( state: &GameState, source_id: ObjectId, @@ -257,6 +294,9 @@ pub fn activated_ability_definitions( .chain(runtime_granted_top_of_library_plot_abilities( state, source_id, )) + // CR 702.49: runtime/effective Ninjutsu markers must share this + // index space with the payment-context lookup below. + .chain(runtime_ninjutsu_family_marker_abilities(state, source_id)) // CR 702.6: statically granted equip (Bram, Bludgeon Brawl) chained // LAST — the identical append order is REQUIRED in // `activation_ability_definition` so `ability_index` stays consistent. @@ -280,8 +320,8 @@ fn activation_ability_definition( // Must match the append order in `activated_ability_definitions`: printed // abilities first, then runtime-granted cycling, then runtime-granted // graveyard activated (Encore/Scavenge), then runtime-granted - // plot-from-library (Fblthp). Identical order is REQUIRED for - // `ability_index` consistency. + // plot-from-library (Fblthp), then Ninjutsu-family, then equip. + // Identical order is REQUIRED for `ability_index` consistency. runtime_granted_cycling_abilities(state, source_id) .into_iter() .chain(runtime_granted_graveyard_activated_abilities( @@ -290,6 +330,7 @@ fn activation_ability_definition( .chain(runtime_granted_top_of_library_plot_abilities( state, source_id, )) + .chain(runtime_ninjutsu_family_marker_abilities(state, source_id)) .chain(runtime_granted_equip_abilities(state, source_id)) .nth(offset)? }; @@ -1979,6 +2020,7 @@ pub(super) fn build_spell_meta( // still in its origin zone) a non-fused split spell is not over-combined. mana_value: Some(obj.spell_mana_value()), color_count: Some(obj.spell_colors().len() as u32), + colors: obj.spell_colors(), // CR 107.3 + CR 202.3e: structural "has {X}" property of the printed cost, // detected from shards (mana value alone can't reveal it — X contributes 0 // off the stack). @@ -2035,31 +2077,18 @@ pub fn pending_phyrexian_route_is_payable( return false; }; - let (source_types, source_subtypes, activation_tag) = pending + let activation_context = pending .activation_ability_index - .map(|ability_index| { - let (types, subtypes) = activation_source_types(state, spell_object); - ( - types, - subtypes, - Some(activation_ability_tag(state, spell_object, ability_index)), - ) - }) - .unwrap_or_default(); + .map(|ability_index| activation_payment_context(state, spell_object, ability_index)); let spell_meta = pending .activation_ability_index .is_none() .then(|| build_spell_meta(state, player, spell_object)) .flatten(); - let payment_context = if pending.activation_ability_index.is_some() { - Some(PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: activation_tag.flatten(), - }) - } else { - spell_meta.as_ref().map(PaymentContext::Spell) - }; + let payment_context = activation_context + .as_ref() + .map(ActivationPaymentContext::as_payment_context) + .or_else(|| spell_meta.as_ref().map(PaymentContext::Spell)); let any_color = player_can_spend_as_any_color_for_payment( state, player, @@ -14051,12 +14080,14 @@ pub fn can_pay_ability_mana_cost_after_auto_tap( state: &GameState, player: PlayerId, source_id: ObjectId, + ability_index: usize, cost: &crate::types::mana::ManaCost, ) -> bool { can_pay_ability_mana_cost_after_auto_tap_excluding( state, player, source_id, + ability_index, cost, &HashSet::new(), ) @@ -14066,23 +14097,15 @@ pub fn can_pay_ability_mana_cost_after_auto_tap_excluding( state: &GameState, player: PlayerId, source_id: ObjectId, + ability_index: usize, cost: &crate::types::mana::ManaCost, excluded_sources: &HashSet, ) -> bool { let mut simulated = state.clone(); super::layers::flush_layers(&mut simulated); - let (source_types, source_subtypes) = activation_source_types(&simulated, source_id); - // CR 106.6: All current callers of this preview path are tag-`None` - // activations (mana abilities, ninjutsu, AI affordability). The real - // tag-scoped gate (Quinjet power-up restriction) runs at payment time in - // `pay_ability_mana_cost_*`, since `is_payable` defers mana affordability to - // the payment step (CR 601.2g). - let activation_ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: None, - }; + let activation_context = activation_payment_context(&simulated, source_id, ability_index); + let activation_ctx = activation_context.as_payment_context(); can_pay_mana_cost_after_auto_tap_with_context( simulated, @@ -14505,16 +14528,16 @@ pub(super) fn pay_ability_mana_cost( state: &mut GameState, player: PlayerId, source_id: ObjectId, + ability_index: usize, cost: &crate::types::mana::ManaCost, - ability_tag: Option, events: &mut Vec, ) -> Result<(), EngineError> { pay_ability_mana_cost_excluding( state, player, source_id, + ability_index, cost, - ability_tag, events, &HashSet::new(), None, @@ -14526,8 +14549,8 @@ pub(super) fn pay_ability_mana_cost_excluding( state: &mut GameState, player: PlayerId, source_id: ObjectId, + ability_index: usize, cost: &crate::types::mana::ManaCost, - ability_tag: Option, events: &mut Vec, excluded_sources: &HashSet, // CR 107.4b + CR 118.10: When this ability is paying its mana sub-cost while @@ -14540,8 +14563,8 @@ pub(super) fn pay_ability_mana_cost_excluding( state, player, source_id, + ability_index, cost, - ability_tag, events, excluded_sources, sub_cost_demand, @@ -14556,8 +14579,8 @@ pub(super) fn pay_ability_mana_cost_excluding_with_parent( state: &mut GameState, player: PlayerId, source_id: ObjectId, + ability_index: usize, cost: &crate::types::mana::ManaCost, - ability_tag: Option, events: &mut Vec, excluded_sources: &HashSet, sub_cost_demand: Option<&mana_payment::ColorDemand>, @@ -14567,8 +14590,8 @@ pub(super) fn pay_ability_mana_cost_excluding_with_parent( state, player, source_id, + ability_index, cost, - ability_tag, None, events, excluded_sources, @@ -14585,8 +14608,8 @@ pub(super) fn pay_ability_mana_cost_with_choices_excluding_and_resume( state: &mut GameState, player: PlayerId, source_id: ObjectId, + ability_index: usize, cost: &crate::types::mana::ManaCost, - ability_tag: Option, phyrexian_choices: Option<&[crate::types::game_state::ShardChoice]>, events: &mut Vec, excluded_sources: &HashSet, @@ -14597,8 +14620,8 @@ pub(super) fn pay_ability_mana_cost_with_choices_excluding_and_resume( state, player, source_id, + ability_index, cost, - ability_tag, phyrexian_choices, events, excluded_sources, @@ -14613,8 +14636,8 @@ fn pay_ability_mana_cost_with_choices_excluding_and_parent( state: &mut GameState, player: PlayerId, source_id: ObjectId, + ability_index: usize, cost: &crate::types::mana::ManaCost, - ability_tag: Option, phyrexian_choices: Option<&[crate::types::game_state::ShardChoice]>, events: &mut Vec, excluded_sources: &HashSet, @@ -14624,12 +14647,8 @@ fn pay_ability_mana_cost_with_choices_excluding_and_parent( ) -> Result<(), EngineError> { super::layers::flush_layers(state); - let (source_types, source_subtypes) = activation_source_types(state, source_id); - let activation_ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag, - }; + let activation_context = activation_payment_context(state, source_id, ability_index); + let activation_ctx = activation_context.as_payment_context(); let _spent_units = auto_tap_and_pay_cost_excluding( state, @@ -15046,38 +15065,72 @@ pub(super) fn mana_ability_cost_payment_is_paused(state: &GameState) -> bool { ) } -/// CR 106.6: Build (core-types, subtypes) slices for a `PaymentContext::Activation` -/// from the source object. Mirrors `build_spell_meta`'s type extraction so -/// `allows_activation` and `allows_spell` consult identically-shaped strings. -pub(super) fn activation_source_types( - state: &GameState, - source_id: ObjectId, -) -> (Vec, Vec) { - state - .objects - .get(&source_id) - .map(|obj| { - let types = object_type_names(obj); - let subtypes = obj.card_types.subtypes.clone(); - (types, subtypes) - }) - .unwrap_or_default() +/// Owned backing for one exact activated-ability payment context. All payment +/// routes construct this through [`activation_payment_context`] so they use the +/// live source, actual ability index, tag, and color-payment rider together. +pub(super) struct ActivationPaymentContext { + source_types: Vec, + source_subtypes: Vec, + ability_tag: Option, + mana_color_constraint: ActivationManaColorConstraint, } -/// CR 106.6: Read the keyword tag of the ability at `ability_index` on -/// `source_id`. Threaded into `PaymentContext::Activation` so tag-scoped mana -/// spend restrictions (Quinjet: "spend this mana only to activate power-up -/// abilities") can gate which mana is eligible for the activation being paid. -pub(super) fn activation_ability_tag( +impl ActivationPaymentContext { + pub(super) fn as_payment_context(&self) -> PaymentContext<'_> { + PaymentContext::Activation { + source_types: &self.source_types, + source_subtypes: &self.source_subtypes, + ability_tag: self.ability_tag, + mana_color_constraint: self.mana_color_constraint, + } + } +} + +/// CR 106.6 + CR 602.2b: Build the sole activation-payment context from the +/// source's live characteristics and the exact ability being activated. A +/// missing source or a missing required chosen color fails closed. An absent +/// definition on an otherwise live source carries no activation-cost rider; +/// this preserves ordinary generic-cost evaluation and runtime-synthesized +/// keyword activations. +pub(super) fn activation_payment_context( state: &GameState, source_id: ObjectId, ability_index: usize, -) -> Option { - state - .objects - .get(&source_id) - .and_then(|obj| obj.abilities.get(ability_index)) - .and_then(|def| def.ability_tag) +) -> ActivationPaymentContext { + let Some(source) = state.objects.get(&source_id) else { + return ActivationPaymentContext { + source_types: Vec::new(), + source_subtypes: Vec::new(), + ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Impossible, + }; + }; + let source_types = object_type_names(source); + let source_subtypes = source.card_types.subtypes.clone(); + // Use the same effective-ability lookup as activation itself: runtime-granted + // cycling, graveyard, plot, Ninjutsu-family, and equip abilities live after + // the printed `obj.abilities` slice but retain their enumerated indices. + let Some(ability) = activation_ability_definition(state, source_id, ability_index) else { + return ActivationPaymentContext { + source_types, + source_subtypes, + ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Unrestricted, + }; + }; + let mana_color_constraint = match ability.activation_mana_payment_restriction { + None => ActivationManaColorConstraint::Unrestricted, + Some(ActivationManaPaymentRestriction::OnlySourceChosenColor) => source + .chosen_color() + .map(ActivationManaColorConstraint::Only) + .unwrap_or(ActivationManaColorConstraint::Impossible), + }; + ActivationPaymentContext { + source_types, + source_subtypes, + ability_tag: ability.ability_tag, + mana_color_constraint, + } } /// CR 106.6: When mana with spell grants is spent to cast a spell, apply those @@ -15805,11 +15858,11 @@ pub(crate) fn payable_one_of_activation_branches( player: PlayerId, source_id: ObjectId, costs: &[AbilityCost], - ability_tag: Option, + ability_index: usize, ) -> Vec { costs .iter() - .filter(|branch| can_pay_ability_cost_now(state, player, source_id, branch, ability_tag)) + .filter(|branch| can_pay_ability_cost_now(state, player, source_id, branch, ability_index)) .cloned() .collect() } @@ -15823,10 +15876,10 @@ fn activation_cost_passes_early_affordability_gate( player: PlayerId, source_id: ObjectId, cost: &AbilityCost, - ability_tag: Option, + ability_index: usize, ) -> bool { if find_one_of_cost(cost).is_some() { - can_pay_ability_cost_now(state, player, source_id, cost, ability_tag) + can_pay_ability_cost_now(state, player, source_id, cost, ability_index) } else { // CR 106.6: the tag reaches the payability gate for the same reason it // reaches `can_pay_ability_cost_now` above — tag-scoped mana @@ -16272,7 +16325,7 @@ pub(crate) fn can_pay_ability_cost_now( player: PlayerId, source_id: ObjectId, cost: &AbilityCost, - ability_tag: Option, + ability_index: usize, ) -> bool { let excluded_sources = ability_mana_payment_excluded_sources(cost, source_id); super::costs::can_pay( @@ -16282,7 +16335,7 @@ pub(crate) fn can_pay_ability_cost_now( cost, &super::costs::PaymentScope::Activation { excluded_sources: &excluded_sources, - ability_tag, + ability_index, }, ) } @@ -16434,7 +16487,7 @@ pub fn can_activate_ability_now_with_restriction_gates( .clone() .map(|cost| activation_cost_for_affordability(cost, ability_def.ability_tag)); if affordability_cost.as_ref().is_some_and(|cost| { - !can_pay_ability_cost_now(state, player, source_id, cost, ability_def.ability_tag) + !can_pay_ability_cost_now(state, player, source_id, cost, ability_index) }) { return false; } @@ -16638,12 +16691,8 @@ pub(super) fn try_finalize_pending_activation_mana_leg( .as_ref() .map(|tail| ability_mana_payment_excluded_sources(tail, pending.object_id)) .unwrap_or_default(); - let (source_types, source_subtypes) = activation_source_types(state, pending.object_id); - let activation_ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: activation_ability_tag(state, pending.object_id, ability_index), - }; + let activation_context = activation_payment_context(state, pending.object_id, ability_index); + let activation_ctx = activation_context.as_payment_context(); pending.cost = mana_cost.clone(); pending.activation_cost = remaining; pending.activation_ability_index = Some(ability_index); @@ -16687,12 +16736,8 @@ pub(super) fn finalize_pending_activation_mana_payment( .as_ref() .map(|tail| ability_mana_payment_excluded_sources(tail, pending.object_id)) .unwrap_or_default(); - let (source_types, source_subtypes) = activation_source_types(state, pending.object_id); - let activation_ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: activation_ability_tag(state, pending.object_id, ability_index), - }; + let activation_context = activation_payment_context(state, pending.object_id, ability_index); + let activation_ctx = activation_context.as_payment_context(); let source_id = pending.object_id; state.pending_cast = Some(Box::new(pending)); if let Some(waiting) = casting_costs::maybe_pause_for_phyrexian_choice( @@ -16828,7 +16873,7 @@ pub fn handle_activate_ability( player, source_id, cost, - ability_def.ability_tag, + ability_index, ) { return Err(EngineError::ActionNotAllowed( "Cannot pay activation cost".to_string(), @@ -17298,13 +17343,8 @@ pub fn handle_activate_ability( // CR 118.12a: Pre-check for OneOf costs — detour to WaitingFor before any cost payment. if let Some(costs) = find_one_of_cost(cost) { - let payable = payable_one_of_activation_branches( - state, - player, - source_id, - costs, - ability_def.ability_tag, - ); + let payable = + payable_one_of_activation_branches(state, player, source_id, costs, ability_index); if payable.is_empty() { return Err(EngineError::ActionNotAllowed( "Cannot pay activation cost".to_string(), @@ -17522,7 +17562,7 @@ pub fn handle_activate_ability( player, source_id, cost, - activation_ability_tag(state, source_id, ability_index), + ability_index, events, )? { let pending = pending_activation_after_cost_pause( @@ -17646,14 +17686,9 @@ pub fn handle_activate_ability( )? { return Ok(waiting); } - if let PaymentOutcome::Paused { remaining_cost } = pay_ability_cost_for_activation( - state, - player, - source_id, - cost, - activation_ability_tag(state, source_id, ability_index), - events, - )? { + if let PaymentOutcome::Paused { remaining_cost } = + pay_ability_cost_for_activation(state, player, source_id, cost, ability_index, events)? + { let pending = pending_activation_after_cost_pause( source_id, resolved.clone(), diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 2372a5fb74..1ebf47d964 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -1945,7 +1945,7 @@ fn finish_selected_return_to_hand_after_automatic( player, pending.object_id, &cost, - super::casting::activation_ability_tag(state, pending.object_id, ability_index), + ability_index, events, )? { super::casting::PaymentOutcome::Paid => {} @@ -2174,7 +2174,7 @@ pub(crate) fn handle_activation_cost_one_of_choice( player, pending.object_id, chosen_cost, - pending.ability.context.ability_tag, + pending.activation_ability_index.unwrap_or(usize::MAX), ) { return Err(EngineError::ActionNotAllowed( "Chosen cost branch is not payable".to_string(), @@ -3043,7 +3043,7 @@ pub(crate) fn handle_return_to_hand_for_cost( player, pending.object_id, &cost, - super::casting::activation_ability_tag(state, pending.object_id, ability_index), + ability_index, events, )? { super::casting::PaymentOutcome::Paid => {} @@ -3208,14 +3208,12 @@ pub(crate) fn handle_remove_counter_for_cost( // pay the automatic residual through the outcome-aware authority. // If a self-move pauses, the typed continuation resumes this pending // activation only after the selected counter was paid exactly once. - let ability_tag = - super::casting::activation_ability_tag(state, pending.object_id, ability_index); match super::casting::pay_ability_cost_for_activation( state, player, pending.object_id, &cost, - ability_tag, + ability_index, events, )? { super::casting::PaymentOutcome::Paid => {} @@ -3382,14 +3380,12 @@ pub(crate) fn handle_remove_counter_distribution_for_cost( // CR 601.2h + CR 602.2b: The assigned counter payment is complete // before an automatic residual can pause on a self-move replacement, // so its typed continuation cannot replay the selected distribution. - let ability_tag = - super::casting::activation_ability_tag(state, pending.object_id, ability_index); match super::casting::pay_ability_cost_for_activation( state, player, pending.object_id, &cost, - ability_tag, + ability_index, events, )? { super::casting::PaymentOutcome::Paid => {} @@ -4450,7 +4446,7 @@ pub(super) fn push_activated_ability_to_stack( player, source_id, cost, - super::casting::activation_ability_tag(state, source_id, ability_index), + ability_index, events, )? { @@ -10302,13 +10298,9 @@ fn auto_tap_mana_sources_inner( excluded_sources }; if let Some(sub_cost) = sub_cost { - let (source_types, source_subtypes) = - super::casting::activation_source_types(state, option.object_id); - let activation_ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: ability_def.ability_tag, - }; + let activation_context = + super::casting::activation_payment_context(state, option.object_id, idx); + let activation_ctx = activation_context.as_payment_context(); auto_tap_mana_sources_inner( state, player, @@ -11201,17 +11193,9 @@ fn finalize_mana_payment_with_resume( ) }) .unwrap_or_default(); - let (source_types, source_subtypes) = - super::casting::activation_source_types(state, source_id); - let activation_ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: super::casting::activation_ability_tag( - state, - source_id, - ability_index, - ), - }; + let activation_context = + super::casting::activation_payment_context(state, source_id, ability_index); + let activation_ctx = activation_context.as_payment_context(); if let Some(waiting) = maybe_pause_for_phyrexian_choice( state, player, @@ -11272,8 +11256,8 @@ fn finalize_mana_payment_with_resume( state, player, pending.object_id, + ability_index, &pending.cost, - super::casting::activation_ability_tag(state, pending.object_id, ability_index), None, events, &excluded_sources, @@ -11597,8 +11581,8 @@ pub fn finalize_mana_payment_with_phyrexian_choices( state, player, pending.object_id, + ability_index, &pending.cost, - super::casting::activation_ability_tag(state, pending.object_id, ability_index), Some(phyrexian_choices), events, &excluded_sources, diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 0e1926d053..73bb868943 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -1059,11 +1059,16 @@ fn activation_mana_payment_auto_taps_activation_only_source() { Zone::Battlefield, ); let cost = ManaCost::generic(1); + Arc::make_mut(&mut state.objects.get_mut(&ability_source).unwrap().abilities).push( + AbilityDefinition::new(AbilityKind::Activated, Effect::Proliferate) + .cost(AbilityCost::Mana { cost: cost.clone() }), + ); assert!(can_pay_ability_mana_cost_after_auto_tap( &state, PlayerId(0), ability_source, + 0, &cost )); @@ -1072,8 +1077,8 @@ fn activation_mana_payment_auto_taps_activation_only_source() { &mut state, PlayerId(0), ability_source, + 0, &cost, - None, &mut events, ) .unwrap(); @@ -25843,7 +25848,7 @@ fn can_pay_sacrifice_cost_with_eligible() { PlayerId(0), source, &cost, - None + 0 )); } @@ -25869,7 +25874,7 @@ fn cannot_pay_sacrifice_cost_no_eligible() { PlayerId(0), source, &cost, - None + 0 )); } @@ -31080,7 +31085,7 @@ fn composite_activated_pay_life_cost_deducts_life() { let life_before = state.players[0].life; let mut events = Vec::new(); - pay_ability_cost_for_activation(&mut state, PlayerId(0), fetch, &cost, None, &mut events) + pay_ability_cost_for_activation(&mut state, PlayerId(0), fetch, &cost, 0, &mut events) .expect("fetchland-style composite cost should be payable"); assert_eq!(state.players[0].life, life_before - 1); @@ -33084,7 +33089,7 @@ mod remove_counter_cost { selection: CounterCostSelection::SingleObject, }; let mut events = Vec::new(); - pay_ability_cost_for_activation(&mut state, PlayerId(0), source, &cost, None, &mut events) + pay_ability_cost_for_activation(&mut state, PlayerId(0), source, &cost, 0, &mut events) .expect("cost should pay with 2 +1/+1 counters available"); let remaining = state .objects @@ -33126,7 +33131,7 @@ mod remove_counter_cost { "cost must be unpayable when the source has no +1/+1 counters" ); assert!( - !can_pay_ability_cost_now(&state, PlayerId(0), source, &cost, None), + !can_pay_ability_cost_now(&state, PlayerId(0), source, &cost, 0), "can_pay_ability_cost_now must reject an unpayable remove-counter cost" ); } @@ -33163,7 +33168,7 @@ mod remove_counter_cost { selection: CounterCostSelection::SingleObject, }; let mut events = Vec::new(); - pay_ability_cost_for_activation(&mut state, PlayerId(0), source, &cost, None, &mut events) + pay_ability_cost_for_activation(&mut state, PlayerId(0), source, &cost, 0, &mut events) .unwrap(); let removed_count = events .iter() @@ -34612,7 +34617,7 @@ mod unattach_cost { PlayerId(0), equipment, &cost, - None, + 0, &mut Vec::new(), ) .expect("attached Equipment should be able to unattach as a cost"); @@ -36328,7 +36333,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - None, + 0, &mut events, ) .expect("exert cost pays"); @@ -36388,7 +36393,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - None, + 0, &mut events, ) .expect("first exert"); @@ -36397,7 +36402,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - None, + 0, &mut events, ) .expect("second exert"); @@ -36445,7 +36450,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - None, + 0, &mut events, ); assert!(matches!(result, Err(EngineError::ActionNotAllowed(_)))); @@ -36465,7 +36470,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - None, + 0, &mut events, ) .expect("exert cost pays"); diff --git a/crates/engine/src/game/cost_payability.rs b/crates/engine/src/game/cost_payability.rs index 09d8762904..b9173455f7 100644 --- a/crates/engine/src/game/cost_payability.rs +++ b/crates/engine/src/game/cost_payability.rs @@ -216,6 +216,7 @@ impl AbilityCost { state: &GameState, player: PlayerId, source: ObjectId, + ability_index: usize, ) -> bool { match self { AbilityCost::Mana { cost } => { @@ -224,6 +225,7 @@ impl AbilityCost { state, player, source, + ability_index, cost, &excluded_sources, ) @@ -239,7 +241,9 @@ impl AbilityCost { } if has_tap => { has_enough_tap_creatures(state, player, source, requirement, filter, true) } - other => other.is_payable_for_mana_ability(state, player, source), + other => { + other.is_payable_for_mana_ability(state, player, source, ability_index) + } }) } // Every other kind has no mana-pool component — defer to the diff --git a/crates/engine/src/game/costs.rs b/crates/engine/src/game/costs.rs index 13b0de91f0..2288a15705 100644 --- a/crates/engine/src/game/costs.rs +++ b/crates/engine/src/game/costs.rs @@ -201,11 +201,10 @@ fn find_eligible_tap_creatures_targets( pub(crate) enum PaymentScope<'a> { Activation { excluded_sources: &'a HashSet, - /// CR 106.6: Keyword tag of the activated ability whose cost is being - /// paid. Threaded into `PaymentContext::Activation` so tag-scoped mana - /// spend restrictions (Quinjet → power-up) gate eligible mana. Resolution - /// scope never carries a tag (resolution-time costs aren't activations). - ability_tag: Option, + /// CR 106.6: Exact activated ability whose mana cost is being paid. + /// This builds the live activation payment context, including any + /// source-chosen-color rider and keyword tag. + ability_index: usize, }, /// `ability` is normally the PAYER-ADJUSTED `ResolvedAbility` clone /// (controller swapped to the resolved payer, per `effects/pay.rs`). All @@ -573,7 +572,7 @@ pub fn pay_ability_cost_for_activation( player: PlayerId, source_id: ObjectId, cost: &AbilityCost, - ability_tag: Option, + ability_index: usize, events: &mut Vec, ) -> Result { pay_ability_cost_for_activation_with_cost_move_replacement( @@ -581,7 +580,7 @@ pub fn pay_ability_cost_for_activation( player, source_id, cost, - ability_tag, + ability_index, events, ) } @@ -591,7 +590,7 @@ fn pay_ability_cost_for_activation_with_cost_move_replacement( player: PlayerId, source_id: ObjectId, cost: &AbilityCost, - ability_tag: Option, + ability_index: usize, events: &mut Vec, ) -> Result { let excluded_sources = ability_mana_payment_excluded_sources(cost, source_id); @@ -603,7 +602,7 @@ fn pay_ability_cost_for_activation_with_cost_move_replacement( events, &PaymentScope::Activation { excluded_sources: &excluded_sources, - ability_tag, + ability_index, }, None, )?; @@ -760,18 +759,18 @@ fn pay_ability_cost_inner( // source permanent's types. PaymentScope::Activation { excluded_sources, - ability_tag, + ability_index, .. } => { if excluded_sources.is_empty() { - pay_ability_mana_cost(state, player, source_id, cost, *ability_tag, events)?; + pay_ability_mana_cost(state, player, source_id, *ability_index, cost, events)?; } else { pay_ability_mana_cost_excluding( state, player, source_id, + *ability_index, cost, - *ability_tag, events, excluded_sources, // Top-level ability cost payment: no outer cost on the stack. @@ -2241,7 +2240,7 @@ mod tests { let excluded = ability_mana_payment_excluded_sources(&cost, src); let scope = PaymentScope::Activation { excluded_sources: &excluded, - ability_tag: None, + ability_index: 0, }; assert!( can_pay(&scenario.state, P0, src, &cost, &scope), @@ -2270,7 +2269,7 @@ mod tests { let excluded = ability_mana_payment_excluded_sources(&cost, src); let scope = PaymentScope::Activation { excluded_sources: &excluded, - ability_tag: None, + ability_index: 0, }; let mut events = Vec::new(); let outcome = pay_ability_cost_inner( @@ -2323,7 +2322,7 @@ mod tests { P0, src, &graveyard_cost, - None, + 0, &mut Vec::new(), ); assert!(matches!(rejected, Err(EngineError::ActionNotAllowed(_)))); @@ -2339,7 +2338,7 @@ mod tests { P0, src, &battlefield_cost, - None, + 0, &mut Vec::new(), ) .expect("battlefield self-return cost should be payable"); @@ -2356,7 +2355,7 @@ mod tests { cost, &PaymentScope::Activation { excluded_sources: &excluded, - ability_tag: None, + ability_index: 0, }, ) } diff --git a/crates/engine/src/game/effects/mana.rs b/crates/engine/src/game/effects/mana.rs index 2232b16877..4ba090c5e5 100644 --- a/crates/engine/src/game/effects/mana.rs +++ b/crates/engine/src/game/effects/mana.rs @@ -317,11 +317,26 @@ pub(crate) fn resolve_restrictions( ManaSpendRestriction::SpellType(t) => { Some(ManaRestriction::OnlyForSpellType(t.clone())) } + // Preserve the historical behavior of this older template: it is + // omitted when the source has no creature-type choice. The newer + // `SpellOfSourceChosenColor` below deliberately differs; its + // missing choice must make the produced mana unspendable. ManaSpendRestriction::ChosenCreatureType => state .objects .get(&source_id) .and_then(|obj| obj.chosen_creature_type()) .map(|ct| ManaRestriction::OnlyForCreatureType(ct.to_string())), + // CR 105.2 + CR 106.6: The spell's color must equal the mana + // source's live chosen color. A missing source/choice is not an + // omitted restriction; it makes this produced mana unspendable. + ManaSpendRestriction::SpellOfSourceChosenColor => Some( + state + .objects + .get(&source_id) + .and_then(|obj| obj.chosen_color()) + .map(ManaRestriction::OnlyForSpellColor) + .unwrap_or(ManaRestriction::Impossible), + ), // CR 106.6: Combined spell type + ability activation restriction. ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type, @@ -393,12 +408,12 @@ pub(crate) fn resolve_restrictions( crate::types::mana::SpecialAction::TurnFaceUp, )) } - // CR 106.6: Disjunction — recursively lower each branch. If every branch - // dropped (e.g. an unresolvable `ChosenCreatureType` with no chosen type), - // the disjunction has no payable cases, so drop it too. + // CR 106.6: Disjunction — recursively lower each branch. The + // chosen-color branch preserves its fail-closed `Impossible`; the + // legacy chosen-creature-type branch retains its historical drop. ManaSpendRestriction::Any(subs) => { let inner = resolve_restrictions(subs, state, source_id); - (!inner.is_empty()).then_some(ManaRestriction::OnlyForAny(inner)) + Some(ManaRestriction::OnlyForAny(inner)) } }) .collect() diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 878ee4b28e..4d6db28c17 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -6191,17 +6191,9 @@ fn apply_action( .ok_or_else(|| EngineError::InvalidAction("Player not found".to_string()))?; let activation_ability_index = pending_ref.activation_ability_index; let current_shards = if let Some(ability_index) = activation_ability_index { - let (source_types, source_subtypes) = - casting::activation_source_types(state, spell_object); - let activation_ctx = crate::types::mana::PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: casting::activation_ability_tag( - state, - spell_object, - ability_index, - ), - }; + let activation_context = + casting::activation_payment_context(state, spell_object, ability_index); + let activation_ctx = activation_context.as_payment_context(); let any_color = casting::player_can_spend_as_any_color_for_payment( state, player, @@ -9616,19 +9608,11 @@ pub(super) fn handle_spend_pool_mana( // is correctly eligible to pin when it can legally pay the activation. // Owned holders so the context's borrowed slices outlive the eligibility check. let spell_meta; - let source_types; - let source_subtypes; - let ability_tag; + let activation_context; let ctx = if let Some(ability_index) = activation_ability_index { - let (types, subtypes) = super::casting::activation_source_types(state, object_id); - source_types = types; - source_subtypes = subtypes; - ability_tag = super::casting::activation_ability_tag(state, object_id, ability_index); - Some(crate::types::mana::PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag, - }) + activation_context = + super::casting::activation_payment_context(state, object_id, ability_index); + Some(activation_context.as_payment_context()) } else { spell_meta = super::casting::build_spell_meta(state, player, object_id); spell_meta @@ -9675,7 +9659,7 @@ fn mana_unit_eligible_for_cost( // CR 106.6: a unit whose restrictions reject this context can pay nothing here. if let Some(ctx) = ctx { - if !unit.restrictions.iter().all(|r| r.allows(ctx)) { + if !mana_payment::mana_unit_permits_payment_context(unit, ctx) { return false; } } diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index 80fba528e0..54dde37dbe 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -2057,6 +2057,7 @@ fn unlock_door_restricted_mana_rejected_for_effect_and_spell_payments() { source_types: &["Artifact".to_string()], source_subtypes: &["Equipment".to_string()], ability_tag: None, + mana_color_constraint: crate::types::mana::ActivationManaColorConstraint::Unrestricted, })); let _ = ManaType::Red; } diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 2f48b7cfdd..62d948e518 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -718,6 +718,10 @@ pub fn activate_ninjutsu( // CR 702.49a/d: Extract the activation cost (validated after all other checks, paid before mutations) let mana_cost = ninjutsu_family_cost(ninjutsu_obj).ok_or("Ninjutsu-family card has no mana cost")?; + let ability_index = super::casting::activated_ability_definitions(state, ninjutsu_obj_id) + .into_iter() + .find_map(|(index, ability)| is_ninjutsu_family_marker_ability(&ability).then_some(index)) + .ok_or("Ninjutsu-family card has no activated ability marker")?; // Validate timing if !ninjutsu_timing_ok(&state.phase, &variant) { @@ -781,7 +785,7 @@ pub fn activate_ninjutsu( &AbilityCost::Mana { cost: effective_cost, }, - None, + ability_index, events, ) .map_err(|e| e.to_string())? @@ -1989,6 +1993,18 @@ mod tests { (state, attacker_id, ninja_id) } + #[test] + fn effective_abilities_include_runtime_ninjutsu_marker() { + let (state, _attacker_id, ninja_id) = setup_ninjutsu_scenario(); + + assert!( + crate::game::casting::activated_ability_definitions(&state, ninja_id) + .into_iter() + .any(|(_, ability)| is_ninjutsu_family_marker_ability(&ability)), + "a Ninjutsu keyword without a stored marker must still occupy the effective activation space" + ); + } + /// CR 702.49c + CR 616.1 discriminating test (fail-first): a ninja whose /// battlefield entry parks on a replacement-ordering prompt (two opposite- /// direction enter tap-state `Moved` effects — one enters tapped, one enters diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index beab717b5a..0efd967809 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -1350,7 +1350,7 @@ fn mana_ability_ready_without_simulation_gated( // currently payable. is_payable_for_mana_ability's Mana arm uses auto_tap with // require_current_payability=false, so it does not recurse here. if let Some(cost) = &ability_def.cost { - if !cost.is_payable_for_mana_ability(state, player, source_id) { + if !cost.is_payable_for_mana_ability(state, player, source_id, ability_index) { return false; } } @@ -1658,13 +1658,12 @@ pub(super) fn advance_mana_ability_activation( // 117.1d / CR 118.2). if pending.chosen_mana_payment.is_none() { if let Some(sub_cost) = mana_sub_cost_of(&ability_def.cost) { - let (source_types, source_subtypes) = - super::casting::activation_source_types(state, pending.source_id); - let activation_ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: None, - }; + let activation_context = super::casting::activation_payment_context( + state, + pending.source_id, + pending.ability_index, + ); + let activation_ctx = activation_context.as_payment_context(); let pool = &state.players[pending.player.0 as usize].mana_pool; let plans = enumerate_hybrid_payment_plans(pool, sub_cost, &activation_ctx); match plans.len() { @@ -1674,6 +1673,7 @@ pub(super) fn advance_mana_ability_activation( state, pending.player, pending.source_id, + pending.ability_index, sub_cost, &excluded_sources, ) @@ -2208,7 +2208,7 @@ fn pay_mana_ability_cost_component( pending.player, pending.source_id, cost, - None, + pending.ability_index, events, )? { super::costs::PaymentOutcome::Paid => Ok(ManaAbilityPaymentProgress::Complete), @@ -2289,6 +2289,7 @@ fn pay_mana_ability_cost_component( state, pending.source_id, pending.player, + pending.ability_index, &Some(cost.clone()), events, &mut tappers, @@ -2690,6 +2691,7 @@ fn pay_mana_ability_cost_with_choices( state: &mut GameState, source_id: ObjectId, player: PlayerId, + ability_index: usize, cost: &Option, events: &mut Vec, chosen_tappers: &mut I, @@ -2722,6 +2724,7 @@ where state, source_id, player, + ability_index, cost, chosen_hybrid_payment, events, @@ -2893,7 +2896,12 @@ where }; if matches!( super::costs::pay_ability_cost_for_activation( - state, player, source_id, &cost, None, events, + state, + player, + source_id, + &cost, + ability_index, + events, )?, super::costs::PaymentOutcome::Paused { .. } ) { @@ -2908,7 +2916,12 @@ where Some(c) if is_self_contained_mana_subcost(c) => { if matches!( super::costs::pay_ability_cost_for_activation( - state, player, source_id, c, None, events, + state, + player, + source_id, + c, + ability_index, + events, )?, super::costs::PaymentOutcome::Paused { .. } ) { @@ -3332,6 +3345,7 @@ fn pay_mana_sub_cost( state: &mut GameState, source_id: ObjectId, player: PlayerId, + ability_index: usize, cost: &ManaCost, hybrid_plan: Option<&[ManaType]>, events: &mut Vec, @@ -3352,15 +3366,12 @@ fn pay_mana_sub_cost( // chain, or a self-loop terminate instead of recursing infinitely. let mut excluded_sources = excluded_sources.clone(); excluded_sources.insert(source_id); - // CR 605.1a: A mana ability never carries a power-up tag (power-up - // abilities can't produce mana), so the tag-scoped activation context is - // `None` here — Quinjet's {R}{R} must not pay another mana ability's cost. return super::casting::pay_ability_mana_cost_excluding_with_parent( state, player, source_id, + ability_index, cost, - None, events, &excluded_sources, sub_cost_demand, @@ -3374,14 +3385,9 @@ fn pay_mana_sub_cost( // pool's restriction-blind `pay_cost`. Without this, activation-only // mana (e.g. Heart of Ramos) would silently pay through for the {R} half // of a hypothetical "{R}: Add {G}{G}" mana ability. - let (source_types, source_subtypes) = super::casting::activation_source_types(state, source_id); - // CR 605.1a: Mana abilities never carry a power-up tag, so `ability_tag` is - // `None` for the mana-ability sub-cost activation context. - let ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag: None, - }; + let activation_context = + super::casting::activation_payment_context(state, source_id, ability_index); + let ctx = activation_context.as_payment_context(); state.restamp_pool_pip_ids(player); let spent = select_cost_with_plan( &state.players[player.0 as usize].mana_pool, @@ -4625,6 +4631,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4645,6 +4652,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4664,6 +4672,7 @@ mod tests { source_types: &non_elemental_types, source_subtypes: &non_elemental_subtypes, ability_tag: None, + mana_color_constraint: crate::types::mana::ActivationManaColorConstraint::Unrestricted, }; let mut pool_clone2 = pool.clone(); assert!( @@ -4678,6 +4687,7 @@ mod tests { source_types: &non_elemental_types, source_subtypes: &elemental_subtypes, ability_tag: None, + mana_color_constraint: crate::types::mana::ActivationManaColorConstraint::Unrestricted, }; assert!( pool_clone2 diff --git a/crates/engine/src/game/mana_payment.rs b/crates/engine/src/game/mana_payment.rs index 37d40da5c9..166ae6674c 100644 --- a/crates/engine/src/game/mana_payment.rs +++ b/crates/engine/src/game/mana_payment.rs @@ -2046,7 +2046,14 @@ pub fn land_subtype_to_mana_type(subtype: &str) -> Option { /// Arisen Necropolis). Under such a spell-payment context, real pool mana is /// ineligible — only convoke/delve stand-in units may pay. Layered on top of the /// unit's own spend restrictions so both gates apply. -fn ctx_permits_unit(ctx: &PaymentContext<'_>, unit: &ManaUnit) -> bool { +/// CR 106.6: Shared eligibility predicate for a concrete mana unit in a typed +/// payment context. The ability rider checks the unit's actual mana type before +/// unit restrictions; cost permissions such as "spend as though" are applied +/// later and cannot make an off-color unit satisfy an activation rider. +pub(crate) fn mana_unit_permits_payment_context(unit: &ManaUnit, ctx: &PaymentContext<'_>) -> bool { + if !ctx.permits_actual_mana_type(unit.color) { + return false; + } if let PaymentContext::Spell(meta) = ctx { if meta.cant_spend_mana && !unit.is_convoke_payment() { return false; @@ -2055,6 +2062,10 @@ fn ctx_permits_unit(ctx: &PaymentContext<'_>, unit: &ManaUnit) -> bool { unit.restrictions.iter().all(|r| r.allows(ctx)) } +fn ctx_permits_unit(ctx: &PaymentContext<'_>, unit: &ManaUnit) -> bool { + mana_unit_permits_payment_context(unit, ctx) +} + /// `ctx_permits_unit` lifted over an optional context: no context means every /// unit is eligible (CR 106.6 restrictions only bite when a context is supplied). fn spell_permits_unit(spell: Option<&PaymentContext<'_>>, unit: &ManaUnit) -> bool { @@ -2668,7 +2679,7 @@ mod tests { use crate::types::game_state::LayersDirty; use crate::types::identifiers::CardId; use crate::types::identifiers::ObjectId; - use crate::types::mana::{ManaRestriction, SpellMeta}; + use crate::types::mana::{ManaColor, ManaRestriction, SpellMeta}; use crate::types::zones::Zone; /// The building-block predicate must classify each shape the parser can produce. @@ -2841,6 +2852,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana, @@ -3840,6 +3852,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3864,6 +3877,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3914,6 +3928,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3937,6 +3952,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3981,6 +3997,7 @@ mod tests { source_types: &source_types, source_subtypes: &source_subtypes, ability_tag: None, + mana_color_constraint: crate::types::mana::ActivationManaColorConstraint::Unrestricted, }; assert!(can_pay_for_spell( &pool, @@ -3996,6 +4013,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4033,20 +4051,16 @@ mod tests { .expect("Hydraulic Helper's spend restriction must parse"); assert_eq!( ast, - ManaSpendRestriction::SpellTypeOrAbilityActivation { + vec![ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Artifact".to_string(), ability: AbilityActivationScope::Any, - }, + }], "negative nonartifact restriction must keep ability activation unrestricted" ); // Lower through the real runtime resolver (state-independent for this variant). let state = GameState::new_two_player(42); - let runtime = crate::game::effects::mana::resolve_restrictions( - std::slice::from_ref(&ast), - &state, - ObjectId(1), - ); + let runtime = crate::game::effects::mana::resolve_restrictions(&ast, &state, ObjectId(1)); // The produced {U} carries the lowered restriction. let mut pool = ManaPool::default(); @@ -4074,6 +4088,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4100,6 +4115,8 @@ mod tests { source_types: &creature_types, source_subtypes: &no_subtypes, ability_tag: None, + mana_color_constraint: + crate::types::mana::ActivationManaColorConstraint::Unrestricted, }), crate::types::mana::CostPermissionContext::default(), ), @@ -4114,6 +4131,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4215,6 +4233,7 @@ mod tests { cast_from_zone: Some(crate::types::zones::Zone::Graveyard), mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4238,6 +4257,7 @@ mod tests { cast_from_zone: Some(crate::types::zones::Zone::Hand), mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4284,6 +4304,7 @@ mod tests { cast_from_zone: Some(crate::types::zones::Zone::Graveyard), mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4307,6 +4328,7 @@ mod tests { cast_from_zone: Some(crate::types::zones::Zone::Hand), mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -4357,6 +4379,50 @@ mod tests { )); } + /// CR 106.6: an activation's own chosen-color rider is checked against the + /// actual unit before any "spend as though" permission. A blue unit cannot + /// pay Throne of Eldraine's draw ability after red was chosen, even when an + /// outside effect would otherwise let blue mana pay a red cost. + #[test] + fn chosen_color_activation_rider_rejects_off_color_even_with_any_color() { + let source_types = vec!["Artifact".to_string()]; + let source_subtypes = Vec::new(); + let context = PaymentContext::Activation { + source_types: &source_types, + source_subtypes: &source_subtypes, + ability_tag: None, + mana_color_constraint: crate::types::mana::ActivationManaColorConstraint::Only( + ManaColor::Red, + ), + }; + let blue_cost = ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 0, + }; + let as_though_any_color = crate::types::mana::CostPermissionContext { + any_color: true, + ..crate::types::mana::CostPermissionContext::default() + }; + assert!( + !can_pay_for_spell( + &pool_with(&[(ManaType::Blue, 1)]), + &blue_cost, + Some(&context), + as_though_any_color, + ), + "an as-though permission must not change blue's actual mana color" + ); + assert!( + can_pay_for_spell( + &pool_with(&[(ManaType::Red, 1)]), + &blue_cost, + Some(&context), + as_though_any_color, + ), + "a red unit remains eligible before the as-though permission matches the shard" + ); + } + #[test] fn pay_cost_any_color_spends_available_mana() { // CR 609.4b: pay_cost_with_demand with any_color uses available mana for colored costs. diff --git a/crates/engine/src/game/planeswalker.rs b/crates/engine/src/game/planeswalker.rs index aa2f8577dc..fa126a435e 100644 --- a/crates/engine/src/game/planeswalker.rs +++ b/crates/engine/src/game/planeswalker.rs @@ -392,8 +392,15 @@ fn finalize_loyalty_activation( let cost = crate::types::ability::AbilityCost::Loyalty { amount: loyalty_cost, }; - match super::casting::pay_ability_cost_for_activation(state, player, pw_id, &cost, None, events) - .expect("loyalty validation passed in handle_activate_loyalty") + match super::casting::pay_ability_cost_for_activation( + state, + player, + pw_id, + &cost, + ability_index, + events, + ) + .expect("loyalty validation passed in handle_activate_loyalty") { super::casting::PaymentOutcome::Paid => { complete_loyalty_activation(state, player, pw_id, resolved, ability_index, events) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 4d0143ca84..35fe1342f9 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1542,15 +1542,31 @@ fn pay_replacement_may_cost( Ok(crate::game::costs::PaymentOutcome::Failed { .. }) | Err(_) => false, } } - _ => match crate::game::casting::pay_ability_cost_for_activation( - state, player, source_id, cost, None, events, - ) { - Ok(crate::game::costs::PaymentOutcome::Paid) => true, - Ok(crate::game::costs::PaymentOutcome::Paused { remaining_cost }) => { - return MayCostOutcome::PausedForChoice { remaining_cost }; + // A replacement's may-cost is paid while applying the replacement; it + // is not an activation of `source_id`. Use the dedicated resolution + // payment authority so it neither invents an ability index nor applies + // an unrelated activation-only mana rider. + _ => { + let ability = ResolvedAbility::new( + crate::types::ability::Effect::PayCost { + cost: cost.clone(), + scale: None, + payer: TargetFilter::Controller, + }, + Vec::new(), + source_id, + player, + ); + match crate::game::costs::pay_ability_cost_for_replacement_may_cost( + state, player, cost, &ability, events, + ) { + Ok(crate::game::costs::PaymentOutcome::Paid) => true, + Ok(crate::game::costs::PaymentOutcome::Paused { remaining_cost }) => { + return MayCostOutcome::PausedForChoice { remaining_cost }; + } + Ok(crate::game::costs::PaymentOutcome::Failed { .. }) | Err(_) => false, } - Ok(crate::game::costs::PaymentOutcome::Failed { .. }) | Err(_) => false, - }, + } }; if paid { MayCostOutcome::Paid diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 7cac640b52..93d9f2700c 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -10,12 +10,12 @@ use serde::{Deserialize, Serialize}; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, - ActivationRestriction, AdditionalCost, CastTimingPermission, CastingRestriction, ChoiceType, - ChosenSubtypeKind, ContinuousModification, ControllerRef, CostReduction, - DelayedTriggerCondition, Duration, Effect, EffectScope, FilterProp, ManaProduction, - ModalChoice, ParsedCondition, PlayerFilter, QuantityExpr, QuantityRef, ReplacementDefinition, - SolveCondition, SpellCastingOption, StaticCondition, StaticDefinition, TapStateChange, - TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, + ActivationManaPaymentRestriction, ActivationRestriction, AdditionalCost, CastTimingPermission, + CastingRestriction, ChoiceType, ChosenSubtypeKind, ContinuousModification, ControllerRef, + CostReduction, DelayedTriggerCondition, Duration, Effect, EffectScope, FilterProp, + ManaProduction, ModalChoice, ParsedCondition, PlayerFilter, QuantityExpr, QuantityRef, + ReplacementDefinition, SolveCondition, SpellCastingOption, StaticCondition, StaticDefinition, + TapStateChange, TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, }; use crate::types::format::DeckCopyLimit; use crate::types::keywords::{EscapeCost, FlashbackCost, Keyword, KeywordKind}; @@ -6207,6 +6207,8 @@ fn parse_activated_ability_definition( current_ability_index: Option, ctx: &mut ParseContext, ) -> (AbilityDefinition, String) { + let (effect_text, activation_mana_payment_restriction) = + strip_activated_mana_payment_restriction(effect_text); let (effect_text, constraints) = strip_activated_constraints(effect_text); // CR 207.2c / CR 207.2d: drop a leading ability-/flavor-word label so the cost // after the em-dash parses (covers 5–6-word Universes-Beyond flavor names that @@ -6249,6 +6251,7 @@ fn parse_activated_ability_definition( if !constraints.restrictions.is_empty() { def.activation_restrictions = constraints.restrictions; } + def.activation_mana_payment_restriction = activation_mana_payment_restriction; def.activator_filter = constraints.activator_filter.or_else(|| { constraints .any_player_may_activate @@ -6259,6 +6262,32 @@ fn parse_activated_ability_definition( (def, effect_text) } +/// CR 106.6: Strip the exact terminal rider "Spend only mana of the chosen +/// color to activate this ability." from an activated ability's effect body. +/// This is intentionally an all-consuming nom grammar: other possessives, +/// colors, subjects, or trailing words stay in the effect text and therefore +/// remain an explicit residual parse gap rather than weakening a cost rule. +fn strip_activated_mana_payment_restriction( + text: &str, +) -> (&str, Option) { + const SUFFIX: &str = ". spend only mana of the chosen color to activate this ability"; + let lower = text.to_lowercase(); + let parsed = nom_on_lower(text, &lower, |input| { + let (input, prefix) = take_until(SUFFIX).parse(input)?; + let (input, _) = tag(SUFFIX).parse(input)?; + let (input, _) = opt(tag(".")).parse(input)?; + let (input, _) = all_consuming(multispace0).parse(input)?; + Ok((input, prefix.len())) + }); + match parsed { + Some((prefix_len, _)) => ( + text[..prefix_len].trim_end(), + Some(ActivationManaPaymentRestriction::OnlySourceChosenColor), + ), + None => (text, None), + } +} + /// Parse Oracle text into structured ability definitions. /// /// This is the public API entry point — a thin wrapper around [`parse_oracle_ir`] diff --git a/crates/engine/src/parser/oracle_effect/mana.rs b/crates/engine/src/parser/oracle_effect/mana.rs index 702ae2923f..076e503ce7 100644 --- a/crates/engine/src/parser/oracle_effect/mana.rs +++ b/crates/engine/src/parser/oracle_effect/mana.rs @@ -1624,10 +1624,11 @@ fn split_restricted_spell_and_activation(rest: &str) -> (&str, ActivationTail) { /// - "spend this mana only to cast a creature spell of the chosen type" -> `ChosenCreatureType` /// - "spend this mana only to activate abilities" -> `ActivateOnly` /// -/// Returns `(restriction, grants)` where grants are properties conferred to the spell. +/// Returns `(restrictions, grants)` where every restriction is an AND gate on +/// the produced mana and grants are properties conferred to the spell. pub(crate) fn parse_mana_spend_restriction( lower: &str, -) -> Option<(ManaSpendRestriction, Vec)> { +) -> Option<(Vec, Vec)> { // CR 106.6: Negative spend restriction — "this mana can't be spent to cast // non spells" (Karn, Legacy Reforged). The double negative ("can't // cast non") is the spell-side equivalent of "only to cast ", @@ -1637,7 +1638,7 @@ pub(crate) fn parse_mana_spend_restriction( // ability stays payable) rather than `SpellType` (spells-only), which would // wrongly forbid paying for abilities. if let Some(restriction) = parse_negative_mana_spend_restriction(lower) { - return Some((restriction, vec![])); + return Some((vec![restriction], vec![])); } let (_, base) = nom_on_lower(lower, lower, |i| { @@ -1655,7 +1656,7 @@ pub(crate) fn parse_mana_spend_restriction( .is_some() { return Some(( - ManaSpendRestriction::ActivateTagged(AbilityTag::PowerUp), + vec![ManaSpendRestriction::ActivateTagged(AbilityTag::PowerUp)], vec![], )); } @@ -1666,7 +1667,7 @@ pub(crate) fn parse_mana_spend_restriction( }) .is_some() { - return Some((ManaSpendRestriction::ActivateOnly, vec![])); + return Some((vec![ManaSpendRestriction::ActivateOnly], vec![])); } // "spend this mana only on costs that include/contain {X}" -- X-cost restriction @@ -1679,7 +1680,7 @@ pub(crate) fn parse_mana_spend_restriction( }) .is_some() { - return Some((ManaSpendRestriction::XCostOnly, vec![])); + return Some((vec![ManaSpendRestriction::XCostOnly], vec![])); } // CR 106.6: Activation-first disjunction — "to activate X or cast Y" (Automated @@ -1695,7 +1696,7 @@ pub(crate) fn parse_mana_spend_restriction( let without_to = nom_on_lower(base, &base_lower, |i| value((), tag("to ")).parse(i)) .map_or(base, |(_, rest)| rest); if let Some(restriction) = parse_disjunctive_cast_clauses(without_to.trim()) { - return Some((restriction, vec![])); + return Some((vec![restriction], vec![])); } } @@ -1705,10 +1706,10 @@ pub(crate) fn parse_mana_spend_restriction( // the standalone special-action clauses first (Overgrown Zealot's second // ability is turn-face-up-only). The clause parsers tolerate the leading "to ". if let Some(restriction) = parse_turn_face_up_clause(base, &base_lower) { - return Some((restriction, vec![])); + return Some((vec![restriction], vec![])); } if let Some(restriction) = parse_unlock_door_clause(base, &base_lower) { - return Some((restriction, vec![])); + return Some((vec![restriction], vec![])); } let (_, rest) = nom_on_lower(base, &base_lower, |i| value((), tag("to cast ")).parse(i))?; @@ -1718,24 +1719,62 @@ pub(crate) fn parse_mana_spend_restriction( let (rest, grants) = extract_spell_grants(rest); let rest = rest.trim(); + // CR 105.2a + CR 106.6: This rider has two independent requirements: the + // spell is monocolored, and its sole color equals the source's chosen + // color. Keep them as two restrictions so the generic color-count and + // color-membership building blocks remain independently reusable. + if parse_monocolored_spell_of_source_chosen_color(rest) { + return Some(( + vec![ + ManaSpendRestriction::SpellWithColorCount { + comparator: Comparator::EQ, + count: 1, + }, + ManaSpendRestriction::SpellOfSourceChosenColor, + ], + grants, + )); + } + // CR 106.6: Prefer the whole-remainder single-clause reading first, so a // type union inside one clause ("instant or sorcery spells") stays a single // `SpellType` and only genuinely heterogeneous disjunctions fall through to // the multi-clause path below. if let Some(restriction) = parse_single_cast_clause(rest) { - return Some((restriction, grants)); + return Some((vec![restriction], grants)); } // CR 106.6: Disjunctive spend restriction ("cast X or Y", "cast X, Y, or // activate Z"). Each top-level clause is parsed independently; only when ≥2 // clauses all parse to self-evaluable restrictions do we emit `Any`. if let Some(restriction) = parse_disjunctive_cast_clauses(rest) { - return Some((restriction, grants)); + return Some((vec![restriction], grants)); } None } +/// CR 105.2a + CR 106.6: Pure, terminal grammar for "monocolored spell(s) of +/// that color" and "... of the chosen color." The parser deliberately accepts +/// no possessives, modifiers, or trailing text: a near miss must remain an +/// explicit residual clause rather than weakening a mana-spend restriction. +fn parse_monocolored_spell_of_source_chosen_color(rest: &str) -> bool { + let lower = rest.to_lowercase(); + nom_on_lower(rest, &lower, |input| { + value( + (), + all_consuming(( + tag("monocolored "), + alt((tag("spells"), tag("spell"))), + tag(" of "), + alt((tag("that color"), tag("the chosen color"))), + )), + ) + .parse(input) + }) + .is_some() +} + /// CR 106.6: Parse a single post-"to cast " clause (no grant extraction, no /// disjunction) into a `ManaSpendRestriction`. This is the body of the legacy /// single-clause logic, extracted verbatim so the disjunction path can reuse it @@ -3975,10 +4014,10 @@ mod tests { .expect("negated nonartifact restriction must parse"); assert_eq!( restriction, - ManaSpendRestriction::SpellTypeOrAbilityActivation { + vec![ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Artifact".to_string(), ability: AbilityActivationScope::Any, - } + }] ); assert!(grants.is_empty()); } @@ -3993,10 +4032,10 @@ mod tests { .expect("curly-apostrophe negated restriction must parse"); assert_eq!( restriction, - ManaSpendRestriction::SpellTypeOrAbilityActivation { + vec![ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Artifact".to_string(), ability: AbilityActivationScope::Any, - } + }] ); } @@ -4009,10 +4048,10 @@ mod tests { .expect("negated noncreature restriction must parse"); assert_eq!( restriction, - ManaSpendRestriction::SpellTypeOrAbilityActivation { + vec![ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Creature".to_string(), ability: AbilityActivationScope::Any, - } + }] ); } @@ -4025,7 +4064,7 @@ mod tests { ); assert_eq!( result.map(|(r, _)| r), - Some(ManaSpendRestriction::SpellMatchingCostCriteria { + Some(vec![ManaSpendRestriction::SpellMatchingCostCriteria { spell_type: None, criteria: vec![ SpellCostCriterion::ManaValue { @@ -4034,7 +4073,7 @@ mod tests { }, SpellCostCriterion::HasXInCost, ], - }) + }]) ); } @@ -4047,7 +4086,7 @@ mod tests { ); assert_eq!( result.map(|(r, _)| r), - Some(ManaSpendRestriction::SpellMatchingCostCriteria { + Some(vec![ManaSpendRestriction::SpellMatchingCostCriteria { spell_type: Some("Creature".to_string()), criteria: vec![ SpellCostCriterion::ManaValue { @@ -4056,7 +4095,7 @@ mod tests { }, SpellCostCriterion::HasXInCost, ], - }) + }]) ); } @@ -4080,10 +4119,10 @@ mod tests { ); assert_eq!( result.map(|(r, _)| r), - Some(ManaSpendRestriction::SpellWithManaValue { + Some(vec![ManaSpendRestriction::SpellWithManaValue { comparator: Comparator::GE, value: 5, - }) + }]) ); } @@ -4097,10 +4136,10 @@ mod tests { "spend this mana only to cast a spell from anywhere other than your hand" ) .map(|(r, _)| r), - Some(ManaSpendRestriction::SpellFromZone(ZoneSpend { + Some(vec![ManaSpendRestriction::SpellFromZone(ZoneSpend { zone: Zone::Hand, polarity: ZoneSpendPolarity::NotFrom, - })) + })]) ); // The exclusion marker must not bleed into the inclusion reading. assert_eq!( @@ -4108,10 +4147,10 @@ mod tests { "spend this mana only to cast a spell from your graveyard" ) .map(|(r, _)| r), - Some(ManaSpendRestriction::SpellFromZone(ZoneSpend { + Some(vec![ManaSpendRestriction::SpellFromZone(ZoneSpend { zone: Zone::Graveyard, polarity: ZoneSpendPolarity::From, - })) + })]) ); } @@ -4126,10 +4165,10 @@ mod tests { ); assert_eq!( result.map(|(r, _)| r), - Some(ManaSpendRestriction::Any(vec![ + Some(vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::SpellType("Room".to_string()), ManaSpendRestriction::UnlockDoor, - ])) + ])]) ); } @@ -4142,10 +4181,10 @@ mod tests { ); assert_eq!( result.map(|(r, _)| r), - Some(ManaSpendRestriction::Any(vec![ + Some(vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::SpellType("Room".to_string()), ManaSpendRestriction::UnlockDoor, - ])) + ])]) ); } @@ -4158,9 +4197,9 @@ mod tests { parse_mana_spend_restriction("spend this mana only to cast instant and sorcery spells"); assert_eq!( result.map(|(r, _)| r), - Some(ManaSpendRestriction::SpellType( + Some(vec![ManaSpendRestriction::SpellType( "Instant and Sorcery".to_string() - )) + )]) ); } @@ -4179,11 +4218,11 @@ mod tests { "spend this mana only to cast an enchantment spell, unlock a door, or turn a permanent face up", ) .map(|(r, _)| r), - Some(ManaSpendRestriction::Any(vec![ + Some(vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::SpellType("Enchantment".to_string()), ManaSpendRestriction::UnlockDoor, ManaSpendRestriction::TurnPermanentFaceUp, - ])), + ])]), ); // Tin Street Gossip: face-down spells or turn creatures face up. assert_eq!( @@ -4191,16 +4230,16 @@ mod tests { "spend this mana only to cast face-down spells or to turn creatures face up", ) .map(|(r, _)| r), - Some(ManaSpendRestriction::Any(vec![ + Some(vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::FaceDownSpell, ManaSpendRestriction::TurnPermanentFaceUp, - ])), + ])]), ); // Overgrown Zealot: pure turn-permanents-face-up (no cast clause at all). assert_eq!( parse_mana_spend_restriction("spend this mana only to turn permanents face up") .map(|(r, _)| r), - Some(ManaSpendRestriction::TurnPermanentFaceUp), + Some(vec![ManaSpendRestriction::TurnPermanentFaceUp]), ); } @@ -4216,10 +4255,10 @@ mod tests { .expect("equip abilities plural must parse"); assert_eq!( restriction, - ManaSpendRestriction::Any(vec![ + vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::SpellType("Equipment".to_string()), ManaSpendRestriction::ActivateTagged(AbilityTag::Equip), - ]) + ])] ); assert!(grants.is_empty()); } @@ -4234,11 +4273,48 @@ mod tests { .expect("equip ability singular must parse"); assert_eq!( restriction, - ManaSpendRestriction::Any(vec![ + vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::SpellType("Equipment".to_string()), ManaSpendRestriction::ActivateTagged(AbilityTag::Equip), - ]) + ])] ); assert!(grants.is_empty()); } + + // CR 105.2a + CR 106.6: The Great Henge-style compound rider is an AND, + // not an alternative. The exact grammar consumes the entire subject so a + // trailing qualifier cannot silently widen this restriction. + #[test] + fn mana_spend_restriction_monocolored_spell_of_source_chosen_color() { + assert_eq!( + parse_mana_spend_restriction( + "spend this mana only to cast monocolored spells of that color", + ) + .map(|(restrictions, _)| restrictions), + Some(vec![ + ManaSpendRestriction::SpellWithColorCount { + comparator: Comparator::EQ, + count: 1, + }, + ManaSpendRestriction::SpellOfSourceChosenColor, + ]), + ); + assert_eq!( + parse_mana_spend_restriction( + "spend this mana only to cast monocolored spell of the chosen color", + ) + .map(|(restrictions, _)| restrictions), + Some(vec![ + ManaSpendRestriction::SpellWithColorCount { + comparator: Comparator::EQ, + count: 1, + }, + ManaSpendRestriction::SpellOfSourceChosenColor, + ]), + ); + assert!(parse_mana_spend_restriction( + "spend this mana only to cast monocolored spells of that color with mana value 3 or greater", + ) + .is_none()); + } } diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 290b50c319..6f27355d1a 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -23,8 +23,8 @@ use crate::types::ability::{ AbilityCondition, AbilityDefinition, AbilityKind, CastingPermission, ChoiceType, Chooser, ContinuousModification, ControllerRef, CopyRetargetPermission, CounterSourceRider, DigSource, Duration, Effect, EffectScope, ExcessRecipient, FaceDownBody, FaceDownProfile, FilterProp, - ForEachCategoryAction, LibraryPosition, MultiTargetSpec, ObjectScope, PermissionGrantee, - PlayerFilter, PtValue, QuantityExpr, QuantityRef, RevealUntilDisposition, + ForEachCategoryAction, LibraryPosition, ManaSpendRestriction, MultiTargetSpec, ObjectScope, + PermissionGrantee, PlayerFilter, PtValue, QuantityExpr, QuantityRef, RevealUntilDisposition, SpellStackToGraveyardReplacement, StaticDefinition, TargetChoiceTiming, TargetFilter, TypeFilter, TypedFilter, }; @@ -3787,7 +3787,7 @@ pub(super) fn apply_clause_continuation( } } ContinuationAst::ManaRestriction { - restriction, + restrictions: new_restrictions, grants: new_grants, } => { let Some(previous) = defs.last_mut() else { @@ -3799,7 +3799,7 @@ pub(super) fn apply_clause_continuation( .. } = &mut *previous.effect { - restrictions.push(restriction); + restrictions.extend(new_restrictions); grants.extend(new_grants); } } @@ -6507,10 +6507,14 @@ pub(super) fn parse_followup_continuation_ast( // (coverage green). A dropped unsupported restriction also drops any // paired `grants`; that is intentional (no real card pairs a grant with // an unsupported restriction). - if let Some((restriction, grants)) = super::mana::parse_mana_spend_restriction(&lower) { - if restriction.is_coverage_supported() { + if let Some((restrictions, grants)) = super::mana::parse_mana_spend_restriction(&lower) { + if !restrictions.is_empty() + && restrictions + .iter() + .all(ManaSpendRestriction::is_coverage_supported) + { return Some(ContinuationAst::ManaRestriction { - restriction, + restrictions, grants, }); } diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 35a7e265ed..76838599d9 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -234,7 +234,7 @@ pub(crate) enum ContinuationAst { choice_optional: bool, }, ManaRestriction { - restriction: ManaSpendRestriction, + restrictions: Vec, grants: Vec, }, /// CR 106.6: "that spell can't be countered" — adds grants to the preceding diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 4ada55392b..dc8f2aa80e 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -14796,7 +14796,8 @@ fn mana_spend_restriction_activate_only() { use crate::types::ability::ManaSpendRestriction; let result = parse_mana_spend_restriction("spend this mana only to activate abilities"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::ActivateOnly) ); } @@ -14807,7 +14808,8 @@ fn mana_spend_restriction_noncreature_spells() { use crate::types::ability::ManaSpendRestriction; let result = parse_mana_spend_restriction("spend this mana only to cast noncreature spells"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellType("Noncreature".to_string())) ); } @@ -14818,7 +14820,8 @@ fn mana_spend_restriction_spell_only() { "spend this mana only to cast spells", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellOnly) ); } @@ -14836,7 +14839,8 @@ fn mana_spend_restriction_negative_nonartifact() { let result = parse_mana_spend_restriction("this mana can't be spent to cast nonartifact spells"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Artifact".to_string(), ability: AbilityActivationScope::Any, @@ -14853,7 +14857,8 @@ fn mana_spend_restriction_negative_article_singular_nonartifact() { "this mana can't be spent to cast a nonartifact spell", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some( crate::types::ability::ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Artifact".to_string(), @@ -14873,7 +14878,8 @@ fn mana_spend_restriction_negative_noncreature() { let result = parse_mana_spend_restriction("this mana can't be spent to cast noncreature spells"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Creature".to_string(), ability: AbilityActivationScope::Any, @@ -14997,7 +15003,8 @@ fn mana_spend_restriction_x_cost_only() { use crate::types::ability::ManaSpendRestriction; let result = parse_mana_spend_restriction("spend this mana only on costs that include {x}"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::XCostOnly) ); } @@ -15009,7 +15016,8 @@ fn mana_spend_restriction_instant_or_sorcery() { let result = parse_mana_spend_restriction("spend this mana only to cast instant or sorcery spells"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellType( "Instant or Sorcery".to_string() )) @@ -15026,7 +15034,8 @@ fn mana_spend_restriction_instant_and_sorcery() { let result = parse_mana_spend_restriction("spend this mana only to cast instant and sorcery spells"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellType( "Instant and Sorcery".to_string() )) @@ -15041,7 +15050,8 @@ fn mana_spend_restriction_colorless_eldrazi_spell_or_activation() { "spend this mana only to cast colorless eldrazi spells or activate abilities of colorless eldrazi", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Colorless Eldrazi".to_string(), ability: crate::types::mana::AbilityActivationScope::OfSpellType, @@ -15055,7 +15065,8 @@ fn mana_spend_restriction_singular_source_ability_activation() { "spend this mana only to cast an artifact spell or activate an ability of an artifact source", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Artifact".to_string(), ability: crate::types::mana::AbilityActivationScope::OfSpellType, @@ -15069,7 +15080,8 @@ fn mana_spend_restriction_or_to_activate_source_ability() { "spend this mana only to cast an assassin spell or to activate an ability of an assassin source", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Assassin".to_string(), ability: crate::types::mana::AbilityActivationScope::OfSpellType, @@ -15087,7 +15099,8 @@ fn mana_spend_restriction_bare_activation_or_is_any_ability() { "spend this mana only to cast an artifact spell or activate an ability", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Artifact".to_string(), ability: crate::types::mana::AbilityActivationScope::Any, @@ -15104,7 +15117,8 @@ fn mana_spend_restriction_colorless_or_to_activate_any_ability() { "spend this mana only to cast a colorless spell or to activate an ability", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Colorless".to_string(), ability: crate::types::mana::AbilityActivationScope::Any, @@ -15118,7 +15132,8 @@ fn mana_spend_restriction_any_activation_tail_preserves_inner_or_spell_type() { "spend this mana only to cast an instant or sorcery spell or activate an ability", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Instant or Sorcery".to_string(), ability: crate::types::mana::AbilityActivationScope::Any, @@ -15132,7 +15147,8 @@ fn mana_spend_restriction_any_activation_tail_accepts_to_activate_plural() { "spend this mana only to cast artifact spells or to activate abilities", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Artifact".to_string(), ability: crate::types::mana::AbilityActivationScope::Any, @@ -15146,7 +15162,8 @@ fn mana_spend_restriction_ally_spell_or_source_activation() { "spend this mana only to cast an ally spell or activate an ability of an ally source", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Ally".to_string(), ability: crate::types::mana::AbilityActivationScope::OfSpellType, @@ -15159,7 +15176,8 @@ fn mana_spend_restriction_flashback_spells() { use crate::parser::oracle_effect::mana::parse_mana_spend_restriction; let result = parse_mana_spend_restriction("spend this mana only to cast spells with flashback"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithKeywordKind( KeywordKind::Flashback, )) @@ -15173,7 +15191,8 @@ fn mana_spend_restriction_flashback_spells_from_graveyard() { "spend this mana only to cast spells with flashback from a graveyard", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithKeywordKindFromZone { kind: KeywordKind::Flashback, zone: Zone::Graveyard, @@ -15189,7 +15208,8 @@ fn mana_spend_restriction_mana_value_ge() { "spend this mana only to cast spells with mana value 5 or greater", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithManaValue { comparator: Comparator::GE, value: 5, @@ -15205,7 +15225,8 @@ fn mana_spend_restriction_mana_value_le() { "spend this mana only to cast spells with mana value 3 or less", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithManaValue { comparator: Comparator::LE, value: 3, @@ -15221,7 +15242,8 @@ fn mana_spend_restriction_mana_value_singular_spell_ge() { "spend this mana only to cast a spell with mana value 4 or greater", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithManaValue { comparator: Comparator::GE, value: 4, @@ -15246,7 +15268,8 @@ fn mana_spend_restriction_color_count_exactly() { "spend this mana only to cast spells with exactly three colors", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithColorCount { comparator: Comparator::EQ, count: 3, @@ -15261,7 +15284,8 @@ fn mana_spend_restriction_color_count_exactly_one_color() { let result = parse_mana_spend_restriction("spend this mana only to cast a spell with exactly one color"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithColorCount { comparator: Comparator::EQ, count: 1, @@ -15276,7 +15300,8 @@ fn mana_spend_restriction_color_count_or_more() { let result = parse_mana_spend_restriction("spend this mana only to cast spells with two or more colors"); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithColorCount { comparator: Comparator::GE, count: 2, @@ -15292,7 +15317,8 @@ fn mana_spend_restriction_color_count_or_fewer() { "spend this mana only to cast spells with two or fewer colors", ); assert_eq!( - result.map(|(r, _)| r), + result.and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellWithColorCount { comparator: Comparator::LE, count: 2, @@ -15307,7 +15333,8 @@ fn mana_spend_restriction_from_graveyard() { crate::parser::oracle_effect::mana::parse_mana_spend_restriction( "spend this mana only to cast a spell from your graveyard" ) - .map(|(r, _)| r), + .and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellFromZone(ZoneSpend { zone: Zone::Graveyard, polarity: ZoneSpendPolarity::From, @@ -15317,7 +15344,8 @@ fn mana_spend_restriction_from_graveyard() { crate::parser::oracle_effect::mana::parse_mana_spend_restriction( "spend this mana only to cast spells from exile" ) - .map(|(r, _)| r), + .and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellFromZone(ZoneSpend { zone: Zone::Exile, polarity: ZoneSpendPolarity::From, @@ -15334,7 +15362,8 @@ fn mana_spend_restriction_not_from_hand() { crate::parser::oracle_effect::mana::parse_mana_spend_restriction( "spend this mana only to cast a spell from anywhere other than your hand" ) - .map(|(r, _)| r), + .and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::SpellFromZone(ZoneSpend { zone: Zone::Hand, polarity: ZoneSpendPolarity::NotFrom, @@ -15349,7 +15378,8 @@ fn mana_spend_restriction_on_costs_that_contain_x() { crate::parser::oracle_effect::mana::parse_mana_spend_restriction( "spend this mana only on costs that contain {x}" ) - .map(|(r, _)| r), + .and_then(|(mut restrictions, _)| (restrictions.len() == 1) + .then(|| restrictions.pop().expect("one restriction"))), Some(ManaSpendRestriction::XCostOnly) ); } @@ -15364,10 +15394,10 @@ fn mana_spend_restriction_disjunction_two_spell_types() { .expect("disjunction should parse"); assert_eq!( restriction, - ManaSpendRestriction::Any(vec![ + vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::SpellType("Dragon".to_string()), ManaSpendRestriction::SpellType("Omen".to_string()), - ]) + ])] ); assert!(grants.is_empty()); } @@ -15384,14 +15414,14 @@ fn mana_spend_restriction_disjunction_three_way_heterogeneous() { .expect("three-way disjunction should parse"); assert_eq!( restriction, - ManaSpendRestriction::Any(vec![ + vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::SpellType("Assassin".to_string()), ManaSpendRestriction::SpellWithKeywordKind(KeywordKind::Freerunning), ManaSpendRestriction::SpellTypeOrAbilityActivation { spell_type: "Assassin".to_string(), ability: AbilityActivationScope::OfSpellType, }, - ]) + ])] ); } @@ -15417,7 +15447,9 @@ fn mana_spend_restriction_type_union_stays_single_clause() { .expect("type union should parse as a single SpellType"); assert_eq!( restriction, - ManaSpendRestriction::SpellType("Instant or Sorcery".to_string()) + vec![ManaSpendRestriction::SpellType( + "Instant or Sorcery".to_string() + )] ); } @@ -15430,7 +15462,7 @@ fn mana_spend_restriction_chosen_type_cant_be_countered() { "spend this mana only to cast a creature spell of the chosen type, and that spell can't be countered", ); let (restriction, grants) = result.expect("should parse"); - assert_eq!(restriction, ManaSpendRestriction::ChosenCreatureType); + assert_eq!(restriction, vec![ManaSpendRestriction::ChosenCreatureType]); assert_eq!(grants, vec![ManaSpellGrant::CantBeCountered]); } @@ -15445,7 +15477,7 @@ fn mana_spend_restriction_legendary_cant_be_countered() { let (restriction, grants) = result.expect("should parse"); assert_eq!( restriction, - ManaSpendRestriction::SpellType("Legendary".to_string()) + vec![ManaSpendRestriction::SpellType("Legendary".to_string())] ); assert_eq!(grants, vec![ManaSpellGrant::CantBeCountered]); } @@ -15461,10 +15493,10 @@ fn mana_spend_restriction_activation_first_disjunction() { .expect("activation-first disjunction should parse"); assert_eq!( restriction, - ManaSpendRestriction::Any(vec![ + vec![ManaSpendRestriction::Any(vec![ ManaSpendRestriction::ActivateOnly, ManaSpendRestriction::SpellType("Artifact".to_string()), - ]) + ])] ); assert!(grants.is_empty()); } @@ -22709,3 +22741,59 @@ fn bbfu10_ledger_variant_reaches_filter_prop_scan() { "negative control: a prop-free ledger filter must still read false", ); } + +/// CR 105.2a + CR 106.6 + CR 602.2b: Throne of Eldraine owns both a +/// source-chosen-color mana-production restriction and a differently-scoped +/// activated-ability payment rider. Both must lower as typed data with no +/// residual `Unimplemented` node. +#[test] +fn throne_of_eldraine_parses_all_chosen_color_mana_riders() { + use crate::types::ability::{ + ActivationManaPaymentRestriction, Comparator, ManaSpendRestriction, + }; + + let parsed = parse_oracle_text( + "As Throne of Eldraine enters, choose a color.\n{T}: Add four mana of the chosen color. Spend this mana only to cast monocolored spells of that color.\n{3}, {T}: Draw two cards. Spend only mana of the chosen color to activate this ability.", + "Throne of Eldraine", + &[], + &["Artifact".to_string()], + &[], + ); + assert!( + parsed + .abilities + .iter() + .all(|ability| !super::has_unimplemented(ability)), + "all three lines must be typed: {:#?}", + parsed.abilities + ); + + let mana = parsed + .abilities + .iter() + .find(|ability| matches!(ability.effect.as_ref(), Effect::Mana { .. })) + .expect("mana ability"); + let Effect::Mana { restrictions, .. } = mana.effect.as_ref() else { + unreachable!(); + }; + assert_eq!( + restrictions, + &vec![ + ManaSpendRestriction::SpellWithColorCount { + comparator: Comparator::EQ, + count: 1, + }, + ManaSpendRestriction::SpellOfSourceChosenColor, + ], + ); + + let draw = parsed + .abilities + .iter() + .find(|ability| matches!(ability.effect.as_ref(), Effect::Draw { .. })) + .expect("draw ability"); + assert_eq!( + draw.activation_mana_payment_restriction, + Some(ActivationManaPaymentRestriction::OnlySourceChosenColor), + ); +} diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 98b5edbb4a..bed0cbd8fd 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -2350,6 +2350,10 @@ pub enum ManaSpendRestriction { /// colors" (also "N or more / N or fewer"; colorless = 0). Parameterized over /// [`Comparator`] — one variant per color-count reading. `count` is N. SpellWithColorCount { comparator: Comparator, count: u32 }, + /// CR 105.2 + CR 106.6: "Spend this mana only to cast spells of the + /// source's chosen color." Resolved when the mana is produced; a missing + /// choice lowers to `ManaRestriction::Impossible`, never to no restriction. + SpellOfSourceChosenColor, /// CR 106.6 + CR 400.7: "Spend this mana only to cast spells from your /// graveyard" / "from exile" ([`From`](super::mana::ZoneSpendPolarity::From)) /// and "from anywhere other than your hand" @@ -2455,6 +2459,7 @@ impl ManaSpendRestriction { | ManaSpendRestriction::SpellWithManaValue { .. } | ManaSpendRestriction::SpellMatchingCostCriteria { .. } | ManaSpendRestriction::SpellWithColorCount { .. } + | ManaSpendRestriction::SpellOfSourceChosenColor | ManaSpendRestriction::SpellFromZone(_) | ManaSpendRestriction::UnlockDoor => true, // CR 106.6: coverage for a disjunction requires every named branch to @@ -17058,6 +17063,17 @@ pub enum ActivationRestriction { MatchesCardCastTiming, } +/// CR 106.6: A restriction on which actual mana colors may pay this activated +/// ability's mana cost. Kept separate from `ActivationRestriction`: timing and +/// frequency gates determine whether an ability may be activated, while this +/// gate determines whether its announced cost can be paid. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ActivationManaPaymentRestriction { + /// "Spend only mana of the chosen color to activate this ability." The + /// source's live chosen color is resolved at payment time. + OnlySourceChosenColor, +} + /// Structured spell-casting restrictions parsed from Oracle text. /// These describe when — and, for `CantSpendMana`, how — a spell may be cast. /// Runtime enforcement can be added independently of parsing/export support. @@ -17178,6 +17194,9 @@ pub struct AbilityDefinition { /// single authority for sorcery-speed timing. The legacy `sorcery_speed` /// JSON field is migrated into this `Vec` by the hand-written `Deserialize`. pub activation_restrictions: Vec, + /// CR 106.6: Mana-color payment riders attached to this activated ability. + /// `None` has the legacy unrestricted meaning. + pub activation_mana_payment_restriction: Option, /// CR 602.2a: Who may begin to activate this ability. `None` = only the /// permanent's controller. `Some(All)` = any player. `Some(Opponent)` = /// only opponents of the permanent's controller. @@ -17327,6 +17346,8 @@ struct AbilityDefinitionRepr<'a> { #[serde(skip_serializing_if = "Vec::is_empty")] activation_restrictions: &'a Vec, #[serde(skip_serializing_if = "Option::is_none")] + activation_mana_payment_restriction: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] activator_filter: &'a Option, #[serde(skip_serializing_if = "Option::is_none")] activation_zone: &'a Option, @@ -17395,6 +17416,7 @@ impl Serialize for AbilityDefinition { description, target_prompt, activation_restrictions, + activation_mana_payment_restriction, activator_filter, activation_zone, ability_tag, @@ -17435,6 +17457,7 @@ impl Serialize for AbilityDefinition { description, target_prompt, activation_restrictions, + activation_mana_payment_restriction, activator_filter, activation_zone, ability_tag, @@ -17518,6 +17541,8 @@ struct AbilityDefinitionDe { #[serde(default)] activation_restrictions: Vec, #[serde(default)] + activation_mana_payment_restriction: Option, + #[serde(default)] activator_filter: Option, #[serde(default)] activation_zone: Option, @@ -17595,6 +17620,7 @@ impl<'de> Deserialize<'de> for AbilityDefinition { description: de.description, target_prompt: de.target_prompt, activation_restrictions, + activation_mana_payment_restriction: de.activation_mana_payment_restriction, activator_filter: de.activator_filter, activation_zone: de.activation_zone, ability_tag: de.ability_tag, @@ -17790,6 +17816,7 @@ impl AbilityDefinition { description: None, target_prompt: None, activation_restrictions: Vec::new(), + activation_mana_payment_restriction: None, activator_filter: None, activation_zone: None, ability_tag: None, @@ -17956,6 +17983,14 @@ impl AbilityDefinition { self } + pub fn activation_mana_payment_restriction( + mut self, + restriction: ActivationManaPaymentRestriction, + ) -> Self { + self.activation_mana_payment_restriction = Some(restriction); + self + } + pub fn condition(mut self, condition: AbilityCondition) -> Self { self.condition = Some(condition); self diff --git a/crates/engine/src/types/mana.rs b/crates/engine/src/types/mana.rs index 94a4d86a55..20165d5f3a 100644 --- a/crates/engine/src/types/mana.rs +++ b/crates/engine/src/types/mana.rs @@ -253,6 +253,11 @@ pub struct SpellMeta { /// color-count spend restrictions (`OnlyForSpellWithColorCount`). `None` at /// payment sites with no associated spell. pub color_count: Option, + /// CR 105.2: The spell's exact colors, consulted by color-specific spend + /// restrictions. This is distinct from `color_count`: a monocolored spell + /// must also have the particular required color to satisfy a rider such as + /// "monocolored spells of that color." + pub colors: Vec, /// CR 107.3 + CR 202.3e: Whether the spell's printed mana cost contains an /// `{X}` symbol. Consulted by the "with {X} in their mana costs" disjunct of /// MV/X spend restrictions (`OnlyForSpellMatchingCostCriteria`). `false` at @@ -309,6 +314,11 @@ pub enum PaymentContext<'a> { source_types: &'a [String], source_subtypes: &'a [String], ability_tag: Option, + /// CR 106.6: An ability's own activation-cost rider can constrain the + /// actual mana type paid, independently of what a unit's spend + /// restriction allows. This is checked before unit restrictions so an + /// as-though payment permission cannot bypass it. + mana_color_constraint: ActivationManaColorConstraint, }, /// Payment for a cost during spell or ability resolution. Current /// restriction variants name spell-casting or ability-activation use, so @@ -325,6 +335,50 @@ pub enum PaymentContext<'a> { SpecialAction(SpecialAction), } +impl PaymentContext<'_> { + /// CR 106.6: Whether a mana unit's actual type may be used in this payment + /// context before its own spend restrictions are considered. Activation + /// riders constrain the physical mana paid, so this gate applies equally to + /// unrestricted and restricted units. + pub fn permits_actual_mana_type(&self, mana_type: ManaType) -> bool { + match self { + Self::Activation { + mana_color_constraint, + .. + } => mana_color_constraint.permits(mana_type), + Self::Spell(_) | Self::Effect | Self::SpecialAction(_) => true, + } + } +} + +/// CR 106.6: A color constraint imposed by the activated ability whose cost is +/// being paid (for example, "Spend only mana of the chosen color to activate +/// this ability"). This is an AND gate on the actual `ManaUnit::color`, not a +/// conversion of the mana's type and not an as-though permission. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ActivationManaColorConstraint { + /// The ability has no color-specific activation-payment rider. + #[default] + Unrestricted, + /// Every mana unit spent for this activation must have this actual color. + Only(ManaColor), + /// The rider refers to a source choice that is unavailable. Fail closed: + /// no mana unit may pay the activation cost. + Impossible, +} + +impl ActivationManaColorConstraint { + /// CR 106.6: Whether this exact mana unit type may pay the activation + /// before the unit's own spend restrictions are considered. + pub fn permits(self, mana_type: ManaType) -> bool { + match self { + Self::Unrestricted => true, + Self::Only(color) => mana_type == ManaType::from(color), + Self::Impossible => false, + } + } +} + /// CR 116.2: A class of special action whose mana cost can be the subject of a /// CR 106.6 mana-spend restriction ("Spend this mana only to unlock doors"). /// @@ -534,6 +588,10 @@ pub enum ManaRestriction { /// colors" (also "N or more / N or fewer"). `comparator` applies /// `spell_color_count count`. Colorless spells have color_count 0. OnlyForSpellWithColorCount { comparator: Comparator, count: u32 }, + /// CR 105.2 + CR 106.6: "Spend this mana only to cast spells of [color]." + /// Usually composed with `OnlyForSpellWithColorCount { EQ, 1 }` for the + /// stricter "monocolored spells of that color" reading. + OnlyForSpellColor(ManaColor), /// CR 106.6 + CR 400.7: "Spend this mana only to cast spells from your /// graveyard" / "from exile" ([`ZoneSpendPolarity::From`]) and "from anywhere /// other than your hand" ([`ZoneSpendPolarity::NotFrom`], Mm'menon, the Right @@ -580,6 +638,10 @@ pub enum ManaRestriction { /// Parameterized over the action class so one variant covers every /// restrictable special action (door unlock today; extensible). OnlyForSpecialAction(SpecialAction), + /// A source-dependent template could not resolve a required choice. This + /// remains attached to the produced mana rather than being dropped so the + /// resulting unit is unspendable instead of accidentally unrestricted. + Impossible, /// CR 702.51a: Internal marker for a convoke tap that substitutes for /// paying mana. The payment algorithm may consume it for the current spell, /// but cast-spent metrics and mana-added triggers must ignore it. @@ -730,11 +792,13 @@ fn cmp_mana_restriction(left: &ManaRestriction, right: &ManaRestriction) -> std: ManaRestriction::OnlyForSpellWithManaValue { .. } => 9, ManaRestriction::OnlyForSpellMatchingCostCriteria { .. } => 10, ManaRestriction::OnlyForSpellWithColorCount { .. } => 11, - ManaRestriction::OnlyForSpellFromZone(_) => 12, - ManaRestriction::OnlyForFaceDownSpell => 13, - ManaRestriction::OnlyForAny(_) => 14, - ManaRestriction::OnlyForSpecialAction(_) => 15, - ManaRestriction::ConvokePayment => 16, + ManaRestriction::OnlyForSpellColor(_) => 12, + ManaRestriction::OnlyForSpellFromZone(_) => 13, + ManaRestriction::OnlyForFaceDownSpell => 14, + ManaRestriction::OnlyForAny(_) => 15, + ManaRestriction::OnlyForSpecialAction(_) => 16, + ManaRestriction::Impossible => 17, + ManaRestriction::ConvokePayment => 18, } } rank(left).cmp(&rank(right)).then_with(|| match (left, right) { @@ -803,6 +867,9 @@ fn cmp_mana_restriction(left: &ManaRestriction, right: &ManaRestriction) -> std: ) => comparator_rank(*a_cmp) .cmp(&comparator_rank(*b_cmp)) .then_with(|| a_count.cmp(b_count)), + (ManaRestriction::OnlyForSpellColor(a), ManaRestriction::OnlyForSpellColor(b)) => { + a.cmp(b) + } ( ManaRestriction::OnlyForSpellFromZone(a), ManaRestriction::OnlyForSpellFromZone(b), @@ -1007,6 +1074,9 @@ impl ManaRestriction { ManaRestriction::OnlyForSpellWithColorCount { comparator, count } => meta .color_count .is_some_and(|cc| comparator.evaluate(cc as i32, *count as i32)), + ManaRestriction::OnlyForSpellColor(required_color) => { + meta.colors.contains(required_color) + } // CR 106.6 + CR 400.7: zone-gated spend. `From` requires the spell be // cast from the named zone; `NotFrom` requires it be cast from any // zone *except* the named one (e.g. "from anywhere other than your @@ -1039,6 +1109,7 @@ impl ManaRestriction { ManaRestriction::OnlyForAny(subs) => subs.iter().any(|r| r.allows_spell(meta)), // CR 116.2: Special-action-only mana never pays for a spell cast. ManaRestriction::OnlyForSpecialAction(_) => false, + ManaRestriction::Impossible => false, ManaRestriction::ConvokePayment => true, } } @@ -1063,11 +1134,13 @@ impl ManaRestriction { | ManaRestriction::OnlyForSpellWithManaValue { .. } | ManaRestriction::OnlyForSpellMatchingCostCriteria { .. } | ManaRestriction::OnlyForSpellWithColorCount { .. } + | ManaRestriction::OnlyForSpellColor(_) | ManaRestriction::OnlyForSpellFromZone(_) // CR 708.4: Face-down-spell-only mana never pays for ability activation. | ManaRestriction::OnlyForFaceDownSpell // CR 116.2: Special-action-only mana never pays for ability activation. - | ManaRestriction::OnlyForSpecialAction(_) => false, + | ManaRestriction::OnlyForSpecialAction(_) + | ManaRestriction::Impossible => false, // CR 106.6: The ability-activation half of the OR. `OfSpellType` // restricts to abilities of permanents whose type matches the // restriction ("Elemental sources" includes creature type Elemental — @@ -1110,6 +1183,7 @@ impl ManaRestriction { source_types, source_subtypes, ability_tag, + mana_color_constraint: _, } => self.allows_activation(source_types, source_subtypes, *ability_tag), PaymentContext::Effect => false, // CR 116.2: A special-action payment is permitted only by mana that @@ -2047,16 +2121,15 @@ impl ManaPool { /// never spent. pub fn spend_for(&mut self, color: ManaType, ctx: &PaymentContext<'_>) -> Option { // First pass: prefer unrestricted mana of this color - if let Some(pos) = self - .mana - .iter() - .position(|m| m.color == color && m.restrictions.is_empty()) - { + if let Some(pos) = self.mana.iter().position(|m| { + m.color == color && ctx.permits_actual_mana_type(m.color) && m.restrictions.is_empty() + }) { return Some(self.mana.swap_remove(pos)); } // Second pass: restricted mana that allows this payment context if let Some(pos) = self.mana.iter().position(|m| { m.color == color + && ctx.permits_actual_mana_type(m.color) && !m.restrictions.is_empty() && m.restrictions.iter().all(|r| r.allows(ctx)) }) { @@ -2362,6 +2435,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2373,6 +2447,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2384,6 +2459,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2411,6 +2487,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2422,6 +2499,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2433,6 +2511,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2482,6 +2561,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2493,6 +2573,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2504,6 +2585,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2523,6 +2605,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2535,6 +2618,7 @@ mod tests { source_types: &source_types, source_subtypes: &source_subtypes, ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Unrestricted, })); assert!(!restriction.allows(&PaymentContext::Effect)); } @@ -2549,6 +2633,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2560,6 +2645,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2571,6 +2657,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2607,6 +2694,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2635,6 +2723,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2697,6 +2786,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2723,6 +2813,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2734,6 +2825,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2745,6 +2837,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2756,6 +2849,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2781,6 +2875,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2792,6 +2887,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2803,6 +2899,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2858,6 +2955,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2869,6 +2967,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2909,6 +3008,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2920,6 +3020,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2931,6 +3032,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2942,6 +3044,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2958,11 +3061,13 @@ mod tests { source_types: &["Creature".to_string()], source_subtypes: &["Human".to_string()], ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Unrestricted, })); assert!(restriction.allows(&PaymentContext::Activation { source_types: &["Land".to_string()], source_subtypes: &[], ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Unrestricted, })); } @@ -2979,6 +3084,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -2990,6 +3096,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3004,11 +3111,13 @@ mod tests { source_types: &artifact_types, source_subtypes: &no_subtypes, ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Unrestricted, })); assert!(!restriction.allows(&PaymentContext::Activation { source_types: &creature_types, source_subtypes: &no_subtypes, ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Unrestricted, })); assert!(!restriction.allows(&PaymentContext::Effect)); } @@ -3042,6 +3151,7 @@ mod tests { source_types: &["Creature".to_string()], source_subtypes: &[], ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Unrestricted, })); assert!(!restriction.allows(&PaymentContext::Effect)); assert!(!restriction.allows(&PaymentContext::SpecialAction(SpecialAction::UnlockDoor))); @@ -3070,6 +3180,7 @@ mod tests { source_types: &["Creature".to_string()], source_subtypes: &[], ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Unrestricted, })); assert!(!restriction.allows(&PaymentContext::Effect)); } @@ -3102,6 +3213,73 @@ mod tests { assert!(!restriction.allows(&PaymentContext::Spell(&creature))); } + #[test] + fn source_chosen_color_mana_restriction_uses_live_spell_colors() { + let red_only = ManaRestriction::OnlyForSpellColor(ManaColor::Red); + let red = SpellMeta { + colors: vec![ManaColor::Red], + ..SpellMeta::default() + }; + let blue = SpellMeta { + colors: vec![ManaColor::Blue], + ..SpellMeta::default() + }; + let multicolor = SpellMeta { + colors: vec![ManaColor::Red, ManaColor::Blue], + ..SpellMeta::default() + }; + let colorless = SpellMeta::default(); + assert!(red_only.allows(&PaymentContext::Spell(&red))); + assert!(red_only.allows(&PaymentContext::Spell(&multicolor))); + assert!(!red_only.allows(&PaymentContext::Spell(&blue))); + assert!(!red_only.allows(&PaymentContext::Spell(&colorless))); + assert!(!ManaRestriction::Impossible.allows(&PaymentContext::Spell(&red))); + } + + #[test] + fn activation_color_constraint_rejects_wrong_actual_mana_color() { + assert!(ActivationManaColorConstraint::Only(ManaColor::Red).permits(ManaType::Red)); + assert!(!ActivationManaColorConstraint::Only(ManaColor::Red).permits(ManaType::Blue)); + assert!(!ActivationManaColorConstraint::Only(ManaColor::Red).permits(ManaType::Colorless)); + assert!(!ActivationManaColorConstraint::Impossible.permits(ManaType::Red)); + } + + #[test] + fn spend_for_enforces_activation_color_constraint_for_both_passes() { + let source_types = vec!["Artifact".to_string()]; + let source_subtypes = Vec::new(); + let context = PaymentContext::Activation { + source_types: &source_types, + source_subtypes: &source_subtypes, + ability_tag: None, + mana_color_constraint: ActivationManaColorConstraint::Only(ManaColor::Red), + }; + let mut pool = ManaPool { + mana: vec![ + // First pass would take this unrestricted blue unit without + // checking the activation's actual-color rider. + make_unit(ManaType::Blue), + // Second pass must reject it too, even though its restriction + // otherwise permits any activation. + make_restricted_unit( + ManaType::Blue, + ObjectId(2), + vec![ManaRestriction::OnlyForActivation], + ), + make_unit(ManaType::Red), + ], + }; + + assert!(pool.spend_for(ManaType::Blue, &context).is_none()); + assert_eq!(pool.total(), 3); + assert_eq!( + pool.spend_for(ManaType::Red, &context) + .expect("matching actual mana color should remain spendable") + .color, + ManaType::Red + ); + } + // CR 106.6 + CR 601.2g: "Spend this mana only to cast instant and sorcery // spells" (Tablet of Discovery, issue #1975) names a union of two distinct // spell types. Per the Melek, Izzet Paragon example (CR 601.3e), an "instant @@ -3122,6 +3300,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3133,6 +3312,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3144,6 +3324,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3187,6 +3368,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3198,6 +3380,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3217,6 +3400,7 @@ mod tests { let mv_six = SpellMeta { mana_value: Some(6), color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3225,6 +3409,7 @@ mod tests { let mv_four = SpellMeta { mana_value: Some(4), color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3248,6 +3433,7 @@ mod tests { let mv_two = SpellMeta { mana_value: Some(2), color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3256,6 +3442,7 @@ mod tests { let mv_four = SpellMeta { mana_value: Some(4), color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3280,6 +3467,7 @@ mod tests { let mv_four = SpellMeta { mana_value: Some(4), color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3293,6 +3481,7 @@ mod tests { let mv_five = SpellMeta { mana_value: Some(5), color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3328,6 +3517,7 @@ mod tests { }; let three_colors = SpellMeta { color_count: Some(3), + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3335,6 +3525,7 @@ mod tests { }; let two_colors = SpellMeta { color_count: Some(2), + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3359,6 +3550,7 @@ mod tests { }; let colorless = SpellMeta { color_count: Some(0), + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3366,6 +3558,7 @@ mod tests { }; let one_color = SpellMeta { color_count: Some(1), + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3389,6 +3582,7 @@ mod tests { }; let three_colors = SpellMeta { color_count: Some(3), + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3396,6 +3590,7 @@ mod tests { }; let one_color = SpellMeta { color_count: Some(1), + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3421,6 +3616,7 @@ mod tests { let one_color = SpellMeta { color_count: Some(1), + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3433,6 +3629,7 @@ mod tests { let two_colors = SpellMeta { color_count: Some(2), + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3714,6 +3911,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, @@ -3727,6 +3925,7 @@ mod tests { cast_from_zone: None, mana_value: None, color_count: None, + colors: vec![], has_x_in_cost: false, is_face_down: false, cant_spend_mana: false, diff --git a/crates/engine/tests/integration/companion_special_action.rs b/crates/engine/tests/integration/companion_special_action.rs index de4d79b031..7b91bd1059 100644 --- a/crates/engine/tests/integration/companion_special_action.rs +++ b/crates/engine/tests/integration/companion_special_action.rs @@ -171,6 +171,7 @@ fn companion_restricted_mana_routes_only_to_the_matching_special_action() { source_types: &[], source_subtypes: &[], ability_tag: None, + mana_color_constraint: engine::types::mana::ActivationManaColorConstraint::Unrestricted, })); assert!(!restriction.allows(&PaymentContext::Effect)); } diff --git a/crates/engine/tests/integration/issue_2862_teferi_loyalty.rs b/crates/engine/tests/integration/issue_2862_teferi_loyalty.rs index 6877f69ed7..0a58892866 100644 --- a/crates/engine/tests/integration/issue_2862_teferi_loyalty.rs +++ b/crates/engine/tests/integration/issue_2862_teferi_loyalty.rs @@ -89,7 +89,7 @@ fn issue_2862_teferi_cast_minus_three_pays_loyalty_cost() { P0, teferi, &AbilityCost::Loyalty { amount: -3 }, - None, + 0, &mut events, ) .expect("pay [-3] loyalty cost through activation payment seam"); diff --git a/crates/engine/tests/integration/issue_4220_agatha_soul_cauldron.rs b/crates/engine/tests/integration/issue_4220_agatha_soul_cauldron.rs index 43569112ad..ed4d6471db 100644 --- a/crates/engine/tests/integration/issue_4220_agatha_soul_cauldron.rs +++ b/crates/engine/tests/integration/issue_4220_agatha_soul_cauldron.rs @@ -225,7 +225,7 @@ fn agatha_granted_bb_ability_affordable_via_green_auto_tap_sources() { generic: 0, }; assert!( - can_pay_ability_mana_cost_after_auto_tap(&state, P0, host, &bb_cost), + can_pay_ability_mana_cost_after_auto_tap(&state, P0, host, ability_index, &bb_cost), "auto-tap planner must treat green sources as paying {{B}}{{B}} under Agatha" ); assert!( diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index b72950ab88..91583611b0 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -861,6 +861,7 @@ mod the_kingpin_of_crime_combat_damage; mod the_ur_dragon_eminence; mod the_who_opponent_guess_resolution; mod thoughtweft_trample_regression; +mod throne_of_eldraine_mana_riders; mod throw_instead_tail_class; mod timely_ward_regression; mod tinybones_joins_up_multi_target; diff --git a/crates/engine/tests/integration/restricted_mana_face_down_and_face_up.rs b/crates/engine/tests/integration/restricted_mana_face_down_and_face_up.rs index 6b22700683..f05f0d3b27 100644 --- a/crates/engine/tests/integration/restricted_mana_face_down_and_face_up.rs +++ b/crates/engine/tests/integration/restricted_mana_face_down_and_face_up.rs @@ -120,6 +120,8 @@ fn face_down_spell_mana_rejects_every_production_context() { source_types: &["Creature".to_string()], source_subtypes: &[], ability_tag: None, + mana_color_constraint: + engine::types::mana::ActivationManaColorConstraint::Unrestricted, } ) .is_none(), diff --git a/crates/engine/tests/integration/restricted_mana_mv_or_x.rs b/crates/engine/tests/integration/restricted_mana_mv_or_x.rs index d709c8cd87..1483c5c389 100644 --- a/crates/engine/tests/integration/restricted_mana_mv_or_x.rs +++ b/crates/engine/tests/integration/restricted_mana_mv_or_x.rs @@ -37,6 +37,7 @@ fn spell_meta(types: &[&str], cost: &ManaCost) -> SpellMeta { cast_from_zone: None, mana_value: Some(cost.mana_value()), color_count: None, + colors: Vec::new(), has_x_in_cost: cost.has_x(), is_face_down: false, cant_spend_mana: false, diff --git a/crates/engine/tests/integration/restricted_mana_x_cost_only.rs b/crates/engine/tests/integration/restricted_mana_x_cost_only.rs index af86f0036d..5d7582b4af 100644 --- a/crates/engine/tests/integration/restricted_mana_x_cost_only.rs +++ b/crates/engine/tests/integration/restricted_mana_x_cost_only.rs @@ -37,6 +37,7 @@ fn spell_meta(types: &[&str], cost: &ManaCost) -> SpellMeta { cast_from_zone: None, mana_value: Some(cost.mana_value()), color_count: None, + colors: Vec::new(), has_x_in_cost: cost.has_x(), is_face_down: false, cant_spend_mana: false, diff --git a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs new file mode 100644 index 0000000000..dd830a4484 --- /dev/null +++ b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs @@ -0,0 +1,226 @@ +//! Throne of Eldraine's source-chosen-color mana riders exercise the complete +//! production path: Oracle parsing, mana production, cast payment, activated +//! ability payment, and the manual pool-pin/resume interface. + +use engine::game::casting::activated_ability_definitions; +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::ability::{ChosenAttribute, Effect, ResolvedAbility}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, GameState, PendingCast, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaColor, ManaCost, ManaPipId, ManaType, ManaUnit}; +use engine::types::phase::Phase; + +const THRONE_OF_ELDRAINE: &str = "As Throne of Eldraine enters, choose a color.\n\ +{T}: Add four mana of the chosen color. Spend this mana only to cast monocolored spells of that color.\n\ +{3}, {T}: Draw two cards. Spend only mana of the chosen color to activate this ability."; + +fn add_throne(scenario: &mut GameScenario) -> ObjectId { + scenario + .add_creature_from_oracle(P0, "Throne of Eldraine", 0, 1, THRONE_OF_ELDRAINE) + .as_artifact() + .id() +} + +fn choose_red(state: &mut GameState, throne: ObjectId) { + state + .objects + .get_mut(&throne) + .expect("Throne must be on the battlefield") + .chosen_attributes + .push(ChosenAttribute::Color(ManaColor::Red)); +} + +fn throne_ability_indices(state: &GameState, throne: ObjectId) -> (usize, usize) { + let abilities = activated_ability_definitions(state, throne); + let mana = abilities + .iter() + .find(|(_, ability)| matches!(ability.effect.as_ref(), Effect::Mana { .. })) + .map(|(index, _)| *index) + .expect("Throne must have its mana ability"); + let draw = abilities + .iter() + .find(|(_, ability)| matches!(ability.effect.as_ref(), Effect::Draw { .. })) + .map(|(index, _)| *index) + .expect("Throne must have its draw ability"); + (mana, draw) +} + +fn draw_payment_state(mana: &[ManaType]) -> (GameState, ObjectId, usize) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let throne = add_throne(&mut scenario); + let mut state = scenario.build().state().clone(); + choose_red(&mut state, throne); + let (_, draw) = throne_ability_indices(&state, throne); + for (index, color) in mana.iter().copied().enumerate() { + state.add_mana_to_pool( + P0, + ManaUnit::new(color, ObjectId(10_000 + index as u64), false, Vec::new()), + ); + } + (state, throne, draw) +} + +/// The produced units carry the chosen color of the actual producing Throne, +/// and that restriction is consulted by the normal cast action. Each spell has +/// a generic cost so colored-cost matching cannot hide a spend-restriction bug. +#[test] +fn throne_mana_casts_only_monocolored_spells_of_its_chosen_color() { + for (label, colors, allowed) in [ + ("red", vec![ManaColor::Red], true), + ("blue", vec![ManaColor::Blue], false), + ("multicolored", vec![ManaColor::Red, ManaColor::Blue], false), + ("colorless", vec![], false), + ] { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let throne = add_throne(&mut scenario); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, &format!("{label} test spell"), true, "") + .with_mana_cost(ManaCost::generic(1)) + .id(); + let mut runner = scenario.build(); + choose_red(runner.state_mut(), throne); + { + let obj = runner + .state_mut() + .objects + .get_mut(&spell) + .expect("test spell must be in hand"); + obj.color = colors.clone(); + obj.base_color = colors; + } + + let (mana, _) = throne_ability_indices(runner.state(), throne); + let waiting = runner + .act(GameAction::ActivateAbility { + source_id: throne, + ability_index: mana, + }) + .expect("Throne mana ability must resolve") + .waiting_for; + assert!(matches!(waiting, WaitingFor::Priority { .. })); + let pool = &runner.state().players[P0.0 as usize].mana_pool.mana; + assert_eq!(pool.len(), 4, "Throne must produce four mana"); + assert!( + pool.iter() + .all(|unit| unit.source_id == throne && unit.color == ManaType::Red), + "every unit must retain the producing Throne and its chosen red color: {pool:?}" + ); + + let card_id = runner.state().objects[&spell].card_id; + let result = runner.act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }); + assert_eq!( + result.is_ok(), + allowed, + "a {label} spell must {} be cast with mana from the red-chosen Throne: {result:?}", + if allowed { "" } else { "not" } + ); + } +} + +/// The draw rider constrains the actual mana units used for activation. This +/// covers automatic payment, rejection of blue/mixed pools, and the manual +/// pin/resume route used by the interactive client. +#[test] +fn throne_draw_activation_uses_only_its_chosen_color_in_auto_and_manual_payment() { + let (red_state, red_throne, draw) = + draw_payment_state(&[ManaType::Red, ManaType::Red, ManaType::Red]); + let mut red_runner = GameRunner::from_state(red_state); + let red_waiting = red_runner + .act(GameAction::ActivateAbility { + source_id: red_throne, + ability_index: draw, + }) + .expect("three red mana must pay the red-chosen Throne draw ability") + .waiting_for; + assert!(matches!(red_waiting, WaitingFor::Priority { .. })); + assert!(red_runner.state().objects[&red_throne].tapped); + assert_eq!( + red_runner.state().players[P0.0 as usize].mana_pool.total(), + 0 + ); + + for (label, mana) in [ + ("blue", vec![ManaType::Blue, ManaType::Blue, ManaType::Blue]), + ("mixed", vec![ManaType::Red, ManaType::Red, ManaType::Blue]), + ] { + let (state, throne, draw) = draw_payment_state(&mana); + let mut runner = GameRunner::from_state(state); + assert!( + runner + .act(GameAction::ActivateAbility { + source_id: throne, + ability_index: draw, + }) + .is_err(), + "a {label} pool must not pay a red-chosen Throne draw activation" + ); + assert!(!runner.state().objects[&throne].tapped); + } + + let (mut state, throne, draw) = + draw_payment_state(&[ManaType::Red, ManaType::Red, ManaType::Red, ManaType::Blue]); + let blue_pip = state.players[P0.0 as usize] + .mana_pool + .mana + .iter() + .find(|unit| unit.color == ManaType::Blue) + .expect("manual pool has a blue unit") + .pip_id; + let red_pip = state.players[P0.0 as usize] + .mana_pool + .mana + .iter() + .find(|unit| unit.color == ManaType::Red) + .expect("manual pool has a red unit") + .pip_id; + assert_ne!( + blue_pip, + ManaPipId(0), + "seeded pool units must have stable ids" + ); + + let draw_ability = activated_ability_definitions(&state, throne) + .into_iter() + .find(|(index, _)| *index == draw) + .map(|(_, ability)| ability) + .expect("Throne draw ability definition"); + let mut pending = PendingCast::new( + throne, + CardId(0xED), + ResolvedAbility::new((*draw_ability.effect).clone(), Vec::new(), throne, P0), + ManaCost::generic(3), + ); + pending.activation_ability_index = Some(draw); + state.pending_cast = Some(Box::new(pending)); + state.objects.get_mut(&throne).unwrap().tapped = true; + state.waiting_for = WaitingFor::ManaPayment { + player: P0, + convoke_mode: None, + }; + assert!(matches!(state.waiting_for, WaitingFor::ManaPayment { .. })); + let mut runner = GameRunner::from_state(state); + assert!( + runner + .act(GameAction::SpendPoolMana { pip_id: blue_pip }) + .is_err(), + "manual payment must reject pinning blue mana for the red-chosen activation" + ); + runner + .act(GameAction::SpendPoolMana { pip_id: red_pip }) + .expect("manual payment must accept a red mana pin"); + runner + .act(GameAction::PassPriority) + .expect("pending activation must finalize from the eligible red mana"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); +} diff --git a/crates/phase-ai/src/policies/land_animation.rs b/crates/phase-ai/src/policies/land_animation.rs index f5df443391..67ed9e35bc 100644 --- a/crates/phase-ai/src/policies/land_animation.rs +++ b/crates/phase-ai/src/policies/land_animation.rs @@ -109,7 +109,7 @@ impl TacticalPolicy for LandAnimationPolicy { // manland for mana to help pay its own {1}{W}{B} animation cost and // turns it into a useless tapped creature. Strongly disprefer any // activation that leaves the source tapped. - if animation_leaves_source_tapped(ctx, *source_id, obj, ability_def) { + if animation_leaves_source_tapped(ctx, *source_id, *ability_index, obj, ability_def) { // Route the critical penalty through the band helper (CR-equivalent // score contract) rather than a raw Score literal so the delta stays // clamped to the critical band before `activation` scaling. @@ -277,6 +277,7 @@ fn ability_taps_source(ability: &AbilityDefinition) -> bool { fn animation_leaves_source_tapped( ctx: &PolicyContext<'_>, source_id: ObjectId, + ability_index: usize, source: &game_object::GameObject, ability: &AbilityDefinition, ) -> bool { @@ -294,7 +295,7 @@ fn animation_leaves_source_tapped( return false; } - !can_pay_cost_excluding_source(ctx, source_id, cost) + !can_pay_cost_excluding_source(ctx, source_id, ability_index, cost) } /// True iff the AI can pay `cost` for `source_id`'s ability without tapping the @@ -303,6 +304,7 @@ fn animation_leaves_source_tapped( fn can_pay_cost_excluding_source( ctx: &PolicyContext<'_>, source_id: ObjectId, + ability_index: usize, cost: &ManaCost, ) -> bool { let excluded = HashSet::from([source_id]); @@ -310,6 +312,7 @@ fn can_pay_cost_excluding_source( ctx.state, ctx.ai_player, source_id, + ability_index, cost, &excluded, ) diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 4a15147a5f..4d1cb032ac 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -176,6 +176,7 @@ fn guard_mana_restrictions_payload( pending.extend(children); } ManaRestriction::OnlyForSpell + | ManaRestriction::OnlyForSpellColor(_) | ManaRestriction::OnlyForActivation | ManaRestriction::OnlyForTaggedActivation(_) | ManaRestriction::OnlyForXCosts @@ -192,6 +193,7 @@ fn guard_mana_restrictions_payload( | ManaRestriction::OnlyForSpellFromZone(_) | ManaRestriction::OnlyForFaceDownSpell | ManaRestriction::OnlyForSpecialAction(_) + | ManaRestriction::Impossible | ManaRestriction::ConvokePayment => {} } } From ca544fe3a9732716c905d4b7f5fcaaa3ff24439a Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:09:59 +0200 Subject: [PATCH 2/8] Fix Throne of Eldraine mana payment coverage --- crates/engine/src/ai_support/candidates.rs | 8 +- crates/engine/src/database/synthesis.rs | 44 ++++---- crates/engine/src/game/casting.rs | 41 +------ crates/engine/src/game/casting_costs.rs | 6 + crates/engine/src/game/effects/mana.rs | 2 +- crates/engine/src/game/keywords.rs | 18 +-- crates/engine/src/game/mana_sources.rs | 50 +++++++++ crates/engine/src/game/replacement.rs | 53 ++++----- crates/engine/src/types/mana.rs | 18 ++- .../throne_of_eldraine_mana_riders.rs | 105 ++++++++++++++++++ 10 files changed, 224 insertions(+), 121 deletions(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index f9b3e03b64..a18430943a 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -4041,13 +4041,7 @@ pub(crate) fn priority_actions_with_probe( state, player, *ninjutsu_object_id, - casting::activated_ability_definitions(state, *ninjutsu_object_id) - .into_iter() - .find_map(|(index, ability)| { - crate::game::keywords::is_ninjutsu_family_marker_ability(&ability) - .then_some(index) - }) - .unwrap_or(usize::MAX), + usize::MAX, cost, ); if !can_afford { diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 5024e795a1..4aa4d1b94c 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -718,35 +718,29 @@ pub fn synthesize_ninjutsu_family(face: &mut CardFace) { let abilities: Vec = face .keywords .iter() - .filter_map(ninjutsu_family_marker_ability_for_keyword) + .filter_map(|kw| { + let (variant, cost) = match kw { + Keyword::Ninjutsu(c) => (NinjutsuVariant::Ninjutsu, c), + Keyword::CommanderNinjutsu(c) => (NinjutsuVariant::CommanderNinjutsu, c), + _ => return None, + }; + Some( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::RuntimeHandled { + handler: RuntimeHandler::NinjutsuFamily, + }, + ) + .cost(AbilityCost::NinjutsuFamily { + variant, + mana_cost: cost.clone(), + }), + ) + }) .collect(); face.abilities.extend(abilities); } -/// CR 702.49: Build the marker activated ability for one Ninjutsu-family -/// keyword. The runtime ability gather uses this too, so a keyword provided by -/// a scenario or a continuous effect has the same activation identity as one -/// synthesized while card data is loaded. -pub fn ninjutsu_family_marker_ability_for_keyword(keyword: &Keyword) -> Option { - let (variant, cost) = match keyword { - Keyword::Ninjutsu(cost) => (NinjutsuVariant::Ninjutsu, cost), - Keyword::CommanderNinjutsu(cost) => (NinjutsuVariant::CommanderNinjutsu, cost), - _ => return None, - }; - Some( - AbilityDefinition::new( - AbilityKind::Activated, - Effect::RuntimeHandled { - handler: RuntimeHandler::NinjutsuFamily, - }, - ) - .cost(AbilityCost::NinjutsuFamily { - variant, - mana_cost: cost.clone(), - }), - ) -} - // Warp is handled at runtime via Keyword::Warp(ManaCost): // - `prepare_spell_cast` overrides the mana cost when cast from hand // - `stack.rs::resolve_top` creates a delayed exile trigger on resolution diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index a944ad35e1..5c3b1466e9 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -236,41 +236,6 @@ fn runtime_granted_top_of_library_plot_abilities( )] } -/// CR 702.49: Ninjutsu-family keywords function from Hand (and commander -/// ninjutsu from Command). Card loading normally synthesizes their marker -/// ability, but runtime keyword sets and scenario objects need the identical -/// effective definition so activation payment can resolve the exact index. -fn runtime_ninjutsu_family_marker_abilities( - state: &GameState, - source_id: ObjectId, -) -> Vec { - let Some(obj) = state.objects.get(&source_id) else { - return Vec::new(); - }; - if !matches!(obj.zone, Zone::Hand | Zone::Command) { - return Vec::new(); - } - - // The off-zone collector is authoritative for printed and granted - // characteristics. Include the object's current keyword set as well: test - // scenarios and runtime-only objects can deliberately provide a synthesized - // Ninjutsu keyword without mirroring it into `base_keywords`. - let mut keywords = - crate::game::off_zone_characteristics::effective_off_zone_keywords(state, source_id); - for keyword in &obj.keywords { - if !keywords.contains(keyword) { - keywords.push(keyword.clone()); - } - } - keywords - .into_iter() - .filter_map(|keyword| { - crate::database::synthesis::ninjutsu_family_marker_ability_for_keyword(&keyword) - }) - .filter(|candidate| !obj.abilities.iter().any(|printed| printed == candidate)) - .collect() -} - pub fn activated_ability_definitions( state: &GameState, source_id: ObjectId, @@ -294,9 +259,6 @@ pub fn activated_ability_definitions( .chain(runtime_granted_top_of_library_plot_abilities( state, source_id, )) - // CR 702.49: runtime/effective Ninjutsu markers must share this - // index space with the payment-context lookup below. - .chain(runtime_ninjutsu_family_marker_abilities(state, source_id)) // CR 702.6: statically granted equip (Bram, Bludgeon Brawl) chained // LAST — the identical append order is REQUIRED in // `activation_ability_definition` so `ability_index` stays consistent. @@ -320,7 +282,7 @@ fn activation_ability_definition( // Must match the append order in `activated_ability_definitions`: printed // abilities first, then runtime-granted cycling, then runtime-granted // graveyard activated (Encore/Scavenge), then runtime-granted - // plot-from-library (Fblthp), then Ninjutsu-family, then equip. + // plot-from-library (Fblthp), then equip. // Identical order is REQUIRED for `ability_index` consistency. runtime_granted_cycling_abilities(state, source_id) .into_iter() @@ -330,7 +292,6 @@ fn activation_ability_definition( .chain(runtime_granted_top_of_library_plot_abilities( state, source_id, )) - .chain(runtime_ninjutsu_family_marker_abilities(state, source_id)) .chain(runtime_granted_equip_abilities(state, source_id)) .nth(offset)? }; diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 1ebf47d964..1f7a38a73d 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -9482,6 +9482,12 @@ fn option_allowed_for_context( opt.restrictions .iter() .all(|restriction| restriction.allows(ctx)) + && opt + .atomic_combination + .as_deref() + .unwrap_or(std::slice::from_ref(&opt.mana_type)) + .iter() + .all(|mana_type| ctx.permits_actual_mana_type(*mana_type)) } /// Pick the source with the fewest alternative color options (LCV heuristic). diff --git a/crates/engine/src/game/effects/mana.rs b/crates/engine/src/game/effects/mana.rs index 4ba090c5e5..8ee60e4e15 100644 --- a/crates/engine/src/game/effects/mana.rs +++ b/crates/engine/src/game/effects/mana.rs @@ -413,7 +413,7 @@ pub(crate) fn resolve_restrictions( // legacy chosen-creature-type branch retains its historical drop. ManaSpendRestriction::Any(subs) => { let inner = resolve_restrictions(subs, state, source_id); - Some(ManaRestriction::OnlyForAny(inner)) + (!inner.is_empty()).then_some(ManaRestriction::OnlyForAny(inner)) } }) .collect() diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 62d948e518..6212b1dfb1 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -718,10 +718,6 @@ pub fn activate_ninjutsu( // CR 702.49a/d: Extract the activation cost (validated after all other checks, paid before mutations) let mana_cost = ninjutsu_family_cost(ninjutsu_obj).ok_or("Ninjutsu-family card has no mana cost")?; - let ability_index = super::casting::activated_ability_definitions(state, ninjutsu_obj_id) - .into_iter() - .find_map(|(index, ability)| is_ninjutsu_family_marker_ability(&ability).then_some(index)) - .ok_or("Ninjutsu-family card has no activated ability marker")?; // Validate timing if !ninjutsu_timing_ok(&state.phase, &variant) { @@ -785,7 +781,7 @@ pub fn activate_ninjutsu( &AbilityCost::Mana { cost: effective_cost, }, - ability_index, + usize::MAX, events, ) .map_err(|e| e.to_string())? @@ -1993,18 +1989,6 @@ mod tests { (state, attacker_id, ninja_id) } - #[test] - fn effective_abilities_include_runtime_ninjutsu_marker() { - let (state, _attacker_id, ninja_id) = setup_ninjutsu_scenario(); - - assert!( - crate::game::casting::activated_ability_definitions(&state, ninja_id) - .into_iter() - .any(|(_, ability)| is_ninjutsu_family_marker_ability(&ability)), - "a Ninjutsu keyword without a stored marker must still occupy the effective activation space" - ); - } - /// CR 702.49c + CR 616.1 discriminating test (fail-first): a ninja whose /// battlefield entry parks on a replacement-ordering prompt (two opposite- /// direction enter tap-state `Moved` effects — one enters tapped, one enters diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index b0828604ee..37c7bd22fc 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -1835,11 +1835,61 @@ fn activatable_mana_profiles_for_object( let resolved = super::ability_utils::build_resolved_from_def(ability, object_id, controller); profile_kind_from_production(state, object_id, controller, produced, &resolved) + .and_then(|kind| profile_kind_allowed_for_context(kind, payment_context)) .map(|kind| ActivatableManaProfile { object_id, kind }) }) .collect() } +/// CR 106.6: An activation's mana-color rider constrains actual produced mana, +/// not merely each unit's spend restriction. Keep feasibility profiles aligned +/// with auto-tap's concrete source-option gate. +fn profile_kind_allowed_for_context( + kind: ActivatableManaProfileKind, + payment_context: Option<&PaymentContext<'_>>, +) -> Option { + let Some(ctx) = payment_context else { + return Some(kind); + }; + match kind { + ActivatableManaProfileKind::Exact(types) + if types + .iter() + .all(|mana_type| ctx.permits_actual_mana_type(*mana_type)) => + { + Some(ActivatableManaProfileKind::Exact(types)) + } + ActivatableManaProfileKind::AnyOneColor { count, options } => { + let options: Vec<_> = options + .into_iter() + .filter(|mana_type| ctx.permits_actual_mana_type(*mana_type)) + .collect(); + (!options.is_empty()) + .then_some(ActivatableManaProfileKind::AnyOneColor { count, options }) + } + ActivatableManaProfileKind::AnyCombination { count, options } => { + let options: Vec<_> = options + .into_iter() + .filter(|mana_type| ctx.permits_actual_mana_type(*mana_type)) + .collect(); + (!options.is_empty()) + .then_some(ActivatableManaProfileKind::AnyCombination { count, options }) + } + ActivatableManaProfileKind::CombinationChoices(options) => { + let options: Vec<_> = options + .into_iter() + .filter(|combination| { + combination + .iter() + .all(|mana_type| ctx.permits_actual_mana_type(*mana_type)) + }) + .collect(); + (!options.is_empty()).then_some(ActivatableManaProfileKind::CombinationChoices(options)) + } + ActivatableManaProfileKind::Exact(_) => None, + } +} + fn collect_activatable_mana_profiles( state: &GameState, player: PlayerId, diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 35fe1342f9..5e63f39104 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1390,6 +1390,26 @@ fn replacement_may_cost_has_self_zone_move(cost: &AbilityCost) -> bool { } } +/// Constructs the resolution-scoped payment authority used by replacement +/// may-costs. These payments are not activations, so this deliberately carries +/// no activation-only payment context. +fn replacement_may_cost_payment_ability( + cost: &AbilityCost, + source_id: ObjectId, + player: PlayerId, +) -> ResolvedAbility { + ResolvedAbility::new( + crate::types::ability::Effect::PayCost { + cost: cost.clone(), + scale: None, + payer: TargetFilter::Controller, + }, + Vec::new(), + source_id, + player, + ) +} + fn pay_replacement_may_cost( state: &mut GameState, player: PlayerId, @@ -1463,16 +1483,7 @@ fn pay_replacement_may_cost( // `source_id` and resolves the (here fixed) discard `count` against // it. Modeling it as `Effect::PayCost { cost }` keeps the context // self-describing without inventing a fake target chain. - let ability = ResolvedAbility::new( - crate::types::ability::Effect::PayCost { - cost: cost.clone(), - scale: None, - payer: TargetFilter::Controller, - }, - Vec::new(), - source_id, - player, - ); + let ability = replacement_may_cost_payment_ability(cost, source_id, player); // CR 118.12 + CR 701.9b: when the eligible set exceeds the requirement // the resolution authority sets `WaitingFor::DiscardChoice` for the // player to pick *which* card(s) to discard. The non-composite discard @@ -1505,16 +1516,7 @@ fn pay_replacement_may_cost( // follows the same pattern as Discard: the resolution authority handles // the interactive choice via `WaitingFor::EffectZoneChoice` with is_cost_payment: true. AbilityCost::Exile { filter, .. } if !matches!(filter, Some(TargetFilter::SelfRef)) => { - let ability = ResolvedAbility::new( - crate::types::ability::Effect::PayCost { - cost: cost.clone(), - scale: None, - payer: TargetFilter::Controller, - }, - Vec::new(), - source_id, - player, - ); + let ability = replacement_may_cost_payment_ability(cost, source_id, player); let prior_waiting_for = state.waiting_for.clone(); match crate::game::costs::pay_ability_cost_for_replacement_may_cost( state, player, cost, &ability, events, @@ -1547,16 +1549,7 @@ fn pay_replacement_may_cost( // payment authority so it neither invents an ability index nor applies // an unrelated activation-only mana rider. _ => { - let ability = ResolvedAbility::new( - crate::types::ability::Effect::PayCost { - cost: cost.clone(), - scale: None, - payer: TargetFilter::Controller, - }, - Vec::new(), - source_id, - player, - ); + let ability = replacement_may_cost_payment_ability(cost, source_id, player); match crate::game::costs::pay_ability_cost_for_replacement_may_cost( state, player, cost, &ability, events, ) { diff --git a/crates/engine/src/types/mana.rs b/crates/engine/src/types/mana.rs index 20165d5f3a..f4ddda05fd 100644 --- a/crates/engine/src/types/mana.rs +++ b/crates/engine/src/types/mana.rs @@ -1205,7 +1205,23 @@ impl ManaRestriction { ManaRestriction::OnlyForAny(subs) => { subs.iter().any(|r| r.allows_special_action(action)) } - _ => false, + ManaRestriction::OnlyForSpell + | ManaRestriction::OnlyForSpellType(_) + | ManaRestriction::OnlyForCreatureType(_) + | ManaRestriction::OnlyForTypeSpellsOrAbilities { .. } + | ManaRestriction::OnlyForActivation + | ManaRestriction::OnlyForTaggedActivation(_) + | ManaRestriction::OnlyForXCosts + | ManaRestriction::OnlyForSpellWithKeywordKind(_) + | ManaRestriction::OnlyForSpellWithKeywordKindFromZone(_, _) + | ManaRestriction::OnlyForSpellWithManaValue { .. } + | ManaRestriction::OnlyForSpellMatchingCostCriteria { .. } + | ManaRestriction::OnlyForSpellWithColorCount { .. } + | ManaRestriction::OnlyForSpellColor(_) + | ManaRestriction::OnlyForSpellFromZone(_) + | ManaRestriction::OnlyForFaceDownSpell + | ManaRestriction::Impossible + | ManaRestriction::ConvokePayment => false, } } } diff --git a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs index dd830a4484..2d9528a5f9 100644 --- a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs +++ b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs @@ -50,6 +50,8 @@ fn draw_payment_state(mana: &[ManaType]) -> (GameState, ObjectId, usize) { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); let throne = add_throne(&mut scenario); + scenario.add_card_to_library_top(P0, "Throne draw test card one"); + scenario.add_card_to_library_top(P0, "Throne draw test card two"); let mut state = scenario.build().state().clone(); choose_red(&mut state, throne); let (_, draw) = throne_ability_indices(&state, throne); @@ -216,6 +218,8 @@ fn throne_draw_activation_uses_only_its_chosen_color_in_auto_and_manual_payment( runner .act(GameAction::SpendPoolMana { pip_id: red_pip }) .expect("manual payment must accept a red mana pin"); + let hand_before = runner.state().players[P0.0 as usize].hand.len(); + let library_before = runner.state().players[P0.0 as usize].library.len(); runner .act(GameAction::PassPriority) .expect("pending activation must finalize from the eligible red mana"); @@ -223,4 +227,105 @@ fn throne_draw_activation_uses_only_its_chosen_color_in_auto_and_manual_payment( runner.state().waiting_for, WaitingFor::Priority { .. } )); + runner + .act(GameAction::PassPriority) + .expect("activator passes priority to resolve the paid activation"); + runner + .act(GameAction::PassPriority) + .expect("opponent passes priority to resolve the paid activation"); + let player = &runner.state().players[P0.0 as usize]; + assert_eq!( + player.hand.len(), + hand_before + 2, + "the restricted-mana activation must resolve and draw two cards" + ); + assert_eq!( + player.library.len(), + library_before - 2, + "the draw must consume exactly two library cards" + ); + assert_eq!( + player.mana_pool.mana.len(), + 1, + "only the ineligible blue mana may remain after paying {{3}} with red mana" + ); + assert_eq!( + player.mana_pool.mana[0].pip_id, blue_pip, + "the red pinned mana must be consumed rather than silently abandoned" + ); +} + +/// The activation rider filters source enumeration before auto-tap. This is a +/// real `ActivateAbility` path (rather than a pre-filled pool): an Island is +/// deliberately registered before the three Mountains, so selecting any +/// off-color source for the generic cost would leave the activation unpaid. +#[test] +fn throne_draw_auto_tap_uses_only_chosen_color_sources() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let throne = add_throne(&mut scenario); + let island = scenario.add_basic_land(P0, ManaColor::Blue); + let mountains: Vec<_> = (0..3) + .map(|_| scenario.add_basic_land(P0, ManaColor::Red)) + .collect(); + scenario.with_library_top(P0, &["Throne draw one", "Throne draw two"]); + let mut runner = scenario.build(); + choose_red(runner.state_mut(), throne); + let (_, draw) = throne_ability_indices(runner.state(), throne); + let hand_before = runner.state().players[P0.0 as usize].hand.len(); + + runner + .act(GameAction::ActivateAbility { + source_id: throne, + ability_index: draw, + }) + .expect("three red sources must pay the red-chosen activation") + .waiting_for; + runner + .act(GameAction::PassPriority) + .expect("activator passes priority to resolve the paid activation"); + runner + .act(GameAction::PassPriority) + .expect("opponent passes priority to resolve the paid activation"); + + assert!( + !runner.state().objects[&island].tapped, + "off-color Island must not be selected for a red-only activation payment" + ); + assert!( + mountains + .iter() + .all(|mountain| runner.state().objects[mountain].tapped), + "all three eligible Mountains must be used for the {{3}} cost" + ); + assert_eq!( + runner.state().players[P0.0 as usize].hand.len(), + hand_before + 2, + "the activation must resolve after auto-tapping only eligible sources" + ); + + let mut blue_only = GameScenario::new(); + blue_only.at_phase(Phase::PreCombatMain); + let blue_throne = add_throne(&mut blue_only); + let blue_sources: Vec<_> = (0..3) + .map(|_| blue_only.add_basic_land(P0, ManaColor::Blue)) + .collect(); + let mut blue_runner = blue_only.build(); + choose_red(blue_runner.state_mut(), blue_throne); + let (_, blue_draw) = throne_ability_indices(blue_runner.state(), blue_throne); + assert!( + blue_runner + .act(GameAction::ActivateAbility { + source_id: blue_throne, + ability_index: blue_draw, + }) + .is_err(), + "off-color sources must not make the red-chosen activation payable" + ); + assert!( + blue_sources + .iter() + .all(|source| !blue_runner.state().objects[source].tapped), + "a rejected activation must not tap ineligible mana sources" + ); } From b035972e210f2ed5f55e633ebda769aa4e43e653 Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:18:31 +0200 Subject: [PATCH 3/8] Fix activation color rider source planning --- crates/engine/src/game/casting.rs | 5 +- crates/engine/src/game/casting_costs.rs | 50 +++++++++++-------- crates/engine/src/game/mana_sources.rs | 20 ++++---- .../throne_of_eldraine_mana_riders.rs | 40 +++++++++++++++ 4 files changed, 82 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 5c3b1466e9..4d821093e8 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -5,9 +5,8 @@ use crate::types::ability::{ ChoiceType, ContinuousModification, CostObjectCount, CostPaidObjectSnapshot, CounterCostSelection, Duration, Effect, FilterProp, GameRestriction, ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity, QuantityExpr, QuantityRef, - ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, StaticCondition, - StaticDefinition, SubAbilityLink, - TapCreaturesRequirement, TargetFilter, TargetRef, + ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, StaticCondition, StaticDefinition, + SubAbilityLink, TapCreaturesRequirement, TargetFilter, TargetRef, }; use crate::types::actions::AlternativeCastDecision; use crate::types::card::LayoutKind; diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 1f7a38a73d..c50c38f3e3 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -9466,10 +9466,27 @@ fn option_satisfies( if acceptable.is_empty() { return true; } - match &opt.atomic_combination { - Some(combo) => combo.iter().any(|t| acceptable.contains(t)), - None => acceptable.contains(&opt.mana_type), - } + option_mana_types_for_context(opt, payment_context) + .iter() + .any(|mana_type| acceptable.contains(mana_type)) +} + +/// Mana this source may contribute to the pending payment. An activation +/// color rider restricts mana that is spent, not the mana ability itself: an +/// off-color byproduct from a multi-output source remains in the pool. +fn option_mana_types_for_context( + opt: &ManaSourceOption, + payment_context: Option<&PaymentContext<'_>>, +) -> Vec { + opt.atomic_combination + .as_deref() + .unwrap_or(std::slice::from_ref(&opt.mana_type)) + .iter() + .copied() + .filter(|mana_type| { + payment_context.is_none_or(|ctx| ctx.permits_actual_mana_type(*mana_type)) + }) + .collect() } fn option_allowed_for_context( @@ -9482,12 +9499,6 @@ fn option_allowed_for_context( opt.restrictions .iter() .all(|restriction| restriction.allows(ctx)) - && opt - .atomic_combination - .as_deref() - .unwrap_or(std::slice::from_ref(&opt.mana_type)) - .iter() - .all(|mana_type| ctx.permits_actual_mana_type(*mana_type)) } /// Pick the source with the fewest alternative color options (LCV heuristic). @@ -10207,16 +10218,13 @@ fn auto_tap_mana_sources_inner( if generic_priority(option) != class { continue; } - if !option_allowed_for_context(option, effective_ctx) { + let eligible_width = option_mana_types_for_context(option, effective_ctx).len(); + if !option_allowed_for_context(option, effective_ctx) || eligible_width == 0 { continue; } if used_sources.insert(option.object_id) { - let width = option - .atomic_combination - .as_ref() - .map_or(1, |combo| combo.len()); to_tap.push(option.clone()); - remaining_generic = remaining_generic.saturating_sub(width); + remaining_generic = remaining_generic.saturating_sub(eligible_width); } } } @@ -10513,11 +10521,11 @@ fn assign_combination_sources( let mut best_score = 0usize; let mut best_combo: Option<(&ManaSourceOption, Vec)> = None; for cand in &candidates { - let combo = cand - .atomic_combination - .as_ref() - .expect("combination row invariant"); - let (score, covered) = score_combination(combo, needs, assigned); + let combo = option_mana_types_for_context(cand, payment_context); + if combo.is_empty() { + continue; + } + let (score, covered) = score_combination(&combo, needs, assigned); if score > best_score { best_score = score; best_combo = Some((cand, covered)); diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index 37c7bd22fc..87afb8ae9b 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -1852,12 +1852,12 @@ fn profile_kind_allowed_for_context( return Some(kind); }; match kind { - ActivatableManaProfileKind::Exact(types) - if types - .iter() - .all(|mana_type| ctx.permits_actual_mana_type(*mana_type)) => - { - Some(ActivatableManaProfileKind::Exact(types)) + ActivatableManaProfileKind::Exact(types) => { + let types: Vec<_> = types + .into_iter() + .filter(|mana_type| ctx.permits_actual_mana_type(*mana_type)) + .collect(); + (!types.is_empty()).then_some(ActivatableManaProfileKind::Exact(types)) } ActivatableManaProfileKind::AnyOneColor { count, options } => { let options: Vec<_> = options @@ -1878,15 +1878,17 @@ fn profile_kind_allowed_for_context( ActivatableManaProfileKind::CombinationChoices(options) => { let options: Vec<_> = options .into_iter() - .filter(|combination| { + .map(|combination| { combination .iter() - .all(|mana_type| ctx.permits_actual_mana_type(*mana_type)) + .copied() + .filter(|mana_type| ctx.permits_actual_mana_type(*mana_type)) + .collect::>() }) + .filter(|combination| !combination.is_empty()) .collect(); (!options.is_empty()).then_some(ActivatableManaProfileKind::CombinationChoices(options)) } - ActivatableManaProfileKind::Exact(_) => None, } } diff --git a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs index 2d9528a5f9..0fb599eb99 100644 --- a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs +++ b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs @@ -304,6 +304,46 @@ fn throne_draw_auto_tap_uses_only_chosen_color_sources() { "the activation must resolve after auto-tapping only eligible sources" ); + // A source may produce an off-color byproduct: only the red unit is spent, + // while its blue companion remains in the pool after the activation. + let mut mixed_output = GameScenario::new(); + mixed_output.at_phase(Phase::PreCombatMain); + let mixed_throne = add_throne(&mut mixed_output); + let signet = mixed_output + .add_land_from_oracle(P0, "Izzet Test Signet", "{T}: Add {U}{R}.") + .id(); + let mixed_mountains: Vec<_> = (0..2) + .map(|_| mixed_output.add_basic_land(P0, ManaColor::Red)) + .collect(); + mixed_output.with_library_top(P0, &["Mixed draw one", "Mixed draw two"]); + let mut mixed_runner = mixed_output.build(); + choose_red(mixed_runner.state_mut(), mixed_throne); + let (_, mixed_draw) = throne_ability_indices(mixed_runner.state(), mixed_throne); + mixed_runner + .act(GameAction::ActivateAbility { + source_id: mixed_throne, + ability_index: mixed_draw, + }) + .expect("a red unit from a blue-red source plus two Mountains must pay the activation"); + mixed_runner + .act(GameAction::PassPriority) + .expect("activator passes priority"); + mixed_runner + .act(GameAction::PassPriority) + .expect("opponent passes priority"); + assert!(mixed_runner.state().objects[&signet].tapped); + assert!(mixed_mountains + .iter() + .all(|mountain| mixed_runner.state().objects[mountain].tapped)); + assert!( + mixed_runner.state().players[P0.0 as usize] + .mana_pool + .mana + .iter() + .any(|unit| unit.color == ManaType::Blue), + "the unspent blue byproduct must remain in the pool" + ); + let mut blue_only = GameScenario::new(); blue_only.at_phase(Phase::PreCombatMain); let blue_throne = add_throne(&mut blue_only); From 411e2668bf54fbce83db400ebea93e720f67d7e2 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 25 Jul 2026 03:04:44 -0700 Subject: [PATCH 4/8] fix(PR-6625): remove dead test expression --- .../engine/tests/integration/throne_of_eldraine_mana_riders.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs index 0fb599eb99..db96e58e0a 100644 --- a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs +++ b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs @@ -279,8 +279,7 @@ fn throne_draw_auto_tap_uses_only_chosen_color_sources() { source_id: throne, ability_index: draw, }) - .expect("three red sources must pay the red-chosen activation") - .waiting_for; + .expect("three red sources must pay the red-chosen activation"); runner .act(GameAction::PassPriority) .expect("activator passes priority to resolve the paid activation"); From 47773c42c89131759289ecde31b556d9913a7690 Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:24:20 +0200 Subject: [PATCH 5/8] fix(PR-6625): model unindexed payment provenance --- crates/engine/src/ai_support/candidates.rs | 4 +- crates/engine/src/ai_support/mod.rs | 2 +- crates/engine/src/game/casting.rs | 51 +++++++------ crates/engine/src/game/casting_costs.rs | 25 ++++--- crates/engine/src/game/casting_tests.rs | 46 +++++++----- crates/engine/src/game/cost_payability.rs | 2 +- crates/engine/src/game/costs.rs | 16 ++--- .../src/game/effects/collect_evidence.rs | 2 +- crates/engine/src/game/engine.rs | 8 ++- crates/engine/src/game/keywords.rs | 2 +- crates/engine/src/game/mana_abilities.rs | 72 +++++++++---------- crates/engine/src/game/planeswalker.rs | 2 +- crates/engine/src/game/visibility.rs | 2 +- crates/engine/src/types/game_state.rs | 7 +- .../integration/issue_2862_teferi_loyalty.rs | 2 +- .../issue_4220_agatha_soul_cauldron.rs | 2 +- .../throne_of_eldraine_mana_riders.rs | 41 +++++------ .../phase-ai/src/policies/land_animation.rs | 2 +- 18 files changed, 162 insertions(+), 126 deletions(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index a18430943a..bc5c28fcdd 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -2127,7 +2127,7 @@ pub fn candidate_actions_broad_with_probe( *player, pending_cast.object_id, cost, - pending_cast.activation_ability_index.unwrap_or(usize::MAX), + pending_cast.activation_ability_index, ) }) .map(|(i, _)| { @@ -4041,7 +4041,7 @@ pub(crate) fn priority_actions_with_probe( state, player, *ninjutsu_object_id, - usize::MAX, + None, cost, ); if !can_afford { diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 164f6f0364..6d3650847b 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -286,7 +286,7 @@ fn cheap_reject_candidate(state: &GameState, action: &GameAction) -> bool { *player, pending_cast.object_id, cost, - pending_cast.activation_ability_index.unwrap_or(usize::MAX), + pending_cast.activation_ability_index, ) }), ( diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 4d821093e8..112cde210e 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -2039,7 +2039,7 @@ pub fn pending_phyrexian_route_is_payable( let activation_context = pending .activation_ability_index - .map(|ability_index| activation_payment_context(state, spell_object, ability_index)); + .map(|ability_index| activation_payment_context(state, spell_object, Some(ability_index))); let spell_meta = pending .activation_ability_index .is_none() @@ -14040,7 +14040,7 @@ pub fn can_pay_ability_mana_cost_after_auto_tap( state: &GameState, player: PlayerId, source_id: ObjectId, - ability_index: usize, + ability_index: Option, cost: &crate::types::mana::ManaCost, ) -> bool { can_pay_ability_mana_cost_after_auto_tap_excluding( @@ -14057,7 +14057,7 @@ pub fn can_pay_ability_mana_cost_after_auto_tap_excluding( state: &GameState, player: PlayerId, source_id: ObjectId, - ability_index: usize, + ability_index: Option, cost: &crate::types::mana::ManaCost, excluded_sources: &HashSet, ) -> bool { @@ -14488,7 +14488,7 @@ pub(super) fn pay_ability_mana_cost( state: &mut GameState, player: PlayerId, source_id: ObjectId, - ability_index: usize, + ability_index: Option, cost: &crate::types::mana::ManaCost, events: &mut Vec, ) -> Result<(), EngineError> { @@ -14509,7 +14509,7 @@ pub(super) fn pay_ability_mana_cost_excluding( state: &mut GameState, player: PlayerId, source_id: ObjectId, - ability_index: usize, + ability_index: Option, cost: &crate::types::mana::ManaCost, events: &mut Vec, excluded_sources: &HashSet, @@ -14539,7 +14539,7 @@ pub(super) fn pay_ability_mana_cost_excluding_with_parent( state: &mut GameState, player: PlayerId, source_id: ObjectId, - ability_index: usize, + ability_index: Option, cost: &crate::types::mana::ManaCost, events: &mut Vec, excluded_sources: &HashSet, @@ -14568,7 +14568,7 @@ pub(super) fn pay_ability_mana_cost_with_choices_excluding_and_resume( state: &mut GameState, player: PlayerId, source_id: ObjectId, - ability_index: usize, + ability_index: Option, cost: &crate::types::mana::ManaCost, phyrexian_choices: Option<&[crate::types::game_state::ShardChoice]>, events: &mut Vec, @@ -14596,7 +14596,7 @@ fn pay_ability_mana_cost_with_choices_excluding_and_parent( state: &mut GameState, player: PlayerId, source_id: ObjectId, - ability_index: usize, + ability_index: Option, cost: &crate::types::mana::ManaCost, phyrexian_choices: Option<&[crate::types::game_state::ShardChoice]>, events: &mut Vec, @@ -15055,7 +15055,7 @@ impl ActivationPaymentContext { pub(super) fn activation_payment_context( state: &GameState, source_id: ObjectId, - ability_index: usize, + ability_index: Option, ) -> ActivationPaymentContext { let Some(source) = state.objects.get(&source_id) else { return ActivationPaymentContext { @@ -15070,7 +15070,9 @@ pub(super) fn activation_payment_context( // Use the same effective-ability lookup as activation itself: runtime-granted // cycling, graveyard, plot, Ninjutsu-family, and equip abilities live after // the printed `obj.abilities` slice but retain their enumerated indices. - let Some(ability) = activation_ability_definition(state, source_id, ability_index) else { + let Some(ability) = + ability_index.and_then(|index| activation_ability_definition(state, source_id, index)) + else { return ActivationPaymentContext { source_types, source_subtypes, @@ -15822,7 +15824,9 @@ pub(crate) fn payable_one_of_activation_branches( ) -> Vec { costs .iter() - .filter(|branch| can_pay_ability_cost_now(state, player, source_id, branch, ability_index)) + .filter(|branch| { + can_pay_ability_cost_now(state, player, source_id, branch, Some(ability_index)) + }) .cloned() .collect() } @@ -15839,7 +15843,7 @@ fn activation_cost_passes_early_affordability_gate( ability_index: usize, ) -> bool { if find_one_of_cost(cost).is_some() { - can_pay_ability_cost_now(state, player, source_id, cost, ability_index) + can_pay_ability_cost_now(state, player, source_id, cost, Some(ability_index)) } else { // CR 106.6: the tag reaches the payability gate for the same reason it // reaches `can_pay_ability_cost_now` above — tag-scoped mana @@ -16285,7 +16289,7 @@ pub(crate) fn can_pay_ability_cost_now( player: PlayerId, source_id: ObjectId, cost: &AbilityCost, - ability_index: usize, + ability_index: Option, ) -> bool { let excluded_sources = ability_mana_payment_excluded_sources(cost, source_id); super::costs::can_pay( @@ -16447,7 +16451,7 @@ pub fn can_activate_ability_now_with_restriction_gates( .clone() .map(|cost| activation_cost_for_affordability(cost, ability_def.ability_tag)); if affordability_cost.as_ref().is_some_and(|cost| { - !can_pay_ability_cost_now(state, player, source_id, cost, ability_index) + !can_pay_ability_cost_now(state, player, source_id, cost, Some(ability_index)) }) { return false; } @@ -16651,7 +16655,8 @@ pub(super) fn try_finalize_pending_activation_mana_leg( .as_ref() .map(|tail| ability_mana_payment_excluded_sources(tail, pending.object_id)) .unwrap_or_default(); - let activation_context = activation_payment_context(state, pending.object_id, ability_index); + let activation_context = + activation_payment_context(state, pending.object_id, Some(ability_index)); let activation_ctx = activation_context.as_payment_context(); pending.cost = mana_cost.clone(); pending.activation_cost = remaining; @@ -16696,7 +16701,8 @@ pub(super) fn finalize_pending_activation_mana_payment( .as_ref() .map(|tail| ability_mana_payment_excluded_sources(tail, pending.object_id)) .unwrap_or_default(); - let activation_context = activation_payment_context(state, pending.object_id, ability_index); + let activation_context = + activation_payment_context(state, pending.object_id, Some(ability_index)); let activation_ctx = activation_context.as_payment_context(); let source_id = pending.object_id; state.pending_cast = Some(Box::new(pending)); @@ -17522,7 +17528,7 @@ pub fn handle_activate_ability( player, source_id, cost, - ability_index, + Some(ability_index), events, )? { let pending = pending_activation_after_cost_pause( @@ -17646,9 +17652,14 @@ pub fn handle_activate_ability( )? { return Ok(waiting); } - if let PaymentOutcome::Paused { remaining_cost } = - pay_ability_cost_for_activation(state, player, source_id, cost, ability_index, events)? - { + if let PaymentOutcome::Paused { remaining_cost } = pay_ability_cost_for_activation( + state, + player, + source_id, + cost, + Some(ability_index), + events, + )? { let pending = pending_activation_after_cost_pause( source_id, resolved.clone(), diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index c50c38f3e3..a99df77bc1 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -1945,7 +1945,7 @@ fn finish_selected_return_to_hand_after_automatic( player, pending.object_id, &cost, - ability_index, + Some(ability_index), events, )? { super::casting::PaymentOutcome::Paid => {} @@ -2174,7 +2174,7 @@ pub(crate) fn handle_activation_cost_one_of_choice( player, pending.object_id, chosen_cost, - pending.activation_ability_index.unwrap_or(usize::MAX), + pending.activation_ability_index, ) { return Err(EngineError::ActionNotAllowed( "Chosen cost branch is not payable".to_string(), @@ -3043,7 +3043,7 @@ pub(crate) fn handle_return_to_hand_for_cost( player, pending.object_id, &cost, - ability_index, + Some(ability_index), events, )? { super::casting::PaymentOutcome::Paid => {} @@ -3213,7 +3213,7 @@ pub(crate) fn handle_remove_counter_for_cost( player, pending.object_id, &cost, - ability_index, + Some(ability_index), events, )? { super::casting::PaymentOutcome::Paid => {} @@ -3385,7 +3385,7 @@ pub(crate) fn handle_remove_counter_distribution_for_cost( player, pending.object_id, &cost, - ability_index, + Some(ability_index), events, )? { super::casting::PaymentOutcome::Paid => {} @@ -4446,7 +4446,7 @@ pub(super) fn push_activated_ability_to_stack( player, source_id, cost, - ability_index, + Some(ability_index), events, )? { @@ -10312,8 +10312,11 @@ fn auto_tap_mana_sources_inner( excluded_sources }; if let Some(sub_cost) = sub_cost { - let activation_context = - super::casting::activation_payment_context(state, option.object_id, idx); + let activation_context = super::casting::activation_payment_context( + state, + option.object_id, + Some(idx), + ); let activation_ctx = activation_context.as_payment_context(); auto_tap_mana_sources_inner( state, @@ -11208,7 +11211,7 @@ fn finalize_mana_payment_with_resume( }) .unwrap_or_default(); let activation_context = - super::casting::activation_payment_context(state, source_id, ability_index); + super::casting::activation_payment_context(state, source_id, Some(ability_index)); let activation_ctx = activation_context.as_payment_context(); if let Some(waiting) = maybe_pause_for_phyrexian_choice( state, @@ -11270,7 +11273,7 @@ fn finalize_mana_payment_with_resume( state, player, pending.object_id, - ability_index, + Some(ability_index), &pending.cost, None, events, @@ -11595,7 +11598,7 @@ pub fn finalize_mana_payment_with_phyrexian_choices( state, player, pending.object_id, - ability_index, + Some(ability_index), &pending.cost, Some(phyrexian_choices), events, diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 73bb868943..ac912c88bb 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -1068,7 +1068,7 @@ fn activation_mana_payment_auto_taps_activation_only_source() { &state, PlayerId(0), ability_source, - 0, + Some(0), &cost )); @@ -1077,7 +1077,7 @@ fn activation_mana_payment_auto_taps_activation_only_source() { &mut state, PlayerId(0), ability_source, - 0, + Some(0), &cost, &mut events, ) @@ -25848,7 +25848,7 @@ fn can_pay_sacrifice_cost_with_eligible() { PlayerId(0), source, &cost, - 0 + Some(0) )); } @@ -25874,7 +25874,7 @@ fn cannot_pay_sacrifice_cost_no_eligible() { PlayerId(0), source, &cost, - 0 + Some(0) )); } @@ -31085,7 +31085,7 @@ fn composite_activated_pay_life_cost_deducts_life() { let life_before = state.players[0].life; let mut events = Vec::new(); - pay_ability_cost_for_activation(&mut state, PlayerId(0), fetch, &cost, 0, &mut events) + pay_ability_cost_for_activation(&mut state, PlayerId(0), fetch, &cost, Some(0), &mut events) .expect("fetchland-style composite cost should be payable"); assert_eq!(state.players[0].life, life_before - 1); @@ -33089,8 +33089,15 @@ mod remove_counter_cost { selection: CounterCostSelection::SingleObject, }; let mut events = Vec::new(); - pay_ability_cost_for_activation(&mut state, PlayerId(0), source, &cost, 0, &mut events) - .expect("cost should pay with 2 +1/+1 counters available"); + pay_ability_cost_for_activation( + &mut state, + PlayerId(0), + source, + &cost, + Some(0), + &mut events, + ) + .expect("cost should pay with 2 +1/+1 counters available"); let remaining = state .objects .get(&source) @@ -33131,7 +33138,7 @@ mod remove_counter_cost { "cost must be unpayable when the source has no +1/+1 counters" ); assert!( - !can_pay_ability_cost_now(&state, PlayerId(0), source, &cost, 0), + !can_pay_ability_cost_now(&state, PlayerId(0), source, &cost, Some(0)), "can_pay_ability_cost_now must reject an unpayable remove-counter cost" ); } @@ -33168,8 +33175,15 @@ mod remove_counter_cost { selection: CounterCostSelection::SingleObject, }; let mut events = Vec::new(); - pay_ability_cost_for_activation(&mut state, PlayerId(0), source, &cost, 0, &mut events) - .unwrap(); + pay_ability_cost_for_activation( + &mut state, + PlayerId(0), + source, + &cost, + Some(0), + &mut events, + ) + .unwrap(); let removed_count = events .iter() .filter_map(|e| match e { @@ -34617,7 +34631,7 @@ mod unattach_cost { PlayerId(0), equipment, &cost, - 0, + Some(0), &mut Vec::new(), ) .expect("attached Equipment should be able to unattach as a cost"); @@ -36333,7 +36347,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - 0, + Some(0), &mut events, ) .expect("exert cost pays"); @@ -36393,7 +36407,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - 0, + Some(0), &mut events, ) .expect("first exert"); @@ -36402,7 +36416,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - 0, + Some(0), &mut events, ) .expect("second exert"); @@ -36450,7 +36464,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - 0, + Some(0), &mut events, ); assert!(matches!(result, Err(EngineError::ActionNotAllowed(_)))); @@ -36470,7 +36484,7 @@ mod exert_cost { PlayerId(0), id, &AbilityCost::Exert, - 0, + Some(0), &mut events, ) .expect("exert cost pays"); diff --git a/crates/engine/src/game/cost_payability.rs b/crates/engine/src/game/cost_payability.rs index b9173455f7..062a64424f 100644 --- a/crates/engine/src/game/cost_payability.rs +++ b/crates/engine/src/game/cost_payability.rs @@ -225,7 +225,7 @@ impl AbilityCost { state, player, source, - ability_index, + Some(ability_index), cost, &excluded_sources, ) diff --git a/crates/engine/src/game/costs.rs b/crates/engine/src/game/costs.rs index 2288a15705..eecb16e0e8 100644 --- a/crates/engine/src/game/costs.rs +++ b/crates/engine/src/game/costs.rs @@ -204,7 +204,7 @@ pub(crate) enum PaymentScope<'a> { /// CR 106.6: Exact activated ability whose mana cost is being paid. /// This builds the live activation payment context, including any /// source-chosen-color rider and keyword tag. - ability_index: usize, + ability_index: Option, }, /// `ability` is normally the PAYER-ADJUSTED `ResolvedAbility` clone /// (controller swapped to the resolved payer, per `effects/pay.rs`). All @@ -572,7 +572,7 @@ pub fn pay_ability_cost_for_activation( player: PlayerId, source_id: ObjectId, cost: &AbilityCost, - ability_index: usize, + ability_index: Option, events: &mut Vec, ) -> Result { pay_ability_cost_for_activation_with_cost_move_replacement( @@ -590,7 +590,7 @@ fn pay_ability_cost_for_activation_with_cost_move_replacement( player: PlayerId, source_id: ObjectId, cost: &AbilityCost, - ability_index: usize, + ability_index: Option, events: &mut Vec, ) -> Result { let excluded_sources = ability_mana_payment_excluded_sources(cost, source_id); @@ -2240,7 +2240,7 @@ mod tests { let excluded = ability_mana_payment_excluded_sources(&cost, src); let scope = PaymentScope::Activation { excluded_sources: &excluded, - ability_index: 0, + ability_index: Some(0), }; assert!( can_pay(&scenario.state, P0, src, &cost, &scope), @@ -2269,7 +2269,7 @@ mod tests { let excluded = ability_mana_payment_excluded_sources(&cost, src); let scope = PaymentScope::Activation { excluded_sources: &excluded, - ability_index: 0, + ability_index: Some(0), }; let mut events = Vec::new(); let outcome = pay_ability_cost_inner( @@ -2322,7 +2322,7 @@ mod tests { P0, src, &graveyard_cost, - 0, + Some(0), &mut Vec::new(), ); assert!(matches!(rejected, Err(EngineError::ActionNotAllowed(_)))); @@ -2338,7 +2338,7 @@ mod tests { P0, src, &battlefield_cost, - 0, + Some(0), &mut Vec::new(), ) .expect("battlefield self-return cost should be payable"); @@ -2355,7 +2355,7 @@ mod tests { cost, &PaymentScope::Activation { excluded_sources: &excluded, - ability_index: 0, + ability_index: Some(0), }, ) } diff --git a/crates/engine/src/game/effects/collect_evidence.rs b/crates/engine/src/game/effects/collect_evidence.rs index 600479bc83..7e43d10449 100644 --- a/crates/engine/src/game/effects/collect_evidence.rs +++ b/crates/engine/src/game/effects/collect_evidence.rs @@ -613,7 +613,7 @@ mod tests { PendingManaAbility { player: PlayerId(0), source_id, - ability_index: 0, + ability_index: None, rules_execution_node: None, ability_snapshot: None, color_override: None, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 4d6db28c17..810aacb756 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -6192,7 +6192,11 @@ fn apply_action( let activation_ability_index = pending_ref.activation_ability_index; let current_shards = if let Some(ability_index) = activation_ability_index { let activation_context = - casting::activation_payment_context(state, spell_object, ability_index); + casting::activation_payment_context( + state, + spell_object, + Some(ability_index), + ); let activation_ctx = activation_context.as_payment_context(); let any_color = casting::player_can_spend_as_any_color_for_payment( state, @@ -9611,7 +9615,7 @@ pub(super) fn handle_spend_pool_mana( let activation_context; let ctx = if let Some(ability_index) = activation_ability_index { activation_context = - super::casting::activation_payment_context(state, object_id, ability_index); + super::casting::activation_payment_context(state, object_id, Some(ability_index)); Some(activation_context.as_payment_context()) } else { spell_meta = super::casting::build_spell_meta(state, player, object_id); diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 6212b1dfb1..2f48b7cfdd 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -781,7 +781,7 @@ pub fn activate_ninjutsu( &AbilityCost::Mana { cost: effective_cost, }, - usize::MAX, + None, events, ) .map_err(|e| e.to_string())? diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 0efd967809..ef6513b50b 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -332,16 +332,12 @@ pub(super) fn resolve_mana_ability_excluding( parent: Option<&ManaAbilityCostParent>, ) -> Result<(), EngineError> { let waiting_before = state.waiting_for.clone(); - let ability_index = state - .objects - .get(&source_id) - .and_then(|object| { - object - .abilities - .iter() - .position(|ability| ability == ability_def) - }) - .unwrap_or(usize::MAX); + let ability_index = state.objects.get(&source_id).and_then(|object| { + object + .abilities + .iter() + .position(|ability| ability == ability_def) + }); let rules_execution_node = Some(state.begin_activated_mana_journal_node(source_id)); let pending = PendingManaAbility { player, @@ -662,7 +658,7 @@ pub fn activate_mana_ability( PendingManaAbility { player, source_id, - ability_index, + ability_index: Some(ability_index), rules_execution_node, ability_snapshot: Some(ability_def.clone()), color_override, @@ -686,10 +682,13 @@ pub fn activate_mana_ability( fn complete_mana_ability_activation( state: &mut GameState, source_id: ObjectId, - ability_index: usize, + ability_index: Option, player: PlayerId, events: &mut Vec, ) { + let Some(ability_index) = ability_index else { + return; + }; super::restrictions::record_ability_activation(state, source_id, ability_index); super::casting_targets::emit_keyword_ability_event_if_tagged( state, @@ -924,7 +923,11 @@ pub fn handle_choose_mana_color( let ability_def = state .objects .get(&pending.source_id) - .and_then(|obj| obj.abilities.get(pending.ability_index)) + .and_then(|obj| { + pending + .ability_index + .and_then(|index| obj.abilities.get(index)) + }) .cloned() .or_else(|| pending.ability_snapshot.clone()) .ok_or_else(|| EngineError::InvalidAction("Mana ability no longer exists".to_string()))?; @@ -990,14 +993,7 @@ pub(crate) fn batch_activate_mana_siblings( // The originally-activated source's mana ability is the shape every sibling // was selected to match. Re-resolve each sibling's matching ability index // (a sibling may carry unrelated abilities too). - let reference_def = state - .objects - .get(&pending.source_id) - .and_then(|obj| obj.abilities.get(pending.ability_index)) - .cloned() - .ok_or_else(|| { - EngineError::InvalidAction("Mana ability source no longer exists".to_string()) - })?; + let reference_def = mana_ability_definition(state, pending)?; for &sibling_id in pending.batch_siblings.iter().take(extra) { let Some((index, def)) = state.objects.get(&sibling_id).and_then(|obj| { @@ -1733,7 +1729,11 @@ fn mana_ability_definition( state .objects .get(&pending.source_id) - .and_then(|obj| obj.abilities.get(pending.ability_index)) + .and_then(|obj| { + pending + .ability_index + .and_then(|index| obj.abilities.get(index)) + }) .cloned() .or_else(|| pending.ability_snapshot.clone()) .ok_or_else(|| EngineError::InvalidAction("Mana ability no longer exists".to_string())) @@ -2691,7 +2691,7 @@ fn pay_mana_ability_cost_with_choices( state: &mut GameState, source_id: ObjectId, player: PlayerId, - ability_index: usize, + ability_index: Option, cost: &Option, events: &mut Vec, chosen_tappers: &mut I, @@ -3345,7 +3345,7 @@ fn pay_mana_sub_cost( state: &mut GameState, source_id: ObjectId, player: PlayerId, - ability_index: usize, + ability_index: Option, cost: &ManaCost, hybrid_plan: Option<&[ManaType]>, events: &mut Vec, @@ -7502,7 +7502,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: source, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: None, @@ -7605,7 +7605,7 @@ mod tests { player: PlayerId(0), source_id: source, ability_snapshot: None, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, color_override: None, resume: ManaAbilityResume::Priority, @@ -7815,7 +7815,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: source, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: None, @@ -8048,7 +8048,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: ruins, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: None, @@ -8154,7 +8154,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: ruins, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: None, @@ -9168,7 +9168,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: ruins, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: None, @@ -9540,7 +9540,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(1), source_id: brushland, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: None, @@ -10105,7 +10105,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: source, - ability_index: 0, + ability_index: None, rules_execution_node: None, ability_snapshot: None, color_override: None, @@ -10178,7 +10178,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: altar, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: Some(ProductionOverride::SingleColor(ManaType::Black)), @@ -10306,7 +10306,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: chain, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: Some(ProductionOverride::SingleColor(ManaType::Green)), @@ -10372,7 +10372,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: chain, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: Some(ProductionOverride::SingleColor(ManaType::Red)), @@ -10529,7 +10529,7 @@ mod tests { let pending = PendingManaAbility { player: PlayerId(0), source_id: chain, - ability_index: 0, + ability_index: Some(0), rules_execution_node: None, ability_snapshot: None, color_override: Some(ProductionOverride::SingleColor(ManaType::Green)), diff --git a/crates/engine/src/game/planeswalker.rs b/crates/engine/src/game/planeswalker.rs index fa126a435e..16977676c2 100644 --- a/crates/engine/src/game/planeswalker.rs +++ b/crates/engine/src/game/planeswalker.rs @@ -397,7 +397,7 @@ fn finalize_loyalty_activation( player, pw_id, &cost, - ability_index, + Some(ability_index), events, ) .expect("loyalty validation passed in handle_activate_loyalty") diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 970c2e4812..43f114350d 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1909,7 +1909,7 @@ mod tests { Box::new(PendingManaAbility { player, source_id, - ability_index: 0, + ability_index: None, rules_execution_node: None, ability_snapshot: None, color_override: None, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 2e26c5dc12..9695f28d57 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -6022,7 +6022,10 @@ pub enum ManaChoiceContext { pub struct PendingManaAbility { pub player: PlayerId, pub source_id: ObjectId, - pub ability_index: usize, + /// The live definition index when this activation originated from an + /// enumerated ability. Runtime-synthesized mana abilities retain their + /// snapshot but intentionally have no definition index. + pub ability_index: Option, /// The P1 execution scope assigned when this activation begins. It survives /// player-choice suspension so exact produced and spent mana keep the same /// causal node after resumption. @@ -21197,7 +21200,7 @@ mod tests { mana_ability: Box::new(PendingManaAbility { player: PlayerId(0), source_id: ObjectId(1), - ability_index: 0, + ability_index: None, rules_execution_node: None, ability_snapshot: None, color_override: None, diff --git a/crates/engine/tests/integration/issue_2862_teferi_loyalty.rs b/crates/engine/tests/integration/issue_2862_teferi_loyalty.rs index 0a58892866..1238f20d3c 100644 --- a/crates/engine/tests/integration/issue_2862_teferi_loyalty.rs +++ b/crates/engine/tests/integration/issue_2862_teferi_loyalty.rs @@ -89,7 +89,7 @@ fn issue_2862_teferi_cast_minus_three_pays_loyalty_cost() { P0, teferi, &AbilityCost::Loyalty { amount: -3 }, - 0, + Some(0), &mut events, ) .expect("pay [-3] loyalty cost through activation payment seam"); diff --git a/crates/engine/tests/integration/issue_4220_agatha_soul_cauldron.rs b/crates/engine/tests/integration/issue_4220_agatha_soul_cauldron.rs index ed4d6471db..38fc5ad5f4 100644 --- a/crates/engine/tests/integration/issue_4220_agatha_soul_cauldron.rs +++ b/crates/engine/tests/integration/issue_4220_agatha_soul_cauldron.rs @@ -225,7 +225,7 @@ fn agatha_granted_bb_ability_affordable_via_green_auto_tap_sources() { generic: 0, }; assert!( - can_pay_ability_mana_cost_after_auto_tap(&state, P0, host, ability_index, &bb_cost), + can_pay_ability_mana_cost_after_auto_tap(&state, P0, host, Some(ability_index), &bb_cost), "auto-tap planner must treat green sources as paying {{B}}{{B}} under Agatha" ); assert!( diff --git a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs index db96e58e0a..e7e6543312 100644 --- a/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs +++ b/crates/engine/tests/integration/throne_of_eldraine_mana_riders.rs @@ -46,24 +46,6 @@ fn throne_ability_indices(state: &GameState, throne: ObjectId) -> (usize, usize) (mana, draw) } -fn draw_payment_state(mana: &[ManaType]) -> (GameState, ObjectId, usize) { - let mut scenario = GameScenario::new(); - scenario.at_phase(Phase::PreCombatMain); - let throne = add_throne(&mut scenario); - scenario.add_card_to_library_top(P0, "Throne draw test card one"); - scenario.add_card_to_library_top(P0, "Throne draw test card two"); - let mut state = scenario.build().state().clone(); - choose_red(&mut state, throne); - let (_, draw) = throne_ability_indices(&state, throne); - for (index, color) in mana.iter().copied().enumerate() { - state.add_mana_to_pool( - P0, - ManaUnit::new(color, ObjectId(10_000 + index as u64), false, Vec::new()), - ); - } - (state, throne, draw) -} - /// The produced units carry the chosen color of the actual producing Throne, /// and that restriction is consulted by the normal cast action. Each spell has /// a generic cost so colored-cost matching cannot hide a spend-restriction bug. @@ -127,9 +109,28 @@ fn throne_mana_casts_only_monocolored_spells_of_its_chosen_color() { } } +fn draw_payment_state(mana: &[ManaType]) -> (GameState, ObjectId, usize) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let throne = add_throne(&mut scenario); + scenario.add_card_to_library_top(P0, "Throne draw test card one"); + scenario.add_card_to_library_top(P0, "Throne draw test card two"); + let mut state = scenario.build().state().clone(); + choose_red(&mut state, throne); + let (_, draw) = throne_ability_indices(&state, throne); + for (index, color) in mana.iter().copied().enumerate() { + state.add_mana_to_pool( + P0, + ManaUnit::new(color, ObjectId(10_000 + index as u64), false, Vec::new()), + ); + } + (state, throne, draw) +} + /// The draw rider constrains the actual mana units used for activation. This -/// covers automatic payment, rejection of blue/mixed pools, and the manual -/// pin/resume route used by the interactive client. +/// covers automatic payment and the direct manual-pool fixture. `ActivateAbility` +/// currently has no manual-payment mode, so the latter exercises the internal +/// manual-payment branch without claiming an unreachable production route. #[test] fn throne_draw_activation_uses_only_its_chosen_color_in_auto_and_manual_payment() { let (red_state, red_throne, draw) = diff --git a/crates/phase-ai/src/policies/land_animation.rs b/crates/phase-ai/src/policies/land_animation.rs index 67ed9e35bc..914bab3b0b 100644 --- a/crates/phase-ai/src/policies/land_animation.rs +++ b/crates/phase-ai/src/policies/land_animation.rs @@ -312,7 +312,7 @@ fn can_pay_cost_excluding_source( ctx.state, ctx.ai_player, source_id, - ability_index, + Some(ability_index), cost, &excluded, ) From a430bfe5fd6a31677c065ca2a87f971c30b17c6e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 25 Jul 2026 19:53:48 -0700 Subject: [PATCH 6/8] fix(PR-6625): preserve ability payment context --- crates/engine/src/game/casting.rs | 24 ++++------ crates/engine/src/game/cost_payability.rs | 55 +++++++++++++---------- crates/engine/src/game/costs.rs | 4 +- 3 files changed, 43 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 112cde210e..4d37463624 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -13769,20 +13769,18 @@ pub(super) fn can_feasibly_pay_mana_cost_with_tap_payment_mode( /// affordability gate agrees with the later payment step about which /// restricted mana is eligible: activation-only mana /// (`ManaRestriction::OnlyForActivation`) counts, spell-only mana -/// (`ManaRestriction::OnlyForSpell` etc.) does not. `ability_tag` is +/// (`ManaRestriction::OnlyForSpell` etc.) does not. The exact ability index is /// threaded into the context for tag-scoped restrictions -/// (`OnlyForTaggedActivation`, Quinjet's power-up mana); pass `None` when the -/// activation being probed did not originate from a tagged keyword ability — -/// the conservative reading, matching `can_pay_ability_cost_after_auto_tap`'s -/// documented preview behavior (the exact tag-scoped gate runs at payment -/// time per CR 601.2g). +/// (`OnlyForTaggedActivation`, Quinjet's power-up mana) and the activation's +/// own mana-payment rider. `ability_index` identifies the exact ability being +/// probed; `None` is for callers that have no enumerated ability. pub(super) fn can_feasibly_pay_activation_mana_cost_with_tap_payment_mode( state: &GameState, player: PlayerId, source_id: ObjectId, cost: &crate::types::mana::ManaCost, tap_payment_mode: ConvokeMode, - ability_tag: Option, + ability_index: Option, ) -> bool { if super::casting_costs::cost_has_x(cost) { let mut concrete = cost.clone(); @@ -13793,18 +13791,14 @@ pub(super) fn can_feasibly_pay_activation_mana_cost_with_tap_payment_mode( source_id, &concrete, tap_payment_mode, - ability_tag, + ability_index, ); } let mut simulated = state.clone(); super::layers::flush_layers(&mut simulated); - let (source_types, source_subtypes) = activation_source_types(&simulated, source_id); - let activation_ctx = PaymentContext::Activation { - source_types: &source_types, - source_subtypes: &source_subtypes, - ability_tag, - }; + let activation_context = activation_payment_context(&simulated, source_id, ability_index); + let activation_ctx = activation_context.as_payment_context(); feasibly_payable_with_tap_payment_mode_in_context( &simulated, player, @@ -15850,7 +15844,7 @@ fn activation_cost_passes_early_affordability_gate( // (`OnlyForTaggedActivation`) is spendable at the real payment step, // so a gate that judged the cost without the tag would refuse // activations the payment step would have allowed. - cost.is_payable_for_activation(state, player, source_id, ability_tag) + cost.is_payable_for_activation(state, player, source_id, Some(ability_index)) } } diff --git a/crates/engine/src/game/cost_payability.rs b/crates/engine/src/game/cost_payability.rs index 062a64424f..136785e060 100644 --- a/crates/engine/src/game/cost_payability.rs +++ b/crates/engine/src/game/cost_payability.rs @@ -20,9 +20,9 @@ //! the enumerations. use crate::types::ability::{ - is_variable_remove_counter_cost_count, AbilityCost, AbilityTag, Comparator, - CounterCostSelection, FilterProp, QuantityExpr, QuantityRef, TapCreaturesAggregateStat, - TapCreaturesRequirement, TargetFilter, TypedFilter, + is_variable_remove_counter_cost_count, AbilityCost, Comparator, CounterCostSelection, + FilterProp, QuantityExpr, QuantityRef, TapCreaturesAggregateStat, TapCreaturesRequirement, + TargetFilter, TypedFilter, }; use crate::types::card_type::CoreType; use crate::types::identifiers::ObjectId; @@ -262,8 +262,8 @@ impl AbilityCost { /// Mana affordability is NOT checked here; CR 601.2g handles the mana step /// separately through the mana-payment flow. /// - /// Tag-agnostic entry point: delegates to [`Self::is_payable_for_activation`] - /// with no activation tag. Callers that KNOW the ability whose cost this is + /// Ability-agnostic entry point: delegates to [`Self::is_payable_for_activation`] + /// with no activation index. Callers that KNOW the ability whose cost this is /// (the activation pipeline) must use that method instead so CR 106.6 /// tag-scoped mana is judged the same way the real payment step judges it. pub fn is_payable(&self, state: &GameState, player: PlayerId, source: ObjectId) -> bool { @@ -271,15 +271,14 @@ impl AbilityCost { } /// CR 118.3 + CR 601.2h + CR 106.6: [`Self::is_payable`] with the activated - /// ability's `ability_tag` in hand. + /// ability's exact index in hand. /// - /// Only the sub-costs that consult a `PaymentContext` care about the tag - /// (today: `Waterbend`, whose affordability probe funds a mana cost). The - /// tag must reach them because `PaymentContext::Activation` carries it, and - /// `ManaRestriction::OnlyForTaggedActivation` admits mana only for a - /// matching tag — passing `None` from the early gate would hide mana the - /// real payment step (which receives `ability_def.ability_tag`) would - /// happily spend, suppressing a legally activatable ability. + /// Only sub-costs that consult a `PaymentContext` care about this identity + /// today: `Waterbend`, whose affordability probe funds a mana cost. The + /// index must reach it because `PaymentContext::Activation` derives the + /// exact ability tag from the same definition. Otherwise, + /// `ManaRestriction::OnlyForTaggedActivation` could hide mana that the real + /// payment step may spend, suppressing a legally activatable ability. /// /// Single body for both entry points, so the composite/disjunctive /// traversal is not duplicated across a tagged and an untagged authority. @@ -288,7 +287,7 @@ impl AbilityCost { state: &GameState, player: PlayerId, source: ObjectId, - ability_tag: Option, + ability_index: Option, ) -> bool { match self { // CR 601.2g: Mana affordability is checked by the mana payment step, @@ -648,7 +647,7 @@ impl AbilityCost { } if has_tap => { has_enough_tap_creatures(state, player, source, requirement, filter, true) } - other => other.is_payable_for_activation(state, player, source, ability_tag), + other => other.is_payable_for_activation(state, player, source, ability_index), }) } // CR 118.12a: Disjunctive — payable if **any** sub-cost is @@ -657,7 +656,7 @@ impl AbilityCost { // gate only needs at least one branch to be reachable. AbilityCost::OneOf { costs } => costs .iter() - .any(|c| c.is_payable_for_activation(state, player, source, ability_tag)), + .any(|c| c.is_payable_for_activation(state, player, source, ability_index)), // CR 601.2b + CR 701.67a: Waterbend composes a mana cost with a // tap-creature-or-artifact-to-help option (the whole point of the // keyword). The plain auto-tap pre-check (`can_pay_cost_after_auto_tap`) @@ -678,9 +677,9 @@ impl AbilityCost { // affordability and spell-only mana must not — a spell context // here would disagree with the actual payment step // (`PaymentContext::Activation`) in both directions. The - // activation's own `ability_tag` is threaded through for the same - // reason: the real payment step receives `ability_def.ability_tag` - // (`casting.rs`), so CR 106.6 tag-scoped mana + // activation's own exact index is threaded through for the same + // reason: the real payment step resolves its tag and color rider + // from that same definition (`casting.rs`), so CR 106.6 tag-scoped mana // (`ManaRestriction::OnlyForTaggedActivation`, Quinjet's power-up // mana) must be visible to this gate too — otherwise a Waterbend // cost fundable only by that mana is suppressed before the player @@ -692,7 +691,7 @@ impl AbilityCost { source, cost, crate::types::game_state::ConvokeMode::Waterbend, - ability_tag, + ability_index, ) } // CR 702.49: Ninjutsu requires at least one returnable creature for @@ -1549,12 +1548,22 @@ mod tests { /// discriminate purely on the tag. #[test] fn waterbend_payability_sees_tag_scoped_activation_mana() { + use crate::types::ability::AbilityTag; use crate::types::mana::{ManaRestriction, ManaType, ManaUnit}; let mut scenario = GameScenario::new(); let source = scenario - .add_creature(P0, "Waterbender Ascension", 0, 0) + .add_creature_from_oracle( + P0, + "Waterbender Ascension", + 0, + 0, + "{4}: Draw a card.\n{4}: Draw a card.", + ) .id(); + let abilities = &mut scenario.state.objects.get_mut(&source).unwrap().abilities; + abilities[0].ability_tag = Some(AbilityTag::PowerUp); + abilities[1].ability_tag = Some(AbilityTag::Equip); // Four colorless mana usable ONLY for a Power-up-tagged activation. for _ in 0..4 { scenario.state.add_mana_to_pool( @@ -1574,11 +1583,11 @@ mod tests { }; assert!( - cost.is_payable_for_activation(&scenario.state, P0, source, Some(AbilityTag::PowerUp)), + cost.is_payable_for_activation(&scenario.state, P0, source, Some(0)), "power-up-restricted mana must fund a Power-up-tagged Waterbend activation" ); assert!( - !cost.is_payable_for_activation(&scenario.state, P0, source, Some(AbilityTag::Equip)), + !cost.is_payable_for_activation(&scenario.state, P0, source, Some(1)), "a DIFFERENT tag must not unlock power-up-restricted mana (CR 106.6)" ); assert!( diff --git a/crates/engine/src/game/costs.rs b/crates/engine/src/game/costs.rs index eecb16e0e8..1f723d056a 100644 --- a/crates/engine/src/game/costs.rs +++ b/crates/engine/src/game/costs.rs @@ -1625,8 +1625,8 @@ pub(crate) fn can_pay( scope: &PaymentScope, ) -> bool { match scope { - PaymentScope::Activation { ability_tag, .. } => { - if !cost.is_payable_for_activation(state, payer, source_id, *ability_tag) { + PaymentScope::Activation { ability_index, .. } => { + if !cost.is_payable_for_activation(state, payer, source_id, *ability_index) { return false; } // CR 118.12a: disjunctive activation costs resolve via From 2bad4976aace0f6e9dbdd89ee46af494f6724542 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 25 Jul 2026 20:10:41 -0700 Subject: [PATCH 7/8] fix(PR-6625): project chosen-color mana restrictions --- .../manaSourceSelectionWireTypes.test.ts | 2 + .../adapter/generated/interaction/index.ts | 2 +- client/src/adapter/types.ts | 4 + client/src/i18n/locales/en/game.json | 4 + client/src/pages/GamePage.tsx | 4 + client/src/viewmodel/manaPoolGroups.ts | 2 + crates/engine/src/game/interaction.rs | 8 ++ crates/engine/src/types/interaction.rs | 4 + .../tests/integration/interaction_contract.rs | 104 +++++++++++++++++- 9 files changed, 129 insertions(+), 5 deletions(-) diff --git a/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts b/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts index dac6b9cd9e..09741924d1 100644 --- a/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts +++ b/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts @@ -34,6 +34,7 @@ describe("mana-source selection wire types", () => { }, }, { OnlyForSpellWithColorCount: { comparator: "EQ", count: 2 } }, + { OnlyForSpellColor: "Red" }, { OnlyForSpellFromZone: { zone: "Hand", @@ -48,6 +49,7 @@ describe("mana-source selection wire types", () => { ], }, { OnlyForSpecialAction: "UnlockDoor" }, + "Impossible", "ConvokePayment", ]; const action: GameAction = { diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index fe5ee56930..36daed2eed 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -47,7 +47,7 @@ export type InteractionManaSpecialAction = "companionToHand" | "unlockDoor" | "p export type InteractionManaSpellCostCriterion = { "type": "manaValue", "data": { comparator: InteractionManaComparator, value: number, } } | { "type": "hasXInCost" }; -export type InteractionManaRestriction = { "type": "onlyForSpell" } | { "type": "onlyForSpellType", "data": { spellType: string, } } | { "type": "onlyForCreatureType", "data": { creatureType: string, } } | { "type": "onlyForTypeSpellsOrAbilities", "data": { spellType: string, ability: InteractionManaAbilityActivationScope, } } | { "type": "onlyForActivation" } | { "type": "onlyForTaggedActivation", "data": { tag: string, } } | { "type": "onlyForXCosts" } | { "type": "onlyForSpellWithKeywordKind", "data": { keyword: string, } } | { "type": "onlyForSpellWithKeywordKindFromZone", "data": { keyword: string, zone: InteractionZoneCode, } } | { "type": "onlyForSpellWithManaValue", "data": { comparator: InteractionManaComparator, value: number, } } | { "type": "onlyForSpellMatchingCostCriteria", "data": { spellType: string | null, criteria: Array, } } | { "type": "onlyForSpellWithColorCount", "data": { comparator: InteractionManaComparator, count: number, } } | { "type": "onlyForSpellFromZone", "data": { zone: InteractionZoneCode, polarity: InteractionManaZoneSpendPolarity, } } | { "type": "onlyForFaceDownSpell" } | { "type": "onlyForAny", "data": { restrictions: Array, } } | { "type": "onlyForSpecialAction", "data": { action: InteractionManaSpecialAction, } } | { "type": "convokePayment" }; +export type InteractionManaRestriction = { "type": "onlyForSpell" } | { "type": "onlyForSpellType", "data": { spellType: string, } } | { "type": "onlyForCreatureType", "data": { creatureType: string, } } | { "type": "onlyForTypeSpellsOrAbilities", "data": { spellType: string, ability: InteractionManaAbilityActivationScope, } } | { "type": "onlyForActivation" } | { "type": "onlyForTaggedActivation", "data": { tag: string, } } | { "type": "onlyForXCosts" } | { "type": "onlyForSpellWithKeywordKind", "data": { keyword: string, } } | { "type": "onlyForSpellWithKeywordKindFromZone", "data": { keyword: string, zone: InteractionZoneCode, } } | { "type": "onlyForSpellWithManaValue", "data": { comparator: InteractionManaComparator, value: number, } } | { "type": "onlyForSpellMatchingCostCriteria", "data": { spellType: string | null, criteria: Array, } } | { "type": "onlyForSpellWithColorCount", "data": { comparator: InteractionManaComparator, count: number, } } | { "type": "onlyForSpellColor", "data": { color: InteractionManaColor, } } | { "type": "onlyForSpellFromZone", "data": { zone: InteractionZoneCode, polarity: InteractionManaZoneSpendPolarity, } } | { "type": "onlyForFaceDownSpell" } | { "type": "onlyForAny", "data": { restrictions: Array, } } | { "type": "onlyForSpecialAction", "data": { action: InteractionManaSpecialAction, } } | { "type": "impossible" } | { "type": "convokePayment" }; export type InteractionObjectProperty = { "type": "power" } | { "type": "toughness" } | { "type": "manaValue" } | { "type": "manaSymbolCount", "data": { color: InteractionManaColor, } }; diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 176c97ebec..0d622cf4aa 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -559,6 +559,8 @@ export type ManaRestriction = count: number; }; } + // "Spend this mana only to cast spells of the source's chosen color." + | { OnlyForSpellColor: ManaColor } // "Spend this mana only to cast a spell from, or not from, the named zone." | { OnlyForSpellFromZone: ZoneSpend } // "Spend this mana only to cast a face-down spell." @@ -571,6 +573,8 @@ export type ManaRestriction = | { OnlyForAny: ManaRestriction[] } // "Spend this mana only on the named special action." | { OnlyForSpecialAction: SpecialAction } + // A source-dependent restriction could not resolve its required choice. + | "Impossible" // Internal convoke-tap marker — never surfaced to the player. | "ConvokePayment"; diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 0a7e82b072..14f70977c0 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -542,12 +542,14 @@ "onlyForSpellWithManaValue": "Spend only to cast spells meeting a mana-value requirement", "onlyForSpellMatchingCostCriteria": "Spend only to cast spells meeting a cost requirement", "onlyForSpellWithColorCount": "Spend only to cast spells meeting a color-count requirement", + "onlyForSpellColor": "Spend only to cast spells of the chosen color", "onlyForSpellFromZone": "Spend only to cast spells from an allowed zone", "onlyForFaceDownSpell": "Spend only to cast face-down spells", "onlyForActivation": "Spend only to activate abilities", "onlyForXCosts": "Spend only on costs that include {X}", "onlyForAny": "Spend only on one of the listed uses", "onlyForSpecialAction": "Spend only on a specific special action", + "impossible": "This mana can't be spent", "convokePayment": "Convoke payment", "grantsProperty": "Grants a property to the spell", "grantCantBeCountered": "Spend on this spell to make it uncounterable" @@ -1061,10 +1063,12 @@ "manaValueCriterion": "mana value {{comparator}} {{value}}", "hasXInCostCriterion": "an X in their cost", "onlyForSpellWithColorCount": "Only for spells with {{comparator}} {{count}} colors.", + "onlyForSpellColor": "Only for spells of the {{color}} color.", "onlyForSpellFromZone": "Only for spells {{polarity}} {{zone}}.", "onlyForFaceDownSpell": "Only for face-down spells.", "onlyForAny": "Only for one of these uses: {{restrictions}}", "onlyForSpecialAction": "Only for the {{action}} special action.", + "impossible": "This mana can't be spent.", "convokePayment": "Only for this convoke payment.", "anySpell": "any" }, diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 26cb1d7c70..35f5695079 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -2898,6 +2898,8 @@ export function manaRestrictionLabel( }); case "onlyForSpellWithColorCount": return t("gamePage.manaRestrictions.onlyForSpellWithColorCount", restriction.data); + case "onlyForSpellColor": + return t("gamePage.manaRestrictions.onlyForSpellColor", restriction.data); case "onlyForSpellFromZone": return t("gamePage.manaRestrictions.onlyForSpellFromZone", restriction.data); case "onlyForFaceDownSpell": @@ -2910,6 +2912,8 @@ export function manaRestrictionLabel( }); case "onlyForSpecialAction": return t("gamePage.manaRestrictions.onlyForSpecialAction", restriction.data); + case "impossible": + return t("gamePage.manaRestrictions.impossible"); case "convokePayment": return t("gamePage.manaRestrictions.convokePayment"); } diff --git a/client/src/viewmodel/manaPoolGroups.ts b/client/src/viewmodel/manaPoolGroups.ts index e87a1ba5ab..1b383a3c17 100644 --- a/client/src/viewmodel/manaPoolGroups.ts +++ b/client/src/viewmodel/manaPoolGroups.ts @@ -30,12 +30,14 @@ export const RESTRICTION_LABEL_KEYS: Record = { OnlyForSpellWithManaValue: "manaPool.onlyForSpellWithManaValue", OnlyForSpellMatchingCostCriteria: "manaPool.onlyForSpellMatchingCostCriteria", OnlyForSpellWithColorCount: "manaPool.onlyForSpellWithColorCount", + OnlyForSpellColor: "manaPool.onlyForSpellColor", OnlyForSpellFromZone: "manaPool.onlyForSpellFromZone", OnlyForFaceDownSpell: "manaPool.onlyForFaceDownSpell", OnlyForActivation: "manaPool.onlyForActivation", OnlyForXCosts: "manaPool.onlyForXCosts", OnlyForAny: "manaPool.onlyForAny", OnlyForSpecialAction: "manaPool.onlyForSpecialAction", + Impossible: "manaPool.impossible", ConvokePayment: "manaPool.convokePayment", }; diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 3806138c16..b2813cadcb 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -3660,6 +3660,11 @@ fn interaction_mana_restriction(restriction: &ManaRestriction) -> InteractionMan count: *count, } } + ManaRestriction::OnlyForSpellColor(color) => { + InteractionManaRestriction::OnlyForSpellColor { + color: mana_color_dto(*color), + } + } ManaRestriction::OnlyForSpellFromZone(zone_spend) => { InteractionManaRestriction::OnlyForSpellFromZone { zone: zone_code(zone_spend.zone), @@ -3681,6 +3686,7 @@ fn interaction_mana_restriction(restriction: &ManaRestriction) -> InteractionMan action: interaction_mana_special_action(*action), } } + ManaRestriction::Impossible => InteractionManaRestriction::Impossible, ManaRestriction::ConvokePayment => InteractionManaRestriction::ConvokePayment, } } @@ -7226,6 +7232,7 @@ fn bound_outbound_mana_restriction( | InteractionManaRestriction::OnlyForActivation | InteractionManaRestriction::OnlyForXCosts | InteractionManaRestriction::OnlyForFaceDownSpell + | InteractionManaRestriction::Impossible | InteractionManaRestriction::ConvokePayment => {} InteractionManaRestriction::OnlyForSpellType { spell_type } | InteractionManaRestriction::OnlyForCreatureType { @@ -7254,6 +7261,7 @@ fn bound_outbound_mana_restriction( } InteractionManaRestriction::OnlyForSpellWithManaValue { .. } | InteractionManaRestriction::OnlyForSpellWithColorCount { .. } + | InteractionManaRestriction::OnlyForSpellColor { .. } | InteractionManaRestriction::OnlyForSpellFromZone { .. } | InteractionManaRestriction::OnlyForSpecialAction { .. } => {} InteractionManaRestriction::OnlyForAny { restrictions } => { diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index e2c2fde5c4..af9dc0a6fd 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -339,6 +339,9 @@ pub enum InteractionManaRestriction { comparator: InteractionManaComparator, count: u32, }, + OnlyForSpellColor { + color: InteractionManaColor, + }, OnlyForSpellFromZone { zone: InteractionZoneCode, polarity: InteractionManaZoneSpendPolarity, @@ -350,6 +353,7 @@ pub enum InteractionManaRestriction { OnlyForSpecialAction { action: InteractionManaSpecialAction, }, + Impossible, ConvokePayment, } diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index b9a784a874..3e60294392 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -7,13 +7,14 @@ use engine::game::engine::apply; use engine::game::interaction::{ bind_interaction_authority, derive_viewer_interaction, preview_interaction, submit_interaction, }; -use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; use engine::game::scenario_db::GameScenarioDbExt; use engine::game::visibility::filter_state_for_viewer; use engine::game::DeckEntry; use engine::types::ability::{ - AbilityCost, AbilityDefinition, AbilityKind, CardSelectionMode, Chooser, CounterCostSelection, - Effect, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, TypedFilter, ZoneOwner, + AbilityCost, AbilityDefinition, AbilityKind, CardSelectionMode, Chooser, ChosenAttribute, + CounterCostSelection, Effect, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, + TypedFilter, ZoneOwner, }; use engine::types::actions::{GameAction, MulliganChoice}; use engine::types::card::CardFace; @@ -27,7 +28,7 @@ use engine::types::game_state::{ use engine::types::identifiers::CardId; use engine::types::interaction::{ AmountAssignment, InteractionActionCode, InteractionAvailability, InteractionChoiceId, - InteractionManaAbilityActivationScope, InteractionManaRestriction, + InteractionManaAbilityActivationScope, InteractionManaColor, InteractionManaRestriction, InteractionOpportunityResponse, InteractionOutcomeCode, InteractionPresentationSurface, InteractionPreviewRequest, InteractionPreviewStatus, InteractionReasonCode, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, @@ -1186,6 +1187,101 @@ fn tap_land_for_mana_projects_live_castle_output_per_unit_and_rejects_stale_choi assert_eq!(stale.code, InteractionReasonCode::StaleInteraction); } +#[test] +fn tap_land_for_mana_projects_resolved_and_missing_chosen_color_restrictions() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_land_from_oracle( + P0, + "Chosen Color Contract", + "As this land enters, choose a color.\n{T}: Add {C}. Spend this mana only to cast monocolored spells of the chosen color.", + ) + .id(); + let missing_choice_state = scenario.state().clone(); + scenario + .state_mut() + .objects + .get_mut(&source) + .expect("chosen-color source exists") + .chosen_attributes + .push(ChosenAttribute::Color(ManaColor::Red)); + + let projected_restrictions = |state: &mut GameState, binding: &str| { + bind(state, binding); + let view = priority_view(state); + let InteractionOpportunityResponse::ExactChoices { choices } = + &view.opportunities[0].response + else { + panic!("priority is projected as exact choices"); + }; + choices + .iter() + .find(|choice| { + choice.surfaces.iter().any(|surface| { + matches!( + surface, + InteractionPresentationSurface::Action { + code: InteractionActionCode::TapLandForMana, + .. + } + ) + }) && choice.surfaces.iter().any(|surface| { + matches!( + surface, + InteractionPresentationSurface::Object { + role: InteractionRoleCode::Source, + reference, + .. + } if reference == &source.0.to_string() + ) + }) + }) + .and_then(|choice| { + choice.surfaces.iter().find_map(|surface| match surface { + InteractionPresentationSurface::Mana { + role: InteractionRoleCode::ProducedMana, + restrictions, + .. + } => Some(restrictions.clone()), + _ => None, + }) + }) + .expect("the chosen-color mana source projects one produced mana unit") + }; + + let mut chosen_runner = scenario.build(); + assert_eq!( + projected_restrictions(chosen_runner.state_mut(), "chosen-color-output"), + vec![ + InteractionManaRestriction::OnlyForSpellWithColorCount { + comparator: engine::types::interaction::InteractionManaComparator::Equal, + count: 1, + }, + InteractionManaRestriction::OnlyForSpellColor { + color: InteractionManaColor::Red, + }, + ], + "the viewer contract preserves both axes of the resolved restriction" + ); + + let mut missing_choice_runner = GameRunner::from_state(missing_choice_state); + assert_eq!( + projected_restrictions( + missing_choice_runner.state_mut(), + "missing-chosen-color-output" + ), + vec![ + InteractionManaRestriction::OnlyForSpellWithColorCount { + comparator: engine::types::interaction::InteractionManaComparator::Equal, + count: 1, + }, + InteractionManaRestriction::Impossible, + ], + "a missing choice remains visibly fail-closed instead of appearing unrestricted" + ); +} + #[test] fn preference_and_failed_actions_preserve_capability_but_same_actor_progress_rotates_it() { let mut scenario = GameScenario::new(); From b46216216361009154998fe53cbeedf49e82170b Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:03:02 +0200 Subject: [PATCH 8/8] fix(PR-6625): repair review blockers --- client/src/i18n/locales/de/game.json | 4 ++ client/src/i18n/locales/es/game.json | 4 ++ client/src/i18n/locales/fr/game.json | 4 ++ client/src/i18n/locales/it/game.json | 4 ++ client/src/i18n/locales/pl/game.json | 4 ++ client/src/i18n/locales/pt/game.json | 4 ++ crates/engine/src/game/cost_payability.rs | 19 +++--- .../tests/integration/interaction_contract.rs | 62 ++++++++++++------- 8 files changed, 76 insertions(+), 29 deletions(-) diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 5b9d42aae3..3ebce1e171 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -510,12 +510,14 @@ "onlyForSpellWithManaValue": "Nur zum Wirken von Zaubersprüchen ausgeben, die eine Anforderung an den Manabetrag erfüllen", "onlyForSpellMatchingCostCriteria": "Nur zum Wirken von Zaubersprüchen ausgeben, die eine Kostenanforderung erfüllen", "onlyForSpellWithColorCount": "Nur zum Wirken von Zaubersprüchen ausgeben, die eine Anforderung an die Anzahl der Farben erfüllen", + "onlyForSpellColor": "Nur zum Wirken von Zaubersprüchen der gewählten Farbe ausgeben", "onlyForSpellFromZone": "Nur zum Wirken von Zaubersprüchen aus einer erlaubten Zone ausgeben", "onlyForFaceDownSpell": "Nur zum Wirken verdeckter Zaubersprüche ausgeben", "onlyForActivation": "Nur zum Aktivieren von Fähigkeiten ausgeben", "onlyForXCosts": "Nur für Kosten ausgeben, die {X} enthalten", "onlyForAny": "Nur für eine der aufgeführten Verwendungen ausgeben", "onlyForSpecialAction": "Nur für eine bestimmte Sonderaktion ausgeben", + "impossible": "Dieses Mana kann nicht ausgegeben werden", "convokePayment": "Beschwörbeitrag-Bezahlung", "grantsProperty": "Verleiht dem Zauberspruch eine Eigenschaft", "grantCantBeCountered": "Für diesen Zauberspruch ausgeben, damit er nicht neutralisiert werden kann" @@ -1029,10 +1031,12 @@ "manaValueCriterion": "Manawert {{comparator}} {{value}}", "hasXInCostCriterion": "ein X in ihren Kosten", "onlyForSpellWithColorCount": "Nur für Zaubersprüche mit {{comparator}} {{count}} Farben.", + "onlyForSpellColor": "Nur für Zaubersprüche der Farbe {{color}}.", "onlyForSpellFromZone": "Nur für Zaubersprüche {{polarity}} {{zone}}.", "onlyForFaceDownSpell": "Nur für verdeckte Zaubersprüche.", "onlyForAny": "Nur für eine dieser Verwendungen: {{restrictions}}", "onlyForSpecialAction": "Nur für die Sonderaktion {{action}}.", + "impossible": "Dieses Mana kann nicht ausgegeben werden.", "convokePayment": "Nur für diese Einberufen-Zahlung.", "anySpell": "beliebige" }, diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index af08bd820f..9f6a998304 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -510,12 +510,14 @@ "onlyForSpellWithManaValue": "Gastar solo para lanzar hechizos que cumplan un requisito de valor de maná", "onlyForSpellMatchingCostCriteria": "Gastar solo para lanzar hechizos que cumplan un requisito de coste", "onlyForSpellWithColorCount": "Gastar solo para lanzar hechizos que cumplan un requisito de cantidad de colores", + "onlyForSpellColor": "Gastar solo para lanzar hechizos del color elegido", "onlyForSpellFromZone": "Gastar solo para lanzar hechizos desde una zona permitida", "onlyForFaceDownSpell": "Gastar solo para lanzar hechizos boca abajo", "onlyForActivation": "Gastar solo para activar habilidades", "onlyForXCosts": "Gastar solo en costes que incluyan {X}", "onlyForAny": "Gastar solo en uno de los usos indicados", "onlyForSpecialAction": "Gastar solo en una acción especial específica", + "impossible": "Este maná no se puede gastar", "convokePayment": "Pago de convocar", "grantsProperty": "Otorga una propiedad al hechizo", "grantCantBeCountered": "Gástalo en este hechizo para que no se pueda contrarrestar" @@ -1029,10 +1031,12 @@ "manaValueCriterion": "valor de maná {{comparator}} {{value}}", "hasXInCostCriterion": "una X en su coste", "onlyForSpellWithColorCount": "Solo para hechizos con {{comparator}} {{count}} colores.", + "onlyForSpellColor": "Solo para hechizos del color {{color}}.", "onlyForSpellFromZone": "Solo para hechizos {{polarity}} {{zone}}.", "onlyForFaceDownSpell": "Solo para hechizos boca abajo.", "onlyForAny": "Solo para uno de estos usos: {{restrictions}}", "onlyForSpecialAction": "Solo para la acción especial {{action}}.", + "impossible": "Este maná no se puede gastar.", "convokePayment": "Solo para este pago de convocar.", "anySpell": "cualquiera" }, diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 34beba1d27..a5a83e7346 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -510,12 +510,14 @@ "onlyForSpellWithManaValue": "À dépenser uniquement pour lancer des sorts répondant à une condition de valeur de mana", "onlyForSpellMatchingCostCriteria": "À dépenser uniquement pour lancer des sorts répondant à une condition de coût", "onlyForSpellWithColorCount": "À dépenser uniquement pour lancer des sorts répondant à une condition de nombre de couleurs", + "onlyForSpellColor": "À dépenser uniquement pour lancer des sorts de la couleur choisie", "onlyForSpellFromZone": "À dépenser uniquement pour lancer des sorts depuis une zone autorisée", "onlyForFaceDownSpell": "À dépenser uniquement pour lancer des sorts face cachée", "onlyForActivation": "À dépenser uniquement pour activer des capacités", "onlyForXCosts": "À dépenser uniquement pour des coûts incluant {X}", "onlyForAny": "À dépenser uniquement pour l'une des utilisations indiquées", "onlyForSpecialAction": "À dépenser uniquement pour une action spéciale spécifique", + "impossible": "Ce mana ne peut pas être dépensé", "convokePayment": "Paiement de la convocation", "grantsProperty": "Accorde une propriété au sort", "grantCantBeCountered": "À dépenser pour ce sort afin qu'il ne puisse pas être contrecarré" @@ -1029,10 +1031,12 @@ "manaValueCriterion": "valeur de mana {{comparator}} {{value}}", "hasXInCostCriterion": "un X dans leur coût", "onlyForSpellWithColorCount": "Uniquement pour les sorts avec {{comparator}} {{count}} couleurs.", + "onlyForSpellColor": "Uniquement pour les sorts de la couleur {{color}}.", "onlyForSpellFromZone": "Uniquement pour les sorts {{polarity}} {{zone}}.", "onlyForFaceDownSpell": "Uniquement pour les sorts face cachée.", "onlyForAny": "Uniquement pour l'un de ces usages : {{restrictions}}", "onlyForSpecialAction": "Uniquement pour l'action spéciale {{action}}.", + "impossible": "Ce mana ne peut pas être dépensé.", "convokePayment": "Uniquement pour ce paiement de convocation.", "anySpell": "n'importe quel" }, diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index d46b91444b..1f44c5addf 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -510,12 +510,14 @@ "onlyForSpellWithManaValue": "Spendi solo per lanciare magie che soddisfano un requisito di valore di mana", "onlyForSpellMatchingCostCriteria": "Spendi solo per lanciare magie che soddisfano un requisito di costo", "onlyForSpellWithColorCount": "Spendi solo per lanciare magie che soddisfano un requisito sul numero di colori", + "onlyForSpellColor": "Spendi solo per lanciare magie del colore scelto", "onlyForSpellFromZone": "Spendi solo per lanciare magie da una zona consentita", "onlyForFaceDownSpell": "Spendi solo per lanciare magie a faccia in giù", "onlyForActivation": "Spendi solo per attivare abilità", "onlyForXCosts": "Spendi solo su costi che includono {X}", "onlyForAny": "Spendi solo per uno degli usi elencati", "onlyForSpecialAction": "Spendi solo per un'azione speciale specifica", + "impossible": "Questo mana non può essere speso", "convokePayment": "Pagamento di Convocare", "grantsProperty": "Concede una proprietà alla magia", "grantCantBeCountered": "Spendi per questa magia per renderla impossibile da neutralizzare" @@ -1029,10 +1031,12 @@ "manaValueCriterion": "valore di mana {{comparator}} {{value}}", "hasXInCostCriterion": "una X nel loro costo", "onlyForSpellWithColorCount": "Solo per magie con {{comparator}} {{count}} colori.", + "onlyForSpellColor": "Solo per magie del colore {{color}}.", "onlyForSpellFromZone": "Solo per magie {{polarity}} {{zone}}.", "onlyForFaceDownSpell": "Solo per magie a faccia in giù.", "onlyForAny": "Solo per uno di questi usi: {{restrictions}}", "onlyForSpecialAction": "Solo per l'azione speciale {{action}}.", + "impossible": "Questo mana non può essere speso.", "convokePayment": "Solo per questo pagamento di convocazione.", "anySpell": "qualsiasi" }, diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index cc41b9755d..ed80f18a8a 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -510,12 +510,14 @@ "onlyForSpellWithManaValue": "Wydaj tylko na rzucanie czarów spełniających wymaganie wartości many", "onlyForSpellMatchingCostCriteria": "Wydaj tylko na rzucanie czarów spełniających wymaganie kosztu", "onlyForSpellWithColorCount": "Wydaj tylko na rzucanie czarów spełniających wymaganie liczby kolorów", + "onlyForSpellColor": "Wydaj tylko na rzucanie czarów wybranego koloru", "onlyForSpellFromZone": "Wydaj tylko na rzucanie czarów z dozwolonej strefy", "onlyForFaceDownSpell": "Wydaj tylko na rzucanie zakrytych czarów", "onlyForActivation": "Wydaj tylko na aktywowanie zdolności", "onlyForXCosts": "Wydaj tylko na koszty zawierające {X}", "onlyForAny": "Wydaj tylko na jedno z wymienionych zastosowań", "onlyForSpecialAction": "Wydaj tylko na określoną akcję specjalną", + "impossible": "Ta mana nie może zostać wydana", "convokePayment": "Płatność Convoke", "grantsProperty": "Nadaje czarowi właściwość", "grantCantBeCountered": "Wydaj na ten czar, aby nie można było go skontrować" @@ -1029,10 +1031,12 @@ "manaValueCriterion": "wartość many {{comparator}} {{value}}", "hasXInCostCriterion": "X w ich koszcie", "onlyForSpellWithColorCount": "Tylko na czary z {{comparator}} {{count}} kolorami.", + "onlyForSpellColor": "Tylko na czary w kolorze {{color}}.", "onlyForSpellFromZone": "Tylko na czary {{polarity}} {{zone}}.", "onlyForFaceDownSpell": "Tylko na czary zakryte.", "onlyForAny": "Tylko do jednego z tych zastosowań: {{restrictions}}", "onlyForSpecialAction": "Tylko do specjalnej akcji {{action}}.", + "impossible": "Ta mana nie może zostać wydana.", "convokePayment": "Tylko do tej płatności konwokacji.", "anySpell": "dowolny" }, diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 21d7929746..048b81c120 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -510,12 +510,14 @@ "onlyForSpellWithManaValue": "Gaste apenas para conjurar mágicas que atendam a um requisito de valor de mana", "onlyForSpellMatchingCostCriteria": "Gaste apenas para conjurar mágicas que atendam a um requisito de custo", "onlyForSpellWithColorCount": "Gaste apenas para conjurar mágicas que atendam a um requisito de quantidade de cores", + "onlyForSpellColor": "Gaste este mana apenas para conjurar mágicas da cor escolhida", "onlyForSpellFromZone": "Gaste apenas para conjurar mágicas a partir de uma zona permitida", "onlyForFaceDownSpell": "Gaste apenas para conjurar mágicas com a face voltada para baixo", "onlyForActivation": "Gaste apenas para ativar habilidades", "onlyForXCosts": "Gaste apenas em custos que incluam {X}", "onlyForAny": "Gaste apenas em um dos usos listados", "onlyForSpecialAction": "Gaste apenas em uma ação especial específica", + "impossible": "Este mana não pode ser gasto", "convokePayment": "Pagamento de convocação", "grantsProperty": "Concede uma propriedade à mágica", "grantCantBeCountered": "Gaste nesta mágica para que ela não possa ser anulada" @@ -1029,10 +1031,12 @@ "manaValueCriterion": "valor de mana {{comparator}} {{value}}", "hasXInCostCriterion": "um X em seu custo", "onlyForSpellWithColorCount": "Apenas para mágicas com {{comparator}} {{count}} cores.", + "onlyForSpellColor": "Apenas para mágicas da cor {{color}}.", "onlyForSpellFromZone": "Apenas para mágicas {{polarity}} {{zone}}.", "onlyForFaceDownSpell": "Apenas para mágicas viradas para baixo.", "onlyForAny": "Apenas para um destes usos: {{restrictions}}", "onlyForSpecialAction": "Apenas para a ação especial {{action}}.", + "impossible": "Este mana não pode ser gasto.", "convokePayment": "Apenas para este pagamento de convocar.", "anySpell": "qualquer" }, diff --git a/crates/engine/src/game/cost_payability.rs b/crates/engine/src/game/cost_payability.rs index 136785e060..e41c23c1e6 100644 --- a/crates/engine/src/game/cost_payability.rs +++ b/crates/engine/src/game/cost_payability.rs @@ -1553,15 +1553,20 @@ mod tests { let mut scenario = GameScenario::new(); let source = scenario - .add_creature_from_oracle( - P0, - "Waterbender Ascension", - 0, - 0, - "{4}: Draw a card.\n{4}: Draw a card.", + .add_creature(P0, "Waterbender Ascension", 0, 0) + .as_enchantment() + .from_oracle_text( + "Power-up — Waterbend {4}: Target creature can't be blocked this turn.\n{4}: Draw a card.", ) .id(); - let abilities = &mut scenario.state.objects.get_mut(&source).unwrap().abilities; + let abilities = std::sync::Arc::make_mut( + &mut scenario + .state + .objects + .get_mut(&source) + .expect("Waterbender source exists") + .abilities, + ); abilities[0].ability_tag = Some(AbilityTag::PowerUp); abilities[1].ability_tag = Some(AbilityTag::Equip); // Four colorless mana usable ONLY for a Power-up-tagged activation. diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 3e60294392..03119b4496 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -7,7 +7,7 @@ use engine::game::engine::apply; use engine::game::interaction::{ bind_interaction_authority, derive_viewer_interaction, preview_interaction, submit_interaction, }; -use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::scenario::{GameScenario, P0, P1}; use engine::game::scenario_db::GameScenarioDbExt; use engine::game::visibility::filter_state_for_viewer; use engine::game::DeckEntry; @@ -25,7 +25,7 @@ use engine::types::game_state::{ MulliganDecisionEntry, MulliganDecisionPhase, OpeningHandBottomReason, PendingTriggerSummary, PlayerDeckPool, TurnBoundary, WaitingFor, }; -use engine::types::identifiers::CardId; +use engine::types::identifiers::{CardId, ObjectId}; use engine::types::interaction::{ AmountAssignment, InteractionActionCode, InteractionAvailability, InteractionChoiceId, InteractionManaAbilityActivationScope, InteractionManaColor, InteractionManaRestriction, @@ -1191,23 +1191,28 @@ fn tap_land_for_mana_projects_live_castle_output_per_unit_and_rejects_stale_choi fn tap_land_for_mana_projects_resolved_and_missing_chosen_color_restrictions() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); - let source = scenario - .add_land_from_oracle( - P0, - "Chosen Color Contract", - "As this land enters, choose a color.\n{T}: Add {C}. Spend this mana only to cast monocolored spells of the chosen color.", - ) + let oracle = "As this land enters, choose a color.\n{T}: Add {C}. Spend this mana only to cast monocolored spells of the chosen color."; + let red_source = scenario + .add_land_from_oracle(P0, "Red Chosen Color Contract", oracle) .id(); - let missing_choice_state = scenario.state().clone(); - scenario - .state_mut() - .objects - .get_mut(&source) - .expect("chosen-color source exists") - .chosen_attributes - .push(ChosenAttribute::Color(ManaColor::Red)); + let blue_source = scenario + .add_land_from_oracle(P0, "Blue Chosen Color Contract", oracle) + .id(); + let missing_choice_source = scenario + .add_land_from_oracle(P0, "Missing Chosen Color Contract", oracle) + .id(); + let mut runner = scenario.build(); + for (source, color) in [(red_source, ManaColor::Red), (blue_source, ManaColor::Blue)] { + runner + .state_mut() + .objects + .get_mut(&source) + .expect("chosen-color source exists") + .chosen_attributes + .push(ChosenAttribute::Color(color)); + } - let projected_restrictions = |state: &mut GameState, binding: &str| { + let projected_restrictions = |state: &mut GameState, source: ObjectId, binding: &str| { bind(state, binding); let view = priority_view(state); let InteractionOpportunityResponse::ExactChoices { choices } = @@ -1250,9 +1255,8 @@ fn tap_land_for_mana_projects_resolved_and_missing_chosen_color_restrictions() { .expect("the chosen-color mana source projects one produced mana unit") }; - let mut chosen_runner = scenario.build(); assert_eq!( - projected_restrictions(chosen_runner.state_mut(), "chosen-color-output"), + projected_restrictions(runner.state_mut(), red_source, "red-chosen-color-output"), vec![ InteractionManaRestriction::OnlyForSpellWithColorCount { comparator: engine::types::interaction::InteractionManaComparator::Equal, @@ -1262,13 +1266,27 @@ fn tap_land_for_mana_projects_resolved_and_missing_chosen_color_restrictions() { color: InteractionManaColor::Red, }, ], - "the viewer contract preserves both axes of the resolved restriction" + "the viewer contract preserves the red source's resolved restriction" + ); + + assert_eq!( + projected_restrictions(runner.state_mut(), blue_source, "blue-chosen-color-output"), + vec![ + InteractionManaRestriction::OnlyForSpellWithColorCount { + comparator: engine::types::interaction::InteractionManaComparator::Equal, + count: 1, + }, + InteractionManaRestriction::OnlyForSpellColor { + color: InteractionManaColor::Blue, + }, + ], + "each source projects its own chosen color rather than another source's choice" ); - let mut missing_choice_runner = GameRunner::from_state(missing_choice_state); assert_eq!( projected_restrictions( - missing_choice_runner.state_mut(), + runner.state_mut(), + missing_choice_source, "missing-chosen-color-output" ), vec![