Skip to content
Merged
6 changes: 6 additions & 0 deletions crates/engine/src/ai_support/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,12 @@ fn condition_reads_only_memo_safe_state(c: &ParsedCondition) -> bool {
| ParsedCondition::PlayerCountAtLeast { .. }
| ParsedCondition::HasCityBlessing
| ParsedCondition::HasEnduringStory
// CR 702.179e: reads the controller's `speed` plus a controller-scoped
// battlefield scan for the CR 101.1 cap-raising static (via
// `game::speed`) — the same two apply()-constant sources as the
// designation predicates above and `ControlsCommander` below. No combat,
// damage, or pending-cast history.
| ParsedCondition::HasMaxSpeed
// CR 903.3 / CR 903.3d: a controller-scoped battlefield scan for a commander
// (via `game::commander`), like the other `YouControl*` predicates — reads no
// combat/damage/pending-cast history, so it is memo-safe.
Expand Down
36 changes: 30 additions & 6 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ use crate::types::ability::{
CountScope, CounterSourceRider, DelayedTriggerCondition, DieRollModifier, DoublePTMode,
Duration, EachDamageRecipient, Effect, EffectOutcomeSignal, EffectScope, FilterProp,
ForEachCategoryAction, GameRestriction, LibraryPosition, ManaProduction, ObjectProperty,
ObjectScope, PerpetualModification, PlayerFilter, PlayerScope, PtStat, PtValue, PtValueScope,
QuantityExpr, QuantityRef, ReplacementCondition, ReplacementDefinition, ReplacementMode,
SeatDirection, SharedQuality, SharedQualityRelation, SpeedDelta, SpellCastingOption,
SpellCastingOptionKind, SpellStackToGraveyardReplacement, StackAbilityKind, StaticCondition,
StaticDefinition, TapStateChange, TargetFilter, TriggerDefinition, TypeFilter, TypedFilter,
VoteSubject, ZoneRef,
ObjectScope, ParsedCondition, PerpetualModification, PlayerFilter, PlayerScope, PtStat,
PtValue, PtValueScope, QuantityExpr, QuantityRef, ReplacementCondition, ReplacementDefinition,
ReplacementMode, SeatDirection, SharedQuality, SharedQualityRelation, SpeedDelta,
SpellCastingOption, SpellCastingOptionKind, SpellStackToGraveyardReplacement, StackAbilityKind,
StaticCondition, StaticDefinition, TapStateChange, TargetFilter, TriggerDefinition, TypeFilter,
TypedFilter, VoteSubject, ZoneRef,
};
use crate::types::card::CardFace;
use crate::types::card_type::CoreType;
Expand Down Expand Up @@ -3937,6 +3937,30 @@ fn ability_details(def: &AbilityDefinition) -> Vec<(String, String)> {
d.push(("repeat_for".into(), fmt_quantity(rf)));
}
}
// CR 702.178a: the "Max speed —" prefix is a GATE, not an effect — it lowers
// to an `activation_restrictions` entry, and that field is otherwise absent
// from the per-card parse signature. Without this projection the gate is
// invisible to the parse-diff, so adding or losing it on a card reads as
// "no card-parse changes".
//
// Scoped to exactly the shape `keyword_prefix_activation_restriction`
// (parser/oracle.rs) produces, mirroring the `repeat_for` discipline above:
// projecting the whole `activation_restrictions` surface would migrate every
// card printing an "Activate only if …" clause in one shot, which is a
// deliberate global coverage-schema migration and not this change.
// COUPLING: if another keyword prefix is ever lowered to an activation
// restriction, widen this scope in lockstep or that new class is false-green
// in the parse-diff.
if def.activation_restrictions.iter().any(|r| {
matches!(
r,
ActivationRestriction::RequiresCondition {
condition: Some(ParsedCondition::HasMaxSpeed),
}
)
}) {
d.push(("gate".into(), "max speed".into()));
}
if def.optional_targeting {
d.push(("targeting".into(), "optional (up to)".into()));
}
Expand Down
31 changes: 31 additions & 0 deletions crates/engine/src/game/restrictions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,37 @@ pub(crate) fn evaluate_condition(
// CR 702.195b: The enduring story is a player designation effects and
// restrictions may identify.
ParsedCondition::HasEnduringStory => state.enduring_story.contains(&player),
// CR 702.178a + the "Max Speed" glossary entry, sense 2: the keyword
// grants its ability "only if that permanent's controller (or that
// card's owner, if it isn't on the battlefield) has a speed of 4".
//
// SOURCE-relative, not activator-relative — the one place this leaf
// differs from its designation siblings above. `player` here is whoever
// is activating, and CR 602.2's "unless the object specifically says
// otherwise" lets an `activator_filter` of `PlayerFilter::All` ("Any
// player may activate this ability", 42 cards in the pool) make the
// activator someone other than the controller.
// `HasCityBlessing` reading `player` is right because its cards print
// "only if YOU have the city's blessing", addressed to the activator;
// CR 702.178a's "your" is addressed to the source instead.
//
// CR 702.178b keeps a max speed ability functioning in whatever zone the
// granted ability names, which is what makes the off-battlefield branch
// reachable: five Aetherdrift Surveyors activate theirs from a graveyard.
//
// Delegates to the single `game::speed` authority — the same helper
// `layers.rs` uses for `StaticCondition::HasMaxSpeed` — so CR 702.179e
// ("a player has max speed if their speed is 4") and the CR 101.1
// card-over-rule override that lets a static raise that cap (Gomif) read
// identically whether a card gates a static ability or an activation.
ParsedCondition::HasMaxSpeed => state.objects.get(&source_id).is_some_and(|object| {
let whose_speed = if object.zone == Zone::Battlefield {
object.controller
} else {
object.owner
};
super::speed::has_max_speed(state, whose_speed)
}),
// CR 903.3 / CR 903.3d: owner-scoped ("your commander") vs any-owner ("a
// commander") control. Delegates to the single `game::commander` authority —
// the same helpers `layers.rs` uses for `StaticCondition::ControlsCommander` —
Expand Down
58 changes: 48 additions & 10 deletions crates/engine/src/parser/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2909,6 +2909,48 @@ fn ability_word_to_condition(word: &str) -> Option<crate::types::ability::Static
}
}

