Skip to content
Merged
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
41 changes: 40 additions & 1 deletion crates/engine/src/game/engine_debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/game/morph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs
Original file line number Diff line number Diff line change
@@ -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");
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading