diff --git a/crates/engine/src/game/effects/turn_face_down.rs b/crates/engine/src/game/effects/turn_face_down.rs index b0f9535f26..f35e31c71e 100644 --- a/crates/engine/src/game/effects/turn_face_down.rs +++ b/crates/engine/src/game/effects/turn_face_down.rs @@ -40,61 +40,8 @@ pub fn resolve( _ => return Ok(()), }; - let mut changed = false; for id in crate::game::effects::resolved_battlefield_object_ids(state, ability, &target) { - let Some(obj) = state.objects.get_mut(&id) else { - continue; - }; - // CR 708.2b: A face-down permanent can't be turned face down — nothing - // happens and its characteristics are unchanged. - if obj.face_down { - continue; - } - // CR 712.16 + CR 730.2j: Double-faced and melded permanents already on - // the battlefield can't be turned face down — nothing happens. - if crate::game::transform::is_double_faced_permanent(obj) { - continue; - } - // CR 708.2a + CR 708.8 + CR 613: Preserve the real face from the - // object's printed/base characteristics. Snapshotting from base fields - // (not the live fields) avoids baking in any continuous-effect - // modifications (e.g. a +1/+1 anthem that has inflated power/toughness) - // that are currently active. `apply_back_face_to_object` on turn-up - // writes these values into both live and base fields, so the layer - // system then reapplies all continuous effects from the correct printed - // baseline — not from an already-inflated one. - // - // CR 710.4 + CR 710.2: a FLIPPED flip permanent (CR 712.16 does not - // cover flip cards, so Ixidron / Cyber Conversion may legally turn one - // face down) already owns this slot: `flip::flip_permanent` stashed the - // NORMAL half there, and that half is what must reappear when the - // permanent leaves the battlefield. Overwriting it with a base snapshot - // — which, for a flipped permanent, is the ALTERNATIVE half — would put - // a flipped Kenzo the Hardhearted in the graveyard instead of Bushi - // Tenderfoot. Keep the flip stash; `zones::apply_zone_exit_cleanup` - // runs the CR 708.9 face-down restore BEFORE the CR 710.4 flip revert - // precisely so this single slot serves both. - let snapshot = match &obj.back_face { - Some(flip_stash) if obj.flipped => flip_stash.clone(), - _ => crate::game::printed_cards::snapshot_object_base_face(obj), - }; - // CR 708.2a + CR 205.1a: Apply the effect-specified (or default vanilla - // 2/2) face-down body. - crate::game::morph::apply_face_down_creature_characteristics(obj, &profile); - // The public record of what turned this permanent face down. The zone - // authority (`zone_pipeline::apply_face_down_entry_profile`) stamps the - // same field for an ENTERING face-down permanent; this resolver turns a - // permanent already on the battlefield, so it stamps its own. - obj.face_down_cause = Some(profile.cause); - obj.back_face = Some(snapshot); - changed = true; - events.push(GameEvent::TurnedFaceDown { object_id: id }); - } - - // CR 613: the new face-down copiable characteristics (Layer 1) require a - // full layer re-derive (mirrors the turn-face-up path). - if changed { - crate::game::layers::mark_layers_full(state); + turn_permanent_face_down(state, id, &profile, events); } events.push(GameEvent::EffectResolved { @@ -105,6 +52,79 @@ pub fn resolve( Ok(()) } +/// CR 708.2a + CR 708.2b + CR 712.16 + CR 730.2j + CR 710.4: turn ONE face-up +/// battlefield permanent face down — the single direct-turn authority, shared +/// by the resolving-effect path above and the sandbox `SetFaceState` tool, so +/// the two cannot drift on eligibility, snapshot source, cause stamping, the +/// emitted event, or the layer re-derive. +/// +/// Distinct from `zone_pipeline::apply_face_down_entry_profile`, which serves a +/// permanent ENTERING the battlefield: an entrant carries no live continuous +/// modifications (its live face IS its printed face), owns no flip stash, and +/// cannot be an on-battlefield DFC/meld — none of the guards below apply there. +/// +/// Returns whether the permanent actually turned; `false` covers CR 708.2b +/// (already face down — nothing happens) and CR 712.16 / CR 730.2j +/// (double-faced or melded — nothing happens). Callers that must NOT be silent +/// (the sandbox tool) convert `false` into their own error. +pub(crate) fn turn_permanent_face_down( + state: &mut GameState, + object_id: crate::types::identifiers::ObjectId, + profile: &FaceDownProfile, + events: &mut Vec, +) -> bool { + let Some(obj) = state.objects.get_mut(&object_id) else { + return false; + }; + // CR 708.2b: A face-down permanent can't be turned face down — nothing + // happens and its characteristics are unchanged. + if obj.face_down { + return false; + } + // CR 712.16 + CR 730.2j: Double-faced and melded permanents already on + // the battlefield can't be turned face down — nothing happens. + if crate::game::transform::is_double_faced_permanent(obj) { + return false; + } + // CR 708.2a + CR 708.8 + CR 613: Preserve the real face from the + // object's printed/base characteristics. Snapshotting from base fields + // (not the live fields) avoids baking in any continuous-effect + // modifications (e.g. a +1/+1 anthem that has inflated power/toughness) + // that are currently active. `apply_back_face_to_object` on turn-up + // writes these values into both live and base fields, so the layer + // system then reapplies all continuous effects from the correct printed + // baseline — not from an already-inflated one. + // + // CR 710.4 + CR 710.2: a FLIPPED flip permanent (CR 712.16 does not + // cover flip cards, so Ixidron / Cyber Conversion may legally turn one + // face down) already owns this slot: `flip::flip_permanent` stashed the + // NORMAL half there, and that half is what must reappear when the + // permanent leaves the battlefield. Overwriting it with a base snapshot + // — which, for a flipped permanent, is the ALTERNATIVE half — would put + // a flipped Kenzo the Hardhearted in the graveyard instead of Bushi + // Tenderfoot. Keep the flip stash; `zones::apply_zone_exit_cleanup` + // runs the CR 708.9 face-down restore BEFORE the CR 710.4 flip revert + // precisely so this single slot serves both. + let snapshot = match &obj.back_face { + Some(flip_stash) if obj.flipped => flip_stash.clone(), + _ => crate::game::printed_cards::snapshot_object_base_face(obj), + }; + // CR 708.2a + CR 205.1a: Apply the effect-specified (or default vanilla + // 2/2) face-down body. + crate::game::morph::apply_face_down_creature_characteristics(obj, profile); + // The public record of what turned this permanent face down. The zone + // authority (`zone_pipeline::apply_face_down_entry_profile`) stamps the + // same field for an ENTERING face-down permanent; this authority turns a + // permanent already on the battlefield, so it stamps its own. + obj.face_down_cause = Some(profile.cause); + obj.back_face = Some(snapshot); + events.push(GameEvent::TurnedFaceDown { object_id }); + // CR 613: the new face-down copiable characteristics (Layer 1) require a + // full layer re-derive (mirrors the turn-face-up path). + crate::game::layers::mark_layers_full(state); + true +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index 88d08c9244..85d2c2dcd9 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -338,12 +338,51 @@ pub fn apply_debug_action( (false, true) if on_battlefield && has_stored_face => { crate::game::morph::turn_face_up(state, controller, object_id, events)?; } + // CR 708.2a: turning a permanent face down must SNAPSHOT + // the real face and install the 2/2 in its place. The flag + // alone leaves the permanent with its name, printed P/T and + // abilities while claiming to be face down — and `back_face` + // stays empty, so the arm above can never bring it back + // (#7541). + // + // `effects::turn_face_down::turn_permanent_face_down` is + // the direct-turn authority (shared with the Ixidron / + // Cyber Conversion resolver), NOT the battlefield-entry + // profile: a permanent already on the battlefield needs the + // BASE-face snapshot (a live snapshot bakes active + // continuous modifications into the restored card), keeps a + // flipped permanent's stashed normal half, refuses + // double-faced and melded permanents (CR 712.16 / + // CR 730.2j), and emits the `TurnedFaceDown` event the + // triggers observe. + // + // CR 708.2b — "A face-down permanent can't be turned face + // down. If a spell or ability attempts to turn a face-down + // permanent face down, nothing happens" — falls out of the + // `was_face_down` guard rather than being re-asserted. + (true, false) if on_battlefield => { + // The guard already excludes the face-down case, so a + // refusal here is the CR 712.16 / CR 730.2j class. + // Report it, mirroring the face-up arm's error stance, + // rather than silently doing nothing. + if !crate::game::effects::turn_face_down::turn_permanent_face_down( + state, + object_id, + &crate::types::ability::FaceDownProfile::vanilla_2_2() + .caused_by(crate::types::ability::FaceDownCause::TurnedFaceDown), + events, + ) { + return Err(EngineError::InvalidAction( + "Debug: a double-faced or melded permanent can't be turned \ + face down (CR 712.16 / CR 730.2j)" + .to_string(), + )); + } + } // 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. + // turn), it is already in the requested state, or it is face + // down with no stored face for `turn_face_up` to restore. _ => { validate_object_mut(state, object_id)?.face_down = fd; } 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 index 9a0565f38d..8447802be2 100644 --- a/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs +++ b/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs @@ -87,6 +87,91 @@ fn the_sandbox_turn_face_up_restores_the_stored_face() { ); } +/// #7541, the other direction: turning a permanent face down must SNAPSHOT its +/// face, or the permanent keeps its name and printed P/T while claiming to be +/// face down — and `back_face` stays empty, so the repaired face-up path can +/// never bring it back. The round trip is the assertion. +#[test] +fn the_sandbox_turn_face_down_snapshots_the_real_face_and_the_round_trip_closes() { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature(P0, "Open Bear", 4, 4) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 2, + }) + .id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + + let write = |runner: &mut engine::game::scenario::GameRunner, down: bool| { + runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(down), + transformed: None, + flipped: None, + })) + .expect("the debug face-state write runs") + }; + + write(&mut runner, true); + let obj = &runner.state().objects[&id]; + assert!(obj.face_down); + assert_eq!(obj.name, "", "CR 708.2a: no name while face down"); + assert_eq!( + (obj.base_power, obj.base_toughness), + (Some(2), Some(2)), + "CR 708.2a: a 2/2, not the printed 4/4" + ); + assert!( + obj.back_face.is_some(), + "the real face is stashed, which is what makes the way back possible" + ); + + write(&mut runner, false); + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down); + assert_eq!(obj.name, "Open Bear"); + assert_eq!((obj.base_power, obj.base_toughness), (Some(4), Some(4))); +} + +/// CR 708.2b: "A face-down permanent can't be turned face down. If a spell or +/// ability attempts to turn a face-down permanent face down, nothing happens +/// and that effect doesn't change any of its characteristics or their copiable +/// values." +/// +/// The stored face must survive a second face-down write, or the 2/2 would be +/// snapshotted over the real card and the permanent could never be restored. +/// +/// What this row does NOT do: discriminate. It stays green with the face-down +/// arm removed, because the flag-only fallback is also harmless here. It pins +/// the guard so a future rewrite that drops `was_face_down` from the arm's +/// pattern turns it red. +#[test] +fn a_second_turn_face_down_leaves_the_stored_face_alone() { + let (mut runner, id) = board(); + let stored = runner.state().objects[&id] + .back_face + .clone() + .expect("setup: the real face is stashed"); + + runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(true), + transformed: None, + flipped: None, + })) + .expect("the debug face-state write runs"); + + assert_eq!( + runner.state().objects[&id].back_face, + Some(stored), + "CR 708.2b: nothing happens, so the stored face is untouched" + ); +} + /// 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] @@ -110,3 +195,154 @@ fn a_permanent_without_a_stored_face_keeps_the_plain_flag_write() { assert!(!obj.face_down); assert_eq!(obj.name, "Ordinary Bear"); } + +// ── Review round 2: the direct-turn authority, not the entry profile ───────── + +/// CR 708.2a + CR 613: the snapshot must come from the BASE face. The +/// battlefield-entry profile snapshots the LIVE face, so a permanent carrying a +/// continuous modification (here: a +1/+1 counter, live 5/5 on a printed 4/4) +/// came back from the round trip with the modification baked into its base — +/// and the still-present counter then inflated it AGAIN. +#[test] +fn a_modified_permanent_round_trips_to_its_base_face() { + use engine::types::counter::CounterType; + + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature(P0, "Open Bear", 4, 4) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 2, + }) + .id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + runner + .state_mut() + .objects + .get_mut(&id) + .unwrap() + .counters + .insert(CounterType::Plus1Plus1, 1); + + let down = runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(true), + transformed: None, + flipped: None, + })) + .expect("the debug face-down write runs"); + assert!( + down.events.iter().any( + |event| matches!(event, GameEvent::TurnedFaceDown { object_id } if *object_id == id) + ), + "CR 603.2: the direct turn emits the event the turned-face-down triggers \ + observe (its own game action, distinct from transforming — CR 701.27b)" + ); + assert_eq!( + runner.state().objects[&id] + .back_face + .as_ref() + .map(|face| face.power), + Some(Some(4)), + "the stash holds the PRINTED 4/4, not the counter-inflated live 5/5" + ); + + runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(false), + transformed: None, + flipped: None, + })) + .expect("the debug face-up write runs"); + let obj = &runner.state().objects[&id]; + assert_eq!( + (obj.base_power, obj.base_toughness), + (Some(4), Some(4)), + "CR 708.8: the restored base is the printed face" + ); + assert_eq!( + obj.power, + Some(5), + "the surviving counter applies ON TOP of the printed base — exactly once" + ); +} + +/// CR 710.4 + CR 710.2: a flipped permanent's `back_face` slot already holds +/// its stashed NORMAL half. The direct turn must keep that stash (it is what +/// leaves the battlefield later), not overwrite it with a snapshot of the +/// flipped half. +#[test] +fn a_flipped_permanents_normal_half_survives_the_turn_face_down() { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature(P0, "Alternative Half", 4, 4) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 2, + }) + .id(); + let normal = scenario.add_creature(P0, "Normal Half", 1, 1).id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + + let stash = + engine::game::printed_cards::snapshot_object_base_face(&runner.state().objects[&normal]); + { + let obj = runner.state_mut().objects.get_mut(&id).unwrap(); + obj.flipped = true; + obj.back_face = Some(stash.clone()); + } + + runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(true), + transformed: None, + flipped: None, + })) + .expect("the debug face-down write runs"); + assert_eq!( + runner.state().objects[&id] + .back_face + .as_ref() + .map(|face| face.name.as_str()), + Some("Normal Half"), + "the flip stash is the face that must reappear off the battlefield" + ); +} + +/// CR 712.16 + CR 730.2j: double-faced and melded permanents can't be turned +/// face down. The sandbox reports the refusal instead of silently corrupting +/// the permanent, mirroring the face-up arm's error stance. +#[test] +fn a_melded_permanent_refuses_the_debug_turn_face_down() { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature(P0, "Melded Horror", 9, 10) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 2, + }) + .id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + runner.state_mut().objects.get_mut(&id).unwrap().merge_kind = + Some(engine::game::game_object::MergeKind::Meld); + + let refused = runner.act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(true), + transformed: None, + flipped: None, + })); + assert!( + refused.is_err(), + "CR 730.2j: the tool must refuse, not corrupt" + ); + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down, "nothing happened"); + assert_eq!(obj.name, "Melded Horror", "characteristics unchanged"); +}