/// CR 207.2c vs. CR 702: which em-dash prefix on an ACTIVATED ability gates it.
///
/// Both kinds of prefix reach `ability_word_to_condition` through the same
/// `strip_ability_word_with_name` path, but they mean opposite things:
///
/// - An **ability word** (CR 207.2c — threshold, metalcraft, delirium, spell
/// mastery, revolt, ferocious) "has no rules meaning". The condition it names
/// is printed in the ability's own text ("Activate only as long as you control
/// three or more artifacts" — Mox Opal), where `strip_activated_constraints`
/// already lowers it. Adding a second gate from the label would apply the
/// printed one twice, and on the cards whose label gates only the EFFECT it
/// would refuse an activation the card allows. So: `None`.
/// - A **keyword ability** prefix carries the whole gate and the text prints no
/// other one. CR 702.186b: ∞ — "As long as this permanent is harnessed, it has
/// [ability]". CR 702.178a: Max speed — "As long as your speed is 4, this
/// object has '[Ability]'." In both, the ability is ABSENT while the gate is
/// unmet, which is an activation restriction (CR 602.5) and NOT an
/// intervening-if `condition` (CR 608.2c + the Shelldock Isle ruling, which the
/// engine deliberately does not use for activation legality).
///
/// The `_ => None` arm is the CR 207.2c class: `ability_word_to_condition`'s
/// remaining entries are ability words, every one of which lowers to a
/// `QuantityComparison` its own ability text also states.
fn keyword_prefix_activation_restriction(
condition: Option<&StaticCondition>,
) -> Option<ActivationRestriction> {
match condition? {
StaticCondition::SourceIsHarnessed => Some(ActivationRestriction::SourceIsHarnessed),
// CR 702.178a's glossary line names whose speed: "that permanent's
// controller (or that card's owner, if it isn't on the battlefield)".
// `ParsedCondition::HasMaxSpeed` resolves that from the SOURCE rather
// than from the activating player, who CR 602.2 allows to be a
// different person: "Only an object's controller ... can activate its
// activated ability unless the object specifically says otherwise"
// ("Any player may activate this ability" is that otherwise).
StaticCondition::HasMaxSpeed => Some(ActivationRestriction::RequiresCondition {
condition: Some(ParsedCondition::HasMaxSpeed),
}),
_ => None,
}
}

