diff --git a/crates/engine/src/ai_support/filter.rs b/crates/engine/src/ai_support/filter.rs index 61a76951f2..b06267ceab 100644 --- a/crates/engine/src/ai_support/filter.rs +++ b/crates/engine/src/ai_support/filter.rs @@ -996,6 +996,10 @@ fn condition_reads_only_memo_safe_state(c: &ParsedCondition) -> bool { | ParsedCondition::CardsLeftYourGraveyardThisTurnAtLeast { .. } | ParsedCondition::PlayerCountAtLeast { .. } | ParsedCondition::HasCityBlessing + // 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. + | ParsedCondition::ControlsCommander { .. } // CR 503.1: reads only `state.phase`, apply()-constant global state. | ParsedCondition::IsDuringUpkeep // CR 102.2 / CR 102.3: reads `state.active_player` plus team topology, diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index 2750c3b6f2..13a5922a83 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -1,8 +1,8 @@ use crate::game::game_object::GameObject; use crate::types::ability::{ AbilityCost, AbilityDefinition, ActivationRestriction, CastingPermission, CastingRestriction, - ControllerRef, FilterProp, ParsedCondition, QuantityExpr, SpellCastingOptionKind, TargetFilter, - TypeFilter, + CommanderOwnership, ControllerRef, FilterProp, ParsedCondition, QuantityExpr, + SpellCastingOptionKind, TargetFilter, TypeFilter, }; use crate::types::card_type::{CoreType, Supertype}; use crate::types::counter::{CounterMatch, CounterType}; @@ -1635,6 +1635,15 @@ pub(crate) fn evaluate_condition( // CR 702.131c: The city's blessing is a player designation that effects // and restrictions may identify. ParsedCondition::HasCityBlessing => state.city_blessing.contains(&player), + // 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` — + // so a live re-evaluation at every activation-legality query correctly + // distinguishes owning your commander from merely controlling a stolen one. + ParsedCondition::ControlsCommander { ownership } => match ownership { + CommanderOwnership::Own => super::commander::controls_own_commander(state, player), + CommanderOwnership::Any => super::commander::controls_any_commander(state, player), + }, // CR 102.1: "The active player is the player whose turn it is." ParsedCondition::IsYourTurn => state.active_player == player, // CR 102.3 / CR 805.4a: the active player is on a team other than diff --git a/crates/engine/src/parser/oracle_condition.rs b/crates/engine/src/parser/oracle_condition.rs index cd99f7cade..0a6033405b 100644 --- a/crates/engine/src/parser/oracle_condition.rs +++ b/crates/engine/src/parser/oracle_condition.rs @@ -12,8 +12,8 @@ use super::oracle_nom::condition as nom_condition; use super::oracle_nom::primitives as nom_primitives; use super::oracle_target::parse_type_phrase; use crate::types::ability::{ - CommanderOwnership, Comparator, ControllerRef, FilterProp, ParsedCondition, QuantityExpr, - QuantityRef, StaticCondition, TargetFilter, TypedFilter, + Comparator, FilterProp, ParsedCondition, QuantityExpr, QuantityRef, StaticCondition, + TargetFilter, TypedFilter, }; use crate::types::card_type::CoreType; use crate::types::counter::CounterMatch; @@ -277,30 +277,16 @@ fn static_condition_to_restriction_condition( // CR 102.3 + CR 805.4a: keep the opponent relation distinct from // `Not(IsYourTurn)`, which would incorrectly include a teammate's turn. StaticCondition::DuringOpponentsTurn => Some(ParsedCondition::IsOpponentsTurn), - // CR 903.3d: "If an effect refers to controlling a commander, it refers to a - // permanent on the battlefield that is a commander" — regardless of who OWNS it. - // That is exactly an `ObjectCount` over the `IsCommander` filter scoped to your - // control, so it converts through the same presence bridge as `IsPresent`. - // - // `CommanderOwnership::Own` ("your commander") additionally requires you to own - // the permanent, and `TargetFilter` has no owner axis — it is rejected below - // rather than silently widened to "any commander you control", which would let - // a STOLEN commander satisfy a condition the card restricts to your own. - StaticCondition::ControlsCommander { - ownership: CommanderOwnership::Any, - } => Some(ParsedCondition::QuantityComparison { - lhs: QuantityExpr::Ref { - qty: QuantityRef::ObjectCount { - filter: TargetFilter::Typed(TypedFilter { - controller: Some(ControllerRef::You), - properties: vec![FilterProp::IsCommander], - ..Default::default() - }), - }, - }, - comparator: Comparator::GE, - rhs: QuantityExpr::Fixed { value: 1 }, - }), + // CR 903.3 / CR 903.3d: both commander-control phrasings mirror directly onto + // the `ParsedCondition` variant carrying the same `CommanderOwnership` axis. + // `Own` ("your commander") requires you to OWN the permanent (CR 109.5, + // Lieutenant); `Any` ("a commander") is controller-only, any owner (CR 903.3d, + // a stolen commander counts). Runtime evaluation delegates to the single + // `game::commander` authority — the same helpers `layers.rs` uses for the static + // form — so `Own` cannot be silently widened to "any commander you control". + StaticCondition::ControlsCommander { ownership } => { + Some(ParsedCondition::ControlsCommander { ownership }) + } // Source zone/state leaves with an exact restriction evaluator. StaticCondition::SourceInZone { zone } => Some(ParsedCondition::SourceInZone { zone }), StaticCondition::SourceIsAttacking => Some(ParsedCondition::SourceIsAttacking), @@ -400,9 +386,6 @@ fn static_condition_to_restriction_condition( | StaticCondition::WasCast { .. } | StaticCondition::IsRingBearer | StaticCondition::RingLevelAtLeast { .. } - | StaticCondition::ControlsCommander { - ownership: CommanderOwnership::Own, - } | StaticCondition::SourceIsTapped | StaticCondition::IsTapped { .. } | StaticCondition::SourceIsFaceUp @@ -829,7 +812,8 @@ fn capitalize_condition_word(text: &str) -> String { mod tests { use super::*; use crate::types::ability::{ - AggregateFunction, CountScope, PlayerScope, SharedQuality, TypeFilter, + AggregateFunction, CommanderOwnership, ControllerRef, CountScope, PlayerScope, + SharedQuality, TypeFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::CounterType; @@ -1063,16 +1047,8 @@ mod tests { /// filter-carrying `SourceMatchesFilter`, which `ParsedCondition` has no variant to /// hold. /// - /// "you control your commander" is the second, and it is the sharper one. The sibling - /// phrase "you control **a** commander" (CR 903.3d — any commander you control, - /// regardless of owner) DOES convert, to an `ObjectCount` over the `IsCommander` - /// filter. The possessive form additionally requires you to OWN the permanent, and - /// `TargetFilter` has no owner axis — so converting it with the same filter would - /// silently let a STOLEN commander satisfy a condition the card restricts to your own. - /// Reject beats approximate. - /// - /// Fail-on-revert: routing `Unsupported` back into `parse_restriction_only_condition`, - /// or widening the `Own` arm to reuse the `Any` filter, makes these `Some(..)` again. + /// Fail-on-revert: routing `Unsupported` back into `parse_restriction_only_condition` + /// makes this `Some(..)` again. #[test] fn recognized_but_nonrepresentable_condition_fails_the_parse() { // Assert WHICH `StaticCondition` is rejected by running the conversion directly. @@ -1103,52 +1079,44 @@ mod tests { SharedRestrictionParse::Unsupported )); assert_eq!(parse_restriction_condition("~ is a creature"), None); - - // The possessive commander form requires OWNERSHIP, which `TargetFilter` cannot - // express; its sibling "you control A commander" DOES convert (test below). - let own = shared_static("you control your commander"); - assert!(matches!( - own, - StaticCondition::ControlsCommander { - ownership: CommanderOwnership::Own - } - )); - assert_eq!(static_condition_to_restriction_condition(own), None); - assert!(matches!( - parse_shared_restriction_condition("you control your commander"), - SharedRestrictionParse::Unsupported - )); - assert_eq!( - parse_restriction_condition("you control your commander"), - None - ); } - /// CR 903.3d: "you control a commander" refers to a permanent on the battlefield that - /// is a commander — regardless of owner. It converts to an `ObjectCount` over the - /// `IsCommander` filter scoped to your control. + /// CR 903.3 / CR 903.3d: both commander-control phrasings now convert to the + /// parameterized `ParsedCondition::ControlsCommander` variant carrying the same + /// `CommanderOwnership` axis as the sibling `StaticCondition`/`TriggerCondition` + /// forms. "your commander" → `Own` (CR 109.5, owner-scoped Lieutenant); "a + /// commander" → `Any` (CR 903.3d, any owner, a stolen commander counts). + /// + /// This replaces the earlier split where `Any` lowered to an `ObjectCount` + /// `QuantityComparison` and `Own` was rejected outright (the possessive form has no + /// owner-axis `TargetFilter`). Both now delegate to the single `game::commander` + /// runtime authority — the same one `layers.rs` uses for the static form — so `Own` + /// is represented exactly instead of dropped, and `Deflecting Swat`'s "a commander" + /// free-cast condition remains satisfiable. /// - /// The legacy restriction grammar read this as subtype `"commander"` — a subtype no - /// permanent has — so Deflecting Swat's free-cast condition could NEVER be satisfied. + /// Fail-on-revert: collapsing the converter back to the `Any`→`ObjectCount` / + /// `Own`→reject split makes the `Own` assertion fail (it becomes `None`), and the + /// `Any` assertion fail (it becomes a `QuantityComparison`). #[test] - fn controls_a_commander_converts_to_object_count() { - match shared("you control a commander") { - ParsedCondition::QuantityComparison { - lhs: - QuantityExpr::Ref { - qty: - QuantityRef::ObjectCount { - filter: TargetFilter::Typed(tf), - }, - }, - comparator: Comparator::GE, - rhs: QuantityExpr::Fixed { value: 1 }, - } => { - assert_eq!(tf.controller, Some(ControllerRef::You)); - assert!(tf.properties.contains(&FilterProp::IsCommander)); - } - other => panic!("expected ObjectCount(IsCommander) >= 1, got {other:?}"), - } + fn both_commander_phrasings_convert_to_controls_commander() { + assert_eq!( + shared("you control your commander"), + ParsedCondition::ControlsCommander { + ownership: CommanderOwnership::Own, + }, + ); + assert_eq!( + parse_restriction_condition("you control your commander"), + Some(ParsedCondition::ControlsCommander { + ownership: CommanderOwnership::Own, + }), + ); + assert_eq!( + shared("you control a commander"), + ParsedCondition::ControlsCommander { + ownership: CommanderOwnership::Any, + }, + ); } /// CR 122.1 + CR 711.2a: a counter BAND must never be widened into an "at least" diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_ir.snap index ba7308ea9d..563162d620 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_ir.snap @@ -25,28 +25,8 @@ expression: "&ir" "CastingOption": { "kind": "CastWithoutManaCost", "condition": { - "type": "QuantityComparison", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [], - "controller": "You", - "properties": [ - { - "type": "IsCommander" - } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } + "type": "ControlsCommander", + "ownership": "Any" } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_lowered.snap index 32cec74234..9ad53bc8f5 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_lowered.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_lowered.snap @@ -42,28 +42,8 @@ expression: "&lowered" { "kind": "CastWithoutManaCost", "condition": { - "type": "QuantityComparison", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [], - "controller": "You", - "properties": [ - { - "type": "IsCommander" - } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } + "type": "ControlsCommander", + "ownership": "Any" } } ] diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 4ed11bb88e..8f48c8d841 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -8217,6 +8217,17 @@ pub enum ParsedCondition { SpellTargetsFilter { filter: TargetFilter, }, + /// CR 903.3 + CR 109.5: "you control your commander" — owner-scoped + /// (Lieutenant). CR 903.3d: "you control a commander" — controller-only, any + /// owner. The restriction-layer mirror of `StaticCondition::ControlsCommander` + /// / `TriggerCondition::ControlsCommander`; the `ownership` axis selects which + /// CR clause applies. Evaluated by `restrictions::evaluate_condition`, which + /// delegates to the single `crate::game::commander` authority — the same + /// helpers `layers.rs` uses for the static form, so both condition + /// vocabularies agree on the rule. + ControlsCommander { + ownership: CommanderOwnership, + }, // -- Combinators -- /// CR 601.3 / CR 602.5: All inner conditions must be true. Used for compound /// casting/activation restrictions like "Cast this spell only if you control a diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 7fc79fba42..8ab4448f94 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -906,6 +906,7 @@ mod swans_prevention_followup; mod swarm_combat_witness; mod tales_of_the_ancestors_catch_up_draw; mod talon_gates_from_hand_activation; +mod tchaka_venerable_king; mod teamwork_aggregate_legal_actions; mod teamwork_origin_composition; mod teferi_time_raveler_sorcery_speed_lock; diff --git a/crates/engine/tests/integration/tchaka_venerable_king.rs b/crates/engine/tests/integration/tchaka_venerable_king.rs new file mode 100644 index 0000000000..2ca8123450 --- /dev/null +++ b/crates/engine/tests/integration/tchaka_venerable_king.rs @@ -0,0 +1,513 @@ +//! Full engine coverage for "T'Chaka, Venerable King" (set `msc`, Scryfall +//! oracle_id `2a9f2d69-328c-46f4-be37-60b85b197b72`). +//! +//! Oracle text (verbatim): +//! "When T'Chaka enters, mill three cards, then you may put an artifact or +//! land card from among the milled cards into your hand. +//! {3}, Exile this card from your graveyard: You become the monarch. +//! Activate only if you control your commander." +//! +//! These tests drive the REAL parse -> synthesis -> apply pipeline (no +//! AST-shape assertions). Two mechanics are covered: +//! +//! 1. The graveyard-activated monarch ability's commander-referential +//! activation restriction. This change teaches the parser/restriction layers +//! to represent "Activate only if you control your commander" as +//! `ParsedCondition::ControlsCommander { ownership: Own }` +//! (CR 903.3 + CR 109.5 — owner-scoped) instead of dropping it to +//! `Effect::Unimplemented`. Runtime evaluation delegates to the single +//! `game::commander` authority. +//! +//! The DISCRIMINATING fixture is the stolen-commander case: a player who +//! controls an opponent's commander but not their own must NOT satisfy "your +//! commander" (CR 903.3d is any-owner; "your commander" is owner-scoped). +//! This fails if the runtime arm delegates to `controls_any_commander`, and +//! the "no restriction at all" cases fail if the converter reverts to +//! leaving the clause `Unimplemented`. +//! +//! 2. The ETB "mill three, then you may put an artifact or land card from among +//! the milled cards into your hand" (CR 701.17c) — a regression guard on the +//! already-correct tracked-set pipeline. + +use engine::ai_support::legal_actions; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +/// Verbatim Oracle text. The self-reference "T'Chaka" is normalized against the +/// full card name by the same synthesis pipeline production uses. +const TCHAKA_ORACLE: &str = "When T'Chaka enters, mill three cards, then you may put an artifact or land card from among the milled cards into your hand.\n{3}, Exile this card from your graveyard: You become the monarch. Activate only if you control your commander."; + +const TCHAKA_NAME: &str = "T'Chaka, Venerable King"; + +/// `n` units of colorless mana in the pool — funds the `{3}` activation cost. +fn floating_colorless(n: usize) -> Vec { + (0..n) + .map(|_| ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![])) + .collect() +} + +/// How the acting player's commander is positioned for the activation-gate tests. +#[derive(Clone, Copy)] +enum CommanderSetup { + /// A commander on the battlefield, owned AND controlled by P0 (Lieutenant + /// gate ON — CR 903.3 + CR 109.5). + OwnOnBattlefield, + /// A commander on the battlefield controlled by P0 but OWNED by P1 — a + /// stolen opponent's commander. Satisfies "a commander" (CR 903.3d) but NOT + /// "your commander" (CR 109.5). + StolenOnly, + /// P0's own commander sitting in the command zone (not on the battlefield), + /// so it is not a permanent P0 controls (CR 903.3d requires the battlefield). + OwnInCommandZone, +} + +/// Build a scenario with T'Chaka in P0's graveyard, `{3}` funded, and P0's +/// commander positioned per `setup`. Returns `(runner, tchaka_id, commander_id)` +/// — the commander id is returned in every case so tests can reach-guard that the +/// fixture staged the commander it claims (owner, zone) before asserting the gate. +fn graveyard_scenario(setup: CommanderSetup) -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let tchaka = scenario + .add_creature_to_graveyard(P0, TCHAKA_NAME, 2, 2) + .from_oracle_text(TCHAKA_ORACLE) + .id(); + + scenario.with_mana_pool(P0, floating_colorless(3)); + + // Stage the commander (always owned by P0 initially). The command-zone case is + // moved before build; the battlefield cases are flagged after build (the + // commander flag / stolen ownership are not builder-exposed). + let commander = scenario.add_creature(P0, "Regal Vanguard", 3, 3).id(); + let on_battlefield = match setup { + CommanderSetup::OwnOnBattlefield | CommanderSetup::StolenOnly => true, + CommanderSetup::OwnInCommandZone => { + scenario.with_commander(commander); // moves it to the command zone + false + } + }; + + let mut runner = scenario.build(); + + if on_battlefield { + let obj = runner + .state_mut() + .objects + .get_mut(&commander) + .expect("commander object exists"); + obj.is_commander = true; + if matches!(setup, CommanderSetup::StolenOnly) { + // Controlled by P0 (from add_creature) but owned by P1: it is P1's + // commander, not P0's. + obj.owner = P1; + } + } + + (runner, tchaka, commander) +} + +/// Whether the AI candidate generator offers T'Chaka's graveyard activation. +fn activation_offered(runner: &GameRunner, tchaka: ObjectId) -> bool { + legal_actions(runner.state()).iter().any(|a| { + matches!( + a, + GameAction::ActivateAbility { source_id, .. } if *source_id == tchaka + ) + }) +} + +/// CR 903.3 + CR 109.5: "Activate only if you control your commander" is +/// owner-scoped. Own-on-battlefield activates and makes P0 the monarch; a +/// stolen opponent's commander and an own commander in the command zone both +/// leave the ability un-activatable. +#[test] +fn tchaka_monarch_activation_gated_on_owning_commander() { + // (a) POSITIVE reach-guard: own commander on the battlefield. The gate can + // pass and the ability resolves — proving the negative cases below are not + // vacuous. + let (mut runner, tchaka, commander) = graveyard_scenario(CommanderSetup::OwnOnBattlefield); + // Fixture reach-guard: P0's own commander really is a battlefield permanent. + assert_eq!( + runner.state().objects[&commander].zone, + Zone::Battlefield, + "fixture: the own commander must be on the battlefield" + ); + assert_eq!( + runner.state().objects[&commander].owner, + P0, + "fixture: the own commander must be owned by P0" + ); + assert!( + activation_offered(&runner, tchaka), + "own commander on the battlefield: the graveyard activation must be legal" + ); + let outcome = runner.activate(tchaka, 0).pay_with(&[tchaka]).resolve(); + assert_eq!( + outcome.state().monarch, + Some(P0), + "CR 725.1: resolving BecomeMonarch makes P0 the monarch" + ); + // CR 602.1a: "Exile this card from your graveyard" is part of the activation + // cost (everything before the colon), so T'Chaka ends up in exile. + outcome.assert_zone(&[tchaka], Zone::Exile); + + // (b) THE DISCRIMINATOR: only a stolen opponent's commander. "your commander" + // (CR 109.5) is not satisfied — fails if the runtime arm uses + // `controls_any_commander`, or if the restriction reverted to Unimplemented. + let (mut runner, tchaka, commander) = graveyard_scenario(CommanderSetup::StolenOnly); + // Fixture reach-guard: the commander is on the battlefield but OWNED by P1, so + // "your commander" (owner-scoped) must be the thing failing — not a mis-stage. + assert_eq!( + runner.state().objects[&commander].zone, + Zone::Battlefield, + "fixture: the stolen commander must be on the battlefield" + ); + assert_eq!( + runner.state().objects[&commander].owner, + P1, + "fixture: the stolen commander must be owned by the opponent (P1)" + ); + assert!( + !activation_offered(&runner, tchaka), + "stolen commander (owned by opponent): 'your commander' is owner-scoped — must be illegal" + ); + assert!( + runner + .act(GameAction::ActivateAbility { + source_id: tchaka, + ability_index: 0, + }) + .is_err(), + "the apply path must also reject activation with only a stolen commander" + ); + + // (c) Own commander in the command zone (not a controlled permanent). + let (mut runner, tchaka, commander) = graveyard_scenario(CommanderSetup::OwnInCommandZone); + // Fixture reach-guard: P0's OWN commander really is sitting in the command zone + // (CR 903.3d requires the battlefield), so the negative below tests the gate, + // not a fixture that silently failed to place the commander. + assert_eq!( + runner.state().objects[&commander].zone, + Zone::Command, + "fixture: the commander must be in the command zone" + ); + assert!( + runner.state().objects[&commander].is_commander, + "fixture: the command-zone object must be flagged as a commander" + ); + assert_eq!( + runner.state().objects[&commander].owner, + P0, + "fixture: the command-zone commander must be P0's own" + ); + assert!( + !activation_offered(&runner, tchaka), + "commander in the command zone: not a permanent you control — must be illegal" + ); + assert!( + runner + .act(GameAction::ActivateAbility { + source_id: tchaka, + ability_index: 0, + }) + .is_err(), + "the apply path must also reject activation with the commander in the command zone" + ); +} + +// --------------------------------------------------------------------------- +// `ControlsCommander { ownership: Any }` — the any-owner sibling of T'Chaka's +// owner-scoped `Own` gate. T'Chaka only exercises `Own`; this drives the `Any` +// evaluator (`game::commander::controls_any_commander`) through the real cast +// pipeline via Deadly Rollick's commander free-cast. +// --------------------------------------------------------------------------- + +/// Deadly Rollick, verbatim Oracle text. "If you control a commander" is +/// any-owner (CR 903.3d), so its free-cast permission lowers to +/// `ParsedCondition::ControlsCommander { ownership: Any }` — distinct from +/// T'Chaka's owner-scoped "your commander". +const DEADLY_ROLLICK_ORACLE: &str = + "If you control a commander, you may cast this spell without paying its mana cost.\nExile target creature."; + +const DEADLY_ROLLICK_NAME: &str = "Deadly Rollick"; + +/// Where the caster's commander sits for the `Any` free-cast tests. +#[derive(Clone, Copy)] +enum AnyCommanderSetup { + /// Own commander on the battlefield — `controls_any_commander` true. The + /// positive reach-guard proving the free-cast path is live. + OwnOnBattlefield, + /// A STOLEN opponent's commander on the battlefield: owned by P1, controlled by + /// P0. `controls_any_commander` is true (any-owner) even though + /// `controls_own_commander` is false — the discriminator that Deadly Rollick's + /// gate delegates to the any-owner evaluator, NOT the owner-scoped one. + StolenOnBattlefield, + /// No commander anywhere — `controls_any_commander` false. + NoCommander, +} + +/// Deadly Rollick (`{3}{B}`) in P0's hand with an EMPTY mana pool, so it is +/// castable ONLY via the commander free-cast. A vanilla creature (`Bystander`, +/// controlled by P1) is always on the battlefield as the legal "Exile target +/// creature" target, so castability turns on the commander gate alone — never on +/// target availability. Returns `(runner, deadly_rollick_id, bystander_id)`. +fn deadly_rollick_scenario(setup: AnyCommanderSetup) -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let rollick = scenario + .add_spell_to_hand_from_oracle(P0, DEADLY_ROLLICK_NAME, true, DEADLY_ROLLICK_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 3, + }) + .id(); + + // Always-present legal target so "Exile target creature" never gates casting. + let bystander = scenario.add_creature(P1, "Bystander", 1, 1).id(); + + // No `with_mana_pool`: the printed {3}{B} is unpayable, isolating the free cast. + let battlefield_commander = match setup { + AnyCommanderSetup::OwnOnBattlefield | AnyCommanderSetup::StolenOnBattlefield => { + Some(scenario.add_creature(P0, "Regal Vanguard", 3, 3).id()) + } + AnyCommanderSetup::NoCommander => None, + }; + + let mut runner = scenario.build(); + + if let Some(cmd) = battlefield_commander { + let obj = runner + .state_mut() + .objects + .get_mut(&cmd) + .expect("commander object exists"); + obj.is_commander = true; + if matches!(setup, AnyCommanderSetup::StolenOnBattlefield) { + // Controlled by P0 (from add_creature) but owned by P1: it is P1's + // commander that P0 controls — satisfies "a commander", not "your + // commander". + obj.owner = P1; + } + } + + (runner, rollick, bystander) +} + +/// Whether the candidate generator offers casting `spell` right now — via the +/// ordinary `CastSpell` surface (reached through the free alternative cost with an +/// empty pool) or the dedicated `CastSpellForFree`. +fn cast_offered(runner: &GameRunner, spell: ObjectId) -> bool { + legal_actions(runner.state()).iter().any(|a| { + matches!( + a, + GameAction::CastSpell { object_id, .. } + | GameAction::CastSpellForFree { object_id, .. } + if *object_id == spell + ) + }) +} + +/// CR 903.3d: "If you control a commander" (Deadly Rollick) is any-owner — the +/// `ControlsCommander { ownership: Any }` gate. With an empty pool the spell is +/// castable ONLY when that free-cast condition holds, so the offered/withheld cast +/// candidate is a direct production probe of the `Any` evaluator. This guards the +/// offer surface at both ends; the full free-cast → target → resolve pipeline for +/// the discriminating stolen case is exercised by +/// `deadly_rollick_stolen_commander_free_cast_resolves_and_exiles_target`. +#[test] +fn deadly_rollick_free_cast_offer_gated_on_controlling_any_commander() { + // (a) POSITIVE reach-guard: own commander on the battlefield → offered. Proves + // the free-cast path is live so the negative case below is not vacuous. + let (runner, rollick, _bystander) = + deadly_rollick_scenario(AnyCommanderSetup::OwnOnBattlefield); + assert!( + cast_offered(&runner, rollick), + "own commander on the battlefield: the commander free cast must be offered \ + (empty pool, so the printed {{3}}{{B}} is unpayable)" + ); + + // (b) NEGATIVE: no commander controlled. With an empty pool the printed cost is + // unpayable and the free cast is gated off, so no cast candidate is offered. + let (runner, rollick, _bystander) = deadly_rollick_scenario(AnyCommanderSetup::NoCommander); + assert!( + !cast_offered(&runner, rollick), + "no commander controlled: with an empty pool the spell must not be castable" + ); +} + +/// CR 903.3d + CR 118.9: the DISCRIMINATOR, driven all the way through resolution. +/// P0 controls ONLY a stolen opponent's commander (owned by P1). `Any` is +/// any-owner, so Deadly Rollick's "cast without paying its mana cost" is legal; +/// this is the any-owner counterpart to +/// `tchaka_monarch_activation_gated_on_owning_commander`, where the same stolen +/// commander is REJECTED by the owner-scoped gate. +/// +/// The pool is EMPTY, so the spell can reach the stack ONLY via the free cast — +/// resolving it and exiling the Bystander proves the whole alternative-cost → +/// target → resolve path actually runs, not merely that a cast action was listed. +/// A regression that broke the `Any` evaluator, the free-cast application, target +/// binding, or resolution would leave the Bystander on the battlefield here. +#[test] +fn deadly_rollick_stolen_commander_free_cast_resolves_and_exiles_target() { + let (mut runner, rollick, bystander) = + deadly_rollick_scenario(AnyCommanderSetup::StolenOnBattlefield); + + // Reach-guard: the free cast is the ONLY way this is castable (empty pool). + assert!( + cast_offered(&runner, rollick), + "stolen commander: the any-owner free cast must be offered before resolving" + ); + + // CR 118.9 + CR 601.2b: the "cast without paying its mana cost" permission is + // offered as the alternative-cost choice (accept = free cast, decline = pay the + // printed {3}{B}). `.accept_optional()` takes the free cast; `.target_object` + // binds the "Exile target creature" target before the spell hits the stack. + let outcome = runner + .cast(rollick) + .accept_optional() + .target_object(bystander) + .resolve(); + + // CR 608.2d: "Exile target creature" resolves — the Bystander leaves the + // battlefield for exile, proving the free cast was taken, the target bound, and + // the spell resolved. (Owner-scoped `controls_own_commander` would have made the + // spell uncastable for free with an empty pool, so it could never reach here.) + outcome.assert_zone(&[bystander], Zone::Exile); +} + +/// Stage three cards atop P0's library: an artifact and a land (both eligible for +/// the "artifact or land card" filter) and a sorcery (ineligible). Types are set +/// via double-cast so each card carries exactly one core type. +fn stage_milled_library(scenario: &mut GameScenario) -> (ObjectId, ObjectId, ObjectId) { + let artifact = scenario + .add_spell_to_library_top(P0, "Milled Artifact", false) + .as_creature() + .as_artifact() + .id(); + let land = scenario + .add_spell_to_library_top(P0, "Milled Land", false) + .as_creature() + .as_land() + .id(); + // A plain sorcery: neither artifact nor land, so the ETB filter excludes it. + let dud = scenario + .add_spell_to_library_top(P0, "Milled Sorcery", false) + .id(); + (artifact, land, dud) +} + +/// Put T'Chaka into P0's hand with its real `{G}{W}` cost and fund the pool so +/// the cast auto-pays. +fn hand_tchaka(scenario: &mut GameScenario) -> ObjectId { + let tchaka = scenario + .add_creature_to_hand_from_oracle(P0, TCHAKA_NAME, 2, 2, TCHAKA_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green, ManaCostShard::White], + generic: 0, + }) + .id(); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Green, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::White, ObjectId(0), false, vec![]), + ], + ); + tchaka +} + +/// CR 701.17c: the ETB mills three, then the controller may move an artifact or +/// land card from among the milled cards to hand. Selecting the artifact moves +/// exactly it; the land and the ineligible sorcery stay in the graveyard. +#[test] +fn tchaka_etb_mill_then_put_selected_artifact_to_hand() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let (artifact, land, dud) = stage_milled_library(&mut scenario); + let tchaka = hand_tchaka(&mut scenario); + + let mut runner = scenario.build(); + + // Cast T'Chaka; its ETB trigger mills the three staged cards, and we pick the + // artifact from among the milled cards. The "artifact in hand" assertion is + // the reach-guard: if the tracked-set filter failed to offer the artifact, + // the up-to-one choice (min 0) would submit nothing and this would fail. + let outcome = runner.cast(tchaka).effect_zone(&[artifact]).resolve(); + + outcome.assert_zone(&[tchaka], Zone::Battlefield); + outcome.assert_zone(&[artifact], Zone::Hand); + outcome.assert_zone(&[land, dud], Zone::Graveyard); +} + +/// Declining the optional "you may put ... into your hand" leaves all three +/// milled cards in the graveyard (CR 701.17a — they were milled; the optional +/// move is skipped). +#[test] +fn tchaka_etb_mill_then_decline_keeps_all_milled_cards() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let (artifact, land, dud) = stage_milled_library(&mut scenario); + let tchaka = hand_tchaka(&mut scenario); + + let mut runner = scenario.build(); + + // Resolve the cast without declaring an effect-zone pick — the driver halts + // at the up-to-one `EffectZoneChoice`. + let outcome = runner.cast(tchaka).resolve(); + assert!( + matches!( + outcome.final_waiting_for(), + WaitingFor::EffectZoneChoice { .. } + ), + "the optional 'you may put ...' surfaces an up-to-one EffectZoneChoice, got {:?}", + outcome.final_waiting_for() + ); + + // Decline by submitting an empty selection (up_to => min 0). + runner + .act(GameAction::SelectCards { cards: vec![] }) + .expect("declining the optional put must be accepted"); + + for card in [artifact, land, dud] { + assert_eq!( + runner.state().objects[&card].zone, + Zone::Graveyard, + "declined milled card {} must remain in the graveyard", + card.0 + ); + } + assert!( + !runner.state().players[P0.0 as usize] + .hand + .iter() + .any(|&c| c == artifact || c == land || c == dud), + "no milled card may reach hand when the optional put is declined" + ); + + // Prove the decline COMPLETED the cast/ETB flow rather than stalling on the + // consumed prompt: the ETB choice was drained (priority, no lingering choice), + // the stack is empty, and T'Chaka actually entered the battlefield. A stalled + // continuation would leave the graveyard assertions above true while failing + // here. + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "after declining, the ETB choice must be consumed and priority restored, got {:?}", + runner.state().waiting_for + ); + assert!( + runner.state().stack.is_empty(), + "after declining, T'Chaka's spell/ETB must have fully resolved off the stack" + ); + assert_eq!( + runner.state().objects[&tchaka].zone, + Zone::Battlefield, + "T'Chaka must have entered the battlefield once its cast/ETB completed" + ); +}