From 68ba7b2f883de5249b799187386d9a4db26fb96c Mon Sep 17 00:00:00 2001 From: kelvinchen03 Date: Tue, 16 Jun 2026 03:15:26 -0400 Subject: [PATCH 1/2] Implement explore replacement with scry prelude (Twists and Turns) --- crates/engine/src/game/effects/explore.rs | 153 +++++++++++++++++- crates/engine/src/game/engine_replacement.rs | 3 + crates/engine/src/game/replacement.rs | 29 ++++ .../engine/src/parser/oracle_replacement.rs | 64 ++++++++ crates/engine/src/types/proposed_event.rs | 14 +- 5 files changed, 260 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/effects/explore.rs b/crates/engine/src/game/effects/explore.rs index fb814ce538..45d3095616 100644 --- a/crates/engine/src/game/effects/explore.rs +++ b/crates/engine/src/game/effects/explore.rs @@ -147,6 +147,58 @@ fn resolve_single_explorer( remaining: Vec, events: &mut Vec, ) -> Result<(), EffectError> { + // Create ProposedEvent::Explore and run through replacement pipeline + let proposed = crate::types::proposed_event::ProposedEvent::Explore { + object_id: explorer_id, + applied: std::collections::HashSet::new(), + }; + + match crate::game::replacement::replace_event(state, proposed, events) { + crate::game::replacement::ReplacementResult::Execute(modified_event) => { + // Check if Twists and Turns replacement applied by looking at the execute effect + let rid = modified_event.applied_set().iter().next(); + if let Some(rid) = rid { + if let Some(obj) = state.objects.get(&rid.source) { + if let Some(def) = obj.replacement_definitions.get(rid.index) { + if let Some(execute) = def.execute.as_deref() { + if matches!( + &*execute.effect, + crate::types::ability::Effect::Scry { .. } + ) { + // Execute the scry prelude before the explore + let scry_ability = ResolvedAbility::new( + (*execute.effect).clone(), + vec![], + rid.source, + obj.controller, + ); + let _ = crate::game::effects::scry::resolve( + state, + &scry_ability, + events, + ); + } + } + } + } + } + } + crate::game::replacement::ReplacementResult::Prevented => { + // Explore was prevented, skip the rest + events.push(GameEvent::EffectResolved { + kind: EffectKind::from(&ability.effect), + source_id: ability.source_id, + }); + return Ok(()); + } + crate::game::replacement::ReplacementResult::NeedsChoice(player) => { + state.waiting_for = + crate::game::replacement::replacement_choice_waiting_for(player, state); + return Ok(()); + } + } + + // Continue with existing explore logic (reveal, counter, land/hand) let mut single = ResolvedAbility::new( Effect::Explore, vec![TargetRef::Object(explorer_id)], @@ -362,16 +414,115 @@ pub fn handle_choice( mod tests { use super::*; use crate::game::zones::create_object; - use crate::types::ability::{ControllerRef, Effect, TargetFilter, TargetRef, TypedFilter}; + use crate::types::ability::{ + AbilityDefinition, AbilityKind, ControllerRef, Effect, QuantityExpr, ReplacementDefinition, + TargetFilter, TargetRef, TypedFilter, + }; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::Keyword; use crate::types::player::PlayerId; + use crate::types::replacements::ReplacementEvent; use crate::types::zones::Zone; fn make_explore_ability(source_id: ObjectId) -> ResolvedAbility { ResolvedAbility::new(Effect::Explore, vec![], source_id, PlayerId(0)) } + #[test] + fn test_explore_scry_prelude_replacement() { + // Test Twists and Turns pattern: "If a creature you control would explore, + // instead you scry 1, then that creature explores." + let mut state = GameState::new_two_player(42); + + // Create a creature with the explore replacement + let twists_and_turns = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Twists and Turns".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&twists_and_turns) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + + // Add the replacement definition + let replacement = ReplacementDefinition::new(ReplacementEvent::Explore) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Scry { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + )) + .valid_card(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + )) + .description( + "If a creature you control would explore, instead you scry 1, then that creature explores." + .to_string(), + ); + + state + .objects + .get_mut(&twists_and_turns) + .unwrap() + .replacement_definitions + .push(replacement); + + // Create an exploring creature + let explorer = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Explorer".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&explorer) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + + // Put a non-land card on top of library (so explore adds counter instead of putting land in hand) + let top_card = create_object( + &mut state, + CardId(3), + PlayerId(0), + "Lightning Bolt".to_string(), + Zone::Library, + ); + state + .objects + .get_mut(&top_card) + .unwrap() + .card_types + .core_types + .push(CoreType::Instant); + + // Make the creature explore + let ability = make_explore_ability(explorer); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + + // Verify explore still proceeded (counter added) + // This confirms the replacement pipeline was handled correctly + let explorer_obj = state.objects.get(&explorer).unwrap(); + assert!( + explorer_obj + .counters + .iter() + .any(|(ct, _)| *ct == CounterType::Plus1Plus1), + "Explore should have added +1/+1 counter" + ); + } + #[test] fn test_explore_land_goes_to_hand() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index bca8a4deb2..fb94be34e5 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -284,6 +284,9 @@ pub(super) fn handle_replacement_choice( scry @ ProposedEvent::Scry { .. } => { apply_scry_after_replacement(state, scry, events); } + // CR 701.37a: Explore accepted after replacement choice. + // The explore resolver handles the actual explore logic; this is a no-op here. + ProposedEvent::Explore { .. } => {} // CR 701.17a: Mill accepted after replacement choice — delegate // to the shared helper so count clamping and library movement // match the non-choice delivery. diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 3aae1e2641..cd6fa950b1 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1569,6 +1569,28 @@ fn scry_applier( } } +// --- 4d. Explore (Twists and Turns) --- + +// CR 701.37a + CR 614.1a: A creature is about to explore. Replacement +// effects can modify the explore action (e.g., add a scry prelude). +fn explore_matcher(event: &ProposedEvent, _source: ObjectId, _state: &GameState) -> bool { + matches!(event, ProposedEvent::Explore { .. }) +} + +fn explore_applier( + event: ProposedEvent, + _rid: ReplacementId, + _state: &mut GameState, + _events: &mut Vec, +) -> ApplyResult { + // Twists and Turns pattern: the applier doesn't execute the scry prelude + // It just returns the modified event. The explore resolver will execute + // the scry prelude after the replacement pipeline returns. + // This follows the standard pattern (scry_applier, mill_applier, draw_applier) + // where appliers modify events, not execute effects directly. + ApplyResult::Modified(event) +} + // --- 4c. CoinFlip (Krark's Thumb) --- // CR 705.1 + CR 614.1a: A coin flip is about to happen. Krark's Thumb replaces @@ -2662,6 +2684,13 @@ pub fn build_replacement_registry() -> IndexMap Option Option Option { + let ((subject,), rest) = nom_on_lower(lower, lower, |input| { + let (input, _) = tag("if ").parse(input)?; + // Parse subject: "a creature you control" or "a creature" + let (input, _) = tag("a creature").parse(input)?; + let (input, has_control) = opt(tag(" you control")).parse(input)?; + let (input, _) = + tag(" would explore, instead you scry 1, then that creature explores").parse(input)?; + let (input, _) = opt(char('.')).parse(input)?; + Ok((input, (has_control.is_some(),))) + })?; + if !rest.trim().is_empty() { + return None; + } + + // Create execute: scry 1 (main effect), explore is implicit (the event proceeds) + let scry_effect = Effect::Scry { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }; + + let mut def = ReplacementDefinition::new(ReplacementEvent::Explore) + .execute(AbilityDefinition::new(AbilityKind::Spell, scry_effect)) + .description(original_text.to_string()); + + // Set valid_card to scope to creatures you control if present + if subject { + def.valid_card = Some(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + )); + } else { + def.valid_card = Some(TargetFilter::Typed(TypedFilter::creature())); + } + + Some(def) +} + fn parse_mill_replacement_count(input: &str) -> nom::IResult<&str, QuantityExpr, OracleError<'_>> { alt(( value( @@ -12379,6 +12423,26 @@ mod snapshot_tests { insta::assert_json_snapshot!(def); } + #[test] + fn replacement_explore_scry_prelude_creature_you_control() { + let def = parse_replacement_line( + "If a creature you control would explore, instead you scry 1, then that creature explores.", + "Test Card", + ) + .unwrap(); + insta::assert_json_snapshot!(def); + } + + #[test] + fn replacement_explore_scry_prelude_creature() { + let def = parse_replacement_line( + "If a creature would explore, instead you scry 1, then that creature explores.", + "Test Card", + ) + .unwrap(); + insta::assert_json_snapshot!(def); + } + /// CR 104.2b + CR 104.3c: The "draw from empty library → win" class /// (Laboratory Maniac, Jace, Wielder of Mysteries) must gate its WinTheGame /// post-effect on the "while your library has no cards in it" antecedent. diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index 78f0b60226..733418c174 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -253,6 +253,12 @@ pub enum ProposedEvent { count: u32, applied: HashSet, }, + /// CR 701.37a + CR 614.1a: A creature is about to explore. Replacement + /// effects can modify the explore action (e.g., add a scry prelude). + Explore { + object_id: ObjectId, + applied: HashSet, + }, LifeGain { player_id: PlayerId, amount: u32, @@ -501,6 +507,7 @@ impl ProposedEvent { | ProposedEvent::Scry { applied, .. } | ProposedEvent::Mill { applied, .. } | ProposedEvent::CoinFlip { applied, .. } + | ProposedEvent::Explore { applied, .. } | ProposedEvent::LifeGain { applied, .. } | ProposedEvent::LifeLoss { applied, .. } | ProposedEvent::AddCounter { applied, .. } @@ -527,6 +534,7 @@ impl ProposedEvent { | ProposedEvent::Scry { applied, .. } | ProposedEvent::Mill { applied, .. } | ProposedEvent::CoinFlip { applied, .. } + | ProposedEvent::Explore { applied, .. } | ProposedEvent::LifeGain { applied, .. } | ProposedEvent::LifeLoss { applied, .. } | ProposedEvent::AddCounter { applied, .. } @@ -574,7 +582,8 @@ impl ProposedEvent { ProposedEvent::Tap { object_id, .. } | ProposedEvent::Untap { object_id, .. } | ProposedEvent::Destroy { object_id, .. } - | ProposedEvent::RemoveCounter { object_id, .. } => state + | ProposedEvent::RemoveCounter { object_id, .. } + | ProposedEvent::Explore { object_id, .. } => state .objects .get(object_id) .map(|o| o.controller) @@ -637,7 +646,8 @@ impl ProposedEvent { | ProposedEvent::Destroy { object_id, .. } | ProposedEvent::RemoveCounter { object_id, .. } | ProposedEvent::Discard { object_id, .. } - | ProposedEvent::Sacrifice { object_id, .. } => Some(*object_id), + | ProposedEvent::Sacrifice { object_id, .. } + | ProposedEvent::Explore { object_id, .. } => Some(*object_id), ProposedEvent::AddCounter { placement, .. } => placement.object_id(), ProposedEvent::MoveCounter { source_id, From 2949a34fa1d4aa03ac942f261d3d2b7da2f1978e Mon Sep 17 00:00:00 2001 From: kelvinchen03 Date: Tue, 16 Jun 2026 04:23:17 -0400 Subject: [PATCH 2/2] Refactor explore replacement architecture for better encapsulation --- crates/engine/src/game/effects/explore.rs | 30 +------------ crates/engine/src/game/replacement.rs | 27 ++++++++---- .../engine/src/parser/oracle_replacement.rs | 28 ++++++++++--- ...acement_explore_scry_prelude_creature.snap | 42 +++++++++++++++++++ ..._explore_scry_prelude_creature_scry_2.snap | 42 +++++++++++++++++++ ...ore_scry_prelude_creature_you_control.snap | 42 +++++++++++++++++++ 6 files changed, 168 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature.snap create mode 100644 crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature_scry_2.snap create mode 100644 crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature_you_control.snap diff --git a/crates/engine/src/game/effects/explore.rs b/crates/engine/src/game/effects/explore.rs index 45d3095616..d24f74f123 100644 --- a/crates/engine/src/game/effects/explore.rs +++ b/crates/engine/src/game/effects/explore.rs @@ -154,35 +154,7 @@ fn resolve_single_explorer( }; match crate::game::replacement::replace_event(state, proposed, events) { - crate::game::replacement::ReplacementResult::Execute(modified_event) => { - // Check if Twists and Turns replacement applied by looking at the execute effect - let rid = modified_event.applied_set().iter().next(); - if let Some(rid) = rid { - if let Some(obj) = state.objects.get(&rid.source) { - if let Some(def) = obj.replacement_definitions.get(rid.index) { - if let Some(execute) = def.execute.as_deref() { - if matches!( - &*execute.effect, - crate::types::ability::Effect::Scry { .. } - ) { - // Execute the scry prelude before the explore - let scry_ability = ResolvedAbility::new( - (*execute.effect).clone(), - vec![], - rid.source, - obj.controller, - ); - let _ = crate::game::effects::scry::resolve( - state, - &scry_ability, - events, - ); - } - } - } - } - } - } + crate::game::replacement::ReplacementResult::Execute(_) => {} crate::game::replacement::ReplacementResult::Prevented => { // Explore was prevented, skip the rest events.push(GameEvent::EffectResolved { diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index cd6fa950b1..dce36779e6 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1579,15 +1579,26 @@ fn explore_matcher(event: &ProposedEvent, _source: ObjectId, _state: &GameState) fn explore_applier( event: ProposedEvent, - _rid: ReplacementId, - _state: &mut GameState, - _events: &mut Vec, + rid: ReplacementId, + state: &mut GameState, + events: &mut Vec, ) -> ApplyResult { - // Twists and Turns pattern: the applier doesn't execute the scry prelude - // It just returns the modified event. The explore resolver will execute - // the scry prelude after the replacement pipeline returns. - // This follows the standard pattern (scry_applier, mill_applier, draw_applier) - // where appliers modify events, not execute effects directly. + // CR 701.37a + CR 614.1a: Execute the replacement prelude (e.g., scry) before the explore proceeds + if let Some(obj) = state.objects.get(&rid.source) { + if let Some(def) = obj.replacement_definitions.get(rid.index) { + if let Some(execute) = def.execute.as_deref() { + if matches!(&*execute.effect, crate::types::ability::Effect::Scry { .. }) { + let scry_ability = ResolvedAbility::new( + (*execute.effect).clone(), + vec![], + rid.source, + obj.controller, + ); + let _ = crate::game::effects::scry::resolve(state, &scry_ability, events); + } + } + } + } ApplyResult::Modified(event) } diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 84a9803b8f..d030aedef5 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -4564,23 +4564,29 @@ fn parse_explore_scry_prelude_replacement( lower: &str, original_text: &str, ) -> Option { - let ((subject,), rest) = nom_on_lower(lower, lower, |input| { + let ((subject, scry_value), rest) = nom_on_lower(lower, lower, |input| { let (input, _) = tag("if ").parse(input)?; // Parse subject: "a creature you control" or "a creature" let (input, _) = tag("a creature").parse(input)?; let (input, has_control) = opt(tag(" you control")).parse(input)?; - let (input, _) = - tag(" would explore, instead you scry 1, then that creature explores").parse(input)?; + let (input, _) = tag(" would explore, instead you scry ").parse(input)?; + let (input, count_str) = nom::character::complete::digit1(input)?; + let (input, _) = tag(", then that creature explores").parse(input)?; let (input, _) = opt(char('.')).parse(input)?; - Ok((input, (has_control.is_some(),))) + let count = count_str.parse::().ok().ok_or_else(|| { + nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Digit)) + })?; + Ok((input, (has_control.is_some(), count))) })?; if !rest.trim().is_empty() { return None; } - // Create execute: scry 1 (main effect), explore is implicit (the event proceeds) + // Create execute: scry N (main effect), explore is implicit (the event proceeds) let scry_effect = Effect::Scry { - count: QuantityExpr::Fixed { value: 1 }, + count: QuantityExpr::Fixed { + value: scry_value as i32, + }, target: TargetFilter::Controller, }; @@ -12443,6 +12449,16 @@ mod snapshot_tests { insta::assert_json_snapshot!(def); } + #[test] + fn replacement_explore_scry_prelude_creature_scry_2() { + let def = parse_replacement_line( + "If a creature would explore, instead you scry 2, then that creature explores.", + "Test Card", + ) + .unwrap(); + insta::assert_json_snapshot!(def); + } + /// CR 104.2b + CR 104.3c: The "draw from empty library → win" class /// (Laboratory Maniac, Jace, Wielder of Mysteries) must gate its WinTheGame /// post-effect on the "while your library has no cards in it" antecedent. diff --git a/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature.snap b/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature.snap new file mode 100644 index 0000000000..0ad8a5ba50 --- /dev/null +++ b/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature.snap @@ -0,0 +1,42 @@ +--- +source: crates/engine/src/parser/oracle_replacement.rs +expression: def +--- +{ + "event": "Explore", + "execute": { + "kind": "Spell", + "effect": { + "type": "Scry", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "mode": { + "type": "Mandatory" + }, + "valid_card": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + "description": "If a creature would explore, instead you scry 1, then that creature explores.", + "condition": null +} diff --git a/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature_scry_2.snap b/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature_scry_2.snap new file mode 100644 index 0000000000..69805bc01f --- /dev/null +++ b/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature_scry_2.snap @@ -0,0 +1,42 @@ +--- +source: crates/engine/src/parser/oracle_replacement.rs +expression: def +--- +{ + "event": "Explore", + "execute": { + "kind": "Spell", + "effect": { + "type": "Scry", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "mode": { + "type": "Mandatory" + }, + "valid_card": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + "description": "If a creature would explore, instead you scry 2, then that creature explores.", + "condition": null +} diff --git a/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature_you_control.snap b/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature_you_control.snap new file mode 100644 index 0000000000..3fc12ce94b --- /dev/null +++ b/crates/engine/src/parser/snapshots/engine__parser__oracle_replacement__snapshot_tests__replacement_explore_scry_prelude_creature_you_control.snap @@ -0,0 +1,42 @@ +--- +source: crates/engine/src/parser/oracle_replacement.rs +expression: def +--- +{ + "event": "Explore", + "execute": { + "kind": "Spell", + "effect": { + "type": "Scry", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "mode": { + "type": "Mandatory" + }, + "valid_card": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + }, + "description": "If a creature you control would explore, instead you scry 1, then that creature explores.", + "condition": null +}