/// Convert an ability-word `StaticCondition` to an `AbilityCondition` for spell effects.
/// CR 608.2c: Bridge an ability-word / "instead if" `StaticCondition` to its
/// effect-resolution `AbilityCondition` form. Delegates to the single
Expand Down Expand Up @@ -5154,18 +5196,14 @@ pub(crate) fn parse_oracle_ir(
Some(PrintedAbilityIndex::placeholder()),
&mut ctx,
);
// CR 702.186b: ∞ ("As long as harnessed, it has [ability]") gates an
// activated ability's legality (the ability is absent while
// unharnessed) — an activation restriction, NOT an intervening-if
// `condition` (a resolution-time gate, CR 608.2c + Shelldock Isle
// ruling, which the engine deliberately does not use for activation
// legality). Applied AFTER the call because
// A KEYWORD prefix ("as long as [gate], this object has [ability]")
// gates the ability's very presence, so it lowers to an activation
// restriction. Applied AFTER the call because
// `parse_activated_ability_ir` captures the cost-text constraints in
// the activation shell before this outer router stamp is applied.
if matches!(aw_condition, Some(StaticCondition::SourceIsHarnessed)) {
ir.shell
.activation_restrictions
.push(ActivationRestriction::SourceIsHarnessed);
if let Some(restriction) = keyword_prefix_activation_restriction(aw_condition.as_ref())
{
ir.shell.activation_restrictions.push(restriction);
}
if ability_cant_be_copied {
ir.shell.cant_be_copied = true;
Expand Down
7 changes: 6 additions & 1 deletion crates/engine/src/parser/oracle_condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ fn static_condition_to_restriction_condition(
}),
// Player-state leaves with an exact restriction evaluator.
StaticCondition::HasCityBlessing => Some(ParsedCondition::HasCityBlessing),
// CR 702.179e: max speed is a player-state leaf in the same sense as the
// city's blessing — a designation read off the scoped player, with no
// filter or quantity to approximate. Both readings call
// `game::speed::has_max_speed`, so the restriction cannot drift from the
// static one this condition also feeds.
StaticCondition::HasMaxSpeed => Some(ParsedCondition::HasMaxSpeed),
// CR 702.195b: The enduring story designation is available to restrictions.
StaticCondition::HasEnduringStory => Some(ParsedCondition::HasEnduringStory),
StaticCondition::OpponentPoisonAtLeast { count } => {
Expand Down Expand Up @@ -369,7 +375,6 @@ fn static_condition_to_restriction_condition(
| StaticCondition::IsPresent { filter: None }
| StaticCondition::ChosenColorIs { .. }
| StaticCondition::ChosenLabelIs { .. }
| StaticCondition::HasMaxSpeed
| StaticCondition::SpeedGE { .. }
| StaticCondition::DayNightIs { .. }
| StaticCondition::CastVariantPaid { .. }
Expand Down
107 changes: 107 additions & 0 deletions crates/engine/src/parser/oracle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22031,6 +22031,113 @@ fn city_blessing_activation_restriction_does_not_emit_condition_warning() {
)));
}

/// CR 702.178a: "Max speed — [Ability]" means "As long as your speed is 4, this
/// object has '[Ability]'." The ability is ABSENT below max speed, so the prefix
/// lowers to an activation restriction (CR 602.5) — the same treatment CR 702.186b
/// already gets for ∞.
///
/// Starting Column carries both shapes on one card, so a single parse shows the
/// gate landing on the right ability: its plain `{T}: Add one mana of any color`
/// must stay activatable at any speed.
#[test]
fn max_speed_prefix_gates_only_the_ability_it_labels() {
let oracle = "Start your engines! (If you have no speed, it starts at 1. It increases once on each of your turns when an opponent loses life. Max speed is 4.)\n{T}: Add one mana of any color.\nMax speed — {T}, Sacrifice this artifact: Draw two cards, then discard a card.";
let parsed = parse(oracle, "Starting Column", &[], &["Artifact"], &[]);

let gate = ActivationRestriction::RequiresCondition {
condition: Some(ParsedCondition::HasMaxSpeed),
};

let draw_ability = parsed
.abilities
.iter()
.find(|ability| matches!(*ability.effect, Effect::Draw { .. }))
.expect("expected the labeled draw ability");
assert!(
draw_ability.activation_restrictions.contains(&gate),
"the labeled ability must be gated: {:?}",
draw_ability.activation_restrictions
);

let mana_ability = parsed
.abilities
.iter()
.find(|ability| matches!(*ability.effect, Effect::Mana { .. }))
.expect("expected the unlabeled mana ability");
assert!(
!mana_ability.activation_restrictions.contains(&gate),
"the UNLABELED ability on the same card must stay ungated: {:?}",
mana_ability.activation_restrictions
);
}

