Skip to content
Open
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
5 changes: 5 additions & 0 deletions crates/engine/src/game/engine_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions crates/engine/src/game/morph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down Expand Up @@ -600,6 +611,74 @@ 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_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
Expand Down
78 changes: 58 additions & 20 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2627,6 +2627,57 @@ 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<GameEvent>,
) -> 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 {
use crate::game::ability_utils::build_resolved_from_def;

// 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.
let mut current = Some(execute.as_ref());
while let Some(def) = current {
let ability = build_resolved_from_def(def, object_id, controller);
let _ = crate::game::effects::resolve_ability_chain(state, &ability, events, 1);
current = def.sub_ability.as_deref();
}
}

ApplyResult::Modified(ProposedEvent::TurnFaceUp { object_id, applied })
}

// --- 14. Counter (spell countering) ---

fn counter_matcher(event: &ProposedEvent, _source: ObjectId, _state: &GameState) -> bool {
Expand Down Expand Up @@ -2778,21 +2829,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<GameEvent>,
) -> 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
Expand Down Expand Up @@ -3019,11 +3055,13 @@ pub fn build_replacement_registry() -> IndexMap<ReplacementEvent, ReplacementHan
applier: produce_mana_applier,
},
);
let placeholder = || ReplacementHandlerEntry {
matcher: placeholder_matcher,
applier: placeholder_applier,
};
registry.insert(ReplacementEvent::TurnFaceUp, placeholder());
registry.insert(
ReplacementEvent::TurnFaceUp,
ReplacementHandlerEntry {
matcher: turn_face_up_matcher,
applier: turn_face_up_applier,
},
);

// CR 614.1b + CR 614.10: BeginTurn skip replacements (Stranglehold, etc.)
registry.insert(
Expand Down
7 changes: 7 additions & 0 deletions crates/engine/src/parser/oracle_classifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,13 @@ pub(crate) fn is_replacement_pattern(lower: &str) -> 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)
}

Expand Down
105 changes: 105 additions & 0 deletions crates/engine/src/parser/oracle_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option<Replaceme
return Some(def);
}

// --- "As ~ is turned face up, [effect]" → TurnFaceUp replacement (megamorph/
// disguise). CR 614.1e + CR 708.11: the effect applies as the permanent is
// turned face up, so it is a replacement, not a stack triggered ability. ---
if let Some(def) = parse_turned_face_up_replacement(&norm_lower, &text) {
return Some(def);
}

// --- The Mimeoplasm: "As ~ enters, you may exile N cards from graveyards. If you do, ..." ---
// Check before other "as enters" patterns to ensure it matches correctly
if let Some(def) = parse_as_enters_exile_from_graveyards(&norm_lower, &normalized, &text) {
Expand Down Expand Up @@ -5022,6 +5029,66 @@ fn parse_explore_replacement(lower: &str, original_text: &str) -> Option<Replace
/// other times are unaffected). The alternative effect appears BEFORE "instead"
/// ("remove all wind counters from it instead", "put two +1/+1 counters on it
/// instead").
/// CR 614.1e + CR 708.11: "As ~ is turned face up, [effect]"
/// is a replacement effect — the alternative action applies AS the permanent is
/// turned face up (no stack-response window), and the subject is always the
/// permanent itself. Scoped to effects that resolve against the permanent itself
/// (`SelfRef`): the self-counter class — Hooded Hydra "put five +1/+1 counters on
/// it", Bubble Smuggler "put four +1/+1 counters on it". Forms that need an
/// external target choice during the turn-up (Gift of Doom "you may attach it to
/// a creature") are gapped by `turn_face_up_effect_is_self_resolving` rather than
/// mis-resolved. `norm_lower` has self-references normalized to `~`.
fn parse_turned_face_up_replacement(norm_lower: &str, text: &str) -> Option<ReplacementDefinition> {
// 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<ReplacementDefinition> {
if !nom_primitives::scan_contains(lower, "untap step")
|| !nom_primitives::scan_contains(lower, "instead")
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions crates/engine/src/types/proposed_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,12 @@ pub enum ProposedEvent {
object_id: ObjectId,
applied: HashSet<ReplacementId>,
},
/// 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<ReplacementId>,
},
Destroy {
object_id: ObjectId,
source: Option<ObjectId>,
Expand Down Expand Up @@ -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, .. }
Expand Down Expand Up @@ -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, .. }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, .. }
Expand Down
Loading