From c8ae5f87cc2278cd1d1981178c9581d13e78ef14 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Sun, 12 Jul 2026 02:05:26 -0500 Subject: [PATCH 1/3] fix(parser): UNSUPPORTED full-set one-off: The Dining Car --- crates/engine/src/analysis/ability_graph.rs | 1 + crates/engine/src/game/ability_rw.rs | 8 ++ crates/engine/src/game/ability_scan.rs | 5 + crates/engine/src/game/casting.rs | 32 +++++- crates/engine/src/game/casting_tests.rs | 101 ++++++++++++++++ crates/engine/src/game/coverage.rs | 1 + crates/engine/src/game/effects/mod.rs | 85 ++++++++++++++ crates/engine/src/game/printed_cards.rs | 1 + crates/engine/src/game/trigger_index.rs | 1 + crates/engine/src/game/turns.rs | 2 + .../src/parser/oracle_effect/imperative.rs | 40 ++++++- crates/engine/src/parser/oracle_effect/mod.rs | 7 ++ .../src/parser/oracle_effect/sequence.rs | 1 + .../engine/src/parser/oracle_effect/tests.rs | 69 +++++++++++ crates/engine/src/parser/oracle_ir/doc.rs | 1 + .../src/parser/oracle_static/cost_mod.rs | 39 +++++++ .../src/parser/oracle_static/dispatch.rs | 30 +---- crates/engine/src/parser/oracle_static/mod.rs | 5 +- .../engine/src/parser/oracle_static/tests.rs | 57 +++++++++ crates/engine/src/parser/oracle_target.rs | 108 ++++++++++++++++-- crates/engine/src/types/ability.rs | 24 ++++ crates/engine/src/types/game_state.rs | 26 +++++ 22 files changed, 606 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index a875c0af67..f97201cba0 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -919,6 +919,7 @@ fn effect_projection(effect: &Effect) -> Projection { | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 21d7b18c2d..82e3ba218c 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2808,6 +2808,13 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::ChooseDamageSource { source_filter: target, } + // CR 602.2: The Dining Car's transient activation-cost reduction scopes by + // `source_filter`, walked for a nested frozen event-context tag like every + // single-filter effect. + | Effect::ReduceActivatedAbilityCost { + source_filter: target, + .. + } | Effect::ReturnAsAura { enchant_filter: target, .. @@ -5045,6 +5052,7 @@ fn rw_effect( Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::CreateEmblem { .. } | Effect::CreateDamageReplacement { .. } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 67e973ce85..6957b92253 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -1004,6 +1004,10 @@ fn scan_effect(x: &Effect) -> Axes { Effect::CreateDelayedTrigger { .. } => Axes::CONSERVATIVE, Effect::AddTargetReplacement { .. } => Axes::CONSERVATIVE, Effect::AddRestriction { .. } => Axes::CONSERVATIVE, + Effect::ReduceActivatedAbilityCost { + source_filter, + amount: _, + } => scan_target_filter(source_filter), Effect::ReduceNextSpellCost { spell_filter, amount: _, @@ -3591,6 +3595,7 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index a9b0c634cb..050d444d3e 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -16152,8 +16152,13 @@ fn apply_static_activated_ability_cost_reduction( player: PlayerId, source_id: ObjectId, ) { - // CR 604.1: O(1) presence gate — no ReduceAbilityCost static means no reduction. - if !static_kind_present(state, StaticModeKind::ReduceAbilityCost) { + // CR 604.1: O(1) presence gate — nothing to do unless a printed + // ReduceAbilityCost static (CR 611.3) OR a resolution-generated transient + // reduction (CR 611.2 — The Dining Car's chaos ability, held in + // `pending_activation_cost_reductions`) is present. + let has_static = static_kind_present(state, StaticModeKind::ReduceAbilityCost); + let has_transient = !state.pending_activation_cost_reductions.is_empty(); + if !has_static && !has_transient { return; } crate::game::perf_counters::record_static_full_scan(); @@ -16270,6 +16275,29 @@ fn apply_static_activated_ability_cost_reduction( CostModifyMode::Minimum => {} } } + + // CR 611.2 + CR 602.2: resolution-generated, turn-scoped reductions + // (`Effect::ReduceActivatedAbilityCost` — The Dining Car's chaos ability). + // These live in `pending_activation_cost_reductions` (not a printed static) and + // are applied here so the single cost authority (CR 601.2f) stays intact. + // Cleared at cleanup (CR 514.2). + for reduction in &state.pending_activation_cost_reductions { + if reduction.amount == 0 { + continue; + } + // CR 602.2: scope by the reduction's `source_filter` against the ability's + // SOURCE permanent, anchoring "you control" on the reduction's controller. + let ctx = super::filter::FilterContext::from_source_with_controller( + reduction.source_id, + reduction.controller, + ); + if !super::filter::matches_target_filter(state, source_id, &reduction.source_filter, &ctx) { + continue; + } + // CR 118.7a: subtract generic mana; The Dining Car specifies no one-mana + // floor, so the floor is 0. + reduce_generic_in_cost_with_minimum_mana(cost, reduction.amount, 0); + } } /// CR 116.2 + CR 118.7a: Reduce (or raise) the generic mana of a special diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index fbd3df5158..8bdd5c4ceb 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -9432,6 +9432,107 @@ fn activated_ability_cost_reduction_applies_to_matching_permanent_type() { ); } +/// Helper: an artifact permanent with a pure `{2}` generic-mana activated ability +/// (no tap/sacrifice, so affordability is a clean function of available mana). +fn make_artifact_with_generic_ability( + state: &mut GameState, + card_id: CardId, + owner: PlayerId, + name: &str, + is_token: bool, +) -> ObjectId { + let id = create_object(state, card_id, owner, name.to_string(), Zone::Battlefield); + let obj = state.objects.get_mut(&id).unwrap(); + obj.is_token = is_token; + obj.card_types.core_types.push(CoreType::Artifact); + Arc::make_mut(&mut obj.abilities).push( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ) + .cost(AbilityCost::Mana { + cost: ManaCost::generic(2), + }), + ); + id +} + +/// CR 611.2 + CR 602.2 + CR 514.2: RUNTIME PROOF of The Dining Car's transient +/// activation-cost reduction. A `PendingActivationCostReduction` scoped to +/// "artifact tokens you control" reduces a controlled artifact TOKEN's activated +/// ability by {2}, but leaves a controlled artifact NONTOKEN and an OPPONENT's +/// artifact token untouched — then the reduction is cleared at cleanup. The same +/// `apply_static_activated_ability_cost_reduction` authority the AI's affordability +/// check routes through is exercised via `can_activate_ability_now`. +#[test] +fn transient_activation_cost_reduction_hits_only_controlled_artifact_tokens() { + let mut state = setup_game_at_main_phase(); + + // FilterContext anchor for the reduction (The Dining Car itself). + let dining_car = create_object( + &mut state, + CardId(800), + PlayerId(0), + "The Dining Car".to_string(), + Zone::Battlefield, + ); + + let controlled_token = + make_artifact_with_generic_ability(&mut state, CardId(801), PlayerId(0), "Token A", true); + let controlled_nontoken = + make_artifact_with_generic_ability(&mut state, CardId(802), PlayerId(0), "Nontoken", false); + let opponent_token = + make_artifact_with_generic_ability(&mut state, CardId(803), PlayerId(1), "Opp Token", true); + + // The chaos reduction: activated abilities of artifact tokens P0 controls cost + // {2} less this turn. + state.pending_activation_cost_reductions.push( + crate::types::game_state::PendingActivationCostReduction { + controller: PlayerId(0), + source_id: dining_car, + amount: 2, + source_filter: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + controller: Some(ControllerRef::You), + properties: vec![FilterProp::Token], + }), + }, + ); + + // P0 has ZERO mana: only the reduced (cost 2→0) controlled token is affordable. + assert!( + can_activate_ability_now(&state, PlayerId(0), controlled_token, 0), + "controlled artifact token's {{2}} cost is reduced to {{0}}, affordable with no mana", + ); + assert!( + !can_activate_ability_now(&state, PlayerId(0), controlled_nontoken, 0), + "artifact NONTOKEN is not a token → not reduced → still {{2}}, unaffordable with no mana", + ); + // P0's reduction must not leak onto P1's artifact token. + assert!( + !can_activate_ability_now(&state, PlayerId(1), opponent_token, 0), + "opponent's artifact token is not controlled by the reduction's 'you' → not reduced", + ); + + // Sanity: with {2} available the unreduced nontoken IS affordable — proving the + // earlier `false` was a cost gate, not an unrelated legality gate. + add_mana(&mut state, PlayerId(0), ManaType::Colorless, 2); + assert!( + can_activate_ability_now(&state, PlayerId(0), controlled_nontoken, 0), + "with {{2}} mana the unreduced nontoken ability is affordable", + ); + + // CR 514.2: the "this turn" reduction ends at cleanup (start_next_turn clears it). + crate::game::turns::start_next_turn(&mut state, &mut Vec::new()); + assert!( + state.pending_activation_cost_reductions.is_empty(), + "the 'this turn' activation-cost reduction must be cleared at cleanup", + ); +} + #[test] fn activated_ability_cost_reduction_mana_exemption_skips_mana_abilities() { // CR 601.2f + CR 605.1a: A "cost {2} less to activate that aren't mana diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 1609d7add7..e0ee695524 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -3497,6 +3497,7 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { | Effect::Cleanup { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::CreateEmblem { .. } | Effect::PayCost { .. } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 7598c58101..349fa1fe7a 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3318,6 +3318,9 @@ pub fn resolve_effect( Effect::ReduceNextSpellCost { .. } => { resolve_reduce_next_spell_cost(state, ability, events) } + Effect::ReduceActivatedAbilityCost { .. } => { + resolve_reduce_activated_ability_cost(state, ability, events) + } Effect::GrantNextSpellAbility { .. } => { resolve_grant_next_spell_ability(state, ability, events) } @@ -9494,6 +9497,41 @@ fn resolve_reduce_next_spell_cost( Ok(()) } +/// CR 602.2 + CR 601.2f + CR 611.2: Register a turn-scoped activation-cost reduction +/// (The Dining Car's chaos ability). Unlike `resolve_reduce_next_spell_cost` this is +/// not consumed on use — it is read by every matching activation this turn and cleared +/// at cleanup (CR 514.2, `turns.rs`). +fn resolve_reduce_activated_ability_cost( + state: &mut GameState, + ability: &crate::types::ability::ResolvedAbility, + events: &mut Vec, +) -> Result<(), crate::types::ability::EffectError> { + let (amount, source_filter) = match &ability.effect { + Effect::ReduceActivatedAbilityCost { + amount, + source_filter, + } => (*amount, source_filter.clone()), + _ => { + return Err(crate::types::ability::EffectError::MissingParam( + "ReduceActivatedAbilityCost".to_string(), + )) + } + }; + state.pending_activation_cost_reductions.push( + crate::types::game_state::PendingActivationCostReduction { + controller: ability.controller, + source_id: ability.source_id, + amount, + source_filter, + }, + ); + events.push(GameEvent::EffectResolved { + kind: crate::types::ability::EffectKind::ReduceActivatedAbilityCost, + source_id: ability.source_id, + }); + Ok(()) +} + /// CR 601.2f: Register a pending next-spell modifier (keyword grant, uncounterability, flash). /// Consumed when the player casts their next qualifying spell. fn resolve_grant_next_spell_ability( @@ -22907,4 +22945,51 @@ mod tests { ); } } + + /// CR 611.2 + CR 602.2: resolving `Effect::ReduceActivatedAbilityCost` registers + /// exactly one `PendingActivationCostReduction` carrying the effect's amount and + /// source filter, anchored to the resolving ability's controller/source, and + /// emits an `EffectResolved` event. + #[test] + fn reduce_activated_ability_cost_resolution_registers_pending_reduction() { + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "The Dining Car".to_string(), + Zone::Battlefield, + ); + let source_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + controller: Some(ControllerRef::You), + properties: vec![FilterProp::Token], + }); + let ability = ResolvedAbility::new( + Effect::ReduceActivatedAbilityCost { + amount: 2, + source_filter: source_filter.clone(), + }, + vec![], + source_id, + PlayerId(0), + ); + + let mut events = Vec::new(); + resolve_reduce_activated_ability_cost(&mut state, &ability, &mut events).unwrap(); + + assert_eq!(state.pending_activation_cost_reductions.len(), 1); + let reduction = &state.pending_activation_cost_reductions[0]; + assert_eq!(reduction.controller, PlayerId(0)); + assert_eq!(reduction.source_id, source_id); + assert_eq!(reduction.amount, 2); + assert_eq!(reduction.source_filter, source_filter); + assert!(events.iter().any(|e| matches!( + e, + GameEvent::EffectResolved { + kind: crate::types::ability::EffectKind::ReduceActivatedAbilityCost, + .. + } + ))); + } } diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index 7f9b9b6f1e..3c4b91de7e 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -1129,6 +1129,7 @@ fn walk_effect(effect: &Effect, out: &mut Vec) { | Effect::SetClassLevel { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index a8d3605bab..4f78f66a93 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -820,6 +820,7 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey | EffectKind::AddTargetReplacement | EffectKind::AddRestriction | EffectKind::ReduceNextSpellCost + | EffectKind::ReduceActivatedAbilityCost | EffectKind::GrantNextSpellAbility | EffectKind::AddPendingETBCounters | EffectKind::AddPendingEntersModifications diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 6ec939360f..a5c5a0e6d5 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -975,6 +975,8 @@ pub fn start_next_turn(state: &mut GameState, events: &mut Vec) { // CR 601.2f: Clear one-shot cost reductions and spell modifiers from the previous turn. state.pending_spell_cost_reductions.clear(); state.pending_next_spell_modifiers.clear(); + // CR 514.2: "this turn" activation-cost reductions (The Dining Car) end during cleanup. + state.pending_activation_cost_reductions.clear(); // CR 614.1c: Pending ETB counters are turn-scoped (e.g., "this turn" effects). state.pending_etb_counters.clear(); state.modal_modes_chosen_this_turn.clear(); diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index fa12226575..7ab5cb87ef 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -25,6 +25,7 @@ use crate::parser::oracle_ir::diagnostic::OracleDiagnostic; use crate::parser::oracle_nom::bridge::{nom_on_lower, nom_parse_lower, split_once_on_lower}; use crate::parser::oracle_nom::primitives as nom_primitives; use crate::parser::oracle_nom::quantity as nom_quantity; +use crate::parser::oracle_static::parse_activated_ability_cost_head; use crate::parser::oracle_static::{ parse_continuous_modifications, parse_may_look_at_face_down_filter, parse_quoted_ability_modifications, @@ -42,13 +43,14 @@ use crate::types::ability::{ use crate::types::card_type::CoreType; use crate::types::phase::Phase; use crate::types::player::PlayerCounterKind; -use crate::types::statics::StaticMode; +use crate::types::statics::{CostModifyMode, StaticMode}; use crate::types::zones::Zone; use super::super::oracle_target::{ parse_anaphoric_target_ref, parse_event_context_ref, parse_fight_target, parse_mass_type_union, parse_target, parse_target_with_ctx, parse_target_with_syntax, parse_type_phrase, - parse_word_bounded, resolve_pronoun_target, resolve_singular_exiled_card_target, TargetSyntax, + parse_type_phrase_with_ctx, parse_word_bounded, resolve_pronoun_target, + resolve_singular_exiled_card_target, TargetSyntax, }; use super::super::oracle_util::{ contains_possessive, contains_self_or_object_pronoun, parse_count_expr, parse_mana_symbols, @@ -56,6 +58,40 @@ use super::super::oracle_util::{ starts_with_possessive, TextPair, }; +/// CR 611.2 + CR 602.1: Parse the transient (this-turn) activated-ability +/// cost-reduction effect — "activated abilities of cost {N} less to +/// activate [this turn]" (The Dining Car's chaos ability). Reuses the shared +/// static grammar head `parse_activated_ability_cost_head`, so the static +/// (Training Grounds) and transient forms share one authority. +/// +/// Only the fixed-amount, `Reduce`, `"activated"` transient case has a real +/// driver today; loyalty / `Raise` / variable-`{X}` transient wordings are left +/// unparsed (→ `Unimplemented`) rather than emitting a speculative effect. +/// +/// The static/transient separation is a DISPATCH-SITE invariant, not a textual +/// one: a standalone printed line (Training Grounds) is claimed by +/// `parse_static_line` before `parse_imperative_effect` ever runs, so this arm +/// only fires for a trigger's effect body. The turn scope is enforced by the +/// cleanup clear of `pending_activation_cost_reductions` (CR 514.2), never by +/// text keyed here (the "this turn" is already stripped into an +/// `UntilEndOfTurn` duration upstream before the body reaches this parser). +pub(crate) fn try_parse_activated_ability_cost_reduction_effect( + tp: TextPair, + ctx: &mut ParseContext, +) -> Option { + let (_rest, (keyword, subject, amount, is_x, mode)) = + parse_activated_ability_cost_head(tp.lower).ok()?; + if keyword != "activated" || is_x || !matches!(mode, CostModifyMode::Reduce) { + return None; + } + // "artifact tokens you control" → Typed[Artifact, You, FilterProp::Token]. + let (source_filter, _after) = parse_type_phrase_with_ctx(subject, ctx); + Some(Effect::ReduceActivatedAbilityCost { + amount, + source_filter, + }) +} + /// CR 702.26: Phasing direction used by the "phase in"/"phase out" dispatch. #[derive(Copy, Clone)] enum PhaseDir { diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 4a5699cfbb..a01a6ef48a 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -20454,6 +20454,13 @@ fn parse_imperative_effect_inner(tp: TextPair, ctx: &mut ParseContext) -> Parsed return parsed_clause(effect); } + // CR 611.2 + CR 602.1: transient (this-turn) activated-ability cost reduction — + // "activated abilities of cost {N} less to activate [this turn]" + // (The Dining Car's chaos ability). Reuses the shared static grammar head. + if let Some(effect) = imperative::try_parse_activated_ability_cost_reduction_effect(tp, ctx) { + return parsed_clause(effect); + } + // --- Fallback --- let verb = tp.lower.split_whitespace().next().unwrap_or("unknown"); tracing::debug!( diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 5a06d18afe..ad76f45533 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -5573,6 +5573,7 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index f2cd72b2c5..64c2fb5104 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -43973,3 +43973,72 @@ fn drawn_this_turn_followup_overwrites_prior_life_payment() { root.sub_ability ); } + +/// CR 611.2 + CR 602.1: The Dining Car's chaos body — "activated abilities of +/// artifact tokens you control cost {2} less to activate" — parses to the +/// transient `Effect::ReduceActivatedAbilityCost`. The `source_filter` must be +/// scoped to artifact TOKENS you control (the discount hits only tokens), so +/// `FilterProp::Token` is load-bearing and asserted. +#[test] +fn dining_car_chaos_body_parses_reduce_activated_ability_cost() { + let effect = parse_effect( + "activated abilities of artifact tokens you control cost {2} less to activate", + ); + let Effect::ReduceActivatedAbilityCost { + amount, + source_filter, + } = effect + else { + panic!("expected ReduceActivatedAbilityCost, got {effect:?}"); + }; + assert_eq!(amount, 2); + let TargetFilter::Typed(tf) = &source_filter else { + panic!("expected a Typed source filter, got {source_filter:?}"); + }; + assert_eq!(tf.controller, Some(ControllerRef::You)); + assert!( + tf.type_filters.contains(&TypeFilter::Artifact), + "source filter must be an artifact filter, got {tf:?}" + ); + assert!( + tf.properties.contains(&FilterProp::Token), + "the discount must hit only artifact TOKENS, got {tf:?}" + ); +} + +/// The transient effect arm is textually indiscriminate BY DESIGN: when +/// `parse_imperative_effect` is reached directly with a standalone reducer line, +/// it emits `Effect::ReduceActivatedAbilityCost`. The static (Training Grounds) +/// vs. transient (The Dining Car) separation is enforced upstream at the dispatch +/// site — a printed static line is claimed by `parse_static_line` before this +/// parser runs (see `training_grounds_standalone_line_stays_static_no_transient_effect` +/// in the static tests). This test locks the arm's intended behavior so a future +/// maintainer cannot "fix" the helper to reject it and silently break the Dining +/// Car path. +#[test] +fn imperative_effect_arm_fires_when_reached_directly() { + let effect = + parse_effect("activated abilities of creatures you control cost {2} less to activate"); + assert!( + matches!(effect, Effect::ReduceActivatedAbilityCost { amount: 2, .. }), + "reached directly, the transient arm should fire; got {effect:?}" + ); +} + +/// The transient arm is deliberately narrow: only fixed-amount, `Reduce`, +/// `"activated"` wordings emit an effect. Loyalty / `Raise` / variable-`{X}` +/// transient wordings have no driver today and stay honestly `Unimplemented` +/// rather than emitting a speculative parse. +#[test] +fn transient_cost_reduction_arm_rejects_unsupported_shapes() { + // Raise direction — no transient driver. + assert!(matches!( + parse_effect("activated abilities of creatures you control cost {2} more to activate"), + Effect::Unimplemented { .. } + )); + // Loyalty keyword — no transient driver. + assert!(matches!( + parse_effect("loyalty abilities of creatures you control cost {2} less to activate"), + Effect::Unimplemented { .. } + )); +} diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index 658d94c8cb..d60b68d2e1 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -1316,6 +1316,7 @@ fn stamp_effect_printed_slot(effect: &mut Effect, slot: usize, kind: PrintedItem Effect::AddTargetReplacement { .. } => {} Effect::AddRestriction { .. } => {} Effect::ReduceNextSpellCost { .. } => {} + Effect::ReduceActivatedAbilityCost { .. } => {} Effect::GrantNextSpellAbility { .. } => {} Effect::AddPendingETBCounters { .. } => {} Effect::PayCost { .. } => {} diff --git a/crates/engine/src/parser/oracle_static/cost_mod.rs b/crates/engine/src/parser/oracle_static/cost_mod.rs index 2affb74f6d..7966587490 100644 --- a/crates/engine/src/parser/oracle_static/cost_mod.rs +++ b/crates/engine/src/parser/oracle_static/cost_mod.rs @@ -94,6 +94,45 @@ pub(crate) fn parse_action_cost_reduction(text: &str, lower: &str) -> Option abilities of cost {N|X} to activate". +/// Returns `(keyword_tag, subject_slice, amount, is_x, mode)` with the remainder +/// positioned immediately after "activate", so the static caller can continue with +/// `opt(parse_where_x_is_self_stat)` and the transient-effect caller can ignore the +/// tail. Single authority for both the permanent-static form (dispatch.rs) and the +/// transient-effect form (`Effect::ReduceActivatedAbilityCost`, oracle_effect). +/// The input must already be lowercase (mana braces are case-stable: `{2}`, `{x}`). +pub(crate) fn parse_activated_ability_cost_head( + i: &str, +) -> OracleResult<'_, (&'static str, &str, u32, bool, CostModifyMode)> { + let (i, keyword) = alt(( + value("activated", tag("activated abilities of ")), + value("loyalty", tag("loyalty abilities of ")), + )) + .parse(i)?; + let (i, subject) = take_until(" cost ").parse(i)?; + let (i, _) = tag(" cost ").parse(i)?; + // CR 107.3 + CR 601.2f: the amount is a fixed `{N}` (Training Grounds) or the + // variable `{X}` (Agatha), whose value is supplied by a trailing referent the + // caller parses. + let (i, (amount_n, is_x)) = nom::sequence::delimited( + tag("{"), + alt(( + map(nom_primitives::parse_number, |n| (n, false)), + value((0u32, true), tag("x")), + )), + tag("}"), + ) + .parse(i)?; + let (i, _) = tag(" ").parse(i)?; + let (i, mode) = alt(( + value(CostModifyMode::Reduce, tag("less to activate")), + value(CostModifyMode::Raise, tag("more to activate")), + )) + .parse(i)?; + Ok((i, (keyword, subject, amount_n, is_x, mode))) +} + pub(crate) fn parse_activated_cost_reduction_minimum_mana(lower: &str) -> Option { preceded( take_until::<_, _, OracleError<'_>>( diff --git a/crates/engine/src/parser/oracle_static/dispatch.rs b/crates/engine/src/parser/oracle_static/dispatch.rs index 52aa15dd1c..b5c2e2e125 100644 --- a/crates/engine/src/parser/oracle_static/dispatch.rs +++ b/crates/engine/src/parser/oracle_static/dispatch.rs @@ -2914,31 +2914,11 @@ pub(crate) fn parse_static_line_inner( // either the chosen-name source phrase (→ HasChosenName) or a type phrase. if let Some(((amount_n, is_x, mode, subject_filter, dynamic_count, keyword), _)) = nom_on_lower(tp.original, tp.lower, |i| { - let (i, keyword) = alt(( - value("activated", tag("activated abilities of ")), - value("loyalty", tag("loyalty abilities of ")), - )) - .parse(i)?; - let (i, subject) = take_until(" cost ").parse(i)?; - let (i, _) = tag(" cost ").parse(i)?; - // CR 107.3 + CR 601.2f: the amount is a fixed `{N}` (Training Grounds) - // or the variable `{X}` (Agatha), whose value is supplied by the - // trailing "where X is …" referent parsed below. - let (i, (amount_n, is_x)) = nom::sequence::delimited( - tag("{"), - alt(( - map(nom_primitives::parse_number, |n| (n, false)), - value((0u32, true), tag("x")), - )), - tag("}"), - ) - .parse(i)?; - let (i, _) = tag(" ").parse(i)?; - let (i, mode) = alt(( - value(CostModifyMode::Reduce, tag("less to activate")), - value(CostModifyMode::Raise, tag("more to activate")), - )) - .parse(i)?; + // CR 601.2f + CR 606.1: shared grammar head (also used by the transient + // `Effect::ReduceActivatedAbilityCost` form) — " + // abilities of cost {N|X} to activate". + let (i, (keyword, subject, amount_n, is_x, mode)) = + super::cost_mod::parse_activated_ability_cost_head(i)?; // CR 208.1 + CR 113.7: optional dynamic referent for `{X}` // ("where X is ~'s power", Agatha). let (i, dynamic_count) = opt(parse_where_x_is_self_stat).parse(i)?; diff --git a/crates/engine/src/parser/oracle_static/mod.rs b/crates/engine/src/parser/oracle_static/mod.rs index 0fe120b546..134021348b 100644 --- a/crates/engine/src/parser/oracle_static/mod.rs +++ b/crates/engine/src/parser/oracle_static/mod.rs @@ -138,8 +138,9 @@ mod support { } pub(crate) use cost_mod::{ - parse_alternative_keyword_cost, parse_cast_spells_alternative_cost_multi, - parse_collect_evidence_alt_cost, parse_spells_alternative_cost, + parse_activated_ability_cost_head, parse_alternative_keyword_cost, + parse_cast_spells_alternative_cost_multi, parse_collect_evidence_alt_cost, + parse_spells_alternative_cost, }; pub(crate) use evasion::{ classify_block_exception, is_extra_blockers_static_candidate, is_forced_block_static_candidate, diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 91d5ded7cf..dcd14cf7b0 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -28252,3 +28252,60 @@ fn rayami_flying_grant_is_conditional_on_matching_exiled_card() { "Rayami must not gain vigilance — no exiled card has vigilance" ); } + +/// DISPATCH-SITE INVARIANT (pipeline-level): a standalone printed reducer line +/// (Training Grounds) is claimed by `parse_static_line` as a `ReduceAbilityCost` +/// static BEFORE `parse_imperative_effect` ever runs. Training Grounds' text and +/// The Dining Car's chaos body are textually identical at the shared grammar head +/// (`parse_activated_ability_cost_head`); the ONLY discriminator is the dispatch +/// site. This test drives the real pipeline (not `parse_imperative_effect` in +/// isolation) to prove the printed line stays a static and emits NO transient +/// `Effect::ReduceActivatedAbilityCost`. +#[test] +fn training_grounds_standalone_line_stays_static_no_transient_effect() { + // 1. The bare line is claimed by the static classifier. + let def = parse_static_line( + "Activated abilities of creatures you control cost {2} less to activate.", + ) + .expect("standalone reducer line must parse as a static"); + assert!( + matches!( + def.mode, + StaticMode::ReduceAbilityCost { + ref keyword, + mode: CostModifyMode::Reduce, + amount: 2, + .. + } if keyword == "activated" + ), + "expected a ReduceAbilityCost static, got {:?}", + def.mode + ); + + // 2. A full-card parse of Training Grounds yields the ReduceAbilityCost static + // and NO transient ReduceActivatedAbilityCost effect (no abilities/triggers). + let parsed = crate::parser::parse_oracle_text( + "Activated abilities of creatures you control cost {2} less to activate.", + "Training Grounds", + &[], + &["Enchantment".to_string()], + &[], + ); + assert_eq!( + parsed.statics.len(), + 1, + "Training Grounds should parse to exactly one static, got {:?}", + parsed.statics + ); + assert!(matches!( + parsed.statics[0].mode, + StaticMode::ReduceAbilityCost { .. } + )); + assert!( + parsed.abilities.is_empty() && parsed.triggers.is_empty(), + "the printed reducer line is a static, never a transient effect; got \ + abilities={:?} triggers={:?}", + parsed.abilities, + parsed.triggers, + ); +} diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index 14fff7749d..2d03d4cb68 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -4647,15 +4647,26 @@ fn parse_superlative_property_suffix( ctx: &mut ParseContext, ) -> Option<(FilterProp, usize)> { let trimmed = text.trim_start(); - // "with the among " — greatest/highest are - // synonyms (both AggregateFunction::Max), property is the second axis. - // Factor the 2×3 cross product into two alts (PATTERNS.md §8b). + // "with the among " — the superlative head selects + // the aggregate direction and the property is the second axis. Factor the + // 2×3 cross product into two alts (PATTERNS.md §8b). + // CR 208.1 (toughness) + CR 202.3 (mana value): greatest/highest = Max, + // least/lowest/smallest = Min. Both directions feed the same generic + // superlative_property_filter_prop, so the runtime (values.min()/max() in + // game/quantity.rs) and the Sacrifice/Destroy/return resolvers select the + // constrained object unchanged. let (rest, (function, property)) = ( tag::<_, _, OracleError<'_>>("with the "), - value( - AggregateFunction::Max, - alt((tag("greatest "), tag("highest "))), - ), + alt(( + value( + AggregateFunction::Max, + alt((tag("greatest "), tag("highest "))), + ), + value( + AggregateFunction::Min, + alt((tag("least "), tag("lowest "), tag("smallest "))), + ), + )), alt(( value(ObjectProperty::Power, tag("power")), value(ObjectProperty::Toughness, tag("toughness")), @@ -7627,6 +7638,89 @@ mod tests { } } + /// Extract the `AggregateFunction` a superlative-property suffix encodes, + /// regardless of whether it landed as a `PtComparison` (power/toughness) or a + /// `Cmc` (mana value) filter prop. + fn superlative_aggregate_function(text: &str) -> AggregateFunction { + let mut ctx = ParseContext::default(); + let (prop, _consumed) = parse_superlative_property_suffix(text, &mut ctx) + .unwrap_or_else(|| panic!("superlative suffix should parse: {text}")); + let value = match prop { + FilterProp::PtComparison { value, .. } | FilterProp::Cmc { value, .. } => value, + other => panic!("expected PtComparison/Cmc, got {other:?}"), + }; + match value { + QuantityExpr::Ref { + qty: QuantityRef::Aggregate { function, .. }, + } => function, + other => panic!("expected Aggregate quantity, got {other:?}"), + } + } + + /// CR 208.1 + CR 202.3: the superlative head maps each direction word to an + /// `AggregateFunction` — least/lowest/smallest = Min (new), greatest/highest = + /// Max (regression). Tests the parameterized `alt` at the building-block level + /// across its full input range, not one card. + #[test] + fn superlative_direction_maps_word_to_aggregate_function() { + for word in ["least", "lowest", "smallest"] { + let text = format!("with the {word} power among creatures you control"); + assert_eq!( + superlative_aggregate_function(&text), + AggregateFunction::Min, + "{word} should map to Min" + ); + } + for word in ["greatest", "highest"] { + let text = format!("with the {word} power among creatures you control"); + assert_eq!( + superlative_aggregate_function(&text), + AggregateFunction::Max, + "{word} should map to Max" + ); + } + } + + /// CR 208.1 + CR 701.21: "with the least toughness among creatures you control" + /// (The Dining Car's upkeep sacrifice) → a Min-aggregate toughness + /// `PtComparison` over "creatures you control", tie-inclusive (`EQ`). + #[test] + fn superlative_least_toughness_suffix_emits_min_aggregate_pt_comparison() { + let text = "with the least toughness among creatures you control"; + let mut ctx = ParseContext::default(); + let (prop, consumed) = + parse_power_suffix(text, &mut ctx).expect("least-toughness suffix should parse"); + assert_eq!(consumed, text.len(), "the whole suffix must be consumed"); + let FilterProp::PtComparison { + stat, + comparator, + value, + .. + } = prop + else { + panic!("expected PtComparison, got {prop:?}"); + }; + assert_eq!(stat, PtStat::Toughness); + assert_eq!(comparator, Comparator::EQ); + let QuantityExpr::Ref { + qty: + QuantityRef::Aggregate { + function, + property, + filter, + }, + } = value + else { + panic!("expected Aggregate quantity, got {value:?}"); + }; + assert_eq!(function, AggregateFunction::Min); + assert_eq!(property, ObjectProperty::Toughness); + // The eligible set is "creatures you control". + let tf = typed_leg(&filter).expect("aggregate filter should be a typed creature filter"); + assert_eq!(tf.controller, Some(ControllerRef::You)); + assert!(tf.type_filters.contains(&TypeFilter::Creature)); + } + /// CR 122.1 + CR 702.62b (Clockspinning): "target permanent or suspended /// card" is a battlefield∪exile target pool. The permanent leg must carry an /// explicit `InZone{Battlefield}` (so `extract_explicit_zones` unions both diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 040aa37b32..8b642e5e9b 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10916,6 +10916,24 @@ pub enum Effect { #[serde(default, skip_serializing_if = "Option::is_none")] spell_filter: Option, }, + /// CR 602.2 + CR 601.2f + CR 611.2: "Activated abilities of cost {N} + /// less to activate this turn." Resolution-generated, turn-scoped + /// activation-cost reduction (The Dining Car's chaos ability). Registers a + /// `PendingActivationCostReduction` that every matching activation reads until + /// the cleanup step clears it (CR 514.2). The permanent/static twin is + /// `StaticMode::ReduceAbilityCost { keyword: "activated" }` (Training Grounds); + /// this is its transient sibling, so it lives at the `Effect` layer. The static + /// twin also carries `exemption`/`minimum_mana`/`activator`, driven by real + /// static cards (Suppression Field, Zirda, Training Grounds' floor); no known + /// *transient* reducer carries those clauses, so per the minimalism gate they + /// are omitted here and added (each a `#[serde(default)]` field) only when a + /// card drives them. + ReduceActivatedAbilityCost { + amount: u32, + /// Which source permanents' activated abilities are reduced + /// ("artifact tokens you control" → Typed[Artifact, You, Token]). + source_filter: TargetFilter, + }, /// CR 601.2f: "The next [type] spell you cast this turn [has keyword/can't be countered/etc.]." /// Creates a one-shot modifier applied when the player casts their next qualifying spell. GrantNextSpellAbility { @@ -13576,6 +13594,7 @@ impl Effect { | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } @@ -13994,6 +14013,7 @@ impl Effect { | Effect::ReassembleContraption { .. } | Effect::ReassembleContraptionOnSprocket { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::RevealFromHand { .. } | Effect::RingTemptsYou | Effect::Ripple { .. } @@ -14246,6 +14266,7 @@ impl Effect { | Effect::ReassembleContraption { .. } | Effect::ReassembleContraptionOnSprocket { .. } | Effect::ReduceNextSpellCost { .. } + | Effect::ReduceActivatedAbilityCost { .. } | Effect::RevealFromHand { .. } | Effect::RingTemptsYou | Effect::Ripple { .. } @@ -14399,6 +14420,7 @@ pub fn effect_variant_name(effect: &Effect) -> &str { Effect::AddTargetReplacement { .. } => "AddTargetReplacement", Effect::AddRestriction { .. } => "AddRestriction", Effect::ReduceNextSpellCost { .. } => "ReduceNextSpellCost", + Effect::ReduceActivatedAbilityCost { .. } => "ReduceActivatedAbilityCost", Effect::GrantNextSpellAbility { .. } => "GrantNextSpellAbility", Effect::AddPendingETBCounters { .. } => "AddPendingETBCounters", Effect::AddPendingEntersModifications { .. } => "AddPendingEntersModifications", @@ -14640,6 +14662,7 @@ pub enum EffectKind { AddTargetReplacement, AddRestriction, ReduceNextSpellCost, + ReduceActivatedAbilityCost, GrantNextSpellAbility, AddPendingETBCounters, AddPendingEntersModifications, @@ -14898,6 +14921,7 @@ impl From<&Effect> for EffectKind { Effect::AddTargetReplacement { .. } => EffectKind::AddTargetReplacement, Effect::AddRestriction { .. } => EffectKind::AddRestriction, Effect::ReduceNextSpellCost { .. } => EffectKind::ReduceNextSpellCost, + Effect::ReduceActivatedAbilityCost { .. } => EffectKind::ReduceActivatedAbilityCost, Effect::GrantNextSpellAbility { .. } => EffectKind::GrantNextSpellAbility, Effect::AddPendingETBCounters { .. } => EffectKind::AddPendingETBCounters, Effect::AddPendingEntersModifications { .. } => { diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index c06d550f22..6786b596e0 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -382,6 +382,24 @@ pub struct PendingSpellCostReduction { pub spell_filter: Option, } +/// CR 602.2 + CR 601.2f + CR 514.2: A turn-scoped reduction to the activation cost +/// of activated abilities whose SOURCE permanent matches `source_filter`. Created +/// by `Effect::ReduceActivatedAbilityCost` (The Dining Car's chaos ability); read by +/// every activation this turn; cleared at cleanup. Distinct from +/// `PendingSpellCostReduction` (spell casts, next-spell one-shot) — activations are +/// CR 602, not CR 601, and this reduction is not consumed on use. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingActivationCostReduction { + /// Reference "you" for `source_filter`'s `ControllerRef::You`. + pub controller: PlayerId, + /// `FilterContext` anchor (`from_source_with_controller`). + pub source_id: ObjectId, + /// Generic mana reduction amount. + pub amount: u32, + /// Which source permanents' activated abilities are reduced. + pub source_filter: TargetFilter, +} + /// CR 601.2f: Describes a one-shot modification applied to the next qualifying spell a player /// casts. Created by effects like "the next spell you cast this turn has convoke" or "the next /// creature spell you cast this turn can't be countered." @@ -7575,6 +7593,11 @@ pub struct GameState { /// Consumed when the player casts their next qualifying spell. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pending_spell_cost_reductions: Vec, + /// CR 602.2 + CR 514.2: Turn-scoped reductions to the activation cost of + /// activated abilities whose source permanent matches `source_filter`. Read by + /// every matching activation this turn; cleared at cleanup (not consumed on use). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pending_activation_cost_reductions: Vec, /// CR 601.2f: One-shot ability modifiers for the next spell cast. /// Consumed when the player casts their next qualifying spell. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -9008,6 +9031,7 @@ impl GameState { creature_types_dealt_combat_damage_this_turn: im::HashSet::new(), mana_spent_on_spells_this_turn: HashMap::new(), pending_spell_cost_reductions: Vec::new(), + pending_activation_cost_reductions: Vec::new(), pending_next_spell_modifiers: Vec::new(), pending_etb_counters: Vec::new(), modal_modes_chosen_this_turn: HashSet::new(), @@ -9731,6 +9755,8 @@ impl PartialEq for GameState { && self.creature_types_dealt_combat_damage_this_turn == other.creature_types_dealt_combat_damage_this_turn && self.pending_spell_cost_reductions == other.pending_spell_cost_reductions + && self.pending_activation_cost_reductions + == other.pending_activation_cost_reductions && self.pending_next_spell_modifiers == other.pending_next_spell_modifiers && self.pending_etb_counters == other.pending_etb_counters && self.modal_modes_chosen_this_turn == other.modal_modes_chosen_this_turn From 356c28fb2844f446a939219a77050a5c30244d75 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Sun, 12 Jul 2026 18:44:11 -0500 Subject: [PATCH 2/3] rework: route Dining Car transient activation-cost reduction through GenericEffect + teach cost hook to read duration-scoped continuous ReduceAbilityCost (removes duplicate Effect variant) --- crates/engine/src/analysis/ability_graph.rs | 1 - crates/engine/src/game/ability_rw.rs | 8 - crates/engine/src/game/ability_scan.rs | 5 - crates/engine/src/game/casting.rs | 276 +++++++++++------- crates/engine/src/game/casting_tests.rs | 149 +++++++--- crates/engine/src/game/coverage.rs | 1 - crates/engine/src/game/effects/effect.rs | 32 ++ crates/engine/src/game/effects/mod.rs | 85 ------ crates/engine/src/game/layers.rs | 10 +- crates/engine/src/game/printed_cards.rs | 1 - crates/engine/src/game/trigger_index.rs | 1 - crates/engine/src/game/turns.rs | 2 - .../src/parser/oracle_effect/imperative.rs | 63 +++- .../src/parser/oracle_effect/sequence.rs | 1 - .../engine/src/parser/oracle_effect/tests.rs | 90 ++++-- crates/engine/src/parser/oracle_ir/doc.rs | 1 - .../src/parser/oracle_static/cost_mod.rs | 4 +- .../src/parser/oracle_static/dispatch.rs | 5 +- crates/engine/src/types/ability.rs | 24 -- crates/engine/src/types/game_state.rs | 26 -- 20 files changed, 443 insertions(+), 342 deletions(-) diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index 68e9e3b92b..535333a828 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -923,7 +923,6 @@ fn effect_projection(effect: &Effect) -> Projection { | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 64c1228f4a..be76cc4934 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2810,13 +2810,6 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::ChooseDamageSource { source_filter: target, } - // CR 602.2: The Dining Car's transient activation-cost reduction scopes by - // `source_filter`, walked for a nested frozen event-context tag like every - // single-filter effect. - | Effect::ReduceActivatedAbilityCost { - source_filter: target, - .. - } | Effect::ReturnAsAura { enchant_filter: target, .. @@ -5054,7 +5047,6 @@ fn rw_effect( Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::CreateEmblem { .. } | Effect::CreateDamageReplacement { .. } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 91621794e7..1d92454588 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -1006,10 +1006,6 @@ fn scan_effect(x: &Effect) -> Axes { Effect::CreateDelayedTrigger { .. } => Axes::CONSERVATIVE, Effect::AddTargetReplacement { .. } => Axes::CONSERVATIVE, Effect::AddRestriction { .. } => Axes::CONSERVATIVE, - Effect::ReduceActivatedAbilityCost { - source_filter, - amount: _, - } => scan_target_filter(source_filter), Effect::ReduceNextSpellCost { spell_filter, amount: _, @@ -4251,7 +4247,6 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 5531ddf86a..394b414610 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -16165,12 +16165,15 @@ fn apply_static_activated_ability_cost_reduction( player: PlayerId, source_id: ObjectId, ) { - // CR 604.1: O(1) presence gate — nothing to do unless a printed - // ReduceAbilityCost static (CR 611.3) OR a resolution-generated transient - // reduction (CR 611.2 — The Dining Car's chaos ability, held in - // `pending_activation_cost_reductions`) is present. + // CR 604.1: presence gate — nothing to do unless a printed ReduceAbilityCost + // static (CR 611.3) OR a duration-scoped continuous ReduceAbilityCost effect + // (CR 611.2 — The Dining Car's transient chaos discount) is present. The O(1) + // `static_mode_presence` index covers only battlefield/command-zone printed + // statics, so the transient authority needs its own small TCE scan — the same + // split gate `visibility::viewer_may_look_at_face_down` uses for the + // duration-bound `MayLookAtFaceDown` permission. let has_static = static_kind_present(state, StaticModeKind::ReduceAbilityCost); - let has_transient = !state.pending_activation_cost_reductions.is_empty(); + let has_transient = transient_reduce_ability_cost_present(state); if !has_static && !has_transient { return; } @@ -16198,118 +16201,179 @@ fn apply_static_activated_ability_cost_reduction( // `&`) before the loop mutates it. let ability_is_loyalty = crate::types::ability::is_loyalty_ability_cost(cost); + // CR 611.3 + CR 601.2f: printed battlefield/command-zone `ReduceAbilityCost` + // statics (Training Grounds, Suppression Field, Zirda, Agatha, …). for (static_source, def) in super::functioning_abilities::battlefield_active_statics(state) { - let StaticMode::ReduceAbilityCost { - mode, - keyword, - amount, - minimum_mana, - dynamic_count, - exemption, - activator, - } = &def.mode - else { - continue; - }; - // CR 601.2f + CR 606.1: match the "activated" blanket arm, a tag-keyed - // keyword (power-up, exhaust, …), or the "loyalty" arm against a loyalty - // ability's cost. - let keyword_matches = keyword == "activated" - || Some(keyword.as_str()) == active_keyword - || (keyword == "loyalty" && ability_is_loyalty); - if !keyword_matches || *amount == 0 { + if !matches!(def.mode, StaticMode::ReduceAbilityCost { .. }) { continue; } - // CR 605.1a: a mana ability bypasses a "unless they're mana abilities" - // adjustment (Suppression Field's tax, Zirda's discount). - if *exemption == ActivationExemption::ManaAbilities && ability_is_mana { - continue; - } - // CR 602.2: an activator-scoped static ("abilities you activate" — Zirda, - // the Dawnwaker; Fluctuator) keys off WHO is activating the ability, - // evaluated relative to the static's controller — NOT who controls the - // ability's source. Reuse the activator-permission predicate with the - // static's controller as the reference point so "you" resolves to the - // static controller. An ability on a permanent this player doesn't control - // (activatable via `activator_filter`) is still discounted when they - // activate it, and an ability on a permanent they DO control but activated - // by someone else is not. `None` leaves the source/global scope untouched. - if let Some(activator) = activator { - if !player_may_begin_activating( - state, - player, - static_source.controller, - Some(activator), - ) { + // CR 604.1 + CR 109.5: "you control" in the affected filter anchors on the + // static's current controller, read live from the battlefield object. + let ctx = super::filter::FilterContext::from_source(state, static_source.id); + apply_one_reduce_ability_cost( + state, + cost, + source_id, + player, + active_keyword, + ability_is_mana, + ability_is_loyalty, + &def.mode, + def.affected.as_ref(), + static_source.id, + static_source.controller, + &ctx, + ); + } + + // CR 611.2 + CR 118.7: duration-scoped continuous `ReduceAbilityCost` effects + // (The Dining Car's transient "activated abilities of cost {N} less this + // turn"). Installed by a resolving ability as a `GenericEffect` and read here, + // off the TCE, through the SAME per-static authority as battlefield statics — + // there is no parallel reduction pathway. The `UntilEndOfTurn` duration expires + // the effect at cleanup (CR 514.2), so no explicit clear is needed. CR 611.2c: + // the affected set is dynamic (re-evaluated each activation), so a token + // created later this turn is still discounted. + for tce in &state.transient_continuous_effects { + for modification in &tce.modifications { + let ContinuousModification::AddStaticMode { + mode: reduce_mode @ StaticMode::ReduceAbilityCost { .. }, + } = modification + else { continue; - } - } - if def.affected.as_ref().is_some_and(|filter| { - !super::filter::matches_target_filter( - state, - source_id, - filter, - &super::filter::FilterContext::from_source(state, static_source.id), - ) - }) { - continue; - } - // CR 601.2f + CR 208.1 + CR 113.7: When `dynamic_count` is present the - // per-unit `amount` is multiplied by the resolved quantity (Agatha of - // the Vile Cauldron: amount 1 × ~'s power). Resolve against the static's - // own source so "~'s power" reads Agatha's post-layer power. Mirrors the - // dynamic-count multiply in `keywords::apply_ability_cost_reduction`. - let multiplier = dynamic_count.as_ref().map_or(1u32, |qty_ref| { - let expr = crate::types::ability::QuantityExpr::Ref { - qty: qty_ref.clone(), }; - super::quantity::resolve_quantity( + // CR 608.2c + CR 109.5: "you control" is latched to the installing + // player captured on the TCE, not the source's current controller. + let ctx = super::filter::FilterContext::from_source_with_controller( + tce.source_id, + tce.controller, + ); + apply_one_reduce_ability_cost( state, - &expr, - static_source.controller, - static_source.id, - ) - .max(0) as u32 - }); - let effective = amount.saturating_mul(multiplier); - // CR 118.7: Apply the adjustment in the static's direction. `Reduce` - // subtracts generic mana (honoring the optional one-mana floor); - // `Raise` adds generic mana (Skyseer's Chariot). `Minimum` is not - // emitted for activated-ability statics and is treated as a no-op. - match mode { - CostModifyMode::Reduce => { - reduce_generic_in_cost_with_minimum_mana( - cost, - effective, - minimum_mana.unwrap_or(0), - ); - } - CostModifyMode::Raise => increase_generic_in_cost(cost, effective), - CostModifyMode::Minimum => {} + cost, + source_id, + player, + active_keyword, + ability_is_mana, + ability_is_loyalty, + reduce_mode, + Some(&tce.affected), + tce.source_id, + tce.controller, + &ctx, + ); } } +} - // CR 611.2 + CR 602.2: resolution-generated, turn-scoped reductions - // (`Effect::ReduceActivatedAbilityCost` — The Dining Car's chaos ability). - // These live in `pending_activation_cost_reductions` (not a printed static) and - // are applied here so the single cost authority (CR 601.2f) stays intact. - // Cleared at cleanup (CR 514.2). - for reduction in &state.pending_activation_cost_reductions { - if reduction.amount == 0 { - continue; +/// CR 604.1: presence gate for the transient (duration-scoped) `ReduceAbilityCost` +/// authority. The O(1) `static_mode_presence` index tracks only battlefield / +/// command-zone printed statics, never TCE-borne `AddStaticMode` modes, so this +/// small scan of `transient_continuous_effects` is the gate for the transient +/// side — mirroring the split presence gate in +/// `visibility::viewer_may_look_at_face_down`. +fn transient_reduce_ability_cost_present(state: &GameState) -> bool { + state.transient_continuous_effects.iter().any(|tce| { + tce.modifications.iter().any(|m| { + matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ReduceAbilityCost { .. }, + } + ) + }) + }) +} + +/// CR 601.2f + CR 118.7 + CR 605.1a + CR 606.1: Apply ONE `ReduceAbilityCost` +/// static to the activating ability's `cost`. The single authority for both a +/// printed battlefield static (Training Grounds) and a duration-scoped continuous +/// effect (The Dining Car's transient chaos discount), so both apply through +/// identical keyword-match, mana-exemption, activator-scope, source-filter, and +/// dynamic-count logic. `reduce_mode` must be a `StaticMode::ReduceAbilityCost`; +/// `affected` is its source-scope filter (evaluated against the ability's SOURCE +/// permanent via `filter_ctx`); `static_source_id`/`static_controller` anchor the +/// dynamic-count resolution and the activator-permission check. +#[allow(clippy::too_many_arguments)] +fn apply_one_reduce_ability_cost( + state: &GameState, + cost: &mut AbilityCost, + ability_source_id: ObjectId, + player: PlayerId, + active_keyword: Option<&'static str>, + ability_is_mana: bool, + ability_is_loyalty: bool, + reduce_mode: &StaticMode, + affected: Option<&TargetFilter>, + static_source_id: ObjectId, + static_controller: PlayerId, + filter_ctx: &super::filter::FilterContext, +) { + let StaticMode::ReduceAbilityCost { + mode, + keyword, + amount, + minimum_mana, + dynamic_count, + exemption, + activator, + } = reduce_mode + else { + return; + }; + // CR 601.2f + CR 606.1: match the "activated" blanket arm, a tag-keyed keyword + // (power-up, exhaust, …), or the "loyalty" arm against a loyalty ability's cost. + let keyword_matches = keyword == "activated" + || Some(keyword.as_str()) == active_keyword + || (keyword == "loyalty" && ability_is_loyalty); + if !keyword_matches || *amount == 0 { + return; + } + // CR 605.1a: a mana ability bypasses a "unless they're mana abilities" + // adjustment (Suppression Field's tax, Zirda's discount). + if *exemption == ActivationExemption::ManaAbilities && ability_is_mana { + return; + } + // CR 602.2: an activator-scoped static ("abilities you activate" — Zirda, the + // Dawnwaker; Fluctuator) keys off WHO is activating the ability, evaluated + // relative to the static's controller — NOT who controls the ability's source. + // Reuse the activator-permission predicate with the static's controller as the + // reference point so "you" resolves to the static controller. `None` leaves the + // source/global scope untouched. + if let Some(activator) = activator { + if !player_may_begin_activating(state, player, static_controller, Some(activator)) { + return; } - // CR 602.2: scope by the reduction's `source_filter` against the ability's - // SOURCE permanent, anchoring "you control" on the reduction's controller. - let ctx = super::filter::FilterContext::from_source_with_controller( - reduction.source_id, - reduction.controller, - ); - if !super::filter::matches_target_filter(state, source_id, &reduction.source_filter, &ctx) { - continue; + } + // CR 602.2: scope by the source filter against the ability's SOURCE permanent. + if affected.is_some_and(|filter| { + !super::filter::matches_target_filter(state, ability_source_id, filter, filter_ctx) + }) { + return; + } + // CR 601.2f + CR 208.1 + CR 113.7: When `dynamic_count` is present the per-unit + // `amount` is multiplied by the resolved quantity (Agatha of the Vile Cauldron: + // amount 1 × ~'s power). Resolve against the static's own source so "~'s power" + // reads the source's post-layer power. Mirrors the dynamic-count multiply in + // `keywords::apply_ability_cost_reduction`. + let multiplier = dynamic_count.as_ref().map_or(1u32, |qty_ref| { + let expr = crate::types::ability::QuantityExpr::Ref { + qty: qty_ref.clone(), + }; + super::quantity::resolve_quantity(state, &expr, static_controller, static_source_id).max(0) + as u32 + }); + let effective = amount.saturating_mul(multiplier); + // CR 118.7: Apply the adjustment in the static's direction. `Reduce` subtracts + // generic mana (honoring the optional one-mana floor); `Raise` adds generic + // mana (Skyseer's Chariot). `Minimum` is not emitted for activated-ability + // statics and is treated as a no-op. + match mode { + CostModifyMode::Reduce => { + reduce_generic_in_cost_with_minimum_mana(cost, effective, minimum_mana.unwrap_or(0)); } - // CR 118.7a: subtract generic mana; The Dining Car specifies no one-mana - // floor, so the floor is 0. - reduce_generic_in_cost_with_minimum_mana(cost, reduction.amount, 0); + CostModifyMode::Raise => increase_generic_in_cost(cost, effective), + CostModifyMode::Minimum => {} } } diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 8bdd5c4ceb..ec88318e3f 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -9432,14 +9432,16 @@ fn activated_ability_cost_reduction_applies_to_matching_permanent_type() { ); } -/// Helper: an artifact permanent with a pure `{2}` generic-mana activated ability -/// (no tap/sacrifice, so affordability is a clean function of available mana). +/// Helper: an artifact permanent with a pure `{generic_cost}` generic-mana +/// activated ability (no tap/sacrifice, so affordability is a clean function of +/// the controller's mana pool). fn make_artifact_with_generic_ability( state: &mut GameState, card_id: CardId, owner: PlayerId, name: &str, is_token: bool, + generic_cost: u32, ) -> ObjectId { let id = create_object(state, card_id, owner, name.to_string(), Zone::Battlefield); let obj = state.objects.get_mut(&id).unwrap(); @@ -9454,24 +9456,29 @@ fn make_artifact_with_generic_ability( }, ) .cost(AbilityCost::Mana { - cost: ManaCost::generic(2), + cost: ManaCost::generic(generic_cost), }), ); id } -/// CR 611.2 + CR 602.2 + CR 514.2: RUNTIME PROOF of The Dining Car's transient -/// activation-cost reduction. A `PendingActivationCostReduction` scoped to -/// "artifact tokens you control" reduces a controlled artifact TOKEN's activated -/// ability by {2}, but leaves a controlled artifact NONTOKEN and an OPPONENT's -/// artifact token untouched — then the reduction is cleared at cleanup. The same -/// `apply_static_activated_ability_cost_reduction` authority the AI's affordability -/// check routes through is exercised via `can_activate_ability_now`. +/// CR 611.2 + CR 601.2f + CR 118.7 + CR 514.2: RUNTIME PROOF of The Dining Car's +/// transient activation-cost reduction, exercised through the REAL mechanism. +/// Resolving the exact `Effect::GenericEffect` the parser emits installs a +/// `Duration::UntilEndOfTurn` continuous `ReduceAbilityCost` effect, and the +/// single cost authority (`apply_static_activated_ability_cost_reduction`, which +/// the AI's affordability check routes through) reads it off the transient +/// continuous effect — the same authority that applies printed battlefield +/// `ReduceAbilityCost` statics. Proves: a controlled artifact TOKEN is +/// discounted; a controlled NONTOKEN and an OPPONENT's token are not; the +/// discount applies EXACTLY ONCE (a `{3}` token reduces to `{1}`, not `{0}` — a +/// double-apply via layer grafting would floor it); and it ends when the +/// continuous effect is pruned at cleanup. #[test] fn transient_activation_cost_reduction_hits_only_controlled_artifact_tokens() { let mut state = setup_game_at_main_phase(); - // FilterContext anchor for the reduction (The Dining Car itself). + // The Dining Car itself — the reduction's source/anchor. let dining_car = create_object( &mut state, CardId(800), @@ -9480,33 +9487,87 @@ fn transient_activation_cost_reduction_hits_only_controlled_artifact_tokens() { Zone::Battlefield, ); - let controlled_token = - make_artifact_with_generic_ability(&mut state, CardId(801), PlayerId(0), "Token A", true); - let controlled_nontoken = - make_artifact_with_generic_ability(&mut state, CardId(802), PlayerId(0), "Nontoken", false); - let opponent_token = - make_artifact_with_generic_ability(&mut state, CardId(803), PlayerId(1), "Opp Token", true); - - // The chaos reduction: activated abilities of artifact tokens P0 controls cost - // {2} less this turn. - state.pending_activation_cost_reductions.push( - crate::types::game_state::PendingActivationCostReduction { - controller: PlayerId(0), - source_id: dining_car, - amount: 2, - source_filter: TargetFilter::Typed(TypedFilter { - type_filters: vec![TypeFilter::Artifact], - controller: Some(ControllerRef::You), - properties: vec![FilterProp::Token], - }), - }, + let controlled_token = make_artifact_with_generic_ability( + &mut state, + CardId(801), + PlayerId(0), + "Token A", + true, + 2, + ); + // A {3}-cost controlled token — reduces to {1}, not {0}, so it proves the + // discount is applied EXACTLY ONCE (guards against a layer graft double-apply). + let controlled_token_three = make_artifact_with_generic_ability( + &mut state, + CardId(804), + PlayerId(0), + "Token B", + true, + 3, + ); + let controlled_nontoken = make_artifact_with_generic_ability( + &mut state, + CardId(802), + PlayerId(0), + "Nontoken", + false, + 2, + ); + let opponent_token = make_artifact_with_generic_ability( + &mut state, + CardId(803), + PlayerId(1), + "Opp Token", + true, + 2, ); - // P0 has ZERO mana: only the reduced (cost 2→0) controlled token is affordable. + // Resolve the exact GenericEffect the parser emits for the chaos body + // "activated abilities of artifact tokens you control cost {2} less to + // activate": a Duration::UntilEndOfTurn continuous ReduceAbilityCost carried + // as an AddStaticMode modification with the source filter in `affected`. + let reduce_mode = StaticMode::ReduceAbilityCost { + mode: crate::types::statics::CostModifyMode::Reduce, + keyword: "activated".to_string(), + amount: 2, + minimum_mana: None, + dynamic_count: None, + exemption: crate::types::statics::ActivationExemption::None, + activator: None, + }; + let source_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + controller: Some(ControllerRef::You), + properties: vec![FilterProp::Token], + }); + let effect = Effect::GenericEffect { + static_abilities: vec![StaticDefinition::new(reduce_mode.clone()) + .affected(source_filter) + .modifications(vec![ContinuousModification::AddStaticMode { + mode: reduce_mode, + }])], + duration: Some(crate::types::ability::Duration::UntilEndOfTurn), + target: None, + }; + let ability = + crate::types::ability::ResolvedAbility::new(effect, vec![], dining_car, PlayerId(0)); + let mut events = Vec::new(); + crate::game::effects::resolve_effect(&mut state, &ability, &mut events).unwrap(); + // A full layer flush proves the reduction is NOT grafted onto objects (the + // layers skip), so the cost hook applies it exactly once — the {3}-token + // assertion below would fail (reduce to {0}) if grafting double-applied it. + crate::game::layers::flush_layers(&mut state); + + // P0 has ZERO mana. `can_activate_ability_now`'s 4th arg is the ability index, + // not available mana — affordability reads the controller's actual pool. assert!( can_activate_ability_now(&state, PlayerId(0), controlled_token, 0), "controlled artifact token's {{2}} cost is reduced to {{0}}, affordable with no mana", ); + assert!( + !can_activate_ability_now(&state, PlayerId(0), controlled_token_three, 0), + "single-apply: {{3}} reduces to {{1}} (needs 1 mana), NOT {{0}} — a double-apply would floor it", + ); assert!( !can_activate_ability_now(&state, PlayerId(0), controlled_nontoken, 0), "artifact NONTOKEN is not a token → not reduced → still {{2}}, unaffordable with no mana", @@ -9517,19 +9578,25 @@ fn transient_activation_cost_reduction_hits_only_controlled_artifact_tokens() { "opponent's artifact token is not controlled by the reduction's 'you' → not reduced", ); - // Sanity: with {2} available the unreduced nontoken IS affordable — proving the - // earlier `false` was a cost gate, not an unrelated legality gate. - add_mana(&mut state, PlayerId(0), ManaType::Colorless, 2); + // With exactly {1} mana the {3}-token reduced to {1} is affordable — positive + // proof the discount is exactly {2}. + add_mana(&mut state, PlayerId(0), ManaType::Colorless, 1); assert!( - can_activate_ability_now(&state, PlayerId(0), controlled_nontoken, 0), - "with {{2}} mana the unreduced nontoken ability is affordable", + can_activate_ability_now(&state, PlayerId(0), controlled_token_three, 0), + "the {{3}} token reduced to {{1}} is affordable with {{1}} mana", ); - // CR 514.2: the "this turn" reduction ends at cleanup (start_next_turn clears it). - crate::game::turns::start_next_turn(&mut state, &mut Vec::new()); + // CR 514.2: the continuous "this turn" reduction is pruned at cleanup, and the + // controlled token returns to full {2} cost — unaffordable with the {1} mana + // now in the pool (it WAS affordable at {0} mana while the reduction held). + crate::game::layers::prune_end_of_turn_effects(&mut state); + assert!( + !transient_reduce_ability_cost_present(&state), + "the UntilEndOfTurn ReduceAbilityCost continuous effect must be pruned at cleanup", + ); assert!( - state.pending_activation_cost_reductions.is_empty(), - "the 'this turn' activation-cost reduction must be cleared at cleanup", + !can_activate_ability_now(&state, PlayerId(0), controlled_token, 0), + "after cleanup the reduction is gone → the token's {{2}} cost is unaffordable with {{1}} mana", ); } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index f26c4644b9..3c474f481a 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -3502,7 +3502,6 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { | Effect::Cleanup { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::CreateEmblem { .. } | Effect::PayCost { .. } diff --git a/crates/engine/src/game/effects/effect.rs b/crates/engine/src/game/effects/effect.rs index e5e70b8c40..ec730b6c44 100644 --- a/crates/engine/src/game/effects/effect.rs +++ b/crates/engine/src/game/effects/effect.rs @@ -168,6 +168,38 @@ fn register_transient_effect( } } + // CR 118.7 + CR 611.2c: A transient "activated abilities of cost {N} less + // this turn" reduction (The Dining Car's chaos ability) rides as an + // `AddStaticMode { ReduceAbilityCost }` whose `affected` names the SOURCE + // permanents whose activated abilities are cheaper. Like `MayLookAtFaceDown`, + // it is read DIRECTLY off the TCE by the single cost authority + // (`casting::reduce_activated_ability_cost`) and never grafted onto individual + // objects (`layers.rs` skips it). A cost modification is a rules-modifying + // continuous effect, so per CR 611.2c its affected set stays dynamic — the + // filter must ride on the TCE intact rather than be frozen to a + // `SpecificObject` set by the broadcast branch below (a token created later + // this turn must still be discounted). + if modifications.iter().any(|m| { + matches!( + m, + ContinuousModification::AddStaticMode { + mode: crate::types::statics::StaticMode::ReduceAbilityCost { .. }, + } + ) + }) { + if let Some(affected) = static_def.affected.clone() { + state.add_transient_continuous_effect( + ability.source_id, + ability.controller, + duration.clone(), + affected, + modifications, + static_def.condition.clone(), + ); + return; + } + } + // CR 608.2c (issue #323 class): SelfRef is the printed-name anaphor and // always refers to the source object regardless of `ability.targets`. // Short-circuit BEFORE the chosen-targets branch so chained Effect diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 048bada251..0a41f030d0 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3322,9 +3322,6 @@ pub fn resolve_effect( Effect::ReduceNextSpellCost { .. } => { resolve_reduce_next_spell_cost(state, ability, events) } - Effect::ReduceActivatedAbilityCost { .. } => { - resolve_reduce_activated_ability_cost(state, ability, events) - } Effect::GrantNextSpellAbility { .. } => { resolve_grant_next_spell_ability(state, ability, events) } @@ -9535,41 +9532,6 @@ fn resolve_reduce_next_spell_cost( Ok(()) } -/// CR 602.2 + CR 601.2f + CR 611.2: Register a turn-scoped activation-cost reduction -/// (The Dining Car's chaos ability). Unlike `resolve_reduce_next_spell_cost` this is -/// not consumed on use — it is read by every matching activation this turn and cleared -/// at cleanup (CR 514.2, `turns.rs`). -fn resolve_reduce_activated_ability_cost( - state: &mut GameState, - ability: &crate::types::ability::ResolvedAbility, - events: &mut Vec, -) -> Result<(), crate::types::ability::EffectError> { - let (amount, source_filter) = match &ability.effect { - Effect::ReduceActivatedAbilityCost { - amount, - source_filter, - } => (*amount, source_filter.clone()), - _ => { - return Err(crate::types::ability::EffectError::MissingParam( - "ReduceActivatedAbilityCost".to_string(), - )) - } - }; - state.pending_activation_cost_reductions.push( - crate::types::game_state::PendingActivationCostReduction { - controller: ability.controller, - source_id: ability.source_id, - amount, - source_filter, - }, - ); - events.push(GameEvent::EffectResolved { - kind: crate::types::ability::EffectKind::ReduceActivatedAbilityCost, - source_id: ability.source_id, - }); - Ok(()) -} - /// CR 601.2f: Register a pending next-spell modifier (keyword grant, uncounterability, flash). /// Consumed when the player casts their next qualifying spell. fn resolve_grant_next_spell_ability( @@ -23059,51 +23021,4 @@ mod tests { ); } } - - /// CR 611.2 + CR 602.2: resolving `Effect::ReduceActivatedAbilityCost` registers - /// exactly one `PendingActivationCostReduction` carrying the effect's amount and - /// source filter, anchored to the resolving ability's controller/source, and - /// emits an `EffectResolved` event. - #[test] - fn reduce_activated_ability_cost_resolution_registers_pending_reduction() { - let mut state = GameState::new_two_player(42); - let source_id = create_object( - &mut state, - CardId(1), - PlayerId(0), - "The Dining Car".to_string(), - Zone::Battlefield, - ); - let source_filter = TargetFilter::Typed(TypedFilter { - type_filters: vec![TypeFilter::Artifact], - controller: Some(ControllerRef::You), - properties: vec![FilterProp::Token], - }); - let ability = ResolvedAbility::new( - Effect::ReduceActivatedAbilityCost { - amount: 2, - source_filter: source_filter.clone(), - }, - vec![], - source_id, - PlayerId(0), - ); - - let mut events = Vec::new(); - resolve_reduce_activated_ability_cost(&mut state, &ability, &mut events).unwrap(); - - assert_eq!(state.pending_activation_cost_reductions.len(), 1); - let reduction = &state.pending_activation_cost_reductions[0]; - assert_eq!(reduction.controller, PlayerId(0)); - assert_eq!(reduction.source_id, source_id); - assert_eq!(reduction.amount, 2); - assert_eq!(reduction.source_filter, source_filter); - assert!(events.iter().any(|e| matches!( - e, - GameEvent::EffectResolved { - kind: crate::types::ability::EffectKind::ReduceActivatedAbilityCost, - .. - } - ))); - } } diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index b1dec7f6ea..a1a9ebdba5 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3693,10 +3693,18 @@ pub(crate) fn gather_transient_continuous_effects( // opponent's face-down creature. Skip it here, mirroring the printed // `MayLookAtFaceDown` static (built with empty `modifications`, read // by mode in `battlefield_active_statics`). + // + // CR 118.7 + CR 611.2c: A `ReduceAbilityCost` reduction (The Dining + // Car's transient "activated abilities of cost {N} less this + // turn") is likewise a cost-hook static read DIRECTLY off the TCE by + // `casting::reduce_activated_ability_cost`, not a per-object + // characteristic. Grafting it onto each affected object would let + // `battlefield_active_statics` see it too and double-apply the + // discount, so skip it here for the same reason. if matches!( modification, ContinuousModification::AddStaticMode { - mode: StaticMode::MayLookAtFaceDown, + mode: StaticMode::MayLookAtFaceDown | StaticMode::ReduceAbilityCost { .. }, } ) { continue; diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index 3c4b91de7e..7f9b9b6f1e 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -1129,7 +1129,6 @@ fn walk_effect(effect: &Effect, out: &mut Vec) { | Effect::SetClassLevel { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index dde38fb8eb..c7689ec503 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -819,7 +819,6 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey | EffectKind::AddTargetReplacement | EffectKind::AddRestriction | EffectKind::ReduceNextSpellCost - | EffectKind::ReduceActivatedAbilityCost | EffectKind::GrantNextSpellAbility | EffectKind::AddPendingETBCounters | EffectKind::AddPendingEntersModifications diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 6f0de54f62..36f1ec9c56 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -975,8 +975,6 @@ pub fn start_next_turn(state: &mut GameState, events: &mut Vec) { // CR 601.2f: Clear one-shot cost reductions and spell modifiers from the previous turn. state.pending_spell_cost_reductions.clear(); state.pending_next_spell_modifiers.clear(); - // CR 514.2: "this turn" activation-cost reductions (The Dining Car) end during cleanup. - state.pending_activation_cost_reductions.clear(); // CR 614.1c: Pending ETB counters are turn-scoped (e.g., "this turn" effects). state.pending_etb_counters.clear(); state.modal_modes_chosen_this_turn.clear(); diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 7e80de98d6..cafd444f41 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -43,7 +43,7 @@ use crate::types::ability::{ use crate::types::card_type::CoreType; use crate::types::phase::Phase; use crate::types::player::PlayerCounterKind; -use crate::types::statics::{CostModifyMode, StaticMode}; +use crate::types::statics::{ActivationExemption, CostModifyMode, StaticMode}; use crate::types::zones::Zone; use super::super::oracle_target::{ @@ -58,23 +58,37 @@ use super::super::oracle_util::{ starts_with_possessive, TextPair, }; -/// CR 611.2 + CR 602.1: Parse the transient (this-turn) activated-ability -/// cost-reduction effect — "activated abilities of cost {N} less to -/// activate [this turn]" (The Dining Car's chaos ability). Reuses the shared -/// static grammar head `parse_activated_ability_cost_head`, so the static -/// (Training Grounds) and transient forms share one authority. +/// CR 611.2 + CR 601.2f + CR 118.7: Parse the transient (this-turn) +/// activated-ability cost-reduction effect — "activated abilities of +/// cost {N} less to activate [this turn]" (The Dining Car's chaos ability). /// -/// Only the fixed-amount, `Reduce`, `"activated"` transient case has a real -/// driver today; loyalty / `Raise` / variable-`{X}` transient wordings are left -/// unparsed (→ `Unimplemented`) rather than emitting a speculative effect. +/// This lowers to the SAME `StaticMode::ReduceAbilityCost` the printed static +/// (Training Grounds) produces, wrapped in a `GenericEffect` that installs it as +/// a `Duration::UntilEndOfTurn` continuous effect (CR 611.2). There is no +/// separate transient cost-reduction pathway: the cost hook +/// (`casting::reduce_activated_ability_cost`, the single CR 601.2f/CR 118.5 cost +/// authority) reads this duration-scoped continuous effect right alongside +/// battlefield statics. The reduction rides as an `AddStaticMode` modification — +/// mirroring the `MayLookAtFaceDown` duration-bound permission (Lumbering +/// Laundry) — so `effect.rs::register_transient_effect` keeps the `affected` +/// source filter intact on the TCE (dynamic per CR 611.2c: a rules-modifying +/// effect's affected set is re-evaluated each activation, so tokens created +/// later this turn are still covered) and `layers.rs` does NOT graft it onto +/// individual objects. +/// +/// Reuses the shared static grammar head `parse_activated_ability_cost_head`, so +/// the static and transient forms share one authority. Only the fixed-amount, +/// `Reduce`, `"activated"` transient case has a real driver today; loyalty / +/// `Raise` / variable-`{X}` transient wordings are left unparsed (→ +/// `Unimplemented`) rather than emitting a speculative effect. /// /// The static/transient separation is a DISPATCH-SITE invariant, not a textual /// one: a standalone printed line (Training Grounds) is claimed by /// `parse_static_line` before `parse_imperative_effect` ever runs, so this arm /// only fires for a trigger's effect body. The turn scope is enforced by the -/// cleanup clear of `pending_activation_cost_reductions` (CR 514.2), never by -/// text keyed here (the "this turn" is already stripped into an -/// `UntilEndOfTurn` duration upstream before the body reaches this parser). +/// `UntilEndOfTurn` duration (CR 514.2 cleanup expires the continuous effect), +/// never by text keyed here (the "this turn" is already stripped upstream before +/// the body reaches this parser). pub(crate) fn try_parse_activated_ability_cost_reduction_effect( tp: TextPair, ctx: &mut ParseContext, @@ -86,9 +100,30 @@ pub(crate) fn try_parse_activated_ability_cost_reduction_effect( } // "artifact tokens you control" → Typed[Artifact, You, FilterProp::Token]. let (source_filter, _after) = parse_type_phrase_with_ctx(subject, ctx); - Some(Effect::ReduceActivatedAbilityCost { + // CR 601.2f + CR 118.7: identical shape to Training Grounds' printed static + // (dispatch.rs). `keyword: "activated"` matches every activated ability; + // `exemption`/`activator`/`minimum_mana`/`dynamic_count` carry no clause on + // any known transient reducer, so they take the static's defaults. + let reduce_mode = StaticMode::ReduceAbilityCost { + mode: CostModifyMode::Reduce, + keyword: "activated".to_string(), amount, - source_filter, + minimum_mana: None, + dynamic_count: None, + exemption: ActivationExemption::None, + activator: None, + }; + // CR 611.2c: The reduction rides as an `AddStaticMode` modification (read off + // the TCE by the cost hook), with the source filter in `affected`. `target: + // None` + `duration: UntilEndOfTurn` = a battlefield-wide, this-turn effect. + Some(Effect::GenericEffect { + static_abilities: vec![StaticDefinition::new(reduce_mode.clone()) + .affected(source_filter) + .modifications(vec![ContinuousModification::AddStaticMode { + mode: reduce_mode, + }])], + duration: Some(Duration::UntilEndOfTurn), + target: None, }) } diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index c85424182f..3f80ae9ea1 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -5598,7 +5598,6 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 062ecdf4a7..0d71d39e65 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -44388,31 +44388,69 @@ fn drawn_this_turn_followup_overwrites_prior_life_payment() { ); } -/// CR 611.2 + CR 602.1: The Dining Car's chaos body — "activated abilities of -/// artifact tokens you control cost {2} less to activate" — parses to the -/// transient `Effect::ReduceActivatedAbilityCost`. The `source_filter` must be -/// scoped to artifact TOKENS you control (the discount hits only tokens), so -/// `FilterProp::Token` is load-bearing and asserted. +/// CR 611.2 + CR 601.2f + CR 118.7: The Dining Car's chaos body — "activated +/// abilities of artifact tokens you control cost {2} less to activate" — parses +/// to a `GenericEffect` that installs a `Duration::UntilEndOfTurn` +/// `StaticMode::ReduceAbilityCost` (the SAME static Training Grounds prints), +/// carried as an `AddStaticMode` modification with the source filter in +/// `affected` so the cost hook reads it off the TCE. `FilterProp::Token` is +/// load-bearing (the discount hits only tokens) and asserted on `affected`. #[test] fn dining_car_chaos_body_parses_reduce_activated_ability_cost() { + use crate::types::ability::{ContinuousModification, Duration}; + use crate::types::statics::StaticMode; let effect = parse_effect( "activated abilities of artifact tokens you control cost {2} less to activate", ); - let Effect::ReduceActivatedAbilityCost { - amount, - source_filter, - } = effect + let Effect::GenericEffect { + static_abilities, + duration, + target, + } = &effect else { - panic!("expected ReduceActivatedAbilityCost, got {effect:?}"); + panic!("expected GenericEffect, got {effect:?}"); }; - assert_eq!(amount, 2); - let TargetFilter::Typed(tf) = &source_filter else { - panic!("expected a Typed source filter, got {source_filter:?}"); + assert_eq!(*duration, Some(Duration::UntilEndOfTurn)); + assert_eq!(*target, None); + assert_eq!( + static_abilities.len(), + 1, + "exactly one ReduceAbilityCost static, got {static_abilities:?}" + ); + let def = &static_abilities[0]; + assert!( + matches!( + def.mode, + StaticMode::ReduceAbilityCost { + mode: CostModifyMode::Reduce, + amount: 2, + ref keyword, + .. + } if keyword == "activated" + ), + "expected ReduceAbilityCost(Reduce, 2, \"activated\"), got {:?}", + def.mode + ); + // The reduction rides as an `AddStaticMode` modification so it survives into + // the TCE and is read off it by the cost hook (mirrors MayLookAtFaceDown). + assert!( + def.modifications.iter().any(|m| matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ReduceAbilityCost { amount: 2, .. }, + } + )), + "reduction must ride as an AddStaticMode modification, got {:?}", + def.modifications + ); + // The source filter (artifact TOKENS you control) lives in `affected`. + let Some(TargetFilter::Typed(tf)) = def.affected.as_ref() else { + panic!("expected a Typed affected filter, got {:?}", def.affected); }; assert_eq!(tf.controller, Some(ControllerRef::You)); assert!( tf.type_filters.contains(&TypeFilter::Artifact), - "source filter must be an artifact filter, got {tf:?}" + "affected filter must be an artifact filter, got {tf:?}" ); assert!( tf.properties.contains(&FilterProp::Token), @@ -44422,20 +44460,30 @@ fn dining_car_chaos_body_parses_reduce_activated_ability_cost() { /// The transient effect arm is textually indiscriminate BY DESIGN: when /// `parse_imperative_effect` is reached directly with a standalone reducer line, -/// it emits `Effect::ReduceActivatedAbilityCost`. The static (Training Grounds) -/// vs. transient (The Dining Car) separation is enforced upstream at the dispatch -/// site — a printed static line is claimed by `parse_static_line` before this -/// parser runs (see `training_grounds_standalone_line_stays_static_no_transient_effect` -/// in the static tests). This test locks the arm's intended behavior so a future +/// it emits the `GenericEffect` carrying a `ReduceAbilityCost`. The static +/// (Training Grounds) vs. transient (The Dining Car) separation is enforced +/// upstream at the dispatch site — a printed static line is claimed by +/// `parse_static_line` before this parser runs (see +/// `training_grounds_standalone_line_stays_static_no_transient_effect` in the +/// static tests). This test locks the arm's intended behavior so a future /// maintainer cannot "fix" the helper to reject it and silently break the Dining /// Car path. #[test] fn imperative_effect_arm_fires_when_reached_directly() { + use crate::types::statics::StaticMode; let effect = parse_effect("activated abilities of creatures you control cost {2} less to activate"); + let Effect::GenericEffect { + static_abilities, .. + } = &effect + else { + panic!("reached directly, the transient arm should fire a GenericEffect; got {effect:?}"); + }; assert!( - matches!(effect, Effect::ReduceActivatedAbilityCost { amount: 2, .. }), - "reached directly, the transient arm should fire; got {effect:?}" + static_abilities + .iter() + .any(|def| matches!(def.mode, StaticMode::ReduceAbilityCost { amount: 2, .. })), + "expected a ReduceAbilityCost(2) static, got {static_abilities:?}" ); } diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index 96e58166ae..ceae31f0f5 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -1280,7 +1280,6 @@ fn stamp_effect_printed_slot(effect: &mut Effect, slot: usize, kind: PrintedItem Effect::AddTargetReplacement { .. } => {} Effect::AddRestriction { .. } => {} Effect::ReduceNextSpellCost { .. } => {} - Effect::ReduceActivatedAbilityCost { .. } => {} Effect::GrantNextSpellAbility { .. } => {} Effect::AddPendingETBCounters { .. } => {} Effect::PayCost { .. } => {} diff --git a/crates/engine/src/parser/oracle_static/cost_mod.rs b/crates/engine/src/parser/oracle_static/cost_mod.rs index 7966587490..b5e85316d6 100644 --- a/crates/engine/src/parser/oracle_static/cost_mod.rs +++ b/crates/engine/src/parser/oracle_static/cost_mod.rs @@ -100,7 +100,9 @@ pub(crate) fn parse_action_cost_reduction(text: &str, lower: &str) -> Option - // abilities of cost {N|X} to activate". + // this-turn form, which lowers to a `GenericEffect` carrying this same + // `ReduceAbilityCost` static) — " abilities of + // cost {N|X} to activate". let (i, (keyword, subject, amount_n, is_x, mode)) = super::cost_mod::parse_activated_ability_cost_head(i)?; // CR 208.1 + CR 113.7: optional dynamic referent for `{X}` diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 293627134a..087cd62f52 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10931,24 +10931,6 @@ pub enum Effect { #[serde(default, skip_serializing_if = "Option::is_none")] spell_filter: Option, }, - /// CR 602.2 + CR 601.2f + CR 611.2: "Activated abilities of cost {N} - /// less to activate this turn." Resolution-generated, turn-scoped - /// activation-cost reduction (The Dining Car's chaos ability). Registers a - /// `PendingActivationCostReduction` that every matching activation reads until - /// the cleanup step clears it (CR 514.2). The permanent/static twin is - /// `StaticMode::ReduceAbilityCost { keyword: "activated" }` (Training Grounds); - /// this is its transient sibling, so it lives at the `Effect` layer. The static - /// twin also carries `exemption`/`minimum_mana`/`activator`, driven by real - /// static cards (Suppression Field, Zirda, Training Grounds' floor); no known - /// *transient* reducer carries those clauses, so per the minimalism gate they - /// are omitted here and added (each a `#[serde(default)]` field) only when a - /// card drives them. - ReduceActivatedAbilityCost { - amount: u32, - /// Which source permanents' activated abilities are reduced - /// ("artifact tokens you control" → Typed[Artifact, You, Token]). - source_filter: TargetFilter, - }, /// CR 601.2f: "The next [type] spell you cast this turn [has keyword/can't be countered/etc.]." /// Creates a one-shot modifier applied when the player casts their next qualifying spell. GrantNextSpellAbility { @@ -13613,7 +13595,6 @@ impl Effect { | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::GrantNextSpellAbility { .. } | Effect::AddPendingETBCounters { .. } | Effect::AddPendingEntersModifications { .. } @@ -14032,7 +14013,6 @@ impl Effect { | Effect::ReassembleContraption { .. } | Effect::ReassembleContraptionOnSprocket { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::RevealFromHand { .. } | Effect::RingTemptsYou | Effect::Ripple { .. } @@ -14285,7 +14265,6 @@ impl Effect { | Effect::ReassembleContraption { .. } | Effect::ReassembleContraptionOnSprocket { .. } | Effect::ReduceNextSpellCost { .. } - | Effect::ReduceActivatedAbilityCost { .. } | Effect::RevealFromHand { .. } | Effect::RingTemptsYou | Effect::Ripple { .. } @@ -14439,7 +14418,6 @@ pub fn effect_variant_name(effect: &Effect) -> &str { Effect::AddTargetReplacement { .. } => "AddTargetReplacement", Effect::AddRestriction { .. } => "AddRestriction", Effect::ReduceNextSpellCost { .. } => "ReduceNextSpellCost", - Effect::ReduceActivatedAbilityCost { .. } => "ReduceActivatedAbilityCost", Effect::GrantNextSpellAbility { .. } => "GrantNextSpellAbility", Effect::AddPendingETBCounters { .. } => "AddPendingETBCounters", Effect::AddPendingEntersModifications { .. } => "AddPendingEntersModifications", @@ -14681,7 +14659,6 @@ pub enum EffectKind { AddTargetReplacement, AddRestriction, ReduceNextSpellCost, - ReduceActivatedAbilityCost, GrantNextSpellAbility, AddPendingETBCounters, AddPendingEntersModifications, @@ -14940,7 +14917,6 @@ impl From<&Effect> for EffectKind { Effect::AddTargetReplacement { .. } => EffectKind::AddTargetReplacement, Effect::AddRestriction { .. } => EffectKind::AddRestriction, Effect::ReduceNextSpellCost { .. } => EffectKind::ReduceNextSpellCost, - Effect::ReduceActivatedAbilityCost { .. } => EffectKind::ReduceActivatedAbilityCost, Effect::GrantNextSpellAbility { .. } => EffectKind::GrantNextSpellAbility, Effect::AddPendingETBCounters { .. } => EffectKind::AddPendingETBCounters, Effect::AddPendingEntersModifications { .. } => { diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index fe1b0b257f..8fb4ba36c8 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -423,24 +423,6 @@ pub struct PendingSpellCostReduction { pub spell_filter: Option, } -/// CR 602.2 + CR 601.2f + CR 514.2: A turn-scoped reduction to the activation cost -/// of activated abilities whose SOURCE permanent matches `source_filter`. Created -/// by `Effect::ReduceActivatedAbilityCost` (The Dining Car's chaos ability); read by -/// every activation this turn; cleared at cleanup. Distinct from -/// `PendingSpellCostReduction` (spell casts, next-spell one-shot) — activations are -/// CR 602, not CR 601, and this reduction is not consumed on use. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PendingActivationCostReduction { - /// Reference "you" for `source_filter`'s `ControllerRef::You`. - pub controller: PlayerId, - /// `FilterContext` anchor (`from_source_with_controller`). - pub source_id: ObjectId, - /// Generic mana reduction amount. - pub amount: u32, - /// Which source permanents' activated abilities are reduced. - pub source_filter: TargetFilter, -} - /// CR 601.2f: Describes a one-shot modification applied to the next qualifying spell a player /// casts. Created by effects like "the next spell you cast this turn has convoke" or "the next /// creature spell you cast this turn can't be countered." @@ -7715,11 +7697,6 @@ pub struct GameState { /// Consumed when the player casts their next qualifying spell. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pending_spell_cost_reductions: Vec, - /// CR 602.2 + CR 514.2: Turn-scoped reductions to the activation cost of - /// activated abilities whose source permanent matches `source_filter`. Read by - /// every matching activation this turn; cleared at cleanup (not consumed on use). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub pending_activation_cost_reductions: Vec, /// CR 601.2f: One-shot ability modifiers for the next spell cast. /// Consumed when the player casts their next qualifying spell. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -9648,7 +9625,6 @@ impl GameState { creature_types_dealt_combat_damage_this_turn: im::HashSet::new(), mana_spent_on_spells_this_turn: HashMap::new(), pending_spell_cost_reductions: Vec::new(), - pending_activation_cost_reductions: Vec::new(), pending_next_spell_modifiers: Vec::new(), pending_etb_counters: Vec::new(), modal_modes_chosen_this_turn: HashSet::new(), @@ -10945,8 +10921,6 @@ impl PartialEq for GameState { && self.creature_types_dealt_combat_damage_this_turn == other.creature_types_dealt_combat_damage_this_turn && self.pending_spell_cost_reductions == other.pending_spell_cost_reductions - && self.pending_activation_cost_reductions - == other.pending_activation_cost_reductions && self.pending_next_spell_modifiers == other.pending_next_spell_modifiers && self.pending_etb_counters == other.pending_etb_counters && self.modal_modes_chosen_this_turn == other.modal_modes_chosen_this_turn From 39c4fc71ca21dc0290b6e7b1b1624041d6716a54 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 12 Jul 2026 18:07:48 -0700 Subject: [PATCH 3/3] fix(PR-5651): skip static scan without reducers Co-authored-by: Nicholas Tindle --- crates/engine/src/game/casting.rs | 47 +++++++++++++++++-------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 394b414610..9c87ea4dd4 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -16202,28 +16202,33 @@ fn apply_static_activated_ability_cost_reduction( let ability_is_loyalty = crate::types::ability::is_loyalty_ability_cost(cost); // CR 611.3 + CR 601.2f: printed battlefield/command-zone `ReduceAbilityCost` - // statics (Training Grounds, Suppression Field, Zirda, Agatha, …). - for (static_source, def) in super::functioning_abilities::battlefield_active_statics(state) { - if !matches!(def.mode, StaticMode::ReduceAbilityCost { .. }) { - continue; + // statics (Training Grounds, Suppression Field, Zirda, Agatha, …). The + // presence index avoids scanning all static sources when this activation is + // affected only by a duration-scoped continuous reduction. + if has_static { + for (static_source, def) in super::functioning_abilities::battlefield_active_statics(state) + { + if !matches!(def.mode, StaticMode::ReduceAbilityCost { .. }) { + continue; + } + // CR 604.1 + CR 109.5: "you control" in the affected filter anchors on the + // static's current controller, read live from the battlefield object. + let ctx = super::filter::FilterContext::from_source(state, static_source.id); + apply_one_reduce_ability_cost( + state, + cost, + source_id, + player, + active_keyword, + ability_is_mana, + ability_is_loyalty, + &def.mode, + def.affected.as_ref(), + static_source.id, + static_source.controller, + &ctx, + ); } - // CR 604.1 + CR 109.5: "you control" in the affected filter anchors on the - // static's current controller, read live from the battlefield object. - let ctx = super::filter::FilterContext::from_source(state, static_source.id); - apply_one_reduce_ability_cost( - state, - cost, - source_id, - player, - active_keyword, - ability_is_mana, - ability_is_loyalty, - &def.mode, - def.affected.as_ref(), - static_source.id, - static_source.controller, - &ctx, - ); } // CR 611.2 + CR 118.7: duration-scoped continuous `ReduceAbilityCost` effects