/// The gate is not specific to the draw class: Howlsquad Heavy's labeled ability
/// is a mana ability, which bypasses the stack (CR 605.3a), so an ungated one puts
/// real mana in the pool with nothing to respond to.
#[test]
fn max_speed_prefix_gates_a_labeled_mana_ability() {
let oracle = "Start your engines!\nOther Goblins you control have haste.\nAt the beginning of combat on your turn, create a 1/1 red Goblin creature token. That token attacks this combat if able.\nMax speed — {T}: Add {R} for each Goblin you control.";
let parsed = parse(
oracle,
"Howlsquad Heavy",
&[],
&["Creature"],
&["Goblin", "Mercenary"],
);

let mana_ability = parsed
.abilities
.iter()
.find(|ability| matches!(*ability.effect, Effect::Mana { .. }))
.expect("expected the labeled mana ability");
assert!(
mana_ability
.activation_restrictions
.contains(&ActivationRestriction::RequiresCondition {
condition: Some(ParsedCondition::HasMaxSpeed),
}),
"{:?}",
mana_ability.activation_restrictions
);
}

/// CR 207.2c: an ability WORD "has no rules meaning". The condition it names is
/// printed in the ability's own text, so the label must contribute no second gate
/// — otherwise the printed one is applied twice, and a card whose label gates only
/// the EFFECT would have a legal activation refused.
///
/// Mox Opal is the counterpart to the two rows above: same em-dash prefix grammar,
/// same `strip_ability_word_with_name` path, opposite correct outcome. Exactly one
/// restriction survives, and it is the printed clause.
#[test]
fn an_ability_word_prefix_contributes_no_activation_gate() {
let oracle =
"Metalcraft — {T}: Add one mana of any color. Activate only if you control three or more artifacts.";
let parsed = parse(oracle, "Mox Opal", &[], &["Artifact"], &[]);

let mana_ability = parsed
.abilities
.iter()
.find(|ability| matches!(*ability.effect, Effect::Mana { .. }))
.expect("expected the mana ability");
assert_eq!(
mana_ability.activation_restrictions.len(),
1,
"the label must not add a gate beside the printed clause: {:?}",
mana_ability.activation_restrictions
);
assert!(
matches!(
mana_ability.activation_restrictions[0],
ActivationRestriction::RequiresCondition {
condition: Some(ParsedCondition::QuantityComparison { .. })
}
),
"the surviving gate must be the printed artifact count: {:?}",
mana_ability.activation_restrictions
);
}

#[test]
fn normalized_source_power_activation_restriction_does_not_emit_condition_warning() {
let oracle = "{T}: This creature deals 4 damage to target creature. Activate only if this creature's power is 4 or greater.";
Expand Down
12 changes: 12 additions & 0 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9426,6 +9426,18 @@ pub enum ParsedCondition {
HasCityBlessing,
/// CR 702.195b: True when the activating player has the enduring story designation.
HasEnduringStory,
/// CR 702.178a: True when the SOURCE's player has max speed — its
/// controller on the battlefield, its owner anywhere else, per the "Max
/// Speed" glossary entry (sense 2). Unlike its designation siblings above,
/// this leaf does NOT read the activating player: CR 702.178a's "your" is
/// addressed to the object, and CR 602.2's "unless the object specifically
/// says otherwise" lets an `activator_filter` of `PlayerFilter::All` make
/// the activator someone else entirely.
/// Evaluation delegates to `game::speed::has_max_speed`, the same authority
/// `StaticCondition::HasMaxSpeed` uses in `layers.rs`, so CR 702.179e's
/// speed-is-4 test and the CR 101.1 card-over-rule override that raises that
/// cap cannot diverge between the static and restriction readings.
HasMaxSpeed,
/// CR 102.1: "The active player is the player whose turn it is." True when
/// the scoped player is the active player — gates a casting/restriction
/// predicate on "if it's your turn". For "if it's not your turn" the parser
Expand Down
Loading
Loading