diff --git a/crates/engine/src/ai_support/filter.rs b/crates/engine/src/ai_support/filter.rs index 13c948acc7..9b930dd7fd 100644 --- a/crates/engine/src/ai_support/filter.rs +++ b/crates/engine/src/ai_support/filter.rs @@ -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. diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index d76cacfd25..8a5e538b7a 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -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; @@ -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())); } diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index b93ce75275..55e6aabc39 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -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` — diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 6db5dfcad3..fc56b42ae1 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -2909,6 +2909,48 @@ fn ability_word_to_condition(word: &str) -> Option 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 { + 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 @@ -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; diff --git a/crates/engine/src/parser/oracle_condition.rs b/crates/engine/src/parser/oracle_condition.rs index 4ae7184683..6f0c809d56 100644 --- a/crates/engine/src/parser/oracle_condition.rs +++ b/crates/engine/src/parser/oracle_condition.rs @@ -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 } => { @@ -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 { .. } diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index d6156f6cb5..804c03d51a 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -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."; diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 7cedebef5c..fd804ec357 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -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 diff --git a/crates/engine/tests/integration/howlsquad_max_speed_reads_controller.rs b/crates/engine/tests/integration/howlsquad_max_speed_reads_controller.rs new file mode 100644 index 0000000000..550d9acb5c --- /dev/null +++ b/crates/engine/tests/integration/howlsquad_max_speed_reads_controller.rs @@ -0,0 +1,221 @@ +//! Howlsquad Heavy — "Max speed — {T}: Add {R} for each Goblin you control." +//! +//! Reported from a real game: the Goblin was taken from its owner, and its max +//! speed mana ability could be activated by its new controller even though that +//! controller was at speed 2. The owner was at speed 4. +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 702.178a: "Max speed — [Ability]" means "As long as YOUR speed is 4, +//! this object has '[Ability]'." The glossary entry spells out whose speed: +//! "that permanent's controller (or that card's owner, if it isn't on the +//! battlefield)". On the battlefield it is the CONTROLLER, always. +//! - CR 702.179e: a player has max speed if their speed is 4. +//! +//! The three rows below vary exactly one thing at a time, so the failing row +//! names the wrong player rather than merely reporting that something is off. + +use engine::game::scenario::GameRunner; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::ability::Effect; +use engine::types::ability::PlayerFilter; +use engine::types::identifiers::ObjectId; +use engine::types::player::PlayerId; + +const HOWLSQUAD: &str = "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."; + +fn set_speed(runner: &mut engine::game::scenario::GameRunner, player: PlayerId, speed: Option) { + for p in runner.state_mut().players.iter_mut() { + if p.id == player { + p.speed = speed; + } + } +} + +/// The index of Howlsquad's mana ability among the permanent's abilities. +fn mana_ability_index(runner: &engine::game::scenario::GameRunner, source: ObjectId) -> usize { + runner.state().objects[&source] + .abilities + .iter() + .position(|a| matches!(a.effect.as_ref(), Effect::Mana { .. })) + .expect("Howlsquad Heavy must carry a Mana activated ability") +} + +/// Can the controller actually activate the max speed mana ability? +/// +/// Measured by ATTEMPTING the activation, which is what the player did. An +/// earlier revision of this file read `ai_support::legal_actions` instead and +/// measured nothing at all: that list does not surface mana abilities at +/// priority — a plain Mountain is absent from it too, which the harness row at +/// the bottom of this file pins. +fn mana_produced_by_activating( + runner: &mut engine::game::scenario::GameRunner, + source: ObjectId, +) -> usize { + let index = mana_ability_index(runner, source); + let before = runner.state().players[0].mana_pool.mana.len(); + if runner + .act(engine::types::actions::GameAction::ActivateAbility { + source_id: source, + ability_index: index, + }) + .is_err() + { + return 0; + } + // Mana abilities do not use the stack (CR 605.3a), so the pool is the + // outcome. Counting pips rather than trusting the activation's `Ok` keeps + // "offered but inert" and "actually produced" apart — the report says the + // player saw real red mana appear. + runner.state().players[0] + .mana_pool + .mana + .len() + .saturating_sub(before) +} + +/// P0 controls Howlsquad Heavy; P1 owns it. `p0` / `p1` are the two speeds. +fn howlsquad_under_p0_control( + p0: Option, + p1: Option, +) -> (engine::game::scenario::GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(engine::types::phase::Phase::PreCombatMain); + // Owned by P1, controlled by P0 — the board the report describes. + let howlsquad = scenario + .add_creature_from_oracle(P1, "Howlsquad Heavy", 4, 4, HOWLSQUAD) + // "Add {R} for each Goblin you control" counts Howlsquad itself, so the + // subtype is load-bearing: without it the ability produces nothing even + // when it is legitimately active, and every row reads zero. + .with_subtypes(vec!["Goblin"]) + .controlled_by(P0) + .id(); + let mut runner = scenario.build(); + set_speed(&mut runner, P0, p0); + set_speed(&mut runner, P1, p1); + (runner, howlsquad) +} + +/// Control row: the CONTROLLER is at max speed, so the ability is available. +/// +/// Without this row a failing row below would only show that the ability is +/// never offered, which would prove nothing about whose speed is read. +#[test] +fn the_controller_at_max_speed_may_activate_it() { + let (mut runner, howlsquad) = howlsquad_under_p0_control(Some(4), Some(0)); + assert!( + mana_produced_by_activating(&mut runner, howlsquad) > 0, + "CR 702.178a: the controller has speed 4, so the ability is active" + ); +} + +/// Baseline: nobody is at max speed, so nothing is available. +#[test] +fn nobody_at_max_speed_means_no_ability() { + let (mut runner, howlsquad) = howlsquad_under_p0_control(Some(2), Some(2)); + assert_eq!( + mana_produced_by_activating(&mut runner, howlsquad), + 0, + "no player has speed 4, so the max speed ability grants nothing" + ); +} + +/// The report: only the OWNER is at max speed. The controller is not. +/// +/// This row differs from the baseline above in exactly one value — P1's speed, +/// which CR 702.178a does not consult for a permanent on the battlefield. If +/// this row can activate while the baseline cannot, the engine is reading the +/// wrong player's speed. +#[test] +fn only_the_owner_at_max_speed_must_not_unlock_the_ability() { + let (mut runner, howlsquad) = howlsquad_under_p0_control(Some(2), Some(4)); + assert_eq!( + mana_produced_by_activating(&mut runner, howlsquad), + 0, + "CR 702.178a reads the CONTROLLER's speed (2), never the owner's (4)" + ); +} + +/// Why this file does not read `ai_support::legal_actions`. +/// +/// That list does not surface mana abilities while a player holds priority — +/// measured here on a plain Mountain, which is as ordinary a mana source as +/// exists. Recorded rather than deleted: it is the reason the rows above +/// attempt the activation instead, and without it a future reader would +/// reasonably reach for the same wrong instrument. +#[test] +fn the_legal_action_list_does_not_surface_mana_abilities_at_priority() { + let mut scenario = GameScenario::new(); + scenario.at_phase(engine::types::phase::Phase::PreCombatMain); + let mountain = scenario.add_basic_land(P0, engine::types::mana::ManaColor::Red); + let runner = scenario.build(); + assert!( + !engine::ai_support::legal_actions(runner.state()) + .iter() + .any(|action| action.source_object() == Some(mountain)), + "if a Mountain DOES appear here, this note is stale and the rows above \ + could use the cheaper instrument" + ); +} + +/// CR 702.178a is SOURCE-relative, so the activating player is the wrong person +/// to ask. Raised in review of the fix for this file: the restriction evaluator +/// is handed whoever is activating, and CR 602.2 lets that be someone other +/// than the controller — 42 cards in the pool print "Any player may activate +/// this ability" (`PlayerFilter::All`), measured against +/// `client/public/card-data.json`. +/// +/// No printed card combines that permission with a max speed ability today, so +/// these rows stamp the permission onto Howlsquad's ability directly. That is +/// the honest way to test the class: the leaf must read the source's player +/// whether or not a card currently exercises the difference. +fn open_activation_to_every_player(runner: &mut GameRunner, source: ObjectId) { + let index = mana_ability_index(runner, source); + let object = runner + .state_mut() + .objects + .get_mut(&source) + .expect("the source must still be on the battlefield"); + // `abilities` is shared behind an `Arc`; `make_mut` clones it for this object + // alone rather than reaching through the shared handle. + std::sync::Arc::make_mut(&mut object.abilities)[index].activator_filter = + Some(PlayerFilter::All); +} + +/// Is `player` — who need not hold priority right now — permitted to activate? +/// +/// Measured on `can_activate_ability_now`, the gate the activation path itself +/// consults, because it takes the activating player as an argument. The rows +/// above drive the whole pipeline instead; this one cannot, since driving P1 to +/// priority inside P0's main phase would measure the priority system rather than +/// the condition. Stated plainly: these two rows prove the GATE reads the right +/// player, not that the pipeline behind it produces mana. +fn may_activate_as(runner: &GameRunner, source: ObjectId, player: PlayerId) -> bool { + let index = mana_ability_index(runner, source); + engine::game::casting::can_activate_ability_now(runner.state(), player, source, index) +} + +/// The controller has max speed, so the ability EXISTS (CR 702.178a) and any +/// player permitted to activate it may — even one at speed 0. +#[test] +fn a_non_controller_may_activate_it_while_the_controller_is_at_max_speed() { + let (mut runner, howlsquad) = howlsquad_under_p0_control(Some(4), Some(0)); + open_activation_to_every_player(&mut runner, howlsquad); + assert!( + may_activate_as(&runner, howlsquad, P1), + "CR 702.178a reads the CONTROLLER's speed (4); the activator's own speed \ + is not part of the condition" + ); +} + +/// The mirror row, and the one that fails if the evaluator reads the activator: +/// the activator is at max speed and the controller is not, so the ability does +/// not exist at all. +#[test] +fn a_non_controller_at_max_speed_cannot_activate_it_while_the_controller_is_not() { + let (mut runner, howlsquad) = howlsquad_under_p0_control(Some(2), Some(4)); + open_activation_to_every_player(&mut runner, howlsquad); + assert!( + !may_activate_as(&runner, howlsquad, P1), + "CR 702.178a reads the CONTROLLER's speed (2), never the activator's (4)" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 0bdc5ed291..a688071dd7 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -301,6 +301,7 @@ mod heroic_sacrifice_redirect; mod hit_the_mother_lode; mod hogaak_cant_spend_mana_1095; mod hollow_one_cost_reduction; +mod howlsquad_max_speed_reads_controller; mod hunters_insight_combat_draw; mod ichneumon_druid; mod inevitable_betrayal_no_mana_cost; @@ -840,6 +841,7 @@ mod martial_impetus_other_attacker_exclusion_6017; mod mass_phase_out_1792_repro; mod master_of_ceremonies; mod mauhur_swarming_of_moria; +mod max_speed_owner_arm_from_graveyard; mod maze_of_ith_untap_bidirectional_prevent; mod mazemind_tome_existential_counter_state_trigger; mod mbaku_attacked_monarch_intervening_if; diff --git a/crates/engine/tests/integration/max_speed_owner_arm_from_graveyard.rs b/crates/engine/tests/integration/max_speed_owner_arm_from_graveyard.rs new file mode 100644 index 0000000000..4634a6dffc --- /dev/null +++ b/crates/engine/tests/integration/max_speed_owner_arm_from_graveyard.rs @@ -0,0 +1,158 @@ +//! Max speed from a graveyard — the OWNER arm of `ParsedCondition::HasMaxSpeed`. +//! +//! `restrictions.rs` scopes the max-speed gate to the source's controller on the +//! battlefield and to its owner anywhere else. The battlefield arm is covered by +//! `howlsquad_max_speed_reads_controller.rs`; this file drives the owner arm on +//! the production `GameAction::ActivateAbility` path, from the zone that makes +//! it reachable. +//! +//! Card: Loxodon Surveyor (Oracle text verbatim from `client/public/card-data.json`), +//! one of five Aetherdrift Surveyors printing +//! "Max speed — {3}, Exile this card from your graveyard: Draw a card." +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 702.178a: "Max speed — [Ability]" means "As long as your speed is 4, +//! this object has '[Ability]'." +//! - CR 702.178b: a max speed ability functions from whatever zones the ability +//! it grants functions from — which is what puts this one in a graveyard. +//! - CR 702.179e: a player has max speed if their speed is 4. +//! - CR 108.4 + CR 108.4a: a card that is not a permanent or spell has no +//! controller, and anything asking for one uses its owner instead. That is the +//! rules basis for the `else` arm reading `owner`. +//! +//! MEASURED LIMIT — what these rows cannot prove. CR 108.4a's substitution is +//! also what the engine performs on the zone change: `the_engine_resets_the_ +//! controller_when_a_permanent_dies` below pins that a permanent owned by P0 and +//! controlled by P1 arrives in the graveyard with `controller == owner == P0`. +//! So for every reachable graveyard state the owner arm and a controller read +//! return the same player, and no test can separate them there. What the two +//! activation rows do separate is the owner from the OTHER player: the opponent +//! sits at the opposite speed in both, so a gate reading the wrong player flips +//! both rows. The `owner` spelling is kept because CR 108.4a says owner, not +//! because a controller read is currently observable. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const LOXODON_SURVEYOR: &str = "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.)\nMax speed — {3}, Exile this card from your graveyard: Draw a card."; + +fn floating_generic(count: usize) -> Vec { + (0..count) + .map(|_| ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![])) + .collect() +} + +fn set_speed(runner: &mut engine::game::scenario::GameRunner, player: PlayerId, speed: Option) { + for p in runner.state_mut().players.iter_mut() { + if p.id == player { + p.speed = speed; + } + } +} + +/// P0 owns a Loxodon Surveyor in their graveyard and holds exactly the {3} the +/// ability costs. `p0` / `p1` are the two speeds. +fn surveyor_in_p0_graveyard( + p0: Option, + p1: Option, +) -> (engine::game::scenario::GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let surveyor = scenario + .add_creature_to_graveyard(P0, "Loxodon Surveyor", 3, 3) + .from_oracle_text(LOXODON_SURVEYOR) + .id(); + scenario.with_mana_pool(P0, floating_generic(3)); + let mut runner = scenario.build(); + set_speed(&mut runner, P0, p0); + set_speed(&mut runner, P1, p1); + (runner, surveyor) +} + +/// Attempt the activation the player would make, and report whether the cost was +/// actually paid. +/// +/// The outcome is read from the card's ZONE, not from the `Ok`/`Err` of the +/// submission: "Exile this card from your graveyard" is part of the activation +/// cost (CR 602.1a), so a genuinely activated ability has already moved the card +/// to exile. That keeps "accepted" and "accepted but inert" apart. +fn activated_from_graveyard( + runner: &mut engine::game::scenario::GameRunner, + surveyor: ObjectId, +) -> bool { + let index = runner.state().objects[&surveyor] + .abilities + .iter() + .position(|a| a.cost.is_some()) + .expect("Loxodon Surveyor must carry its max speed activated ability"); + let _ = runner.act(GameAction::ActivateAbility { + source_id: surveyor, + ability_index: index, + }); + runner.state().objects[&surveyor].zone == Zone::Exile +} + +#[test] +fn the_owner_at_max_speed_may_activate_it_from_their_graveyard() { + // Owner at 4, opponent at 0 — if the gate read the opponent, this row fails. + let (mut runner, surveyor) = surveyor_in_p0_graveyard(Some(4), Some(0)); + assert!( + activated_from_graveyard(&mut runner, surveyor), + "CR 702.178a + CR 108.4a: the card's owner has speed 4, so the granted \ + graveyard ability exists and its cost can be paid" + ); +} + +#[test] +fn the_owner_below_max_speed_may_not_activate_it_from_their_graveyard() { + // The mirror: owner at 3, opponent at 4. Both rows move together only if the + // gate reads the owner. + let (mut runner, surveyor) = surveyor_in_p0_graveyard(Some(3), Some(4)); + assert!( + !activated_from_graveyard(&mut runner, surveyor), + "CR 702.179e: the owner is at speed 3, so the ability is not granted — \ + the opponent's speed 4 must not stand in for it" + ); + assert_eq!( + runner.state().objects[&surveyor].zone, + Zone::Graveyard, + "a rejected activation pays no cost, so the card stays put" + ); +} + +/// Nail-down, not evidence: this row is green with and without the max speed +/// fix. It pins the measurement the module doc rests on — that a graveyard card +/// never carries a controller different from its owner, which is why the two +/// rows above cannot discriminate the owner arm from a controller read. +#[test] +fn the_engine_resets_the_controller_when_a_permanent_dies() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let surveyor = scenario + .add_creature_from_oracle(P0, "Loxodon Surveyor", 3, 3, LOXODON_SURVEYOR) + .controlled_by(P1) + .with_damage_marked(3) + .id(); + let mut runner = scenario.build(); + runner.pass_both_players(); + let obj = &runner.state().objects[&surveyor]; + assert_eq!( + obj.zone, + Zone::Graveyard, + "lethal damage is a CR 704.5g SBA" + ); + assert_eq!( + obj.owner, P0, + "CR 404.1: a card goes to its owner's graveyard" + ); + assert_eq!( + obj.controller, P0, + "CR 108.4: a card in a graveyard is neither permanent nor spell, so it \ + has no controller — the engine substitutes the owner (CR 108.4a)" + ); +}