Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 20 additions & 14 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions crates/engine/src/game/casting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/day_night.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/become_copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/change_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/flip_coin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/flip_permanent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/prepare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/set_room_door_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/transform_effect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
50 changes: 23 additions & 27 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/engine_debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/engine_mdfc_land_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ fn make_back_face(
layout_kind: Option<LayoutKind>,
) -> BackFaceData {
BackFaceData {
is_swap_snapshot: false,
name: name.to_string(),
power: None,
toughness: None,
Expand Down
Loading
Loading