diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index cb7ed9bf29..ce6e83204e 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -308,7 +308,46 @@ pub fn apply_debug_action( } => { validate_object(state, object_id)?; if let Some(fd) = face_down { - validate_object_mut(state, object_id)?.face_down = fd; + let (zone, was_face_down, has_stored_face, controller) = { + let obj = state.objects.get(&object_id).unwrap(); + ( + obj.zone, + obj.face_down, + obj.back_face.is_some(), + obj.controller, + ) + }; + // CR 702.37e + CR 708.2a: turning a permanent face up must + // RESTORE the stored face, not just clear the flag — the same + // class as the `transformed` arm below, and for the same reason. + // A flag-only write leaves the CR 708.2a vanilla 2/2 installed + // (no name, no abilities, no printed P/T), so the tool appears to + // do nothing, no CR 613.7f timestamp is drawn, the + // "as ~ is turned face up" replacement never applies, and no + // `TurnedFaceUp` event reaches the triggers (#7539). + // + // `morph::turn_face_up` is that single authority, shared with the + // paid `GameAction::TurnFaceUp` special action and the free + // effect callers, so the tool cannot drift from either. It also + // owns the CR 701.40b legality question (a manifested card is + // turned up only if it is a creature card with a mana cost), and + // reports it as an error rather than silently doing nothing. + let on_battlefield = zone == Zone::Battlefield; + match (fd, was_face_down) { + // Turn face up: restore the stored face. + (false, true) if on_battlefield && has_stored_face => { + crate::game::morph::turn_face_up(state, controller, object_id, events)?; + } + // Everything else is a flag write with nothing to move: the + // object is not on the battlefield (no permanent exists to + // turn, it is already in the requested state, it is face + // down with no stored face for `turn_face_up` to restore, or + // it is the debug-only face-down write outside #7539's + // face-up scope. + _ => { + validate_object_mut(state, object_id)?.face_down = fd; + } + } } if let Some(f) = flipped { validate_object_mut(state, object_id)?.flipped = f; diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index dfa4f393ce..f81e77a819 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -438,7 +438,9 @@ pub(crate) fn turn_face_up_prepare( }) } -/// CR 702.37c: Turning a face-down permanent face up restores its original characteristics. +/// CR 702.37e: Turning a face-down permanent face up ends the morph effect and +/// the permanent "regains its normal characteristics". (CR 702.37c is the +/// CASTING half — it is what turns the card face down in the first place.) /// /// Validates that the player controls the permanent and that it has morph/disguise /// cost data stored. Sets `face_down = false`, restores characteristics from diff --git a/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs b/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs new file mode 100644 index 0000000000..9a0565f38d --- /dev/null +++ b/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs @@ -0,0 +1,112 @@ +//! Regression for GitHub issue #7539 — the sandbox `Turn Face Up` action must +//! RESTORE the stored face, not just clear the flag. +//! +//! CR 708.2a: a face-down permanent is a 2/2 creature with no name, no mana +//! cost, no creature types and no abilities. Its real characteristics live in +//! `back_face` until it is turned face up. CR 702.37e: the morph effect ends +//! and the permanent "regains its normal characteristics". Clearing `face_down` +//! alone +//! leaves the vanilla 2/2 installed, so the tool appears to do nothing. +//! +//! Same class as #3284 / #3290, where the debug `transformed` write was routed +//! through `transform::transform_permanent` by #3684. The `face_down` write in +//! the same match arm was never carried over. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::actions::{DebugAction, GameAction}; +use engine::types::events::GameEvent; +use engine::types::mana::{ManaCost, ManaCostShard}; +use engine::types::zones::Zone; + +/// A creature card in hand with a real mana cost, so CR 701.40b can derive the +/// turn-face-up cost from the stored face. +fn board() -> ( + engine::game::scenario::GameRunner, + engine::types::identifiers::ObjectId, +) { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature_to_hand(P0, "Hidden Bear", 3, 3) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 1, + }) + .id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + + 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 obj = &runner.state().objects[&id]; + assert!(obj.face_down, "setup: the permanent is face down"); + assert_eq!(obj.zone, Zone::Battlefield); + assert_eq!(obj.name, "", "CR 708.2a: a face-down permanent has no name"); + assert_eq!(obj.base_power, Some(2), "CR 708.2a: it is a 2/2"); + + (runner, id) +} + +/// The defect: the tool must produce the real card, and it must produce the +/// event the turn-face-up triggers observe. +#[test] +fn the_sandbox_turn_face_up_restores_the_stored_face() { + let (mut runner, id) = board(); + + let result = runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(false), + transformed: None, + flipped: None, + })) + .expect("the debug turn-face-up runs"); + + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down); + assert_eq!(obj.name, "Hidden Bear", "the stored face is restored"); + assert_eq!( + (obj.base_power, obj.base_toughness), + (Some(3), Some(3)), + "with its printed power and toughness, not the CR 708.2a 2/2" + ); + + // The discriminating assertion. A flag-only write also leaves `face_down` + // false, so the flag alone cannot tell the two implementations apart — the + // restored characteristics and this event can. `TurnedFaceUp` is what the + // "when this is turned face up" triggers and the + // "as ~ is turned face up" replacement key on; without it the tool changes a + // flag and the game never learns anything happened. + assert!( + result.events.iter().any( + |event| matches!(event, GameEvent::TurnedFaceUp { object_id, .. } if *object_id == id) + ), + "the turn-face-up event must reach the triggers, got {:?}", + result.events + ); +} + +/// Counter-direction: an object with no stored face keeps the plain flag write, +/// so the arm stays a debug tool for states the rules cannot reach. +#[test] +fn a_permanent_without_a_stored_face_keeps_the_plain_flag_write() { + let mut scenario = GameScenario::new(); + let id = scenario.add_creature(P0, "Ordinary Bear", 2, 2).id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + runner.state_mut().objects.get_mut(&id).unwrap().face_down = true; + + runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(false), + transformed: None, + flipped: None, + })) + .expect("the debug write runs"); + + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down); + assert_eq!(obj.name, "Ordinary Bear"); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index b522435a4c..a1334c0285 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -749,6 +749,7 @@ mod issue_735_lily_bowen_power_double; mod issue_7384_proliferate_counter_replacement_frame; mod issue_7386_ozolith_combat_counter_move; mod issue_7470_hidden_strings_optional_frame_leak; +mod issue_7539_debug_turn_face_up; mod issue_787_once_upon_a_time; mod issue_788_unexpectedly_absent; mod issue_822_erode_path_to_exile_search_controller;