From 039573b98b31b0e86fb1a55317684acfd34d5ecb Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:01:33 +0200 Subject: [PATCH 1/5] feat(engine): offer the turn-face-up special action (#6732, #4381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine accepted `GameAction::TurnFaceUp` and its Priority preflight counted it as progress, but `ai_support::candidates::priority_actions_ with_probe` — the list the client renders — never emitted it. Nothing can send an action the engine never advertises, so the whole morph / megamorph / disguise / manifest / cloak class was unturnable in play. Reported from a real game state: the controller had priority, a face-down Coral Trickster (morph {U}) and thirty untapped Islands, and `legalActions` held six casts, four land plays and a pass. #7342 wired the client's dispatch and closed both reports; its test supplies the action to itself (`legalActions: [turnFaceUpAction]`), so it proves the client's half and cannot observe the engine's. That is how the gap survived a green suite. ## One admission authority `morph::turn_face_up_offer` answers "may this player take the action on this permanent right now, and in which shape". Both the Priority preflight and the offer list read it, so the engine's progress gate and the list it renders cannot disagree — which is the disagreement that produced this defect. `turn_face_up_prepare` stays the legality and cost authority underneath; the offer adds the special-action cost reduction and the affordability probe the reducer applies. ## The payment can now finish #4538 was asked for this before it went stale: the affordability probe deliberately reports a mana source whose own cost pauses (CR 605.3b + CR 616.1) as payable, and the compatibility wrapper `pay_special_action_mana_cost` converts that `Paused` into an error. Offering the action without a resume would advertise a flip that cannot complete. The action now owns a typed, cost-snapshotted continuation like the two shipped precedents (`companion.rs`, `end_continuous_effect.rs`): `ManaAbilityResume::TurnFaceUp { player, object_id, cost, announced_x }`. `cost` is locked after the reduction and after CR 107.3d's {X} was concretized, so resumption cannot re-derive it against a board that changed while the choice was pending; `announced_x` travels with it because CR 702.37f / CR 702.168e publish that value to the permanent's own turn-face-up trigger, which fires after payment. `morph::handle_turn_face_up` is the single authority for the whole action — legality, the CR 106.6 spend-restricted payment, the X announcement and the flip — shared with the resume. The reducer arm delegates to it, which is what moved 80 lines out of `engine.rs` and re-pins the CR 603.5 prompt census by the same offset. ## Counter-probe | disabled | failing rows | |---|---| | the offer | `a_face_down_morph_permanent_is_offered_and_flips`, `a_paused_mana_source_resumes_the_locked_turn_face_up` | | the typed resume (old wrapper) | `a_paused_mana_source_resumes_the_locked_turn_face_up`, on the pause being reported as an error | The unpayable and opponent-controlled rows stay green under both, which is what keeps the positive row from passing for the wrong reason. ## Not covered * A morph/disguise cost with {X} (Warbreak Trumpeter, Bane of the Living, Aurelia's Vindicator). CR 107.3d says the player chooses X immediately before paying, so a flat action list has no value to offer and the engine must not choose one. Stated in the enumeration rather than silently dropped; it needs an X announcement for special actions on the client. * `GameAction::PlayFaceDown` is absent from the same list. Separate action, separate change. Co-Authored-By: Claude Opus 5 --- crates/engine/src/ai_support/candidates.rs | 33 +++ .../src/ai_support/payment_continuation.rs | 5 + crates/engine/src/game/engine.rs | 97 +------ crates/engine/src/game/mana_abilities.rs | 37 ++- crates/engine/src/game/morph.rs | 245 ++++++++++++++-- crates/engine/src/types/game_state.rs | 15 + .../issue_6732_offer_turn_face_up.rs | 269 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 8 files changed, 589 insertions(+), 113 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 86e914df22..027451006c 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -4226,6 +4226,39 @@ pub(crate) fn priority_actions_with_probe( } } + // CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b: turning a face-down + // permanent face up is a special action available ANY time its controller + // has priority — no timing gate, no stack, either player's turn. + // + // Offered from the same admission authority the Priority preflight uses + // (`morph::turn_face_up_offer`), so the engine's progress gate and the list + // the client renders cannot disagree. They did until now: the reducer + // accepted `GameAction::TurnFaceUp` and the preflight counted it as + // progress, but this list never emitted it, so no client could ever send it + // and the whole morph / megamorph / disguise / manifest / cloak class was + // unturnable in play (#6732, #4381). + // + // Split second does NOT stop it: CR 702.61b prohibits casting spells and + // activating abilities, and a special action is neither (CR 116.1). + for &object_id in &state.battlefield { + match crate::game::morph::turn_face_up_offer(state, player, object_id) { + Some(crate::game::morph::TurnFaceUpOffer::Ready) => { + actions.push(candidate( + GameAction::TurnFaceUp { object_id, x: 0 }, + TacticalClass::Ability, + Some(player), + )); + } + // CR 107.3d: the player chooses X immediately before paying, so a + // flat action list has no value to offer. Announcing one here would + // be the engine choosing for them. Stated rather than silently + // dropped: Warbreak Trumpeter, Bane of the Living and Aurelia's + // Vindicator stay unofferable until the client can announce an X for + // a special action. + Some(crate::game::morph::TurnFaceUpOffer::RequiresChosenX) | None => {} + } + } + // CR 702.143a-b: Foretell is a priority-time special action from hand // during the player's own turn. It does not use the stack; the runtime // handler pays {2}, exiles the card, marks it foretold, and grants the diff --git a/crates/engine/src/ai_support/payment_continuation.rs b/crates/engine/src/ai_support/payment_continuation.rs index e6bdc40ee4..2ee8c7b685 100644 --- a/crates/engine/src/ai_support/payment_continuation.rs +++ b/crates/engine/src/ai_support/payment_continuation.rs @@ -469,6 +469,7 @@ fn classify_deferred_life_root( ManaAbilityResume::Priority | ManaAbilityResume::CompanionToHand { .. } | ManaAbilityResume::EndContinuousEffect { .. } + | ManaAbilityResume::TurnFaceUp { .. } | ManaAbilityResume::UnlessPayment { .. } | ManaAbilityResume::EffectPayCost { .. } => PaymentContinuationState::NotAffiliated, }, @@ -559,9 +560,13 @@ fn record_root_from_resume( ManaAbilityResume::FinalizePendingManaPayment { player } => { Some(root_from_global(state, *player)?) } + // Special actions and effect payments are not a CAST's payment root: + // they carry their own typed continuation and never resume into a + // pending cast (CR 116.1 — a special action does not use the stack). ManaAbilityResume::Priority | ManaAbilityResume::CompanionToHand { .. } | ManaAbilityResume::EndContinuousEffect { .. } + | ManaAbilityResume::TurnFaceUp { .. } | ManaAbilityResume::UnlessPayment { .. } | ManaAbilityResume::EffectPayCost { .. } => None, }; diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 612ae90237..1554474c27 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -11702,91 +11702,11 @@ fn apply_action( { return Err(EngineError::NotYourPriority); } - let p = *player; - let announced_x = x; - // CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b + CR 106.6: turning - // a face-down permanent face up is a special action whose morph/disguise/ - // manifest cost must be paid *before* the flip. `turn_face_up_prepare` - // validates the action and derives that cost; payment routes through - // `PaymentContext::SpecialAction(TurnFaceUp)` so spend-restricted mana - // ("only to turn permanents face up", Overgrown Zealot / Tin Street - // Gossip) is eligible here while other-context mana is rejected. Mirrors - // the `UnlockDoor` special-action handler. - let cost = super::morph::turn_face_up_prepare(state, object_id, p)?; - let mut cost = casting::apply_special_action_cost_reduction( - state, - p, - crate::types::mana::SpecialAction::TurnFaceUp, - cost, - ); - - // CR 107.3d: "If a cost associated with a special action, such as a suspend - // cost or a morph cost, has an {X} ... in it, the value of X is chosen by the - // player taking the special action immediately before they pay that cost." - // The announcement happens HERE — inside the action, with no priority window - // between choosing X and paying it, exactly as the rule describes. - // - // Warbreak Trumpeter (Morph {X}{X}{R}), Bane of the Living (Morph {X}{B}{B}) - // and Aurelia's Vindicator (Disguise {X}{3}{W}) are the live faces. - let has_x = casting_costs::cost_has_x(&cost); - if has_x { - // CR 118.3: a player can't announce an X they cannot pay for. The cap is - // computed with `object_id: None` deliberately — this is a SPECIAL ACTION, - // not a cast, so cast-time cost modifiers and floors must not apply (the - // special-action reduction was already applied above). - let max_x = casting_costs::max_x_value(state, p, &cost, None); - if announced_x > max_x { - return Err(EngineError::InvalidAction(format!( - "X={announced_x} exceeds the maximum payable value of {max_x} for this \ - turn-face-up cost" - ))); - } - // CR 107.1b + CR 601.2f: each `{X}` shard becomes `announced_x` generic, so - // Warbreak Trumpeter's `{X}{X}{R}` costs 2X + {R}. Without this the X shards - // reach mana payment unresolved and are dropped — the permanent flips for - // its non-X remainder alone. - cost.concretize_x(announced_x); - } else if announced_x != 0 { - // A cost with no {X} admits no choice: CR 107.3d only grants one "if a cost - // ... has an {X} ... in it". Reject rather than silently ignore, so a client - // bug cannot masquerade as a legal flip. - return Err(EngineError::InvalidAction( - "This permanent's turn-face-up cost has no {X}, so X must be 0".to_string(), - )); - } - casting::pay_special_action_mana_cost( - state, - p, - Some(object_id), - &cost, - crate::types::mana::SpecialAction::TurnFaceUp, - &mut events, - )?; - - // CR 702.37f (morph) / CR 702.168e (disguise): "If a permanent's morph cost - // includes X, other abilities of that permanent may also refer to X. The value - // of X in those abilities is equal to the value of X chosen as the morph special - // action was taken." Publish the announced X on the source-keyed carrier BEFORE - // the flip emits `TurnedFaceUp`, so `triggers::build_triggered_ability` — the - // single trigger-instantiation authority — stamps it onto the turn-face-up - // trigger's `chosen_x`. - // - // The stamp must land at INSTANTIATION, not resolution: Aurelia's Vindicator - // spends its X in `multi_target.max` ("exile up to X other target creatures"), - // which is consumed during target selection, before the trigger ever resolves. - // - // Published only when the cost actually HAS an {X} (CR 107.3d grants a choice - // only then). A no-X flip leaves the carrier untouched rather than clobbering it - // with `Some((.., 0))`: an unrelated activated ability of ANOTHER object may be - // on the stack with its own announced X in flight, and that value must survive. - // The carrier is cleared at the start of the next `resolve_top`, so this - // publication cannot outlive the trigger it is for. - if has_x { - state.announced_source_x = Some((object_id, announced_x)); - } - - super::morph::turn_face_up(state, p, object_id, &mut events)?; - WaitingFor::Priority { player: p } + // CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b: the whole action — + // legality, the CR 106.6 spend-restricted payment, CR 107.3d's X + // announcement and the flip — belongs to one authority, shared with the + // CR 616.1 resume that finishes a payment whose mana source paused. + super::morph::handle_turn_face_up(state, *player, object_id, x, &mut events)? } ( WaitingFor::TriggerTargetSelection { @@ -20179,7 +20099,12 @@ mod stage2_injector_tests { // Resolve All consent adds its frozen-authority protocol above this producer: // `:12912 ⇒ :13113`. It does not create a CR 603.5 prompt, and the pinned // line remains the same `OptionalEffectChoice` construction. - "game/engine.rs:13210".to_string(), + // Folding the turn-face-up special action into `morph::handle_turn_face_up` + // removed 80 lines from the reducer above this producer: + // `:13210 ⇒ :13130`. It creates no CR 603.5 prompt either — a special + // action does not use the stack (CR 116.1) — and the pinned line is again + // the same `OptionalEffectChoice` construction, moved wholesale. + "game/engine.rs:13130".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index d66e068f4e..8bb839a1a0 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -3278,6 +3278,22 @@ pub(crate) fn resume_mana_ability_root( ManaAbilityResume::CompanionToHand { player, cost } => { super::companion::resume_companion_to_hand_payment(state, player, cost, events) } + // CR 116.2b + CR 605.3b: NOT compiler-forced either — the `resume =>` + // catch-all below would route a paused turn-face-up payment into + // `resume_waiting_for`, which `unreachable!()`s for this family. + ManaAbilityResume::TurnFaceUp { + player, + object_id, + cost, + announced_x, + } => super::morph::resume_turn_face_up_payment( + state, + player, + object_id, + cost, + announced_x, + events, + ), // CR 116.2c + CR 605.3b: NOT compiler-forced — the `resume =>` catch-all // below would silently route a paused pay-to-end payment into // `resume_waiting_for`, which `unreachable!()`s for this family. @@ -3393,6 +3409,19 @@ pub(crate) fn finish_mana_root_after_deferred_life_payment( ManaAbilityResume::CompanionToHand { player, .. } => Ok( super::companion::finish_paid_companion_to_hand(state, player, events), ), + ManaAbilityResume::TurnFaceUp { + player, + object_id, + announced_x, + .. + } => super::morph::finish_paid_turn_face_up( + state, + player, + object_id, + announced_x > 0, + announced_x, + events, + ), ManaAbilityResume::EndContinuousEffect { player, group, .. } => Ok( super::end_continuous_effect::finish_paid_end_continuous_effect( state, player, group, events, @@ -4766,9 +4795,11 @@ pub(crate) fn resume_waiting_for( | ManaAbilityResume::PhyrexianCastPayment { .. } | ManaAbilityResume::FinalizePendingManaPayment { .. } | ManaAbilityResume::CompanionToHand { .. } - // CR 116.2c: like `CompanionToHand`, the pay-to-end special action is - // resumed by `resume_mana_ability_root`'s named arm, never here. - | ManaAbilityResume::EndContinuousEffect { .. } => { + // CR 116.2c + CR 116.2b: like `CompanionToHand`, the pay-to-end and + // turn-face-up special actions are resumed by + // `resume_mana_ability_root`'s named arms, never here. + | ManaAbilityResume::EndContinuousEffect { .. } + | ManaAbilityResume::TurnFaceUp { .. } => { unreachable!("effect-cost resume is handled by resume_mana_ability_root") } } diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index dfa4f393ce..4ece2bd760 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -3,7 +3,7 @@ use crate::types::ability::{ }; use crate::types::card_type::{CardType, CoreType}; use crate::types::events::GameEvent; -use crate::types::game_state::GameState; +use crate::types::game_state::{GameState, WaitingFor}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; @@ -42,39 +42,72 @@ pub(in crate::game) enum PriorityTurnFaceUpCandidate { RequiresChosenX, } +/// Whether `player` may take the turn-face-up special action on `object_id` +/// right now, and in which shape. +/// +/// The single admission authority for the action, shared by the Priority +/// preflight (which only asks "is any action available?") and by the +/// legal-action enumeration the client renders. Splitting those two would let +/// the engine's own progress gate and its offer list disagree about what a +/// player can do — which is exactly how the action came to be accepted by the +/// reducer and offered nowhere (#6732). +/// +/// `turn_face_up_prepare` remains the legality and cost authority; this adds +/// the action-specific cost reduction and the affordability probe the reducer +/// would apply. +pub(crate) fn turn_face_up_offer( + state: &GameState, + player: PlayerId, + object_id: ObjectId, +) -> Option { + let cost = turn_face_up_prepare(state, object_id, player).ok()?; + let cost = super::casting::apply_special_action_cost_reduction( + state, + player, + crate::types::mana::SpecialAction::TurnFaceUp, + cost, + ); + super::casting::can_pay_special_action_mana_cost_after_auto_tap( + state, + player, + Some(object_id), + &cost, + crate::types::mana::SpecialAction::TurnFaceUp, + ) + .then_some(())?; + Some(if super::casting_costs::cost_has_x(&cost) { + TurnFaceUpOffer::RequiresChosenX + } else { + TurnFaceUpOffer::Ready + }) +} + +/// The shape of an available turn-face-up action. `RequiresChosenX` carries no +/// value on purpose: CR 107.3d says the player chooses X immediately before +/// paying, so neither the progress gate nor the offer list may guess one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TurnFaceUpOffer { + Ready, + RequiresChosenX, +} + /// Enumerates the current holder's face-up special-action outcomes in -/// battlefield order. `turn_face_up_prepare` remains the single legality and -/// cost authority; priority applies the reducer's action-specific cost -/// adjustment and affordability check before offering a primer. +/// battlefield order, from the shared admission authority above. pub(in crate::game) fn priority_turn_face_up_candidates( state: &GameState, principal: &PriorityPrincipal, ) -> Vec { + let player = principal.semantic_holder(); state .battlefield .iter() .copied() .filter_map(|object_id| { - let player = principal.semantic_holder(); - let cost = turn_face_up_prepare(state, object_id, player).ok()?; - let cost = super::casting::apply_special_action_cost_reduction( - state, - player, - crate::types::mana::SpecialAction::TurnFaceUp, - cost, - ); - super::casting::can_pay_special_action_mana_cost_after_auto_tap( - state, - player, - Some(object_id), - &cost, - crate::types::mana::SpecialAction::TurnFaceUp, - ) - .then_some(())?; - Some(if super::casting_costs::cost_has_x(&cost) { - PriorityTurnFaceUpCandidate::RequiresChosenX - } else { - PriorityTurnFaceUpCandidate::Ready(PriorityTurnFaceUpAnnouncement::new(object_id)) + Some(match turn_face_up_offer(state, player, object_id)? { + TurnFaceUpOffer::RequiresChosenX => PriorityTurnFaceUpCandidate::RequiresChosenX, + TurnFaceUpOffer::Ready => PriorityTurnFaceUpCandidate::Ready( + PriorityTurnFaceUpAnnouncement::new(object_id), + ), }) }) .collect() @@ -438,6 +471,170 @@ pub(crate) fn turn_face_up_prepare( }) } +/// CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b: take the turn-face-up +/// special action — derive its cost, announce CR 107.3d's `{X}`, pay, and flip. +/// +/// Single authority for the action, shared by the `GameAction::TurnFaceUp` +/// reducer arm and by the CR 616.1 resume below, so the two cannot drift about +/// what was paid or what X was announced. +/// +/// A paused payment is a real outcome, not an error: +/// `pay_special_action_mana_cost_with_resume` returns `Paused` when an +/// auto-tapped mana source's own cost surfaces a replacement choice +/// (CR 605.3b + CR 616.1). The permanent stays face down and the prompt is +/// returned; [`resume_turn_face_up_payment`] finishes the action once the choice +/// is answered. The compatibility wrapper `pay_special_action_mana_cost` turns +/// that state into an error, which is why this action may not use it. +pub(crate) fn handle_turn_face_up( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + announced_x: u32, + events: &mut Vec, +) -> Result { + // CR 116.2b: `turn_face_up_prepare` is the single legality and cost + // authority, shared with the Priority offer enumeration. + let cost = turn_face_up_prepare(state, object_id, player)?; + let mut cost = super::casting::apply_special_action_cost_reduction( + state, + player, + crate::types::mana::SpecialAction::TurnFaceUp, + cost, + ); + + // CR 107.3d: "If a cost associated with a special action, such as a suspend + // cost or a morph cost, has an {X} … in it, the value of X is chosen by the + // player taking the special action immediately before they pay that cost." + // The announcement happens HERE — inside the action, with no priority window + // between choosing X and paying it, exactly as the rule describes. + // + // Warbreak Trumpeter (Morph {X}{X}{R}), Bane of the Living (Morph {X}{B}{B}) + // and Aurelia's Vindicator (Disguise {X}{3}{W}) are the live faces. + let has_x = super::casting_costs::cost_has_x(&cost); + if has_x { + // CR 118.3: a player can't announce an X they cannot pay for. The cap is + // computed with `object_id: None` deliberately — this is a SPECIAL + // ACTION, not a cast, so cast-time cost modifiers and floors must not + // apply (the special-action reduction was already applied above). + let max_x = super::casting_costs::max_x_value(state, player, &cost, None); + if announced_x > max_x { + return Err(EngineError::InvalidAction(format!( + "X={announced_x} exceeds the maximum payable value of {max_x} for this \ + turn-face-up cost" + ))); + } + // CR 107.1b + CR 601.2f: each `{X}` shard becomes `announced_x` generic, + // so Warbreak Trumpeter's `{X}{X}{R}` costs 2X + {R}. Without this the X + // shards reach mana payment unresolved and are dropped — the permanent + // flips for its non-X remainder alone. + cost.concretize_x(announced_x); + } else if announced_x != 0 { + // A cost with no {X} admits no choice: CR 107.3d only grants one "if a + // cost … has an {X} … in it". Reject rather than silently ignore, so a + // client bug cannot masquerade as a legal flip. + return Err(EngineError::InvalidAction( + "This permanent's turn-face-up cost has no {X}, so X must be 0".to_string(), + )); + } + + match pay_turn_face_up_cost(state, player, object_id, &cost, announced_x, events)? { + super::casting::SpecialActionManaPayment::Paid => { + finish_paid_turn_face_up(state, player, object_id, has_x, announced_x, events) + } + // The permanent is still face down and nothing has been committed: the + // mana source's replacement choice owns the window now. + super::casting::SpecialActionManaPayment::Paused => Ok(state.waiting_for.clone()), + } +} + +/// CR 116.2b + CR 106.6: pay the already-derived turn-face-up cost through +/// `PaymentContext::SpecialAction(TurnFaceUp)`, so spend-restricted mana ("only +/// to turn permanents face up" — Overgrown Zealot, Tin Street Gossip) is +/// eligible here while other-context mana is rejected. +fn pay_turn_face_up_cost( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + cost: &ManaCost, + announced_x: u32, + events: &mut Vec, +) -> Result { + let resume = crate::types::game_state::ManaAbilityResume::TurnFaceUp { + player, + object_id, + cost: cost.clone(), + announced_x, + }; + super::casting::pay_special_action_mana_cost_with_resume( + state, + player, + Some(object_id), + cost, + crate::types::mana::SpecialAction::TurnFaceUp, + Some(&resume), + events, + ) +} + +/// CR 605.3b + CR 616.1: finish a turn-face-up whose mana-source cost paused. +/// `cost` was locked at initiation, so this must not re-derive it against a +/// board that changed while the replacement choice was pending. +pub(crate) fn resume_turn_face_up_payment( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + cost: ManaCost, + announced_x: u32, + events: &mut Vec, +) -> Result { + // The locked cost already had CR 107.3d's `{X}` concretized, so its shards + // no longer say X. `announced_x` is what CR 702.37f publishes, and a nonzero + // value can only have come from a cost that had one. + let has_x = announced_x > 0; + match pay_turn_face_up_cost(state, player, object_id, &cost, announced_x, events)? { + super::casting::SpecialActionManaPayment::Paid => { + finish_paid_turn_face_up(state, player, object_id, has_x, announced_x, events) + } + super::casting::SpecialActionManaPayment::Paused => Ok(state.waiting_for.clone()), + } +} + +/// CR 702.37e: commit the flip, and only once the whole payment has succeeded. +pub(crate) fn finish_paid_turn_face_up( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + has_x: bool, + announced_x: u32, + events: &mut Vec, +) -> Result { + // CR 702.37f (morph) / CR 702.168e (disguise): "If a permanent's morph cost + // includes X, other abilities of that permanent may also refer to X. The + // value of X in those abilities is equal to the value of X chosen as the + // morph special action was taken." Publish the announced X on the + // source-keyed carrier BEFORE the flip emits `TurnedFaceUp`, so + // `triggers::build_triggered_ability` — the single trigger-instantiation + // authority — stamps it onto the turn-face-up trigger's `chosen_x`. + // + // The stamp must land at INSTANTIATION, not resolution: Aurelia's Vindicator + // spends its X in `multi_target.max` ("exile up to X other target + // creatures"), which is consumed during target selection, before the trigger + // ever resolves. + // + // Published only when the cost actually HAS an {X} (CR 107.3d grants a + // choice only then). A no-X flip leaves the carrier untouched rather than + // clobbering it with `Some((.., 0))`: an unrelated activated ability of + // ANOTHER object may be on the stack with its own announced X in flight, and + // that value must survive. The carrier is cleared at the start of the next + // `resolve_top`, so this publication cannot outlive the trigger it is for. + if has_x { + state.announced_source_x = Some((object_id, announced_x)); + } + + turn_face_up(state, player, object_id, events)?; + Ok(WaitingFor::Priority { player }) +} + /// CR 702.37c: Turning a face-down permanent face up restores its original characteristics. /// /// Validates that the player controls the permanent and that it has morph/disguise diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e7f975f92b..c7768bccde 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -6962,6 +6962,21 @@ pub enum ManaAbilityResume { player: PlayerId, cost: ManaCost, }, + /// CR 116.2b + CR 702.37e / CR 702.168d / CR 701.40b + CR 605.3b + CR 616.1: + /// A turn-face-up special action whose auto-tapped mana source paused on a + /// replacement-aware cost move. `cost` is the final cost locked at action + /// initiation — after the special-action reduction and after CR 107.3d's + /// `{X}` was concretized — so resumption cannot re-derive it against a board + /// that changed while the replacement choice was pending. `announced_x` + /// travels with it because CR 702.37f / CR 702.168e publish that value to the + /// permanent's own turn-face-up trigger, which fires after the payment + /// completes. + TurnFaceUp { + player: PlayerId, + object_id: ObjectId, + cost: ManaCost, + announced_x: u32, + }, /// CR 116.2c + CR 605.3b + CR 616.1: A pay-to-end special action whose /// auto-tapped mana source paused on a replacement-aware cost move. `cost` /// is the permission's printed cost, latched at action initiation; diff --git a/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs new file mode 100644 index 0000000000..2666b8a7b4 --- /dev/null +++ b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs @@ -0,0 +1,269 @@ +//! CR 116.2b + CR 702.37e: the turn-face-up special action must be OFFERED, not +//! merely accepted (#6732, #4381). +//! +//! The engine implemented the action and its Priority preflight counted it as +//! progress, but `ai_support::candidates::priority_actions_with_probe` — the +//! list the client renders — never emitted it. Nothing could send an action the +//! engine never advertised, so the whole morph / megamorph / disguise / manifest +//! / cloak class was unturnable in play. #7342 wired the client's dispatch and +//! closed both reports; its test supplies the action to itself, so it proves the +//! client's half and cannot observe the engine's. +//! +//! Reported from a real game state: the controller had priority, a face-down +//! Coral Trickster (morph `{U}`) and thirty untapped Islands, and `legalActions` +//! held six casts, four land plays and a pass. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, Effect, ManaContribution, ManaProduction, + ReplacementDefinition, TargetFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::{ManaAbilityResume, PendingCostMoveResume, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::replacements::ReplacementEvent; +use engine::types::zones::{EtbTapState, Zone}; + +fn morph_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 0, + } +} + +fn pool(kinds: &[ManaType]) -> Vec { + kinds + .iter() + .copied() + .map(|kind| ManaUnit::new(kind, ObjectId(0), false, vec![])) + .collect() +} + +/// A face-down permanent with a morph cost, put onto the battlefield through the +/// engine's own face-down play so `back_face` carries the real card. +fn face_down_morph_board(controller: PlayerId, mana: &[ManaType]) -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand(controller, "Coral Trickster", 2, 1) + .with_keyword(Keyword::Morph(morph_cost())) + .id(); + if !mana.is_empty() { + scenario.with_mana_pool(controller, pool(mana)); + } + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), controller, id, &mut events) + .expect("the card is played face down"); + assert!( + runner.state().objects[&id].face_down, + "setup: the permanent is face down" + ); + + (runner, id) +} + +fn offered_turn_face_ups(runner: &GameRunner) -> Vec { + engine::ai_support::legal_actions(runner.state()) + .into_iter() + .filter_map(|action| match action { + GameAction::TurnFaceUp { object_id, .. } => Some(object_id), + _ => None, + }) + .collect() +} + +/// The defect, end to end: the action is offered, and taking the offer flips the +/// permanent to its real face. +#[test] +fn a_face_down_morph_permanent_is_offered_and_flips() { + let (mut runner, id) = face_down_morph_board(P0, &[ManaType::Blue]); + + assert_eq!( + offered_turn_face_ups(&runner), + vec![id], + "CR 116.2b: the controller has priority and can pay {{U}}, so the special \ + action must be on the list the client renders" + ); + + runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the offered action must be accepted"); + + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down, "CR 702.37e: the permanent is now face up"); + assert_eq!(obj.name, "Coral Trickster", "and shows its real face"); +} + +/// The affordability half of the same authority: an unpayable cost is not +/// offered, so the list never advertises an action the reducer would reject. +/// +/// This is also what keeps the row above from passing for the wrong reason — if +/// the offer were unconditional, this row would fail. +#[test] +fn an_unpayable_turn_face_up_is_not_offered() { + let (runner, _) = face_down_morph_board(P0, &[]); + + assert!( + offered_turn_face_ups(&runner).is_empty(), + "with no mana the morph cost cannot be paid, so nothing is offered" + ); +} + +/// CR 702.37e: "you may turn a face-down permanent YOU CONTROL face up". The +/// offer is per-holder, and `legal_actions` speaks for the priority holder. +#[test] +fn an_opponents_face_down_permanent_is_not_offered() { + let (mut runner, _) = face_down_morph_board(P1, &[ManaType::Blue]); + runner.state_mut().priority_player = P0; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P0 }; + + assert!( + offered_turn_face_ups(&runner).is_empty(), + "a face-down permanent an opponent controls is not this player's to turn up" + ); +} + +// ── The paused mana-source payment (#4538's blocker) ──────────────────────── + +fn redirect_exile_to_graveyard() -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Exile) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + destination: Zone::Graveyard, + origin: None, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )) +} + +/// CR 605.3b + CR 616.1: the offer is only honest if the action can FINISH. +/// +/// A mana source whose own cost exiles it, plus two exile→graveyard +/// replacements, forces a CR 616.1 ordering choice while the turn-face-up cost +/// is being auto-tapped. `casting.rs` deliberately reports such a source as +/// payable, so the offer above is right to include it — but the compatibility +/// wrapper `pay_special_action_mana_cost` converts the resulting `Paused` into +/// an error. That is why #4538 was asked to build a typed resume before the +/// action could be offered. +/// +/// The permanent must still be face down while the choice is open, and must flip +/// exactly once the choice is answered. +#[test] +fn a_paused_mana_source_resumes_the_locked_turn_face_up() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand(P0, "Coral Trickster", 2, 1) + .with_keyword(Keyword::Morph(morph_cost())) + .id(); + let source = scenario + .add_creature(P0, "Self-Exiling Mana Source", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Blue], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Exile { + count: 1, + zone: None, + filter: Some(TargetFilter::SelfRef), + }, + ], + }), + ) + .id(); + for name in ["First Pause Replacement", "Second Pause Replacement"] { + scenario + .add_creature(P0, name, 0, 0) + .as_enchantment() + .with_replacement_definition(redirect_exile_to_graveyard()); + } + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + assert_eq!( + offered_turn_face_ups(&runner), + vec![id], + "the auto-tap probe finds the self-exiling source, so the action is offered" + ); + + let paused = runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the source's own cost pauses the payment rather than failing it"); + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "the mana source's exile replacement owns the window, got {:?}", + paused.waiting_for + ); + assert!( + matches!( + runner.state().pending_cost_move_resume.as_ref(), + Some(PendingCostMoveResume::ManaAbilityPayment { pending, .. }) + if matches!( + &pending.resume, + ManaAbilityResume::TurnFaceUp { player, object_id, .. } + if *player == P0 && *object_id == id + ) + ), + "the typed continuation names the action to finish" + ); + assert!( + runner.state().objects[&id].face_down, + "nothing is committed while the payment is open" + ); + + runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the replacement choice is answered"); + + let obj = &runner.state().objects[&id]; + assert!( + !obj.face_down, + "CR 605.3b: the locked payment completed and the flip committed" + ); + assert_eq!(obj.name, "Coral Trickster"); + assert_eq!( + runner.state().objects[&source].zone, + Zone::Graveyard, + "the mana source's own cost still resolved through its replacement" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 3cc3ccbed6..34a8c766d5 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -720,6 +720,7 @@ mod issue_6643_party_dude_opponents_attacked; mod issue_6677_wakandan_royal_guard; mod issue_6678_captain_america_shield_tap_defender; mod issue_6691_enters_under_their_control; +mod issue_6732_offer_turn_face_up; mod issue_6769_serras_emissary_reanimation; mod issue_680_shalai_and_hallar_forgotten_ancient; mod issue_680_shalai_upkeep_move; From 06f5022e7a7dc6d920c3efdd272e54399590fce0 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 15:19:49 -0700 Subject: [PATCH 2/5] fix(PR-7542): preserve paused turn-up X=0 --- crates/engine/src/game/mana_abilities.rs | 5 ++- crates/engine/src/game/morph.rs | 22 ++++++++---- crates/engine/src/types/game_state.rs | 5 +++ .../issue_6732_offer_turn_face_up.rs | 36 +++++++++++++------ 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 8bb839a1a0..69ff55cfc1 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -3285,12 +3285,14 @@ pub(crate) fn resume_mana_ability_root( player, object_id, cost, + cost_had_x, announced_x, } => super::morph::resume_turn_face_up_payment( state, player, object_id, cost, + cost_had_x, announced_x, events, ), @@ -3412,13 +3414,14 @@ pub(crate) fn finish_mana_root_after_deferred_life_payment( ManaAbilityResume::TurnFaceUp { player, object_id, + cost_had_x, announced_x, .. } => super::morph::finish_paid_turn_face_up( state, player, object_id, - announced_x > 0, + cost_had_x, announced_x, events, ), diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index 4ece2bd760..84ef6a7ea5 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -537,7 +537,7 @@ pub(crate) fn handle_turn_face_up( )); } - match pay_turn_face_up_cost(state, player, object_id, &cost, announced_x, events)? { + match pay_turn_face_up_cost(state, player, object_id, &cost, has_x, announced_x, events)? { super::casting::SpecialActionManaPayment::Paid => { finish_paid_turn_face_up(state, player, object_id, has_x, announced_x, events) } @@ -556,6 +556,7 @@ fn pay_turn_face_up_cost( player: PlayerId, object_id: ObjectId, cost: &ManaCost, + cost_had_x: bool, announced_x: u32, events: &mut Vec, ) -> Result { @@ -563,6 +564,7 @@ fn pay_turn_face_up_cost( player, object_id, cost: cost.clone(), + cost_had_x, announced_x, }; super::casting::pay_special_action_mana_cost_with_resume( @@ -584,16 +586,24 @@ pub(crate) fn resume_turn_face_up_payment( player: PlayerId, object_id: ObjectId, cost: ManaCost, + cost_had_x: bool, announced_x: u32, events: &mut Vec, ) -> Result { // The locked cost already had CR 107.3d's `{X}` concretized, so its shards - // no longer say X. `announced_x` is what CR 702.37f publishes, and a nonzero - // value can only have come from a cost that had one. - let has_x = announced_x > 0; - match pay_turn_face_up_cost(state, player, object_id, &cost, announced_x, events)? { + // no longer say X. Keep the pre-concretization fact separately: X=0 is a + // real announcement and must still bind to the resulting trigger. + match pay_turn_face_up_cost( + state, + player, + object_id, + &cost, + cost_had_x, + announced_x, + events, + )? { super::casting::SpecialActionManaPayment::Paid => { - finish_paid_turn_face_up(state, player, object_id, has_x, announced_x, events) + finish_paid_turn_face_up(state, player, object_id, cost_had_x, announced_x, events) } super::casting::SpecialActionManaPayment::Paused => Ok(state.waiting_for.clone()), } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index c7768bccde..55d9a98d61 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -6975,6 +6975,11 @@ pub enum ManaAbilityResume { player: PlayerId, object_id: ObjectId, cost: ManaCost, + /// Whether the pre-concretization turn-face-up cost contained X. This + /// must remain distinct from an announced value of zero: CR 107.3d + /// permits X=0, and CR 702.37f / CR 702.168e still bind that zero to a + /// resulting turn-face-up trigger after a paused payment resumes. + cost_had_x: bool, announced_x: u32, }, /// CR 116.2c + CR 605.3b + CR 616.1: A pay-to-end special action whose diff --git a/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs index 2666b8a7b4..ec66b976fe 100644 --- a/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs +++ b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs @@ -19,7 +19,9 @@ use engine::types::ability::{ ReplacementDefinition, TargetFilter, }; use engine::types::actions::GameAction; -use engine::types::game_state::{ManaAbilityResume, PendingCostMoveResume, WaitingFor}; +use engine::types::game_state::{ + ManaAbilityResume, PendingCostMoveResume, StackEntryKind, WaitingFor, +}; use engine::types::identifiers::ObjectId; use engine::types::keywords::Keyword; use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; @@ -157,6 +159,11 @@ fn redirect_exile_to_graveyard() -> ReplacementDefinition { )) } +const WARBREAK_TRUMPETER: &str = "Morph {X}{X}{R} (You may cast this card face down as a 2/2 \\ + creature for {3}. Turn it face up any time for its morph \\ + cost.)\nWhen this creature is turned face up, create X 1/1 red \\ + Goblin creature tokens."; + /// CR 605.3b + CR 616.1: the offer is only honest if the action can FINISH. /// /// A mana source whose own cost exiles it, plus two exile→graveyard @@ -174,8 +181,7 @@ fn a_paused_mana_source_resumes_the_locked_turn_face_up() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); let id = scenario - .add_creature_to_hand(P0, "Coral Trickster", 2, 1) - .with_keyword(Keyword::Morph(morph_cost())) + .add_creature_to_hand_from_oracle(P0, "Warbreak Trumpeter", 1, 1, WARBREAK_TRUMPETER) .id(); let source = scenario .add_creature(P0, "Self-Exiling Mana Source", 1, 1) @@ -217,12 +223,6 @@ fn a_paused_mana_source_resumes_the_locked_turn_face_up() { engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) .expect("the card is played face down"); - assert_eq!( - offered_turn_face_ups(&runner), - vec![id], - "the auto-tap probe finds the self-exiling source, so the action is offered" - ); - let paused = runner .act(GameAction::TurnFaceUp { object_id: id, @@ -260,7 +260,23 @@ fn a_paused_mana_source_resumes_the_locked_turn_face_up() { !obj.face_down, "CR 605.3b: the locked payment completed and the flip committed" ); - assert_eq!(obj.name, "Coral Trickster"); + assert_eq!(obj.name, "Warbreak Trumpeter"); + let bound_x = runner + .state() + .stack + .iter() + .find_map(|entry| match &entry.kind { + StackEntryKind::TriggeredAbility { + source_id, ability, .. + } if *source_id == id => Some(ability.chosen_x), + _ => None, + }) + .expect("the turned-face-up trigger must be on the stack after the paused payment"); + assert_eq!( + bound_x, + Some(0), + "a paused X=0 payment must preserve the real zero announcement, not collapse it to no X" + ); assert_eq!( runner.state().objects[&source].zone, Zone::Graveyard, From 0ff2821785d2f760648c52647e54050a52237169 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 17:04:16 -0700 Subject: [PATCH 3/5] fix(PR-7542): remove duplicate turn-up migration --- crates/engine/src/game/mana_abilities.rs | 2 ++ crates/engine/src/types/game_state.rs | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 9ddb6462e5..92fe535cb9 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -3414,6 +3414,8 @@ pub(crate) fn finish_mana_root_after_deferred_life_payment( object_id, announced_x, .. + // CR 702.37e + CR 107.3d: payment has completed, so commit the + // turn-face-up action with its already-announced X value. } => super::morph::finish_paid_turn_face_up(state, player, object_id, announced_x, events), ManaAbilityResume::EndContinuousEffect { player, group, .. } => Ok( super::end_continuous_effect::finish_paid_end_continuous_effect( diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 185303e168..fd965e80d2 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -17669,7 +17669,6 @@ impl GameStateDecode { } } migrate_legacy_batched_zone_change_trigger_fired(&mut value)?; - migrate_legacy_turn_face_up_resume(&mut value)?; let mut state = serde_json::from_value::(value) .map(ResolutionStateWire::into_game_state) .map_err(|error| error.to_string())?; From 6ed8a9cd13fc5a7ba31916c3b5ffc8285e7cfa7c Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:02:55 +0200 Subject: [PATCH 4/5] fix(PR-7542): preserve the waiting state a turn-up replacement raises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn-face-up completion returned `WaitingFor::Priority` unconditionally, overwriting any interactive choice the CR 614.1e "As ~ is turned face up" replacement pipeline installed (CR 616.1 ordering prompts included) and stranding the live `pending_replacement` record. It now seeds the settled outcome before the flip and hands back whatever the pipeline left, on both the fresh route and the paused-payment resume (where the seed also clears the just-answered mana-source prompt instead of resurrecting it). Preserving the pause exposed a second loss: the action's settled epilogue no longer runs, so the `TurnedFaceUp` observer triggers were dropped (measured: the "when turned face up" draw never reached the stack). The completion now parks them through `park_observer_triggers_if_paused`, the established authority for exactly this shape; a no-op on an undisturbed flip. Both halves are load-bearing: with the pre-fix return both new rows fail at the live-choice assertion; with the park removed both fail at the trigger assertion. The four existing rows stay green either way. Known remainder: `turn_face_up` still discards a `NeedsChoice` from TWO simultaneously applicable "as turned up" replacements on one permanent — unreachable from parsed cards today (the parser emits at most one self-anchored definition per card). Co-Authored-By: Claude Fable 5 --- crates/engine/src/game/morph.rs | 22 +- .../issue_6732_offer_turn_face_up.rs | 257 ++++++++++++++++++ 2 files changed, 278 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index 55aef7614b..39e43d18a3 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -630,8 +630,28 @@ pub(crate) fn finish_paid_turn_face_up( state.announced_source_x = Some((object_id, announced_x)); } + // CR 614.1e + CR 708.11 + CR 616.1: the "As ~ is turned face up" replacement + // pipeline inside `turn_face_up` resolves its execute through + // `resolve_ability_chain`, which can install an interactive `WaitingFor` + // (measured: two materially-ordered counter-addition replacements turn the + // execute's `AddCounter` into a CR 616.1 ordering prompt). Seed the settled + // outcome FIRST, then hand back whatever the pipeline left: `Priority` when + // nothing interfered, the live choice when something did. Seeding also + // covers the paused-payment resume route, where `state.waiting_for` still + // holds the mana source's just-answered replacement prompt — returning THAT + // would resurrect a dead prompt. An `Err` from `turn_face_up` cannot leak + // the seed: every dispatch into this completion runs under the action + // boundary, which restores the whole pre-action state on `Err`. + state.waiting_for = WaitingFor::Priority { player }; turn_face_up(state, player, object_id, events)?; - Ok(WaitingFor::Priority { player }) + // CR 603.2 + CR 603.3b: when the pipeline paused, the reducer's settled + // epilogue will not run for this action, so the `TurnedFaceUp` observer + // triggers in `events` would be lost (measured: the "when turned face up" + // draw never reached the stack). Park them into `deferred_triggers` — the + // established authority for exactly this shape — for the drain once the + // interposed choice settles. A no-op when the flip completed undisturbed. + crate::game::triggers::park_observer_triggers_if_paused(state, events, 0); + Ok(state.waiting_for.clone()) } /// CR 702.37e: Turning a face-down permanent face up ends the morph effect and diff --git a/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs index 944d77e34d..4fb0d8e213 100644 --- a/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs +++ b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs @@ -287,3 +287,260 @@ fn a_paused_mana_source_resumes_the_locked_turn_face_up() { "the mana source's own cost still resolved through its replacement" ); } + +// ── The interactive turn-up replacement (review round 2) ──────────────────── + +/// CR 614.1e + CR 708.11: "As ~ is turned face up, put five +1/+1 counters on +/// it" — the parsed Hooded Hydra class. Its `AddCounter`, modified by two +/// materially-ordered replacements, raises a CR 616.1 ordering prompt DURING +/// the flip. The completion must hand that prompt back, not overwrite it with +/// `Priority`. +const COUNTER_MORPH: &str = "Morph {1} (You may cast this card face down as a \ + 2/2 creature for {3}. Turn it face up any time for \ + its morph cost.)\nAs this creature is turned face \ + up, put five +1/+1 counters on it.\nWhen this \ + creature is turned face up, draw a card."; + +fn add_counter_modifier( + scenario: &mut GameScenario, + name: &str, + modification: engine::types::ability::QuantityModification, +) { + scenario + .add_creature(P0, name, 0, 4) + .with_replacement_definition( + ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(modification) + .counter_match(engine::types::counter::CounterMatch::OfType( + engine::types::counter::CounterType::Plus1Plus1, + )), + ); +} + +/// The review-round-2 blocker, fresh route: the flip commits, and the ordering +/// choice the execute raised stays live instead of being clobbered to +/// `Priority` (which stranded a live `pending_replacement` record and silently +/// dropped both modifiers). +#[test] +fn an_interactive_turn_up_replacement_keeps_its_choice_live() { + use engine::types::ability::QuantityModification; + use engine::types::counter::CounterType; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand_from_oracle(P0, "Counter Morph", 2, 2, COUNTER_MORPH) + .id(); + add_counter_modifier( + &mut scenario, + "Plus One Modifier", + QuantityModification::Plus { value: 1 }, + ); + add_counter_modifier( + &mut scenario, + "Times Two Modifier", + QuantityModification::Times { factor: 2 }, + ); + scenario.with_mana_pool(P0, pool(&[ManaType::Colorless])); + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + let paused = runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the special action succeeds up to the replacement's own choice"); + assert!( + matches!(&paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "CR 616.1: the ordering choice the turn-up replacement raised must stay \ + live, got {:?}", + paused.waiting_for + ); + assert!( + runner.state().pending_replacement.is_some(), + "the parked counter addition is still waiting for its order" + ); + let obj = &runner.state().objects[&id]; + assert!( + !obj.face_down, + "CR 708.11: the turn-up itself is not prevented by the pending choice" + ); + assert_eq!( + obj.counters.get(&CounterType::Plus1Plus1), + None, + "no counters land before the order is chosen" + ); + + runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the ordering choice is answered"); + + let count = runner.state().objects[&id] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0); + assert!( + [11, 12].contains(&count), + "both modifiers applied in the chosen order — (5+1)*2 = 12 or 5*2+1 = 11, got {count}" + ); + assert!( + runner.state().pending_replacement.is_none(), + "the counter addition settled; no ghost record remains" + ); + assert!( + runner + .state() + .stack + .iter() + .any(|entry| matches!(&entry.kind, StackEntryKind::TriggeredAbility { source_id, .. } if *source_id == id)), + "CR 603.2: the 'when turned face up' trigger still reaches the stack \ + after the interposed choice" + ); +} + +const WARBREAK_WITH_COUNTERS: &str = "Morph {X}{X}{R} (You may cast this card face down as a \ + 2/2 creature for {3}. Turn it face up any time for its \ + morph cost.)\nAs this creature is turned face up, put \ + five +1/+1 counters on it.\nWhen this creature is \ + turned face up, create X 1/1 red Goblin creature tokens."; + +/// The same blocker through the paused-payment route: the mana source's own +/// replacement choice settles FIRST, the resumed completion flips, and the +/// turn-up replacement's ordering prompt must then surface — not the stale +/// exile prompt, and not a premature `Priority` that strands the parked +/// counters. The X=0 announcement must still bind across BOTH pauses. +#[test] +fn a_resumed_payment_still_surfaces_the_turn_up_replacement_choice() { + use engine::types::ability::QuantityModification; + use engine::types::counter::CounterType; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand_from_oracle(P0, "Warbreak Trumpeter", 1, 1, WARBREAK_WITH_COUNTERS) + .id(); + let source = scenario + .add_creature(P0, "Self-Exiling Mana Source", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Red], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Exile { + count: 1, + zone: None, + filter: Some(TargetFilter::SelfRef), + }, + ], + }), + ) + .id(); + for name in ["First Pause Replacement", "Second Pause Replacement"] { + scenario + .add_creature(P0, name, 0, 0) + .as_enchantment() + .with_replacement_definition(redirect_exile_to_graveyard()); + } + add_counter_modifier( + &mut scenario, + "Plus One Modifier", + QuantityModification::Plus { value: 1 }, + ); + add_counter_modifier( + &mut scenario, + "Times Two Modifier", + QuantityModification::Times { factor: 2 }, + ); + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + let paused = runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the source's own cost pauses the payment rather than failing it"); + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "first pause: the mana source's exile replacement owns the window" + ); + + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the exile choice is answered and the payment resumes"); + let WaitingFor::ReplacementChoice { candidates, .. } = &resumed.waiting_for else { + panic!( + "second pause: the resumed completion must hand back the turn-up \ + replacement's ordering choice, got {:?}", + resumed.waiting_for + ); + }; + let names: Vec<&str> = candidates + .iter() + .map(|candidate| candidate.source_name.as_str()) + .collect(); + assert!( + names.contains(&"Plus One Modifier") && names.contains(&"Times Two Modifier"), + "the live prompt is the COUNTER ordering choice, not the settled exile \ + prompt resurrected — candidates were {names:?}" + ); + assert!( + !runner.state().objects[&id].face_down, + "CR 708.11: the flip itself committed before the counter order is chosen" + ); + + runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the ordering choice is answered"); + + let count = runner.state().objects[&id] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0); + assert!( + [11, 12].contains(&count), + "both modifiers applied in the chosen order, got {count}" + ); + let bound_x = runner + .state() + .stack + .iter() + .find_map(|entry| match &entry.kind { + StackEntryKind::TriggeredAbility { + source_id, ability, .. + } if *source_id == id => Some(ability.chosen_x), + _ => None, + }) + .expect("the turned-face-up trigger must still reach the stack across both pauses"); + assert_eq!( + bound_x, + Some(0), + "the X=0 announcement survives the payment pause AND the turn-up choice" + ); + assert_eq!( + runner.state().objects[&source].zone, + Zone::Graveyard, + "the mana source's own cost still resolved through its replacement" + ); +} From 25994f287091f397396d9ffb2eeaab4f695f17bd Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:29:26 +0200 Subject: [PATCH 5/5] test(engine): prove the chosen replacement order determines the result (#6732, PR 7542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3 on PR 7542: both replacement-order regressions selected index 0 and accepted either 11 or 12, so a stale or extra candidate, a wrong candidate order, or a pipeline ignoring the selection could still pass. Each row now runs once per selectable order through the production GameAction reducer and asserts: - the prompt holds EXACTLY the two live counter modifiers (length 2 plus both names — no stale entries; on the resumed-payment route this also pins that the settled exile prompt does not resurface); - the selection is made by NAME, and the chosen order determines the exact count (CR 616.1): Plus One Modifier first yields (5+1)*2 = 12, Times Two Modifier first yields 5*2+1 = 11 — on the fresh route AND across both pauses of the resumed-payment route, where the X=0 binding and the mana source's graveyard arrival are asserted in both runs. Co-Authored-By: Claude Fable 5 --- .../issue_6732_offer_turn_face_up.rs | 428 ++++++++++-------- 1 file changed, 246 insertions(+), 182 deletions(-) diff --git a/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs index 4fb0d8e213..becb1f274d 100644 --- a/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs +++ b/crates/engine/tests/integration/issue_6732_offer_turn_face_up.rs @@ -321,85 +321,119 @@ fn add_counter_modifier( /// choice the execute raised stays live instead of being clobbered to /// `Priority` (which stranded a live `pending_replacement` record and silently /// dropped both modifiers). +/// +/// Review round 3: one run per selectable order. CR 616.1 lets the affected +/// object's controller pick which applicable replacement applies first, so the +/// pick must be proven to determine the result — (5+1)*2 = 12 when the plus +/// applies first, 5*2+1 = 11 when the doubling applies first — and the prompt +/// must hold EXACTLY the two live modifiers, with no stale or extra candidates. #[test] fn an_interactive_turn_up_replacement_keeps_its_choice_live() { use engine::types::ability::QuantityModification; use engine::types::counter::CounterType; - let mut scenario = GameScenario::new(); - scenario.at_phase(Phase::PreCombatMain); - let id = scenario - .add_creature_to_hand_from_oracle(P0, "Counter Morph", 2, 2, COUNTER_MORPH) - .id(); - add_counter_modifier( - &mut scenario, - "Plus One Modifier", - QuantityModification::Plus { value: 1 }, - ); - add_counter_modifier( - &mut scenario, - "Times Two Modifier", - QuantityModification::Times { factor: 2 }, - ); - scenario.with_mana_pool(P0, pool(&[ManaType::Colorless])); - let mut runner = scenario.build(); - - let mut events = Vec::new(); - engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) - .expect("the card is played face down"); + let run_with_first_choice = |first_choice: &str| { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand_from_oracle(P0, "Counter Morph", 2, 2, COUNTER_MORPH) + .id(); + add_counter_modifier( + &mut scenario, + "Plus One Modifier", + QuantityModification::Plus { value: 1 }, + ); + add_counter_modifier( + &mut scenario, + "Times Two Modifier", + QuantityModification::Times { factor: 2 }, + ); + scenario.with_mana_pool(P0, pool(&[ManaType::Colorless])); + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + let paused = runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the special action succeeds up to the replacement's own choice"); + let WaitingFor::ReplacementChoice { candidates, .. } = &paused.waiting_for else { + panic!( + "CR 616.1: the ordering choice the turn-up replacement raised must stay \ + live, got {:?}", + paused.waiting_for + ); + }; + let names: Vec<&str> = candidates + .iter() + .map(|candidate| candidate.source_name.as_str()) + .collect(); + assert_eq!( + names.len(), + 2, + "exactly the two live modifiers may compete — no stale or extra \ + candidates, got {names:?}" + ); + assert!( + names.contains(&"Plus One Modifier") && names.contains(&"Times Two Modifier"), + "the candidate set is the two counter modifiers, got {names:?}" + ); + assert!( + runner.state().pending_replacement.is_some(), + "the parked counter addition is still waiting for its order" + ); + let obj = &runner.state().objects[&id]; + assert!( + !obj.face_down, + "CR 708.11: the turn-up itself is not prevented by the pending choice" + ); + assert_eq!( + obj.counters.get(&CounterType::Plus1Plus1), + None, + "no counters land before the order is chosen" + ); - let paused = runner - .act(GameAction::TurnFaceUp { - object_id: id, - x: 0, - }) - .expect("the special action succeeds up to the replacement's own choice"); - assert!( - matches!(&paused.waiting_for, WaitingFor::ReplacementChoice { .. }), - "CR 616.1: the ordering choice the turn-up replacement raised must stay \ - live, got {:?}", - paused.waiting_for - ); - assert!( - runner.state().pending_replacement.is_some(), - "the parked counter addition is still waiting for its order" - ); - let obj = &runner.state().objects[&id]; - assert!( - !obj.face_down, - "CR 708.11: the turn-up itself is not prevented by the pending choice" - ); - assert_eq!( - obj.counters.get(&CounterType::Plus1Plus1), - None, - "no counters land before the order is chosen" - ); + let index = names + .iter() + .position(|name| *name == first_choice) + .expect("the requested first choice is among the candidates"); + runner + .act(GameAction::ChooseReplacement { index }) + .expect("the ordering choice is answered"); - runner - .act(GameAction::ChooseReplacement { index: 0 }) - .expect("the ordering choice is answered"); + assert!( + runner.state().pending_replacement.is_none(), + "the counter addition settled; no ghost record remains" + ); + assert!( + runner + .state() + .stack + .iter() + .any(|entry| matches!(&entry.kind, StackEntryKind::TriggeredAbility { source_id, .. } if *source_id == id)), + "CR 603.2: the 'when turned face up' trigger still reaches the stack \ + after the interposed choice" + ); + runner.state().objects[&id] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0) + }; - let count = runner.state().objects[&id] - .counters - .get(&CounterType::Plus1Plus1) - .copied() - .unwrap_or(0); - assert!( - [11, 12].contains(&count), - "both modifiers applied in the chosen order — (5+1)*2 = 12 or 5*2+1 = 11, got {count}" - ); - assert!( - runner.state().pending_replacement.is_none(), - "the counter addition settled; no ghost record remains" + assert_eq!( + run_with_first_choice("Plus One Modifier"), + 12, + "CR 616.1: the plus applies first, then the doubling — (5+1)*2" ); - assert!( - runner - .state() - .stack - .iter() - .any(|entry| matches!(&entry.kind, StackEntryKind::TriggeredAbility { source_id, .. } if *source_id == id)), - "CR 603.2: the 'when turned face up' trigger still reaches the stack \ - after the interposed choice" + assert_eq!( + run_with_first_choice("Times Two Modifier"), + 11, + "CR 616.1: the doubling applies first, then the plus — 5*2+1" ); } @@ -414,133 +448,163 @@ const WARBREAK_WITH_COUNTERS: &str = "Morph {X}{X}{R} (You may cast this card fa /// turn-up replacement's ordering prompt must then surface — not the stale /// exile prompt, and not a premature `Priority` that strands the parked /// counters. The X=0 announcement must still bind across BOTH pauses. +/// +/// Review round 3: one run per selectable order (CR 616.1) — the resumed +/// prompt must hold EXACTLY the two counter modifiers (the settled exile +/// prompt must not resurface among them), and the chosen order must determine +/// the count: plus first (5+1)*2 = 12, doubling first 5*2+1 = 11. #[test] fn a_resumed_payment_still_surfaces_the_turn_up_replacement_choice() { use engine::types::ability::QuantityModification; use engine::types::counter::CounterType; - let mut scenario = GameScenario::new(); - scenario.at_phase(Phase::PreCombatMain); - let id = scenario - .add_creature_to_hand_from_oracle(P0, "Warbreak Trumpeter", 1, 1, WARBREAK_WITH_COUNTERS) - .id(); - let source = scenario - .add_creature(P0, "Self-Exiling Mana Source", 1, 1) - .with_ability_definition( - AbilityDefinition::new( - AbilityKind::Activated, - Effect::Mana { - produced: ManaProduction::Fixed { - colors: vec![ManaColor::Red], - contribution: ManaContribution::Base, - }, - restrictions: vec![], - grants: vec![], - expiry: None, - target: None, - }, + let run_with_first_choice = |first_choice: &str| { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let id = scenario + .add_creature_to_hand_from_oracle( + P0, + "Warbreak Trumpeter", + 1, + 1, + WARBREAK_WITH_COUNTERS, ) - .cost(AbilityCost::Composite { - costs: vec![ - AbilityCost::Tap, - AbilityCost::Exile { - count: 1, - zone: None, - filter: Some(TargetFilter::SelfRef), + .id(); + let source = scenario + .add_creature(P0, "Self-Exiling Mana Source", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Red], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, }, - ], - }), - ) - .id(); - for name in ["First Pause Replacement", "Second Pause Replacement"] { - scenario - .add_creature(P0, name, 0, 0) - .as_enchantment() - .with_replacement_definition(redirect_exile_to_graveyard()); - } - add_counter_modifier( - &mut scenario, - "Plus One Modifier", - QuantityModification::Plus { value: 1 }, - ); - add_counter_modifier( - &mut scenario, - "Times Two Modifier", - QuantityModification::Times { factor: 2 }, - ); - let mut runner = scenario.build(); + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Exile { + count: 1, + zone: None, + filter: Some(TargetFilter::SelfRef), + }, + ], + }), + ) + .id(); + for name in ["First Pause Replacement", "Second Pause Replacement"] { + scenario + .add_creature(P0, name, 0, 0) + .as_enchantment() + .with_replacement_definition(redirect_exile_to_graveyard()); + } + add_counter_modifier( + &mut scenario, + "Plus One Modifier", + QuantityModification::Plus { value: 1 }, + ); + add_counter_modifier( + &mut scenario, + "Times Two Modifier", + QuantityModification::Times { factor: 2 }, + ); + let mut runner = scenario.build(); + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + let paused = runner + .act(GameAction::TurnFaceUp { + object_id: id, + x: 0, + }) + .expect("the source's own cost pauses the payment rather than failing it"); + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "first pause: the mana source's exile replacement owns the window" + ); - let mut events = Vec::new(); - engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) - .expect("the card is played face down"); + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the exile choice is answered and the payment resumes"); + let WaitingFor::ReplacementChoice { candidates, .. } = &resumed.waiting_for else { + panic!( + "second pause: the resumed completion must hand back the turn-up \ + replacement's ordering choice, got {:?}", + resumed.waiting_for + ); + }; + let names: Vec<&str> = candidates + .iter() + .map(|candidate| candidate.source_name.as_str()) + .collect(); + assert_eq!( + names.len(), + 2, + "exactly the two counter modifiers — the settled exile prompt must \ + not resurface among the candidates, got {names:?}" + ); + assert!( + names.contains(&"Plus One Modifier") && names.contains(&"Times Two Modifier"), + "the live prompt is the COUNTER ordering choice, got {names:?}" + ); + assert!( + !runner.state().objects[&id].face_down, + "CR 708.11: the flip itself committed before the counter order is chosen" + ); - let paused = runner - .act(GameAction::TurnFaceUp { - object_id: id, - x: 0, - }) - .expect("the source's own cost pauses the payment rather than failing it"); - assert!( - matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), - "first pause: the mana source's exile replacement owns the window" - ); + let index = names + .iter() + .position(|name| *name == first_choice) + .expect("the requested first choice is among the candidates"); + runner + .act(GameAction::ChooseReplacement { index }) + .expect("the ordering choice is answered"); - let resumed = runner - .act(GameAction::ChooseReplacement { index: 0 }) - .expect("the exile choice is answered and the payment resumes"); - let WaitingFor::ReplacementChoice { candidates, .. } = &resumed.waiting_for else { - panic!( - "second pause: the resumed completion must hand back the turn-up \ - replacement's ordering choice, got {:?}", - resumed.waiting_for + let bound_x = runner + .state() + .stack + .iter() + .find_map(|entry| match &entry.kind { + StackEntryKind::TriggeredAbility { + source_id, ability, .. + } if *source_id == id => Some(ability.chosen_x), + _ => None, + }) + .expect("the turned-face-up trigger must still reach the stack across both pauses"); + assert_eq!( + bound_x, + Some(0), + "the X=0 announcement survives the payment pause AND the turn-up choice" + ); + assert_eq!( + runner.state().objects[&source].zone, + Zone::Graveyard, + "the mana source's own cost still resolved through its replacement" ); - }; - let names: Vec<&str> = candidates - .iter() - .map(|candidate| candidate.source_name.as_str()) - .collect(); - assert!( - names.contains(&"Plus One Modifier") && names.contains(&"Times Two Modifier"), - "the live prompt is the COUNTER ordering choice, not the settled exile \ - prompt resurrected — candidates were {names:?}" - ); - assert!( - !runner.state().objects[&id].face_down, - "CR 708.11: the flip itself committed before the counter order is chosen" - ); - runner - .act(GameAction::ChooseReplacement { index: 0 }) - .expect("the ordering choice is answered"); + runner.state().objects[&id] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0) + }; - let count = runner.state().objects[&id] - .counters - .get(&CounterType::Plus1Plus1) - .copied() - .unwrap_or(0); - assert!( - [11, 12].contains(&count), - "both modifiers applied in the chosen order, got {count}" - ); - let bound_x = runner - .state() - .stack - .iter() - .find_map(|entry| match &entry.kind { - StackEntryKind::TriggeredAbility { - source_id, ability, .. - } if *source_id == id => Some(ability.chosen_x), - _ => None, - }) - .expect("the turned-face-up trigger must still reach the stack across both pauses"); assert_eq!( - bound_x, - Some(0), - "the X=0 announcement survives the payment pause AND the turn-up choice" + run_with_first_choice("Plus One Modifier"), + 12, + "CR 616.1 across both pauses: the plus applies first — (5+1)*2" ); assert_eq!( - runner.state().objects[&source].zone, - Zone::Graveyard, - "the mana source's own cost still resolved through its replacement" + run_with_first_choice("Times Two Modifier"), + 11, + "CR 616.1 across both pauses: the doubling applies first — 5*2+1" ); }