From 0f18f4b4488330b8c3d579a31f8f2ea4e63c5dca Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:09:51 +0200 Subject: [PATCH 1/2] fix(engine): snapshot the real face on a debug turn-face-down (#7541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The face-down half of the arm #7540 repaired. `face_down: Some(true)` set the flag and nothing else, so the permanent kept its name, printed P/T and abilities while claiming to be face down — and `back_face` stayed empty, which meant the repaired face-up path could never bring it back. CR 708.2a: a permanent turned face down "becomes a 2/2 face-down creature with no text, no name, no subtypes, and no mana cost". Those are characteristics to install over a snapshot, not a flag to raise. Routes through `zone_pipeline::apply_face_down_entry_profile`, the authority the manifest, cloak and face-down-cast paths all run through, stamped `FaceDownCause::TurnedFaceDown` so the marker art added by #7535 names the right keyword action. CR 708.2b — "A face-down permanent can't be turned face down … nothing happens and that effect doesn't change any of its characteristics" — falls out of the `was_face_down` guard rather than being re-asserted. A row pins it; that row is a pin, not a discriminator, and says so. Counter-probe: with the arm disabled, the round-trip row fails on `left: "Open Bear" right: ""`. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/engine_debug.rs | 29 ++++++- .../issue_7539_debug_turn_face_up.rs | 85 +++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index 19f42bf9a5..8d5b730e43 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -338,12 +338,33 @@ 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). + // + // `zone_pipeline::apply_face_down_entry_profile` is the + // authority the manifest, cloak and face-down-cast paths all + // run through, so the tool cannot drift from them. + // + // 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 => { + crate::game::zone_pipeline::apply_face_down_entry_profile( + state, + object_id, + &crate::types::ability::FaceDownProfile::vanilla_2_2() + .caused_by(crate::types::ability::FaceDownCause::TurnedFaceDown), + ); + } // 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..da4d711fc8 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] From ba539b9f9aba93cdde63b80d1ab27a941caa88c1 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:57:34 +0200 Subject: [PATCH 2/2] fix(PR-7544): route the debug turn-face-down through the direct-turn authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox arm ran `zone_pipeline::apply_face_down_entry_profile`, which serves battlefield ENTRY: it snapshots the live face, so a permanent carrying continuous modifications came back from the round trip with them baked into its base (and then re-applied on top); it overwrote a flipped permanent's stashed normal half; and it accepted double-faced and melded permanents. The eligible authority already existed in the Ixidron / Cyber Conversion resolver. Its per-object body is now extracted as `effects::turn_face_down::turn_permanent_face_down` — base-face snapshot, flip-stash preservation, CR 712.16 / CR 730.2j refusal, cause stamping, `TurnedFaceDown` event, layer re-derive — shared by the resolver loop and the sandbox arm, which converts a refusal into an error (mirroring the face-up arm) instead of staying silent. Three discriminating rows: a +1/+1-countered 4/4 round-trips to base 4/4 with the counter applying exactly once; a flipped permanent's stashed normal half survives; a melded permanent is refused unchanged. All three fail on the pre-fix entry-profile path; the four existing rows and the resolver's seven unit rows stay green either way. Co-Authored-By: Claude Fable 5 --- .../engine/src/game/effects/turn_face_down.rs | 128 ++++++++------- crates/engine/src/game/engine_debug.rs | 28 +++- .../issue_7539_debug_turn_face_up.rs | 151 ++++++++++++++++++ 3 files changed, 248 insertions(+), 59 deletions(-) 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 8d5b730e43..2e1da9ddbf 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -345,21 +345,39 @@ pub fn apply_debug_action( // stays empty, so the arm above can never bring it back // (#7541). // - // `zone_pipeline::apply_face_down_entry_profile` is the - // authority the manifest, cloak and face-down-cast paths all - // run through, so the tool cannot drift from them. + // `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 => { - crate::game::zone_pipeline::apply_face_down_entry_profile( + // 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 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 da4d711fc8..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 @@ -195,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"); +}