diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 7cf1d43cff..061128fa2c 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -5803,6 +5803,7 @@ mod tests { let mut card_types = crate::types::card_type::CardType::default(); card_types.core_types.push(CoreType::Sorcery); crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Prepared Spell Face".to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 398854b59b..081b6fa6d5 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -3107,11 +3107,10 @@ fn simulate_chosen_split_spell_back_face(obj: &mut crate::game::game_object::Gam swap_to_alternative_spell_face(obj); // Mirror `ChooseModalFace { back_face: true }` so affordability preview and // alt-cost resolution use the chosen face without re-prompting or swapping - // back to the front half (#3987). + // back to the front half (#3987). #7565: the mirror is the transient + // choice flag, not a layout_kind erasure. obj.modal_back_face = true; - if let Some(ref mut bf) = obj.back_face { - bf.layout_kind = None; - } + obj.cast_face_committed = true; } pub(super) fn exile_alt_cost_permission_supports_cast( @@ -8942,13 +8941,8 @@ pub(super) fn consume_pending_spell_cost_reduction( /// spell face for casting. Saves the normal face in `back_face` for later /// restoration. fn swap_to_alternative_spell_face(obj: &mut crate::game::game_object::GameObject) { - let alternative = match obj.back_face.take() { - Some(b) => b, - None => return, - }; - let normal_snapshot = super::printed_cards::snapshot_object_face(obj); - super::printed_cards::apply_back_face_to_object(obj, alternative); - obj.back_face = Some(normal_snapshot); + // #7565: the shared swap preserves the stored slot's layout_kind. + super::printed_cards::swap_object_faces(obj); } /// CR 715 / CR 720: Returns the Adventure-family spell layout if this object @@ -9038,7 +9032,11 @@ fn is_castable_split_face(types: &crate::types::card_type::CardType) -> bool { /// CR 712.11b + CR 709.3: Cast-time face choice for spell//spell MDFCs and /// spell//spell split cards. fn cast_spell_face_choice_available(obj: &crate::game::game_object::GameObject) -> bool { - modal_spell_face_choice_available(obj) || split_spell_face_choice_available(obj) + // CR 601.2b (#7565): a choice already made for the CURRENT cast is not + // offered again on pipeline re-entry; the transient flag clears once the + // cast conversation ends, so a later recast prompts afresh. + !obj.cast_face_committed + && (modal_spell_face_choice_available(obj) || split_spell_face_choice_available(obj)) } /// CR 712.11b: Returns true if `obj` is a Modal double-faced card whose two @@ -13970,8 +13968,10 @@ pub fn can_cast_modal_face_now( }; if back_face { simulate_chosen_split_spell_back_face(obj); - } else if let Some(back) = obj.back_face.as_mut() { - back.layout_kind = None; + } else { + // #7565: mirror the front-face choice on the simulation clone the same + // way the real handler records it. + obj.cast_face_committed = true; } if obj .card_types @@ -20004,6 +20004,12 @@ pub fn handle_cancel_cast( obj.modal_back_face = false; } } + // #7565: a cancelled cast releases its face choice — the object may never + // move zones (it stays in hand), so the zone-change clear cannot cover + // this path. Unconditional: harmless when no choice was made. + if let Some(obj) = state.objects.get_mut(&pending.object_id) { + obj.cast_face_committed = false; + } if pending.casting_variant == CastingVariant::Prototype { // CR 601.2i + CR 702.160a: backing out of a prototyped cast before diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index a146983461..70b6cb58d1 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -10817,6 +10817,7 @@ fn hearth_elemental_self_cost_reduction_counts_adventures() { obj.card_types.core_types.push(ty); if i == 2 { obj.back_face = Some(crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Adventure".to_string(), power: None, toughness: None, @@ -26918,6 +26919,7 @@ fn create_adventure_in_hand(state: &mut GameState, player: PlayerId) -> ObjectId // Adventure face stored in back_face (Stomp - instant, {1}{R}) obj.back_face = Some(crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Stomp".to_string(), power: None, toughness: None, @@ -27010,6 +27012,7 @@ fn create_enchantment_adventure_in_hand(state: &mut GameState, player: PlayerId) }; obj.back_face = Some(crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Embereth Blaze".to_string(), power: None, toughness: None, @@ -27098,6 +27101,7 @@ fn create_omen_in_hand(state: &mut GameState, player: PlayerId) -> ObjectId { obj.mana_cost = ManaCost::generic(5); obj.back_face = Some(crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Good Omen".to_string(), power: None, toughness: None, @@ -30923,6 +30927,7 @@ fn add_disturb_creature_to_graveyard( .push(Keyword::Disturb(disturb_cost.clone())); obj.keywords = obj.base_keywords.clone(); obj.back_face = Some(crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Luminous Phantom".to_string(), power: Some(1), toughness: Some(1), @@ -37215,6 +37220,7 @@ mod mtmte_cast_flow { let mut card_types = CardType::default(); card_types.core_types.push(CoreType::Creature); BackFaceData { + is_swap_snapshot: false, name: "Streetwise Operative".to_string(), power: Some(7), toughness: Some(7), @@ -51432,6 +51438,7 @@ fn exact_resolution_offer_does_not_inherit_sibling_cast_transformed() { obj.card_types.core_types.push(CoreType::Creature); obj.mana_cost = ManaCost::zero(); obj.back_face = Some(crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Back Face".to_string(), power: Some(3), toughness: Some(3), diff --git a/crates/engine/src/game/day_night.rs b/crates/engine/src/game/day_night.rs index e03a649772..5105370c4c 100644 --- a/crates/engine/src/game/day_night.rs +++ b/crates/engine/src/game/day_night.rs @@ -153,6 +153,7 @@ mod tests { obj.color = vec![ManaColor::Green]; obj.base_color = vec![ManaColor::Green]; obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Nightbound Werewolf".to_string(), power: Some(4), toughness: Some(4), diff --git a/crates/engine/src/game/effects/become_copy.rs b/crates/engine/src/game/effects/become_copy.rs index 8d2a44385b..8637a93faa 100644 --- a/crates/engine/src/game/effects/become_copy.rs +++ b/crates/engine/src/game/effects/become_copy.rs @@ -1142,6 +1142,7 @@ mod tests { obj.color = vec![ManaColor::Green]; obj.base_color = vec![ManaColor::Green]; obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Back Face".to_string(), power: Some(5), toughness: Some(4), diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 043adc49a3..9539eb3431 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -9711,6 +9711,7 @@ mod tests { obj.base_characteristics_initialized = true; // Back face: Sorin, Ravenous Neonate — planeswalker with loyalty 3 obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Sorin, Ravenous Neonate".to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/effects/flip_coin.rs b/crates/engine/src/game/effects/flip_coin.rs index 68af5abc7c..fe0d2bf5be 100644 --- a/crates/engine/src/game/effects/flip_coin.rs +++ b/crates/engine/src/game/effects/flip_coin.rs @@ -909,6 +909,7 @@ mod tests { obj.base_power = Some(1); obj.base_toughness = Some(3); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Ral, Leyline Prodigy".to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/effects/flip_permanent.rs b/crates/engine/src/game/effects/flip_permanent.rs index 1c3ecf3270..525bf9567a 100644 --- a/crates/engine/src/game/effects/flip_permanent.rs +++ b/crates/engine/src/game/effects/flip_permanent.rs @@ -123,6 +123,7 @@ mod tests { obj.color = vec![ManaColor::Black]; obj.base_color = obj.color.clone(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Stabwhisker the Odious".to_string(), power: Some(3), toughness: Some(3), diff --git a/crates/engine/src/game/effects/prepare.rs b/crates/engine/src/game/effects/prepare.rs index 23aa654334..53ca377241 100644 --- a/crates/engine/src/game/effects/prepare.rs +++ b/crates/engine/src/game/effects/prepare.rs @@ -1234,6 +1234,7 @@ mod tests { let mut card_types = crate::types::card_type::CardType::default(); card_types.core_types.push(CoreType::Sorcery); crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Test Prepare Face".to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/effects/set_room_door_lock.rs b/crates/engine/src/game/effects/set_room_door_lock.rs index fe6728e5ad..c5767ce83e 100644 --- a/crates/engine/src/game/effects/set_room_door_lock.rs +++ b/crates/engine/src/game/effects/set_room_door_lock.rs @@ -118,6 +118,7 @@ mod tests { /// presence of `back_face` is read by `existing_doors`. fn room_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Right Door".to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 8380be4bf5..f17207640d 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -3859,6 +3859,7 @@ fn incubator_phyrexian_back_face() -> BackFaceData { // no parse to have gone wrong. parse_warnings: vec![], layout_kind: None, + is_swap_snapshot: false, } } diff --git a/crates/engine/src/game/effects/transform_effect.rs b/crates/engine/src/game/effects/transform_effect.rs index efb2804a0c..313e27850e 100644 --- a/crates/engine/src/game/effects/transform_effect.rs +++ b/crates/engine/src/game/effects/transform_effect.rs @@ -194,6 +194,7 @@ mod tests { obj.color = vec![ManaColor::Green]; obj.base_color = vec![ManaColor::Green]; obj.back_face = Some(crate::game::game_object::BackFaceData { + is_swap_snapshot: false, name: "Back Face".to_string(), power: Some(4), toughness: Some(4), diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 42a27dbd9b..c7b8cf796a 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -8643,28 +8643,22 @@ fn apply_action( } if let Some(obj) = state.objects.get_mut(object_id) { if back_face { - // Swap to back face using existing primitives - let back = obj.back_face.take().expect("dual-faced card has back face"); - let front_snapshot = super::printed_cards::snapshot_object_face(obj); - super::printed_cards::apply_back_face_to_object(obj, back); - obj.back_face = Some(front_snapshot); + // Swap to back face — the shared swap preserves the stored + // slot's layout_kind (#7565). + super::printed_cards::swap_object_faces(obj); // CR 712.8a (MDFC) / CR 709.3 (split): non-front face showing; // `apply_zone_exit_cleanup` reverts when leaving the stack. obj.modal_back_face = true; - } else { - // Front face chosen — clear layout_kind so the intercept - // won't re-fire on re-entry into handle_play_land / handle_cast_spell. - if let Some(ref mut bf) = obj.back_face { - bf.layout_kind = None; - } - } - // After choosing either face, clear layout on the stashed other - // half so cast/play re-entry does not re-prompt. - if back_face { - if let Some(ref mut bf) = obj.back_face { - bf.layout_kind = None; - } } + // CR 601.2b (#7565): remember that THIS cast's face choice is + // made so the handle_play_land / handle_cast_spell re-entry + // does not re-prompt. A transient flag, NOT + // `back_face.layout_kind = None`: that erasure was permanent, + // so a recast from hand (Rescue, bounce) silently auto-picked + // the front face and every other layout_kind consumer went + // blind. Cleared on any zone change off the stack and on + // cancel. + obj.cast_face_committed = true; } // CR 712.12 / CR 712.11b: Route the re-entry by the now-active face's // type. A land face is put onto the battlefield via the play-land @@ -13606,10 +13600,14 @@ fn handle_play_land( // CR 712.12: MDFC land face selection if let Some(obj) = state.objects.get(&object_id) { - let is_modal = obj - .back_face - .as_ref() - .is_some_and(|bf| bf.layout_kind == Some(crate::types::card::LayoutKind::Modal)); + // CR 712.12 + CR 601.2b (#7565): the face prompt is offered only while + // this cast's choice is still open — `cast_face_committed` suppresses + // the re-entry re-prompt (layout_kind itself stays untouched). + let is_modal = !obj.cast_face_committed + && obj + .back_face + .as_ref() + .is_some_and(|bf| bf.layout_kind == Some(crate::types::card::LayoutKind::Modal)); let front_is_land = obj .card_types .core_types @@ -13635,10 +13633,7 @@ fn handle_play_land( if is_modal && !front_is_land && back_is_land { // CR 712.12: Only back face is a land — auto-swap (player already chose "play as land") let obj = state.objects.get_mut(&object_id).unwrap(); - let back = obj.back_face.take().expect("MDFC has back face"); - let front_snapshot = super::printed_cards::snapshot_object_face(obj); - super::printed_cards::apply_back_face_to_object(obj, back); - obj.back_face = Some(front_snapshot); + super::printed_cards::swap_object_faces(obj); // CR 712.8a: Mark back-face so apply_zone_exit_cleanup reverts to front face // when this land leaves the battlefield. Do NOT set obj.transformed — MDFC // face selection is not transformation. @@ -15624,6 +15619,7 @@ mod priority_principal_tests { .get_mut(&object_id) .unwrap() .back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Blow Off Steam".to_string(), power: None, toughness: None, @@ -20104,7 +20100,7 @@ mod stage2_injector_tests { // `: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(), + "game/engine.rs:13124".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/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index 85d2c2dcd9..c76cefcd98 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -1605,6 +1605,7 @@ mod tests { let mut card_types = crate::types::card_type::CardType::default(); card_types.core_types.push(CoreType::Sorcery); BackFaceData { + is_swap_snapshot: false, name: "Test Prepare Face".to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/engine_mdfc_land_tests.rs b/crates/engine/src/game/engine_mdfc_land_tests.rs index c82568085c..fc729d8a6d 100644 --- a/crates/engine/src/game/engine_mdfc_land_tests.rs +++ b/crates/engine/src/game/engine_mdfc_land_tests.rs @@ -41,6 +41,7 @@ fn make_back_face( layout_kind: Option, ) -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: name.to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index cde84f7eb6..43d44b6e0b 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -1636,6 +1636,7 @@ fn broadside_bombardiers_boast_activates_after_attacking_and_requires_sacrifice( fn room_back_face(name: &str) -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: name.to_string(), power: None, toughness: None, @@ -1699,6 +1700,170 @@ fn unlock_room_door_special_action_marks_door_and_emits_trigger_event() { ))); } +/// CR 601.2b + CR 709.3 (#7565): the cast-time face choice belongs to ONE +/// cast. Casting a split Room, resolving it, and returning it to hand must +/// offer the face choice AGAIN on the next cast. Before the fix the +/// `ChooseModalFace` handler erased `back_face.layout_kind`, so every later +/// cast silently auto-picked the front face — and every other layout_kind +/// consumer (MDFC land playability, split handling) went blind with it. +#[test] +fn a_recast_split_room_offers_the_face_choice_again() { + use crate::game::stack; + + let mut state = setup_game_at_main_phase(); + let room = create_object( + &mut state, + CardId(903), + PlayerId(0), + "Moldering Gym".to_string(), + Zone::Hand, + ); + { + let obj = state.objects.get_mut(&room).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.card_types.subtypes.push("Room".to_string()); + obj.mana_cost = ManaCost::generic(0); + let mut back = room_back_face("Weight Room"); + back.card_types.core_types.push(CoreType::Enchantment); + back.card_types.subtypes.push("Room".to_string()); + obj.back_face = Some(back); + } + + let first = apply_as_current( + &mut state, + GameAction::CastSpell { + object_id: room, + card_id: CardId(903), + targets: vec![], + payment_mode: crate::types::game_state::CastPaymentMode::Auto, + }, + ) + .unwrap(); + assert!( + matches!(first.waiting_for, WaitingFor::ModalFaceChoice { .. }), + "first cast must offer the face choice, got {:?}", + first.waiting_for + ); + let after_choice = + apply_as_current(&mut state, GameAction::ChooseModalFace { back_face: false }).unwrap(); + assert!( + !matches!(after_choice.waiting_for, WaitingFor::ModalFaceChoice { .. }), + "the SAME cast must not re-prompt after the choice" + ); + assert!( + state.stack.iter().any(|entry| entry.source_id == room), + "the chosen face must be on the stack" + ); + let mut events = Vec::new(); + stack::resolve_top(&mut state, &mut events); + assert_eq!( + state.objects[&room].zone, + Zone::Battlefield, + "the Room must resolve onto the battlefield" + ); + + // Bounce it (Rescue class) and cast again. + crate::game::zones::move_to_zone(&mut state, room, Zone::Hand, &mut events); + let second = apply_as_current( + &mut state, + GameAction::CastSpell { + object_id: room, + card_id: CardId(903), + targets: vec![], + payment_mode: crate::types::game_state::CastPaymentMode::Auto, + }, + ) + .unwrap(); + assert!( + matches!(second.waiting_for, WaitingFor::ModalFaceChoice { .. }), + "the SECOND cast must offer the face choice again (#7565), got {:?}", + second.waiting_for + ); +} + +/// #7565 round 2 (live playtest): the BACK-half round trip. Casting the back +/// half swaps the faces; leaving the battlefield swaps them back via +/// snapshots — and `snapshot_object_face` hardcodes `layout_kind: None`, so +/// the Split marker died in the swap chain and the next cast silently +/// auto-picked the front face. `swap_object_faces` preserves the marker. +#[test] +fn a_room_recast_after_a_back_half_round_trip_offers_the_choice_again() { + use crate::game::stack; + + let mut state = setup_game_at_main_phase(); + let room = create_object( + &mut state, + CardId(904), + PlayerId(0), + "Moldering Gym".to_string(), + Zone::Hand, + ); + { + let obj = state.objects.get_mut(&room).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.card_types.subtypes.push("Room".to_string()); + obj.mana_cost = ManaCost::generic(0); + let mut back = room_back_face("Weight Room"); + back.card_types.core_types.push(CoreType::Enchantment); + back.card_types.subtypes.push("Room".to_string()); + back.mana_cost = ManaCost::generic(0); + obj.back_face = Some(back); + } + + let first = apply_as_current( + &mut state, + GameAction::CastSpell { + object_id: room, + card_id: CardId(904), + targets: vec![], + payment_mode: crate::types::game_state::CastPaymentMode::Auto, + }, + ) + .unwrap(); + assert!( + matches!(first.waiting_for, WaitingFor::ModalFaceChoice { .. }), + "first cast must offer the face choice, got {:?}", + first.waiting_for + ); + // Cast the BACK half — this swaps the faces. + apply_as_current(&mut state, GameAction::ChooseModalFace { back_face: true }).unwrap(); + assert!( + state.stack.iter().any(|entry| entry.source_id == room), + "the back half must be on the stack" + ); + let mut events = Vec::new(); + stack::resolve_top(&mut state, &mut events); + assert_eq!(state.objects[&room].zone, Zone::Battlefield); + assert_eq!( + state.objects[&room].name, "Weight Room", + "the back half resolved onto the battlefield" + ); + + // Bounce — the zone-exit revert swaps the faces back via snapshots. + crate::game::zones::move_to_zone(&mut state, room, Zone::Hand, &mut events); + assert_eq!( + state.objects[&room].name, "Moldering Gym", + "in hand the front face shows again" + ); + + let second = apply_as_current( + &mut state, + GameAction::CastSpell { + object_id: room, + card_id: CardId(904), + targets: vec![], + payment_mode: crate::types::game_state::CastPaymentMode::Auto, + }, + ) + .unwrap(); + assert!( + matches!(second.waiting_for, WaitingFor::ModalFaceChoice { .. }), + "after a BACK-half round trip the next cast must offer the choice again \ + (#7565 round 2), got {:?}", + second.waiting_for + ); +} + /// CR 106.6 + CR 116.2m + CR 709.5e: Smoky Lounge produces {R}{R} restricted /// to "cast Room spells and unlock doors". The door-unlock half lowers to /// `OnlyForSpecialAction(UnlockDoor)`; paying a Room's unlock cost routes diff --git a/crates/engine/src/game/flip.rs b/crates/engine/src/game/flip.rs index e5db14ecc4..f9e1c1b7cd 100644 --- a/crates/engine/src/game/flip.rs +++ b/crates/engine/src/game/flip.rs @@ -428,6 +428,7 @@ mod tests { obj.base_color = vec![ManaColor::White]; obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Kenzo the Hardhearted".to_string(), power: Some(3), toughness: Some(4), diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 7e677046dd..c066514b2f 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -263,6 +263,13 @@ pub struct BackFaceData { /// so the engine can offer face-choice for MDFCs (CR 712.12). #[serde(default, skip_serializing_if = "Option::is_none")] pub layout_kind: Option, + /// #7565: set when this stored face is a swap SNAPSHOT of the object's + /// other half (the live face is currently the alternative). Replaces the + /// old implicit contract "snapshot => layout_kind erased", which muted the + /// layout for every other consumer (cast-face prompt, MDFC land checks). + /// `false` for a still-unswapped printed back face. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_swap_snapshot: bool, } /// CR 719.3b: Tracks the solve state of a Case enchantment. @@ -461,6 +468,17 @@ pub struct GameObject { /// `transformed` flag. #[serde(default)] pub modal_back_face: bool, + /// CR 601.2b + CR 712.11b / CR 709.3 (#7565): a cast-time face choice for + /// the CURRENT cast has been made — the cast pipeline's re-entries must + /// not re-prompt. Transient to the cast conversation: cleared on any zone + /// change that is not onto the stack, and when the cast is cancelled. + /// Deliberately NOT the old `back_face.layout_kind = None` erasure, which + /// poisoned every other `layout_kind` consumer (MDFC land playability, + /// split-cost handling, the recast prompt) for the object's lifetime: + /// `layout_kind` answers "what shape is this card", this flag answers + /// "is this cast's choice already made". + #[serde(default)] + pub cast_face_committed: bool, // Combat pub damage_marked: u32, @@ -1306,6 +1324,9 @@ fn _gameobject_partition_is_total(o: &GameObject) { transformed: _, transformation_count: _, modal_back_face: _, + // #7565: transient cast-conversation bookkeeping, same bucket as + // `modal_back_face` — not a copiable value. + cast_face_committed: _, damage_marked: _, dealt_deathtouch_damage: _, attached_to: _, @@ -2194,6 +2215,7 @@ impl GameObject { transformed: false, transformation_count: 0, modal_back_face: false, + cast_face_committed: false, damage_marked: 0, dealt_deathtouch_damage: false, attached_to: None, diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index a3824fe11d..dbae527b25 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -129,12 +129,15 @@ pub fn effective_disturb_cost(state: &GameState, object_id: ObjectId) -> Option< let keyword = effective_keyword_for_object(state, object_id, KeywordKind::Disturb).or_else(|| { let obj = state.objects.get(&object_id)?; - // `snapshot_object_face` clears layout_kind; a still-unswapped DFC - // back face retains its layout kind and must not grant Disturb. + // #7565: the explicit swap-snapshot marker says "the live face is + // the alternative and this slot holds the stashed normal face" — + // a still-unswapped DFC back face must not grant Disturb. (The old + // discriminator was layout_kind.is_none(), an implicit contract + // with snapshot_object_face's erasure.) let stored_front_face = obj .back_face .as_ref() - .filter(|face| face.layout_kind.is_none())?; + .filter(|face| face.is_swap_snapshot)?; stored_front_face .keywords .iter() diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index de0021ad5f..0fd914441b 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -354,6 +354,24 @@ pub fn apply_back_face_to_object(obj: &mut GameObject, back_face: BackFaceData) obj.parse_warnings = back_face.parse_warnings; } +/// CR 400.7 + CR 712.8a (#7565): swap the object's live face with its stored +/// back face, preserving the stored slot's `layout_kind`. The layout is a +/// printed property of the CARD PAIR, not of whichever half happens to be +/// stashed — `snapshot_object_face` hardcodes `None`, so every bare +/// snapshot/apply/store dance silently erased the marker after one back-face +/// round trip, muting the split/MDFC cast-face prompt and every other +/// `layout_kind` consumer. Single authority for all symmetric face swaps. +pub fn swap_object_faces(obj: &mut GameObject) { + let Some(stored) = obj.back_face.take() else { + return; + }; + let layout_kind = stored.layout_kind; + let mut snapshot = snapshot_object_face(obj); + snapshot.layout_kind = layout_kind; + apply_back_face_to_object(obj, stored); + obj.back_face = Some(snapshot); +} + /// CR 306.5b + CR 310.4b + CR 614.1c: Seed the intrinsic "enters with N /// counters" replacement for planeswalkers (loyalty counters equal to printed /// loyalty) and battles (defense counters equal to printed defense). @@ -758,6 +776,7 @@ pub fn snapshot_object_face(obj: &GameObject) -> BackFaceData { // restores them rather than inheriting whatever the other face had. parse_warnings: obj.parse_warnings.clone(), layout_kind: None, + is_swap_snapshot: true, } } @@ -807,6 +826,7 @@ pub fn snapshot_object_base_face(obj: &GameObject) -> BackFaceData { // face's diagnostics and the layer system never touches it. parse_warnings: obj.parse_warnings.clone(), layout_kind: None, + is_swap_snapshot: true, } } @@ -1061,6 +1081,7 @@ fn back_face_for_card_face_with_printed_ref( // Empty seed; `apply_card_face_to_back_face` below fills it from the face. parse_warnings: Vec::new(), layout_kind: None, + is_swap_snapshot: false, }; apply_card_face_to_back_face(&mut back, face); if layout_kind != LayoutKind::Single { @@ -1901,6 +1922,7 @@ mod tests { object.color = vec![ManaColor::White]; object.base_color = vec![ManaColor::White]; object.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: normal_half.name.clone(), power: None, toughness: None, diff --git a/crates/engine/src/game/specialize.rs b/crates/engine/src/game/specialize.rs index 67263dcac8..5f7db29dc5 100644 --- a/crates/engine/src/game/specialize.rs +++ b/crates/engine/src/game/specialize.rs @@ -100,6 +100,7 @@ pub(crate) fn empty_back_face() -> BackFaceData { casting_options: vec![], parse_warnings: vec![], layout_kind: None, + is_swap_snapshot: false, } } diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 78b24d0018..947e40858e 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -726,11 +726,8 @@ pub(crate) fn effective_stack_ability<'a>( pub(crate) fn restore_alternative_spell_normal_face(state: &mut GameState, object_id: ObjectId) { if let Some(obj) = state.objects.get_mut(&object_id) { - if let Some(normal_face) = obj.back_face.take() { - let alternative_snapshot = super::printed_cards::snapshot_object_face(obj); - super::printed_cards::apply_back_face_to_object(obj, normal_face); - obj.back_face = Some(alternative_snapshot); - } + // #7565: the shared swap preserves the stored slot's layout_kind. + super::printed_cards::swap_object_faces(obj); } } @@ -5081,6 +5078,7 @@ mod tests { let mut card_types = crate::types::card_type::CardType::default(); card_types.core_types.push(core_type); BackFaceData { + is_swap_snapshot: false, name: name.to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/transform.rs b/crates/engine/src/game/transform.rs index 4ef44b7893..79943d3e76 100644 --- a/crates/engine/src/game/transform.rs +++ b/crates/engine/src/game/transform.rs @@ -302,6 +302,7 @@ mod tests { }]; obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Werewolf Back".to_string(), power: Some(4), toughness: Some(4), diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index e41f7e7ba0..6f7394491a 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -29158,6 +29158,7 @@ pub mod tests { obj.card_types.core_types.push(CoreType::Creature); obj.base_card_types = obj.card_types.clone(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Ajani, Nacatl Avenger".to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 3faaa28845..7eaf4b1df1 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -6252,6 +6252,7 @@ mod effect_driven_transformed_entry_tests { }; obj.base_card_types = obj.card_types.clone(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "MDFC Back".to_string(), power: None, toughness: None, diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index ad65f3ab13..2d4d10995f 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -15,7 +15,7 @@ use crate::types::statics::StaticMode; use crate::types::zones::Zone; use super::game_object::GameObject; -use super::printed_cards::{apply_back_face_to_object, snapshot_object_face}; +use super::printed_cards::{apply_back_face_to_object, swap_object_faces}; /// CR 109.1 + CR 601.2a + CR 405.1: A spell is an object on the stack from /// announcement, even while this engine retains its origin-zone field until @@ -288,25 +288,28 @@ pub(crate) fn apply_zone_exit_cleanup( // CR 712.8a + CR 400.7: Transformed permanents revert to front face on any // zone exit (transform DFCs are only valid in transformed state on the battlefield). - if obj_mut.transformed { - if let Some(back_face) = obj_mut.back_face.clone() { - let current_back = snapshot_object_face(obj_mut); - apply_back_face_to_object(obj_mut, back_face); - obj_mut.back_face = Some(current_back); - obj_mut.transformed = false; - } + if obj_mut.transformed && obj_mut.back_face.is_some() { + swap_object_faces(obj_mut); + obj_mut.transformed = false; + } + + // CR 601.2b + CR 400.7 (#7565): the cast conversation ends with any + // move that is not onto the stack (resolve, counter, discard, bounce, + // battlefield entry) — a later cast must offer the face choice afresh. + if to != Zone::Stack { + obj_mut.cast_face_committed = false; } // CR 712.8a + CR 400.7: MDFC objects showing their back face revert to // front face in any zone other than the stack or battlefield (back face is // valid on the stack while the spell is being cast, and on the battlefield). - if obj_mut.modal_back_face && to != Zone::Stack && to != Zone::Battlefield { - if let Some(back_face) = obj_mut.back_face.clone() { - let current_back = snapshot_object_face(obj_mut); - apply_back_face_to_object(obj_mut, back_face); - obj_mut.back_face = Some(current_back); - obj_mut.modal_back_face = false; - } + if obj_mut.modal_back_face + && to != Zone::Stack + && to != Zone::Battlefield + && obj_mut.back_face.is_some() + { + swap_object_faces(obj_mut); + obj_mut.modal_back_face = false; } // CR 708.9: A face-down permanent leaving the battlefield, or a @@ -3770,6 +3773,7 @@ mod tests { }; obj.base_card_types = obj.card_types.clone(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Summon: Esper Maduin".to_string(), power: None, toughness: None, @@ -3837,6 +3841,7 @@ mod tests { }; obj.base_card_types = obj.card_types.clone(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Summon: Esper Maduin".to_string(), power: None, toughness: None, @@ -4284,6 +4289,7 @@ mod tests { obj.base_toughness = Some(1); // Store back face data (original MDFC back face). obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Back Face".to_string(), power: Some(6), toughness: Some(6), @@ -4450,6 +4456,7 @@ mod tests { { let obj = state.objects.get_mut(&id).unwrap(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Back Face".to_string(), power: Some(6), toughness: Some(6), diff --git a/crates/engine/tests/integration/azors_gateway_transform_condition.rs b/crates/engine/tests/integration/azors_gateway_transform_condition.rs index 6ff8f8ed04..802fb75736 100644 --- a/crates/engine/tests/integration/azors_gateway_transform_condition.rs +++ b/crates/engine/tests/integration/azors_gateway_transform_condition.rs @@ -43,6 +43,7 @@ const AZORS_GATEWAY_ORACLE: &str = "{1}, {T}: Draw a card, then exile a card fro fn sanctum_of_the_sun_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Sanctum of the Sun".to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/copied_ability_transform_generation.rs b/crates/engine/tests/integration/copied_ability_transform_generation.rs index bf3af9c4c4..2c809c5b7c 100644 --- a/crates/engine/tests/integration/copied_ability_transform_generation.rs +++ b/crates/engine/tests/integration/copied_ability_transform_generation.rs @@ -58,6 +58,7 @@ const LITHOFORM_ENGINE_ORACLE: &str = "{2}, {T}: Copy target activated or trigge /// CR 712: the back face Thraben Gargoyle transforms into. fn stonewing_antagonizer_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Stonewing Antagonizer".to_string(), power: Some(4), toughness: Some(2), diff --git a/crates/engine/tests/integration/craft_tithing_blade_transform.rs b/crates/engine/tests/integration/craft_tithing_blade_transform.rs index 81f9dbe73f..f7666fbc3a 100644 --- a/crates/engine/tests/integration/craft_tithing_blade_transform.rs +++ b/crates/engine/tests/integration/craft_tithing_blade_transform.rs @@ -363,6 +363,7 @@ fn tithing_blade_crafts_into_transformed_sepulcher() { /// idiom in `game/transform.rs` tests and `integration_bending.rs`). fn consuming_sepulcher_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Consuming Sepulcher".to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index 1dcf2b56d7..48ff8c15f0 100644 --- a/crates/engine/tests/integration/deterministic_game_state_serde.rs +++ b/crates/engine/tests/integration/deterministic_game_state_serde.rs @@ -1172,6 +1172,7 @@ fn serde_hash_owner_census_is_exhaustive_and_every_canonical_owner_names_its_ada fn back_face(name: &str) -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: name.to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/esper_origins_flashback_transform.rs b/crates/engine/tests/integration/esper_origins_flashback_transform.rs index 262498a953..4c91c57923 100644 --- a/crates/engine/tests/integration/esper_origins_flashback_transform.rs +++ b/crates/engine/tests/integration/esper_origins_flashback_transform.rs @@ -53,6 +53,7 @@ fn stage_esper_origins_in_graveyard() -> (engine::game::scenario::GameRunner, Ob card_types.core_types.push(CoreType::Creature); card_types.subtypes = vec!["Saga".to_string(), "Elemental".to_string()]; obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Summon: Esper Maduin".to_string(), power: Some(4), toughness: Some(4), diff --git a/crates/engine/tests/integration/integration_adventure.rs b/crates/engine/tests/integration/integration_adventure.rs index d25b69a7ab..aec173622d 100644 --- a/crates/engine/tests/integration/integration_adventure.rs +++ b/crates/engine/tests/integration/integration_adventure.rs @@ -43,6 +43,7 @@ fn add_mana(runner: &mut GameRunner, player: PlayerId, color: ManaType, count: u /// Build the Adventure back_face data for Stomp. fn stomp_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Stomp".to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs b/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs index e532f31595..4ba38a1f4e 100644 --- a/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs +++ b/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs @@ -25,6 +25,7 @@ use engine::types::Keyword; fn reflection_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Reflection of Kiki-Jiki".to_string(), power: Some(2), toughness: Some(2), @@ -56,6 +57,7 @@ fn reflection_back_face() -> BackFaceData { fn etching_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Etching of Kumano".to_string(), power: Some(2), toughness: Some(2), diff --git a/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs b/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs index 8b16553292..11c99589af 100644 --- a/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs +++ b/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs @@ -26,6 +26,7 @@ fn add_mana(runner: &mut GameRunner, player: PlayerId, color: ManaType, count: u fn blow_off_steam_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Blow Off Steam".to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs b/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs index 6aba669b6a..2e6967c9e5 100644 --- a/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs +++ b/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs @@ -56,6 +56,7 @@ fn register_bend( fn attach_aang_back_face(runner: &mut engine::game::scenario::GameRunner, aang: ObjectId) { let obj = runner.state_mut().objects.get_mut(&aang).unwrap(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Avatar Aang, Master of Elements".to_string(), power: Some(6), toughness: Some(6), diff --git a/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs b/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs index 6cdcd03dfe..98897a0d59 100644 --- a/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs +++ b/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs @@ -21,6 +21,7 @@ fn attach_transform_back_face(runner: &mut GameRunner, object_id: ObjectId) { .get_mut(&object_id) .unwrap() .back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Back Face".to_string(), power: Some(3), toughness: Some(3), diff --git a/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs b/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs index 544d68b4a6..ebe98a79c0 100644 --- a/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs +++ b/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs @@ -24,6 +24,7 @@ Activate only if an opponent has eight or more cards in their graveyard."; fn true_scriptures_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "The True Scriptures".to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/kamigawa_flip_cards.rs b/crates/engine/tests/integration/kamigawa_flip_cards.rs index ef16674be3..c0ef18d70d 100644 --- a/crates/engine/tests/integration/kamigawa_flip_cards.rs +++ b/crates/engine/tests/integration/kamigawa_flip_cards.rs @@ -45,6 +45,7 @@ fn white_mana_cost() -> ManaCost { /// reusing the double-faced applicator would violate CR 710.1c. fn kenzo_alternative_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Kenzo the Hardhearted".to_string(), power: Some(3), toughness: Some(4), @@ -105,6 +106,7 @@ Whenever this creature deals damage to an opponent, flip it."; /// A 2/2 Legendary Creature — Goblin Shaman with a damage-prevention static. fn tok_tok_alternative_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Tok-Tok, Volcano Born".to_string(), power: Some(2), toughness: Some(2), diff --git a/crates/engine/tests/integration/room_door_lock_unlock.rs b/crates/engine/tests/integration/room_door_lock_unlock.rs index 35d0328289..1781a9513e 100644 --- a/crates/engine/tests/integration/room_door_lock_unlock.rs +++ b/crates/engine/tests/integration/room_door_lock_unlock.rs @@ -34,6 +34,7 @@ const P0: PlayerId = PlayerId(0); /// presence of `back_face` is consulted by `room::eligible_doors`. fn room_back_face() -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: "Right Door".to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/rules/battle.rs b/crates/engine/tests/integration/rules/battle.rs index d37403b4a5..7d258a1b94 100644 --- a/crates/engine/tests/integration/rules/battle.rs +++ b/crates/engine/tests/integration/rules/battle.rs @@ -79,6 +79,7 @@ fn siege_victory_cast_during_resolution_enters_transformed() { { let obj = runner.state_mut().objects.get_mut(&battle).unwrap(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Test Back Face".to_string(), power: Some(4), toughness: Some(4), diff --git a/crates/engine/tests/integration/specialize_runtime.rs b/crates/engine/tests/integration/specialize_runtime.rs index 52e88f5efb..a27d95ea21 100644 --- a/crates/engine/tests/integration/specialize_runtime.rs +++ b/crates/engine/tests/integration/specialize_runtime.rs @@ -13,6 +13,7 @@ use engine::types::phase::Phase; fn specialize_back(name: &str, color: ManaColor, shard: ManaCostShard) -> BackFaceData { BackFaceData { + is_swap_snapshot: false, name: name.into(), power: Some(3), toughness: Some(3), diff --git a/crates/engine/tests/integration/std_s07_batch5b.rs b/crates/engine/tests/integration/std_s07_batch5b.rs index c49871ebf4..902767c77f 100644 --- a/crates/engine/tests/integration/std_s07_batch5b.rs +++ b/crates/engine/tests/integration/std_s07_batch5b.rs @@ -94,6 +94,7 @@ fn add_aang_dfc(state: &mut GameState) -> ObjectId { }; obj.base_card_types = obj.card_types.clone(); obj.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Avatar Aang, Master of Elements".to_string(), power: Some(6), toughness: Some(6), diff --git a/crates/engine/tests/integration/stolen_goodies_zero_targets.rs b/crates/engine/tests/integration/stolen_goodies_zero_targets.rs index c00eff8605..7b9100533b 100644 --- a/crates/engine/tests/integration/stolen_goodies_zero_targets.rs +++ b/crates/engine/tests/integration/stolen_goodies_zero_targets.rs @@ -52,6 +52,7 @@ fn stolen_goodies_can_be_cast_with_no_targets() { .get_mut(&picnic) .unwrap() .back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Stolen Goodies".to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs b/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs index 898a6cb4ab..45daf01d86 100644 --- a/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs +++ b/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs @@ -88,6 +88,7 @@ fn tamiyo_third_draw_returns_transformed_not_stranded_in_exile() { .get_mut(&tamiyo) .unwrap() .back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Tamiyo, Seasoned Scholar".to_string(), power: None, toughness: None, diff --git a/crates/engine/tests/integration/wedding_announcement_transform.rs b/crates/engine/tests/integration/wedding_announcement_transform.rs index 70fc1b1efe..0b4cf01ffd 100644 --- a/crates/engine/tests/integration/wedding_announcement_transform.rs +++ b/crates/engine/tests/integration/wedding_announcement_transform.rs @@ -62,6 +62,7 @@ fn wedding_announcement_human_branch_transforms_and_applies_festivity_anthem() { .counters .insert(CounterType::Generic("invitation".to_string()), 2); object.back_face = Some(BackFaceData { + is_swap_snapshot: false, name: "Wedding Festivity".to_string(), power: None, toughness: None,