diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 8b91910f16..9c87ea4dd4 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -16165,8 +16165,16 @@ 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: 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 = transient_reduce_ability_cost_present(state); + if !has_static && !has_transient { return; } crate::game::perf_counters::record_static_full_scan(); @@ -16193,95 +16201,184 @@ fn apply_static_activated_ability_cost_reduction( // `&`) before the loop mutates it. let ability_is_loyalty = crate::types::ability::is_loyalty_ability_cost(cost); - 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 { - 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 611.3 + CR 601.2f: printed battlefield/command-zone `ReduceAbilityCost` + // 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; } - } - if def.affected.as_ref().is_some_and(|filter| { - !super::filter::matches_target_filter( + // 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, - filter, - &super::filter::FilterContext::from_source(state, static_source.id), - ) - }) { - continue; + player, + active_keyword, + ability_is_mana, + ability_is_loyalty, + &def.mode, + def.affected.as_ref(), + static_source.id, + static_source.controller, + &ctx, + ); } - // 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(), + } + + // 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; }; - 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, + cost, + source_id, + player, + active_keyword, + ability_is_mana, + ability_is_loyalty, + reduce_mode, + Some(&tce.affected), + tce.source_id, + tce.controller, + &ctx, + ); + } + } +} + +/// 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 { .. }, + } ) - .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 => {} + }) + }) +} + +/// 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 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)); } + 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 fbd3df5158..ec88318e3f 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -9432,6 +9432,174 @@ fn activated_ability_cost_reduction_applies_to_matching_permanent_type() { ); } +/// 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(); + 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(generic_cost), + }), + ); + id +} + +/// 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(); + + // The Dining Car itself — the reduction's source/anchor. + 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, + 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, + ); + + // 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", + ); + // 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", + ); + + // 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_token_three, 0), + "the {{3}} token reduced to {{1}} is affordable with {{1}} mana", + ); + + // 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!( + !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", + ); +} + #[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/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/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/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index c999118511..b8d1ee1bbb 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::{ActivationExemption, 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,75 @@ use super::super::oracle_util::{ starts_with_possessive, TextPair, }; +/// 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). +/// +/// 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 +/// `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, +) -> 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); + // 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, + 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, + }) +} + /// 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 cf122fbef5..dc4e8beae8 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -20486,6 +20486,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/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 282482389b..3bdf5582c7 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -44603,3 +44603,120 @@ fn drawn_this_turn_followup_overwrites_prior_life_payment() { root.sub_ability ); } + +/// 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::GenericEffect { + static_abilities, + duration, + target, + } = &effect + else { + panic!("expected GenericEffect, got {effect:?}"); + }; + 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), + "affected 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 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!( + static_abilities + .iter() + .any(|def| matches!(def.mode, StaticMode::ReduceAbilityCost { amount: 2, .. })), + "expected a ReduceAbilityCost(2) static, got {static_abilities:?}" + ); +} + +/// 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_static/cost_mod.rs b/crates/engine/src/parser/oracle_static/cost_mod.rs index 2affb74f6d..b5e85316d6 100644 --- a/crates/engine/src/parser/oracle_static/cost_mod.rs +++ b/crates/engine/src/parser/oracle_static/cost_mod.rs @@ -94,6 +94,47 @@ 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 (this-turn) form, which lowers to a `GenericEffect` carrying the same +/// `StaticMode::ReduceAbilityCost` for a `Duration::UntilEndOfTurn` (oracle_effect, +/// The Dining Car's chaos body). +/// 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 f5f2c0b56c..0cf42fbb25 100644 --- a/crates/engine/src/parser/oracle_static/dispatch.rs +++ b/crates/engine/src/parser/oracle_static/dispatch.rs @@ -2920,31 +2920,12 @@ 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 + // 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}` // ("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 deb2a97c44..aaa8a1ea10 100644 --- a/crates/engine/src/parser/oracle_static/mod.rs +++ b/crates/engine/src/parser/oracle_static/mod.rs @@ -139,8 +139,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 6462cccdf6..153cede7c3 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -28444,6 +28444,63 @@ fn rayami_flying_grant_is_conditional_on_matching_exiled_card() { ); } +/// 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 +/// activation-cost-reduction effect. +#[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 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, + ); +} + /// Digital-only Alchemy: a dynamic P/T pump that scales by the source's own /// intensity — Minthara of the Absolute / Teysa of the Ghost Council ("get /// +X/+0, where X is ~'s intensity") and Quickbeast Amulet ("gets +X/+X, where