From 15daea3b86a078c7a19b446504b4f98941c5d4ff Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Wed, 17 Jun 2026 20:20:48 -0700 Subject: [PATCH] feat(engine): model "As ~ is turned face up" as a TurnFaceUp replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review (rules-correctness): the megamorph/disguise templating "As ~ is turned face up, [effect]" (Hooded Hydra, Bubble Smuggler, Gift of Doom, Crowd-Control Warden) applies its effect AS the permanent is turned face up (CR 711 + CR 614.12) — a replacement effect, not a stack triggered ability. The prior trigger modeling gave players an illegal response window. - Parser: parse_turned_face_up_replacement (oracle_replacement.rs) emits a ReplacementDefinition(ReplacementEvent::TurnFaceUp){valid_card: SelfRef, execute: }, modeled on the as-enters / untap-step replacements. - Runtime: add ProposedEvent::TurnFaceUp and wire a real applier (replacing the placeholder) — it runs the alternative effect chain bound to the permanent being turned up (source = that object so the it/SelfRef anaphor binds) and returns the event unchanged (the turn-up still happens). morph::turn_face_up raises it through replace_event after restoring the real face. Tests: parser (As-form -> TurnFaceUp replacement + PutCounter execute) and an end-to-end runtime test (a face-down megamorph turned face up gains five +1/+1 counters). Full engine lib suite green (12451 passed), 0 regressions. Closes #3691 --- crates/engine/src/game/engine_replacement.rs | 5 + crates/engine/src/game/morph.rs | 136 ++++++++++++++++++ crates/engine/src/game/replacement.rs | 75 +++++++--- crates/engine/src/parser/oracle_classifier.rs | 7 + .../engine/src/parser/oracle_replacement.rs | 105 ++++++++++++++ crates/engine/src/types/proposed_event.rs | 10 ++ 6 files changed, 318 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 9fbcbcae21..a0a00a4d66 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -270,6 +270,11 @@ pub(super) fn handle_replacement_choice( events.push(GameEvent::PermanentUntapped { object_id }); } } + // CR 614.1e + CR 708.11: TurnFaceUp is performed inline in + // `morph::turn_face_up` (the replacement only adds its actions and + // does not prevent the turn-up), so there is nothing to apply on + // the post-replacement Execute path here. + ProposedEvent::TurnFaceUp { .. } => {} // CR 121.1 + CR 614.6 + CR 614.11: Draw accepted after // replacement choice — delegate to the shared post-replacement // helper so library-zone move + per-turn accounting match the diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index 74fc9f980e..1253023e3d 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -249,6 +249,17 @@ pub fn turn_face_up( crate::game::layers::mark_layers_full(state); + // CR 614.1e + CR 708.11: now that the permanent is face up + // (carrying its real abilities), apply any "As ~ is turned face up, [effect]" + // replacement effects as part of turning it up. The turn-up is not prevented + // — the applier returns the event unchanged and performs its actions (e.g. + // Hooded Hydra's +1/+1 counters) bound to this permanent. + let proposed = crate::types::proposed_event::ProposedEvent::TurnFaceUp { + object_id, + applied: std::collections::HashSet::new(), + }; + let _ = crate::game::replacement::replace_event(state, proposed, events); + events.push(GameEvent::TurnedFaceUp { object_id }); Ok(()) @@ -600,6 +611,131 @@ mod tests { assert_eq!(obj.color, vec![ManaColor::Green]); } + #[test] + fn turn_face_up_applies_as_turned_face_up_replacement() { + use crate::types::counter::CounterType; + + let mut state = GameState::new_two_player(42); + let player = PlayerId(0); + let id = setup_morph_creature(&mut state, player); + + // CR 614.1e + CR 708.11: give the real face a Hooded-Hydra-style + // "As ~ is turned face up, put five +1/+1 counters on it." replacement. + { + let def = crate::parser::oracle_replacement::parse_replacement_line( + "As this creature is turned face up, put five +1/+1 counters on it.", + "Secret Creature", + ) + .expect("turn-face-up replacement should parse"); + assert_eq!(def.event, crate::types::ReplacementEvent::TurnFaceUp); + let obj = state.objects.get_mut(&id).unwrap(); + obj.replacement_definitions.push(def.clone()); + Arc::make_mut(&mut obj.base_replacement_definitions).push(def); + } + + let mut events = Vec::new(); + play_face_down(&mut state, player, id, &mut events).unwrap(); + turn_face_up(&mut state, player, id, &mut events).unwrap(); + + // The replacement applied AS the permanent was turned face up — it gains + // five +1/+1 counters (and there is no stack trigger / response window). + assert_eq!( + state.objects[&id] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0), + 5, + "the As-turned-face-up replacement must put five +1/+1 counters on it" + ); + } + + #[test] + fn turn_face_up_self_resolving_chain_applies_each_step_once() { + use crate::types::ability::{AbilityDefinition, AbilityKind, Effect, TargetFilter}; + use crate::types::counter::CounterType; + + let mut state = GameState::new_two_player(42); + let player = PlayerId(0); + let id = setup_morph_creature(&mut state, player); + + // A two-step self-resolving "As ~ is turned face up" replacement: put two + // +1/+1 counters on it, then put one more on it (both steps `SelfRef`). + // `resolve_ability_chain` follows the typed `sub_ability` chain itself, so + // each step must run EXACTLY once — total 3 counters, not 4 from a + // double-resolved second step. + { + let inner = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::SelfRef, + }, + ); + let mut outer = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { value: 2 }, + target: TargetFilter::SelfRef, + }, + ); + outer.sub_ability = Some(Box::new(inner)); + let def = crate::types::ability::ReplacementDefinition::new( + crate::types::ReplacementEvent::TurnFaceUp, + ) + .valid_card(TargetFilter::SelfRef) + .execute(outer); + let obj = state.objects.get_mut(&id).unwrap(); + obj.replacement_definitions.push(def.clone()); + Arc::make_mut(&mut obj.base_replacement_definitions).push(def); + } + + let mut events = Vec::new(); + play_face_down(&mut state, player, id, &mut events).unwrap(); + turn_face_up(&mut state, player, id, &mut events).unwrap(); + + assert_eq!( + state.objects[&id] + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0), + 3, + "each self-resolving step must apply exactly once (2 + 1), not double the sub-ability" + ); + } + + #[test] + fn turn_face_up_gaps_attach_to_external_target() { + // CR 708.11: Gift of Doom's "As ~ is turned face up, you may attach it to + // a creature" needs an external host *choice* made during the turn-up, + // which the replacement-apply path does not model. It is gapped honestly + // (no TurnFaceUp replacement is produced) rather than silently attaching + // the Aura to the wrong object — only the self-resolving counter class is + // modeled here. + let attach = crate::parser::oracle_replacement::parse_replacement_line( + "As this permanent is turned face up, you may attach it to a creature.", + "Secret Creature", + ); + assert!( + !attach + .as_ref() + .is_some_and(|d| d.event == crate::types::ReplacementEvent::TurnFaceUp), + "attach-to-an-external-creature turn-face-up must not be modeled as a \ + TurnFaceUp replacement (needs a turn-up-time choice)" + ); + + // The self-resolving counter class IS modeled. + let counters = crate::parser::oracle_replacement::parse_replacement_line( + "As this creature is turned face up, put five +1/+1 counters on it.", + "Secret Creature", + ) + .expect("self-counter turn-face-up replacement should parse"); + assert_eq!(counters.event, crate::types::ReplacementEvent::TurnFaceUp); + } + #[test] fn face_down_clears_printed_ref_and_turn_face_up_restores_it() { // CR 708.2a: a face-down 2/2 exposes no card identity, so its display diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 1b4bea64b5..cd0d2872a8 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -26,6 +26,7 @@ use crate::types::proposed_event::{ use crate::types::replacements::ReplacementEvent; use crate::types::zones::Zone; +use super::ability_utils::build_resolved_from_def; use super::game_object::GameObject; // CR 122.1c shield-counter effects are intrinsic to counters, not stored @@ -2627,6 +2628,53 @@ fn untap_applier( ApplyResult::Prevented } +// --- 13. TurnFaceUp --- + +fn turn_face_up_matcher(event: &ProposedEvent, _source: ObjectId, _state: &GameState) -> bool { + matches!(event, ProposedEvent::TurnFaceUp { .. }) +} + +// CR 614.1e + CR 708.11: "As ~ is turned face up, [effect]" +// applies its alternative action AS the permanent is turned face up. Unlike a +// prevention the turn-up still happens, so the applier performs the replacement's +// actions (bound to the permanent being turned up) and returns the event +// unchanged. The effect's `it`/SelfRef anaphor binds to that permanent. +fn turn_face_up_applier( + event: ProposedEvent, + rid: ReplacementId, + state: &mut GameState, + events: &mut Vec, +) -> ApplyResult { + let ProposedEvent::TurnFaceUp { object_id, applied } = event else { + return ApplyResult::Modified(event); + }; + + let Some(source) = state.objects.get(&rid.source) else { + return ApplyResult::Modified(ProposedEvent::TurnFaceUp { object_id, applied }); + }; + let controller = source.controller; + let execute = source + .replacement_definitions + .get(rid.index) + .and_then(|def| def.execute.clone()); + + if let Some(execute) = execute { + // Bind only the anaphoric self-reference: the execute is resolved with the + // turned-up permanent as its `source_id`, so "it"/`SelfRef` references the + // permanent ("put five +1/+1 counters on it"). The permanent is NOT stuffed + // into ordinary target slots — effects with their own host/target (e.g. + // Gift of Doom's `Effect::Attach` "attach it to a creature") must resolve + // that target/host themselves rather than consuming the permanent as the + // host. `resolve_ability_chain` walks the typed `sub_ability` chain itself, + // so the root execute is resolved exactly once — iterating the chain here + // too would run each sub-ability a second time. + let ability = build_resolved_from_def(execute.as_ref(), object_id, controller); + let _ = crate::game::effects::resolve_ability_chain(state, &ability, events, 1); + } + + ApplyResult::Modified(ProposedEvent::TurnFaceUp { object_id, applied }) +} + // --- 14. Counter (spell countering) --- fn counter_matcher(event: &ProposedEvent, _source: ObjectId, _state: &GameState) -> bool { @@ -2778,21 +2826,6 @@ fn pay_life_applier( ApplyResult::Modified(event) } -// --- Placeholder handlers (no ProposedEvent variant yet) --- - -fn placeholder_matcher(_event: &ProposedEvent, _source: ObjectId, _state: &GameState) -> bool { - false -} - -fn placeholder_applier( - event: ProposedEvent, - _rid: ReplacementId, - _state: &mut GameState, - _events: &mut Vec, -) -> ApplyResult { - ApplyResult::Modified(event) -} - // --- BeginTurn / BeginPhase (CR 614.1b, CR 614.10) --- /// CR 614.1b + CR 614.10: Match a pending turn-start event shape. Per-def @@ -3019,11 +3052,13 @@ pub fn build_replacement_registry() -> IndexMap bool { return true; } + // CR 614.1e + CR 708.11: "As ~ is turned face up, [effect]" + // is a replacement effect. The "When ~ is turned face up" form is a trigger + // and stays out of this path, so the lead is required to be "As". + if lower_starts_with(lower, "as ") && scan_contains(lower, "is turned face up") { + return true; + } + is_replacement_compound_pattern(lower) } diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index bc7f249a6e..da14be4829 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -97,6 +97,13 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option Option Option { + // Anchored self-referential lead. + tag::<_, _, OracleError<'_>>("as ~ is turned face up, ") + .parse(norm_lower) + .ok()?; + // The effect is everything after the lead; pull it from the original line so + // `parse_effect_chain` sees the printed casing. + let lower = text.to_lowercase(); + let (_head, effect_text) = split_once_on_lower(text, &lower, " is turned face up, ")?; + let effect_text = effect_text.trim().trim_end_matches('.').trim(); + if effect_text.is_empty() { + return None; + } + + // CR 708.11: the effect applies AS the permanent is turned face up — there is + // no point at which the controller can use the targeting system. Only effects + // that resolve against the permanent itself (`SelfRef`, e.g. Hooded Hydra's + // "put five +1/+1 counters on it") can be faithfully modeled at this seam. + // Effects that need an external target choice (Gift of Doom's "you may attach + // it to a creature") would require a turn-up-time choice the apply path does + // not provide; gap them rather than silently mis-resolve the host. + let execute = parse_effect_chain(effect_text, AbilityKind::Spell); + if !turn_face_up_effect_is_self_resolving(&execute) { + return None; + } + + Some( + ReplacementDefinition::new(ReplacementEvent::TurnFaceUp) + .valid_card(TargetFilter::SelfRef) + .execute(execute) + .description(text.to_string()), + ) +} + +/// CR 708.11: True when every effect in an "As ~ is turned face up" chain resolves +/// against the permanent itself (`SelfRef`) or needs no target at all, so it can +/// be applied during the turn-up with no targeting window. An effect whose target +/// is an external filter (a creature/permanent/player chosen at resolution) needs +/// a choice the replacement-apply path does not model and must be gapped. +fn turn_face_up_effect_is_self_resolving(ability: &AbilityDefinition) -> bool { + let mut current = Some(ability); + while let Some(def) = current { + match def.effect.target_filter() { + None | Some(TargetFilter::SelfRef) => {} + Some(_) => return false, + } + current = def.sub_ability.as_deref(); + } + true +} + fn parse_untap_step_replacement(original_text: &str, lower: &str) -> Option { if !nom_primitives::scan_contains(lower, "untap step") || !nom_primitives::scan_contains(lower, "instead") @@ -7247,6 +7314,44 @@ mod tests { assert!(def.execute.is_some()); } + #[test] + fn turned_face_up_replacement_megamorph() { + // CR 614.1e + CR 708.11: "As ~ is turned face up, + // [effect]" is a TurnFaceUp REPLACEMENT (applies as the permanent is + // turned up — no stack trigger), bound to the permanent itself. + let def = parse_replacement_line( + "As this creature is turned face up, put five +1/+1 counters on it.", + "Hooded Hydra", + ) + .expect("turn-face-up replacement should parse"); + assert_eq!(def.event, ReplacementEvent::TurnFaceUp); + assert_eq!(def.valid_card, Some(TargetFilter::SelfRef)); + let execute = def.execute.expect("alternative effect must be parsed"); + assert!( + matches!(&*execute.effect, Effect::PutCounter { .. }), + "expected PutCounter, got {:?}", + execute.effect + ); + } + + #[test] + fn turned_face_up_replacement_gaps_external_target_choice() { + // CR 708.11: an "As ~ is turned face up" effect applies during the + // turn-up with no targeting window. Gift of Doom's "you may attach it to a + // creature" needs an external host choice that cannot be made there, so it + // must NOT be modeled as a TurnFaceUp replacement (gapped honestly rather + // than mis-resolving the host) — only the self-resolving `SelfRef` class is. + let def = parse_replacement_line( + "As this permanent is turned face up, you may attach it to a creature.", + "Gift of Doom", + ); + assert!( + !def.as_ref() + .is_some_and(|d| d.event == ReplacementEvent::TurnFaceUp), + "attach-to-external-creature must be gapped, got {def:?}" + ); + } + /// CR 614.12a: Karoo artifact — Mox Diamond's "you may discard ..." cost. /// The non-cost "you may " lead-in is stripped before `parse_single_cost`. #[test] diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index 290823c61d..2c306d20ff 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -354,6 +354,12 @@ pub enum ProposedEvent { object_id: ObjectId, applied: HashSet, }, + /// CR 614.1e + CR 708.11: a permanent is being turned face up. "As ~ is turned + /// face up" replacement effects apply here (megamorph/disguise). + TurnFaceUp { + object_id: ObjectId, + applied: HashSet, + }, Destroy { object_id: ObjectId, source: Option, @@ -535,6 +541,7 @@ impl ProposedEvent { | ProposedEvent::Discard { applied, .. } | ProposedEvent::Tap { applied, .. } | ProposedEvent::Untap { applied, .. } + | ProposedEvent::TurnFaceUp { applied, .. } | ProposedEvent::Destroy { applied, .. } | ProposedEvent::Sacrifice { applied, .. } | ProposedEvent::BeginTurn { applied, .. } @@ -563,6 +570,7 @@ impl ProposedEvent { | ProposedEvent::Discard { applied, .. } | ProposedEvent::Tap { applied, .. } | ProposedEvent::Untap { applied, .. } + | ProposedEvent::TurnFaceUp { applied, .. } | ProposedEvent::Destroy { applied, .. } | ProposedEvent::Sacrifice { applied, .. } | ProposedEvent::BeginTurn { applied, .. } @@ -600,6 +608,7 @@ impl ProposedEvent { .unwrap_or(PlayerId(0)), ProposedEvent::Tap { object_id, .. } | ProposedEvent::Untap { object_id, .. } + | ProposedEvent::TurnFaceUp { object_id, .. } | ProposedEvent::Destroy { object_id, .. } | ProposedEvent::RemoveCounter { object_id, .. } | ProposedEvent::Explore { object_id, .. } => state @@ -663,6 +672,7 @@ impl ProposedEvent { ProposedEvent::ZoneChange { object_id, .. } | ProposedEvent::Tap { object_id, .. } | ProposedEvent::Untap { object_id, .. } + | ProposedEvent::TurnFaceUp { object_id, .. } | ProposedEvent::Destroy { object_id, .. } | ProposedEvent::RemoveCounter { object_id, .. } | ProposedEvent::Discard { object_id, .. }