Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
128 changes: 74 additions & 54 deletions crates/engine/src/game/effects/turn_face_down.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<GameEvent>,
) -> 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::*;
Expand Down
47 changes: 43 additions & 4 deletions crates/engine/src/game/engine_debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading