Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3484,6 +3484,45 @@ 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,
),
};
comparator.evaluate(object_pt_value(spell_obj, *stat, *scope), threshold)
}
_ => spell_record_matches_property(record, prop),
}
}
Expand Down
47 changes: 39 additions & 8 deletions crates/engine/src/parser/oracle_static/static_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,35 @@ fn strip_cost_mod_mana_value_qualifier(prefix: &str) -> (&str, Option<FilterProp
}
}

/// Compose an optional mana-value `FilterProp::Cmc` (from
/// `strip_cost_mod_mana_value_qualifier`) into the cost-modifier spell filter.
/// CR 208.1 + CR 601.2f: Peel a trailing "with power N or greater/less" spell-selection
/// qualifier off a cost-modifier subject (Goreclaw). Mirrors the mana-value qualifier;
/// reuses the canonical P/T-comparison combinator so GE/LE/EQ are all covered. Covers the
/// whole power-gated cost-modifier class, not one card.
fn parse_cost_mod_power_qualifier(prefix: &str) -> 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<FilterProp>)> {
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<TargetFilter>,
prop: Option<FilterProp>,
) -> Option<TargetFilter> {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
};
Expand Down
156 changes: 156 additions & 0 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading