diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index a6d976e61a..40fd3b2a89 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -4381,6 +4381,35 @@ fn static_details(stat: &StaticDefinition) -> Vec<(String, String)> { if let Some(zone) = &stat.affected_zone { d.push(("zone".into(), fmt_zone(zone))); } + // CR 601.2f (#6375, MED-2): a `ModifyCost` static's payload is otherwise + // invisible to the parse-diff — `StaticMode`'s Display collapses `ModifyCost` + // to a bare "ReduceCost"/"RaiseCost"/"MinimumCost" (the `ParsedItem::label`), + // dropping `amount`, `spell_filter`, and `dynamic_count`, and `static_details` + // projected none of them. So a change to WHICH spells the reduction targets + // (Goreclaw's `spell_filter` flipping from `None` to a power-gated creature + // filter) produced a byte-identical signature and a false "No card-parse + // changes detected". Project every semantically-relevant ModifyCost field so + // the diff surfaces the change. Deterministic: `fmt_target` and `Debug` are + // stable, ordering-free encodings. + if let StaticMode::ModifyCost { + mode, + amount, + spell_filter, + dynamic_count, + } = &stat.mode + { + d.push(("cost mode".into(), format!("{mode:?}"))); + d.push(("cost amount".into(), format!("{amount:?}"))); + d.push(( + "cost filter".into(), + spell_filter + .as_ref() + .map_or_else(|| "any".into(), fmt_target), + )); + if let Some(dynamic) = dynamic_count { + d.push(("cost per".into(), format!("{dynamic:?}"))); + } + } d } @@ -10777,6 +10806,86 @@ mod tests { use crate::types::statics::{BlockExceptionKind, ProhibitionScope}; use crate::types::zones::{EtbTapState, Zone}; + /// CR 601.2f (#6375, MED-2): a `ModifyCost` static's `spell_filter` must be + /// visible in the parse-diff signature. Goreclaw's fix flipped `spell_filter` + /// from `None` (reduces ALL spells) to a power-gated creature filter, but the + /// `ParsedItem` label (Display) collapses `ModifyCost` to a bare "ReduceCost" + /// and `static_details` projected no ModifyCost payload — so both variants + /// hashed identically and CI reported "No card-parse changes detected". + /// + /// Red/green: the two static_details projections differ ONLY when the + /// `spell_filter` row is emitted. Revert-probe: dropping the `static_details` + /// ModifyCost projection makes both vectors byte-identical and this fails. + #[test] + fn modify_cost_signature_exposes_spell_filter() { + use crate::types::ability::{ + Comparator, FilterProp, PtStat, PtValueScope, QuantityExpr, TargetFilter, TypeFilter, + TypedFilter, + }; + use crate::types::mana::ManaCost; + use crate::types::statics::{CostModifyMode, StaticMode}; + + let modify_cost_static = |spell_filter: Option| -> StaticDefinition { + StaticDefinition { + mode: StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: ManaCost::generic(2), + spell_filter, + dynamic_count: None, + }, + affected: Some(TargetFilter::Controller), + modifications: vec![], + condition: None, + per_player_condition: None, + affected_zone: None, + effect_zone: None, + active_zones: vec![], + characteristic_defining: false, + description: Some("cost reduction".to_string()), + attack_defended: None, + source_controller: None, + source_object: None, + bypass_beneficiary: None, + protection_does_not_remove: None, + } + }; + + // Goreclaw's real filter: creature spells with power 4 or greater. + let goreclaw_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: None, + properties: vec![FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 4 }, + }], + }); + + let unfiltered = static_details(&modify_cost_static(None)); + let filtered = static_details(&modify_cost_static(Some(goreclaw_filter))); + + // The `spell_filter` change must surface in the projected signature. + assert_ne!( + unfiltered, filtered, + "a ModifyCost spell_filter change (None → power-gated creature filter) \ + must produce a different coverage signature (issue #6375 MED-2)" + ); + // Specifically, the "cost filter" row must reflect each value. + let filter_row = |d: &[(String, String)]| -> String { + d.iter() + .find(|(k, _)| k == "cost filter") + .map(|(_, v)| v.clone()) + .expect("ModifyCost projection must emit a 'cost filter' row") + }; + assert_eq!(filter_row(&unfiltered), "any", "None filter → \"any\""); + assert_ne!( + filter_row(&filtered), + "any", + "a power-gated creature filter must NOT project as \"any\"" + ); + } + #[test] fn change_zone_signature_exposes_enters_attacking() { // #5495: a parser change flipping `enters_attacking` (e.g. teaching diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index f641e8e33b..54896065c4 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -3484,6 +3484,52 @@ fn spell_object_matches_property( .get(&spell_id) .is_some_and(|obj| !obj.kickers_paid.is_empty()) }), + // CR 208 + CR 601.2f: Power/toughness-gated cost modifiers (Goreclaw's + // "creature spells you cast with power 4 or greater cost {2} less") must read + // the LIVE spell object's P/T — the SpellCastRecord snapshot carries none. + // Resolve the spell object via the filter context and evaluate against + // object_pt_value (the same authority the live matcher uses). CR 208.3: a + // noncreature spell has power None → object_pt_value → 0, so a GE gate fails + // closed for it. Snapshot-only callers (no context / no spell_object_id) fall + // through to the fail-closed record arm, unchanged. + FilterProp::PtComparison { + stat, + scope, + comparator, + value, + } => { + let Some(context) = context else { + return false; + }; + let Some(spell_id) = context.spell_object_id else { + return false; + }; + let Some(spell_obj) = context.state.objects.get(&spell_id) else { + return false; + }; + // A dynamic (non-`Fixed`) threshold resolves against the modifier's + // SOURCE, not the candidate spell. No in-class card needs that today + // (Goreclaw is `Fixed(4)`, and the cost-mod parser cannot yet emit a + // P/T-relative threshold), so this is the correct scope for the class; + // a future spell-relative threshold would resolve with a spell scope. + let threshold = match value { + QuantityExpr::Fixed { value } => *value, + _ => resolve_quantity( + context.state, + value, + context.source_controller, + context.source_id, + ), + }; + // CR 208.2a: resolve the candidate spell's off-battlefield power + // through its CDA (if any) before comparing — a dynamic-P/T creature + // spell (Tarmogoyf-class) must gate on its CDA-defined power, not the + // stored 0. A fixed-P/T spell reads its printed value unchanged. + comparator.evaluate( + spell_object_effective_pt_value(context.state, spell_obj, *stat, *scope), + threshold, + ) + } _ => spell_record_matches_property(record, prop), } } @@ -4073,6 +4119,145 @@ fn object_pt_value(obj: &GameObject, stat: PtStat, scope: PtValueScope) -> i32 { } } +/// CR 208.2a + CR 613.4b: a power/toughness characteristic-defining ability +/// (CDA) functions in EVERY zone — hand, stack, graveyard — not just the +/// battlefield. A candidate spell object loaded from card data stores a printed +/// `*` P/T as `Some(0)` (`printed_cards::parse_pt` maps `PtValue::Variable`/ +/// `Quantity` to 0), and the battlefield layer pass (`evaluate_layers`) only +/// recomputes P/T for battlefield objects — a hand/stack object keeps that +/// stored 0. So a power-gated cost modifier (Goreclaw: "creature spells you cast +/// with power 4 or greater cost {2} less") that read `object_pt_value` directly +/// would treat an eligible dynamic-P/T creature spell (Tarmogoyf-class) as power +/// 0 and never reduce it. +/// +/// Resolve the candidate's off-battlefield P/T through the SAME authority the +/// layer pass uses, rather than re-deriving CDA evaluation here: gather the +/// continuous effects the object's own statics source +/// (`active_continuous_effects_from_static_source`), keep only the +/// characteristic-defining self-referential P/T modifications whose condition +/// currently holds — both fixed-constant *set* CDAs (`SetPower`/`SetToughness`, +/// Angry Mob's off-turn "are each 2" clause) and dynamic *set*/*add* CDAs +/// (Tarmogoyf) — resolving each dynamic magnitude with the layer pass's quantity +/// resolver (`resolve_quantity`, mirroring `apply_continuous_effect` at +/// layers.rs). Apply the layer-7a/7b *set* CDAs before the layer-7c *add* CDAs +/// (CR 613.4a: P/T characteristic-defining abilities apply first, in layer 7a). +/// A conditional CDA (Angry Mob's turn-window clauses) is gated exactly as the +/// layer pass gates it — see `self_cda` below. A fixed-P/T object (Fire +/// Elemental) sources no such effect, so this returns its stored +/// `object_pt_value` unchanged. +fn spell_object_effective_pt_value( + state: &GameState, + obj: &GameObject, + stat: PtStat, + scope: PtValueScope, +) -> i32 { + use crate::game::layers::{ + active_continuous_effects_from_static_source, evaluate_condition_with_recipient, + }; + use crate::game::quantity::{ + continuous_modification_dynamic_quantity, quantity_expr_uses_recipient, + resolve_quantity_with_recipient, + }; + use crate::types::ability::ContinuousModification; + + // Stored printed value — 0 for an unevaluated `*` CDA card, the true fixed + // value for a fixed-P/T card. + let (mut power, mut toughness) = match scope { + PtValueScope::Current => (obj.power, obj.toughness), + PtValueScope::Base => (obj.base_power, obj.base_toughness), + }; + + let effects = active_continuous_effects_from_static_source(state, obj); + // Only the object's OWN CDA (SelfRef, characteristic-defining) functions + // off the battlefield per CR 208.2a; a non-CDA "as long as ..." dynamic P/T + // static is battlefield-only and is correctly excluded here. + // CR 611.3a + CR 613.4a: a conditional self-CDA (Angry Mob's turn-window + // clauses) functions only while its condition currently holds. Mirror the + // layer pass's per-recipient gate exactly (`apply_continuous_effect_filtered`, + // layers.rs: an effect applies to a recipient iff + // `effect.condition.is_none_or(evaluate_condition_with_recipient(.., recipient))`). + // For a self-CDA the recipient IS the source object, so `obj.id` is the + // recipient. `active_continuous_effects_from_static_source` already drops any + // def whose non-recipient-context condition fails at the source level (Angry + // Mob's `DuringYourTurn` / `Not{DuringYourTurn}` are pre-filtered there); this + // re-check additionally honors a RETAINED recipient-context condition so a + // conditional constant- or dynamic-P/T CDA is never applied outside its + // window. `None` condition (Tarmogoyf, Fire Elemental) passes unchanged. + let self_cda = |e: &&crate::types::layers::ActiveContinuousEffect| { + e.characteristic_defining + && matches!(e.affected_filter, TargetFilter::SelfRef) + && e.condition.as_ref().is_none_or(|condition| { + evaluate_condition_with_recipient( + state, + condition, + e.controller, + e.source_id, + obj.id, + ) + }) + }; + // Mirror `apply_continuous_effect`'s recipient dispatch: a self-CDA's + // recipient IS its source, so both resolvers see the same object. + let resolve = |m: &ContinuousModification| -> Option { + let expr = continuous_modification_dynamic_quantity(m)?; + Some(if quantity_expr_uses_recipient(expr) { + resolve_quantity_with_recipient(state, expr, obj.controller, obj.id, obj.id) + } else { + resolve_quantity(state, expr, obj.controller, obj.id) + }) + }; + + // CR 613.4a (layer 7a) + CR 613.4b (layer 7b): *set* CDAs establish the base + // P/T first. Both the fixed-constant setters (Angry Mob's off-turn "power and + // toughness are each 2" clause parses to `SetPower { value }` / + // `SetToughness { value }`) and the dynamic setters (Tarmogoyf) are applied in + // this single set stage, before the layer-7c *add* loop below — mirroring the + // layer authority, which lists all six P/T-set variants together (layers.rs + // ~5187-5192) and applies them in one combined stage in timestamp/def/mod + // order (the order this iteration over `effects` preserves for same-source + // self-CDAs). + for effect in effects.iter().filter(self_cda) { + match &effect.modification { + // CR 613.4a: fixed-constant set CDA — defines P/T directly, no + // quantity resolution. + ContinuousModification::SetPower { value } => power = Some(*value), + ContinuousModification::SetToughness { value } => toughness = Some(*value), + ContinuousModification::SetDynamicPower { .. } + | ContinuousModification::SetPowerDynamic { .. } => { + if let Some(v) = resolve(&effect.modification) { + power = Some(v); + } + } + ContinuousModification::SetDynamicToughness { .. } + | ContinuousModification::SetToughnessDynamic { .. } => { + if let Some(v) = resolve(&effect.modification) { + toughness = Some(v); + } + } + _ => {} + } + } + // CR 613.4c (layer 7c): dynamic *add* CDAs (e.g. a life-total CDA) stack on + // top of the set base. + for effect in effects.iter().filter(self_cda) { + match &effect.modification { + ContinuousModification::AddDynamicPower { .. } => { + if let Some(v) = resolve(&effect.modification) { + power = Some(power.unwrap_or(0) + v); + } + } + ContinuousModification::AddDynamicToughness { .. } => { + if let Some(v) = resolve(&effect.modification) { + toughness = Some(toughness.unwrap_or(0) + v); + } + } + _ => {} + } + } + + pt_value_from_pair(stat, power, toughness) +} + fn zone_change_pt_value(record: &ZoneChangeRecord, stat: PtStat, scope: PtValueScope) -> i32 { match scope { PtValueScope::Current => pt_value_from_pair(stat, record.power, record.toughness), diff --git a/crates/engine/src/parser/oracle_static/static_helpers.rs b/crates/engine/src/parser/oracle_static/static_helpers.rs index bdbe18d4cb..bfcfc48b4f 100644 --- a/crates/engine/src/parser/oracle_static/static_helpers.rs +++ b/crates/engine/src/parser/oracle_static/static_helpers.rs @@ -132,13 +132,35 @@ fn strip_cost_mod_mana_value_qualifier(prefix: &str) -> (&str, Option OracleResult<'_, (&str, FilterProp)> { + let (rest, before) = take_until(" with power ").parse(prefix)?; + let (rest, prop) = nom_filter::parse_pt_comparison(rest.trim_start())?; + Ok((rest, (before, prop))) +} +fn strip_cost_mod_power_qualifier(prefix: &str) -> Option<(&str, Option)> { + if take_until::<_, _, OracleError<'_>>(" with power ") + .parse(prefix) + .is_err() + { + return Some((prefix, None)); + } + + let (rest, (before, prop)) = parse_cost_mod_power_qualifier(prefix).ok()?; + rest.trim().is_empty().then_some((before, Some(prop))) +} + +/// Compose an optional qualifier `FilterProp` — a mana-value `Cmc` (from +/// `strip_cost_mod_mana_value_qualifier`) or a `PtComparison` power gate (from +/// `strip_cost_mod_power_qualifier`) — into the cost-modifier spell filter. /// A typed filter absorbs the prop directly; an `Or` (or any non-`Typed`) -/// filter is `And`-wrapped with a card+prop leaf; a bare mana-value gate with no -/// type restriction ("spells you cast with mana value 4 or greater") becomes a -/// card filter carrying the prop. -fn compose_cost_mod_mana_value( +/// filter is `And`-wrapped with a card+prop leaf; a bare gate with no type +/// restriction ("spells you cast with mana value 4 or greater") becomes a card +/// filter carrying the prop. `None` is identity (no qualifier present). +fn compose_cost_mod_qualifier_prop( filter: Option, prop: Option, ) -> Option { @@ -682,6 +704,12 @@ pub(crate) fn try_parse_cost_modification( // the type words and the whole type+MV restriction is dropped (The Scarlet // Witch reduced EVERY spell, not just instants/sorceries — #5606). let (without_chosen, mana_value_prop) = strip_cost_mod_mana_value_qualifier(without_chosen); + // CR 208.1 + CR 601.2f: Peel a trailing "with power N or greater/less" gate + // (Goreclaw: "Creature spells you cast with power 4 or greater cost {2} less") + // the same way. Without peeling, the gate sits after the "spells you cast" + // infix and blocks the type trims, dropping the whole type+power restriction + // (spell_filter came out null and EVERY spell was reduced — #6375). + let (without_chosen, power_prop) = strip_cost_mod_power_qualifier(without_chosen)?; let type_desc = without_chosen .trim_end_matches(" you cast") // allow-noncombinator: moved legacy static parser code; refactor-only split preserves behavior. .trim_end_matches(" your opponents cast") // allow-noncombinator: moved legacy static parser code; refactor-only split preserves behavior. @@ -732,8 +760,11 @@ pub(crate) fn try_parse_cost_modification( (None, true) => Some(TargetFilter::HasChosenName), (tf, false) => tf, }; - // CR 202.3: fold the peeled mana-value gate back into the spell filter. - compose_cost_mod_mana_value(base_filter, mana_value_prop) + // CR 202.3 + CR 208.1: fold the peeled mana-value and power gates back into + // the spell filter. MV and power are mutually exclusive on real cards, and + // composing with `None` is identity, so ordering is harmless. + let base = compose_cost_mod_qualifier_prop(base_filter, mana_value_prop); + compose_cost_mod_qualifier_prop(base, power_prop) } else { None }; diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 3956836015..465af64aba 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -1716,6 +1716,162 @@ fn cost_mod_no_mana_value_gate_unchanged() { ); } +/// CR 208.1 + CR 601.2f (#6375): Goreclaw, Terror of Qal Sisma — "Creature spells +/// you cast with power 4 or greater cost {2} less to cast." restricts the reduction +/// to creature spells of power ≥ 4. Before the fix the trailing "with power" gate +/// sat after the "spells you cast" infix and blocked the type trims, so +/// `spell_filter` came out `null` and EVERY spell was reduced (the reported bug — +/// a power-3 creature spell was wrongly reduced 5→3 at base). The gate now folds +/// into `Typed{ Creature, [PtComparison{ Power, Current, GE, 4 }] }`. +#[test] +fn cost_mod_power_gate_creature_ge() { + let def = parse_static_line( + "Creature spells you cast with power 4 or greater cost {2} less to cast.", + ) + .expect("cost reduction should parse"); + let StaticMode::ModifyCost { spell_filter, .. } = &def.mode else { + panic!("expected ModifyCost, got {:?}", def.mode); + }; + // Revert-guard: reverting the power peel yields `spell_filter: None` (reduces + // every spell) — this assertion then fails. + let Some(TargetFilter::Typed(tf)) = spell_filter.as_ref() else { + panic!("expected Typed(Creature + power gate), got {spell_filter:?}"); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Creature), + "creature type restriction lost: {tf:?}" + ); + assert!( + tf.properties.contains(&FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 4 }, + }), + "missing power >= 4 gate: {tf:?}" + ); +} + +/// CR 208.1 (#6375): a "with power N or less" cost-modifier subject folds a +/// `PtComparison { LE }` power gate — the peel reuses the canonical +/// `parse_pt_comparison` combinator so every comparator direction is covered. +#[test] +fn cost_mod_power_gate_le() { + let def = + parse_static_line("Creature spells you cast with power 2 or less cost {1} less to cast.") + .expect("cost reduction should parse"); + let StaticMode::ModifyCost { spell_filter, .. } = &def.mode else { + panic!("expected ModifyCost, got {:?}", def.mode); + }; + let Some(TargetFilter::Typed(tf)) = spell_filter.as_ref() else { + panic!("expected Typed(Creature + power gate), got {spell_filter:?}"); + }; + assert!( + tf.properties.contains(&FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 2 }, + }), + "missing power <= 2 gate: {tf:?}" + ); +} + +/// CR 208.1 (#6375): a bare "with power N" cost-modifier subject (no "or +/// greater/less") folds an `EQ` power gate — `parse_pt_comparison_tail` maps the +/// bare threshold to equality. +#[test] +fn cost_mod_power_gate_bare_eq() { + let def = parse_static_line("Creature spells you cast with power 4 cost {1} less to cast.") + .expect("cost reduction should parse"); + let StaticMode::ModifyCost { spell_filter, .. } = &def.mode else { + panic!("expected ModifyCost, got {:?}", def.mode); + }; + let Some(TargetFilter::Typed(tf)) = spell_filter.as_ref() else { + panic!("expected Typed(Creature + power gate), got {spell_filter:?}"); + }; + assert!( + tf.properties.contains(&FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::EQ, + value: QuantityExpr::Fixed { value: 4 }, + }), + "missing power == 4 gate: {tf:?}" + ); +} + +/// A cost-modifier power qualifier is all-consuming: an unmodeled trailing rider +/// must remain unsupported rather than silently broadening the reducer. +#[test] +fn cost_mod_power_gate_rejects_unparsed_trailing_rider() { + assert!(parse_static_line( + "Creature spells you cast with power 4 or greater and with flying cost {1} less to cast." + ) + .is_none()); +} + +/// CONTROL (#6375): the fix only touches the cost-modifier subject parser — the +/// attack-trigger parser is untouched. Goreclaw's whole Oracle text still parses +/// its ATTACK trigger's affected filter to the same `PtComparison{ Power, GE, 4 }` +/// on `Typed{ Creature }`, proving the trigger path is unaffected. +#[test] +fn cost_mod_power_gate_attack_trigger_control() { + let parsed = crate::parser::oracle::parse_oracle_text( + "Creature spells you cast with power 4 or greater cost {2} less to cast.\n\ + Whenever Goreclaw, Terror of Qal Sisma attacks, each creature you control \ + with power 4 or greater gets +1/+1 and gains trample until end of turn.", + "Goreclaw, Terror of Qal Sisma", + &[], + &["Legendary".to_string(), "Creature".to_string()], + &["Bear".to_string()], + ); + let power_ge_4 = FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 4 }, + }; + // Navigate structurally to the attack trigger's granted continuous static and + // assert its affected filter is still a creature-typed filter carrying the same + // power gate — the cost-modifier fix must not touch the trigger parser. + let trigger = parsed + .triggers + .iter() + .find(|t| matches!(t.mode, crate::types::TriggerMode::Attacks)) + .expect("Goreclaw must parse an attack trigger"); + let execute = trigger + .execute + .as_ref() + .expect("attack trigger must carry an execute ability"); + let Effect::GenericEffect { + static_abilities, .. + } = execute.effect.as_ref() + else { + panic!( + "attack trigger must grant a continuous static, got {:?}", + execute.effect + ); + }; + let granted = static_abilities + .first() + .expect("attack trigger must grant one continuous static"); + let Some(TargetFilter::Typed(tf)) = granted.affected.as_ref() else { + panic!( + "granted static must affect a Typed(Creature) filter, got {:?}", + granted.affected + ); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Creature), + "attack trigger's affected filter must be creature-typed: {tf:?}" + ); + assert!( + tf.properties.contains(&power_ge_4), + "attack trigger must still gate on power >= 4 (unchanged control): {tf:?}" + ); +} + /// CR 105.2 + CR 700.6 + CR 205.4a + CR 601.2f: a BARE-word spell-subject filter /// for a cost modifier resolves the full color-CATEGORY axis (colorless / /// monocolored / multicolored → `ColorCount`), "historic" (→ `Historic`), a named diff --git a/crates/engine/tests/integration/goreclaw_power_gated_cost_reduction_6375.rs b/crates/engine/tests/integration/goreclaw_power_gated_cost_reduction_6375.rs new file mode 100644 index 0000000000..acfde9e27a --- /dev/null +++ b/crates/engine/tests/integration/goreclaw_power_gated_cost_reduction_6375.rs @@ -0,0 +1,384 @@ +//! Reproduction + regression for issue #6375: Goreclaw, Terror of Qal Sisma's +//! power-gated cost reduction. +//! +//! Goreclaw: "Creature spells you cast with power 4 or greater cost {2} less to +//! cast. / Whenever Goreclaw attacks, each creature you control with power 4 or +//! greater gets +1/+1 and gains trample until end of turn." +//! +//! Bug: the cost-reduction static parsed with `spell_filter: null` — the trailing +//! "with power 4 or greater" gate sat after the "spells you cast" infix and blocked +//! the type/subject trims, so the whole "creature ... with power 4+" restriction was +//! dropped and the reduction (mis)applied to EVERY spell. Confirmed reproduction: a +//! power-3 creature spell was wrongly reduced 5→3 at base. +//! +//! CR 208.1 + CR 601.2f: a cost reduction applies only to spells matching the +//! effect's filter, and only creature spells have a power to compare (CR 208.3 — a +//! noncreature card off the battlefield has power only if a P/T is printed on it). +//! +//! Finding #6 (BLOCKING): these tests drive the REAL production cost path +//! (`display_spell_cost`) casting REAL creature card FACES that carry PRINTED power +//! — built through `create_object_from_card_face` / `apply_card_face_to_object` via +//! `add_real_card` — never a hand-constructed object with a force-set `obj.power`. +//! The Goreclaw cost-reduction static is built by parsing the VERBATIM Oracle cost +//! line through `parse_static_line`, so each assertion tests the NEW parser output +//! directly (not a stale card-data fixture). + +use engine::game::scenario::{GameScenario, P0}; +use engine::game::scenario_db::GameScenarioDbExt; +use engine::parser::oracle_static::parse_static_line; +use engine::types::ability::TargetFilter; +use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +use crate::support::shared_card_db; + +/// Verbatim Goreclaw cost line (issue #6375). Parsed through the real static parser. +const GORECLAW_COST_LINE: &str = + "Creature spells you cast with power 4 or greater cost {2} less to cast."; + +/// Build a board with Goreclaw's parsed cost-reduction static under P0's control, +/// put each named REAL card into P0's hand (printed power comes from the card face), +/// and return the generic mana component the production cost path reports for each. +/// +fn goreclaw_generics(spell_names: &[&str]) -> Vec { + let db = shared_card_db().expect("Goreclaw regression requires the integration card fixture"); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // CR 601.2f: Goreclaw's cost-reduction static, parsed from its verbatim Oracle + // cost line, on a battlefield permanent P0 controls. `affected` carries the + // "you cast" controller scope so it applies to spells P0 casts. + let cost_static = + parse_static_line(GORECLAW_COST_LINE).expect("Goreclaw cost line must parse to a static"); + scenario + .add_creature(P0, "Goreclaw, Terror of Qal Sisma", 4, 5) + .with_static_definition(cost_static); + + // Real card faces in hand — power is populated from the printed P/T by the + // production face-application path, not force-set (Finding #6). + let ids: Vec = spell_names + .iter() + .map(|name| scenario.add_real_card(P0, name, Zone::Hand, db)) + .collect(); + + let mut runner = scenario.build(); + // Finalize public state so the layer pipeline runs and the static-presence + // index reflects Goreclaw's ModifyCost static (mirrors the Cloud Key cost test). + // Goreclaw is not in the fixture DB, so face-reapply skips it and its parsed + // static is preserved. + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + let generics = ids + .iter() + .map(|&id| { + let cost = engine::game::casting::display_spell_cost(runner.state(), P0, id) + .expect("spell should have a displayable cost"); + match cost { + ManaCost::Cost { generic, .. } => generic, + other => panic!("expected ManaCost::Cost, got {other:?}"), + } + }) + .collect(); + generics +} + +/// CR 208.1 + CR 601.2f (#6375): a creature spell with power CLEARLY greater than 4 +/// is reduced by {2}. Fire Elemental (5/4, {3}{R}{R}) → generic 3 reduced to 1. +/// +/// Reach-guard (not vacuous): the observed 3→1 delta proves the reduction actually +/// fired against a real printed-power creature face, not that the assertion is +/// trivially true. Reverting `spell_filter` to `null` keeps this reduced (null +/// reduces everything) — the sibling NOT-reduced tests are the revert-guards. +#[test] +fn power_five_creature_spell_is_reduced_by_two() { + let generics = goreclaw_generics(&["Fire Elemental"]); + assert_eq!( + generics[0], 1, + "a power-5 creature spell must be reduced by {{2}} (generic 3 → 1)" + ); +} + +/// CR 208.1 + CR 601.2f (#6375): the reported bug. A power-3 creature spell is NOT +/// reduced (power 3 < 4). Hill Giant (3/3, {3}{R}) → generic 3, unchanged. +/// +/// Revert-guard: reverting the fix drops the power gate (`spell_filter: null`) and +/// this spell is WRONGLY reduced 3→1 — this assertion then fails (the reported +/// 5→3-at-base bug). Reach-guard: the same board reduces Fire Elemental (power 5) +/// 3→1, proving Goreclaw's static is active and its filter is being evaluated — so +/// Hill Giant's non-reduction is the type/power gate rejecting it, not an inert +/// static. +#[test] +fn power_three_creature_spell_is_not_reduced() { + let generics = goreclaw_generics(&["Hill Giant", "Fire Elemental"]); + assert_eq!( + generics[0], 3, + "a power-3 creature spell must NOT be reduced (power 3 < 4); reverting the \ + power gate wrongly reduces it 3→1 (issue #6375)" + ); + // Reach-guard: the static is live and its filter is evaluated on this board. + assert_eq!( + generics[1], 1, + "reach-guard: a power-5 creature spell must be reduced 3→1 on the same board" + ); +} + +/// CR 208.3 + CR 601.2f (#6375): a noncreature spell is NOT reduced — a noncreature +/// card off the battlefield has no power (reads 0), so it fails the creature type +/// gate. Arc Lightning (Sorcery, {2}{R}) → generic 2, unchanged. +/// +/// Revert-guard: reverting the fix reduces EVERY spell, so this sorcery would be +/// wrongly reduced 2→0 and the assertion fails. Reach-guard: Fire Elemental (a +/// creature spell) is reduced 3→1 on the same board. +#[test] +fn noncreature_spell_is_not_reduced() { + let generics = goreclaw_generics(&["Arc Lightning", "Fire Elemental"]); + assert_eq!( + generics[0], 2, + "a noncreature spell must NOT be reduced (type gate); reverting the filter \ + wrongly reduces it 2→0 (issue #6375)" + ); + // Reach-guard: the static is live and its filter is evaluated on this board. + assert_eq!( + generics[1], 1, + "reach-guard: a power-5 creature spell must be reduced 3→1 on the same board" + ); +} + +/// CR 208.1 + CR 601.2f (#6375): boundary — power EXACTLY 4 satisfies the GE gate +/// and is reduced. Onakke Ogre (4/2, {2}{R}) → generic 2 reduced to 0. +/// +/// Reach-guard: the observed 2→0 delta proves the GE-4 gate admits the boundary +/// value against a real printed-power (exactly 4) creature face. +#[test] +fn power_exactly_four_creature_spell_is_reduced_boundary() { + let generics = goreclaw_generics(&["Onakke Ogre"]); + assert_eq!( + generics[0], 0, + "a power-4 creature spell must be reduced by {{2}} (GE boundary; generic 2 → 0)" + ); +} + +/// Cast-cost probe for a single named spell in P0's hand, with `graveyard` real +/// cards seeded into P0's graveyard FIRST so a graveyard-counting CDA (Tarmogoyf: +/// power = number of card types among cards in all graveyards) resolves against +/// them. Mirrors `goreclaw_generics` but returns the one probed spell's generic. +fn goreclaw_generic_with_graveyard(spell_name: &str, graveyard: &[&str]) -> u32 { + let db = shared_card_db().expect("Goreclaw regression requires the integration card fixture"); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let cost_static = + parse_static_line(GORECLAW_COST_LINE).expect("Goreclaw cost line must parse to a static"); + scenario + .add_creature(P0, "Goreclaw, Terror of Qal Sisma", 4, 5) + .with_static_definition(cost_static); + + // Real cards of DISTINCT card types in the graveyard set Tarmogoyf's CDA + // power to exactly `graveyard.len()`. + for name in graveyard { + scenario.add_real_card(P0, name, Zone::Graveyard, db); + } + + let spell = scenario.add_real_card(P0, spell_name, Zone::Hand, db); + + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + let cost = engine::game::casting::display_spell_cost(runner.state(), P0, spell) + .expect("spell should have a displayable cost"); + match cost { + ManaCost::Cost { generic, .. } => generic, + other => panic!("expected ManaCost::Cost, got {other:?}"), + } +} + +/// CR 208.2a + CR 613.4b (#6375, MED-1): a dynamic-P/T creature spell must gate on +/// its CHARACTERISTIC-DEFINING power, which functions in every zone — not on the +/// stale 0 that a `*` card stores off the battlefield. Tarmogoyf ({1}{G}, power = +/// number of card types among all graveyards) with a four-distinct-type graveyard +/// (Creature + Instant + Sorcery + Artifact) has CDA power 4, so Goreclaw reduces +/// it: generic 1 → 0. +/// +/// Revert-guard: reverting the CDA resolution (`spell_object_effective_pt_value` → +/// the raw `object_pt_value`) reads Tarmogoyf's stored power as 0, fails the GE-4 +/// gate, and leaves the generic at 1 — this assertion then fails. The power-3 +/// sibling below is the discriminating lower bound. +#[test] +fn dynamic_cda_power_four_creature_spell_is_reduced() { + let generic = goreclaw_generic_with_graveyard( + "Tarmogoyf", + &[ + "Absolver Thrull", + "Abrade", + "Arc Lightning", + "Amulet of Vigor", + ], + ); + assert_eq!( + generic, 0, + "a CDA creature spell whose CDA power is 4 must be reduced by {{2}} \ + (generic 1 → 0); reverting the CDA resolution reads power 0 and wrongly \ + leaves it unreduced at 1 (issue #6375 MED-1)" + ); +} + +/// CR 208.2a (#6375, MED-1): the discriminating lower bound. The SAME Tarmogoyf +/// with a three-distinct-type graveyard (Instant + Sorcery + Artifact) has CDA +/// power 3 (< 4) and is NOT reduced: generic 1 stays 1. Together with the power-4 +/// sibling this proves the gate reads the true CDA magnitude (3 vs 4), not merely +/// "nonzero". +#[test] +fn dynamic_cda_power_three_creature_spell_is_not_reduced() { + let generic = goreclaw_generic_with_graveyard( + "Tarmogoyf", + &["Abrade", "Arc Lightning", "Amulet of Vigor"], + ); + assert_eq!( + generic, 1, + "a CDA creature spell whose CDA power is 3 must NOT be reduced (3 < 4); \ + the power-4 sibling on a four-type graveyard IS reduced" + ); +} + +/// CR 208.1 (#6375, MED-1): fixed-P/T control. Fire Elemental (fixed power 5, +/// {3}{R}{R}) on the SAME three-type graveyard as the power-3 CDA test is still +/// reduced 3 → 1 — its printed power is unaffected by CDA resolution. This proves +/// the new resolution path changes nothing for a fixed-P/T spell (it must return +/// the stored `object_pt_value` unchanged when the object sources no self-CDA). +#[test] +fn fixed_power_control_reduced_regardless_of_graveyard() { + let generic = goreclaw_generic_with_graveyard( + "Fire Elemental", + &["Abrade", "Arc Lightning", "Amulet of Vigor"], + ); + assert_eq!( + generic, 1, + "a fixed power-5 creature spell is reduced 3 → 1 regardless of graveyard \ + (control: CDA resolution must not change fixed-P/T behavior)" + ); +} + +/// Cast-cost probe for a real creature-card FACE in P0's hand that carries an +/// attached conditional **constant**-P/T CDA, parsed from a verbatim Oracle line +/// via `parse_static_line` (the Angry Mob off-turn form at +/// `oracle_static/tests.rs`: `SetPower { value }` / `SetToughness { value }` +/// gated on a turn-window `StaticCondition`). +/// +/// The CDA is attached AFTER `rehydrate_game_from_card_db` — which re-applies the +/// printed face and would otherwise drop any extra static on a fixture card — +/// onto both `static_definitions` and `base_static_definitions`, exactly as +/// `GameScenario::with_static_definition` does. The spell's power is still +/// derived through the production `spell_object_effective_pt_value` path from the +/// CDA (via `display_spell_cost`), never a force-set `obj.power` (Finding #6). +fn goreclaw_generic_with_constant_cda(spell_name: &str, cda_line: &str) -> u32 { + let db = shared_card_db().expect("Goreclaw regression requires the integration card fixture"); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let cost_static = + parse_static_line(GORECLAW_COST_LINE).expect("Goreclaw cost line must parse to a static"); + scenario + .add_creature(P0, "Goreclaw, Terror of Qal Sisma", 4, 5) + .with_static_definition(cost_static); + + let spell = scenario.add_real_card(P0, spell_name, Zone::Hand, db); + + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + // Parse the conditional constant-P/T CDA from its Oracle line. Fixture + // self-check: it must be the exact SelfRef constant-P/T CDA form the review + // references (characteristic-defining, SelfRef-affected, `SetPower`/ + // `SetToughness { value }` set variants under a turn-window condition). + let cda = parse_static_line(cda_line) + .expect("conditional constant-P/T CDA line must parse to static"); + assert_eq!( + cda.affected, + Some(TargetFilter::SelfRef), + "constant-P/T CDA must be SelfRef-affected: {cda:?}" + ); + assert!( + cda.characteristic_defining, + "constant-P/T CDA must be characteristic-defining: {cda:?}" + ); + assert!( + cda.condition.is_some(), + "the CDA line under test must carry a turn-window condition: {cda:?}" + ); + + let obj = runner + .state_mut() + .objects + .get_mut(&spell) + .expect("spell object must exist post-rehydrate"); + obj.static_definitions.push(cda.clone()); + std::sync::Arc::make_mut(&mut obj.base_static_definitions).push(cda); + + let cost = engine::game::casting::display_spell_cost(runner.state(), P0, spell) + .expect("spell should have a displayable cost"); + match cost { + ManaCost::Cost { generic, .. } => generic, + other => panic!("expected ManaCost::Cost, got {other:?}"), + } +} + +/// CR 604.3 + CR 613.4a + CR 601.2f (#6375 HIGH): a creature spell whose ACTIVE +/// conditional **constant**-P/T CDA sets power to 4 must gate on that CDA power — +/// the fixed-constant `SetPower` set CDA functions off the battlefield exactly +/// like the dynamic setter. Hill Giant ({3}{R}, printed 3/3) carries a +/// "During your turn, ~'s power and toughness are each 4." CDA; the scenario is +/// at P0's PreCombatMain, so `DuringYourTurn` holds, the constant CDA is active, +/// effective power is 4, and Goreclaw reduces it: generic 3 → 1. +/// +/// Revert-guard (RED carrier): reverting the new fixed `SetPower { value }` / +/// `SetToughness { value }` set-stage arm in `spell_object_effective_pt_value` +/// makes the resolver skip the constant CDA and read Hill Giant's printed power 3 +/// (< 4). The GE-4 gate then rejects it and the generic stays 3 — this assertion +/// fails. That is exactly the off-battlefield "evaluated as printed" bug the +/// review flagged. +#[test] +fn conditional_constant_cda_active_power_four_creature_spell_is_reduced() { + let generic = goreclaw_generic_with_constant_cda( + "Hill Giant", + "During your turn, Hill Giant's power and toughness are each 4.", + ); + assert_eq!( + generic, 1, + "an ACTIVE conditional constant-P/T CDA setting power 4 must be reduced by \ + {{2}} (generic 3 → 1); reverting the new fixed `SetPower` set-stage arm \ + reads printed power 3, fails the GE-4 gate, and wrongly leaves it at 3 \ + (issue #6375 HIGH)" + ); +} + +/// CR 611.3a + CR 604.3 (#6375 HIGH): the discriminating condition-gating case. A +/// conditional constant-P/T CDA must apply ONLY while its condition holds. The +/// SAME Hill Giant carries a "During turns other than yours, ~'s power and +/// toughness are each 4." CDA (gated `Not{DuringYourTurn}`). On P0's own turn the +/// off-window condition is FALSE, so the CDA does not function — the spell reads +/// its printed power 3 (< 4) and is NOT reduced: generic stays 3. +/// +/// Revert-guard: applying the constant CDA UNCONDITIONALLY (dropping the +/// condition gate mirrored from the layer authority) would set power to 4 and +/// wrongly reduce the spell 3 → 1 — this assertion then fails. Together with the +/// active sibling above this proves the resolver both applies the fixed constant +/// CDA and honors its condition, never applying it outside its window. +#[test] +fn conditional_constant_cda_off_window_creature_spell_is_not_reduced() { + let generic = goreclaw_generic_with_constant_cda( + "Hill Giant", + "During turns other than yours, Hill Giant's power and toughness are each 4.", + ); + assert_eq!( + generic, 3, + "an off-window conditional constant-P/T CDA must NOT apply — the spell reads \ + printed power 3 and is not reduced; applying the constant CDA \ + unconditionally would set power 4 and wrongly reduce it to 1 (condition \ + gating, issue #6375 HIGH)" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 6bb421bec8..e80d9bf26e 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -197,6 +197,7 @@ mod gluntch_choose_player_chain; mod goaded_creature_under_pacifism_visible; mod gollum_scheming_guide_card_predicate_guess; mod good_king_mog_xii_chapter_iv_588; +mod goreclaw_power_gated_cost_reduction_6375; mod gran_gran_integration; mod granted_alt_cost_hand_keyword; mod granted_bloodthirst_5802;