diff --git a/crates/engine/src/game/effects/explore.rs b/crates/engine/src/game/effects/explore.rs index fb814ce538..30431965f9 100644 --- a/crates/engine/src/game/effects/explore.rs +++ b/crates/engine/src/game/effects/explore.rs @@ -228,6 +228,39 @@ pub fn resolve( }) .unwrap_or(ability.source_id); + // CR 701.37a + CR 614.1a: Consult explore replacements (Twists and Turns, + // Topography Tracker, …) before the reveal/counter/land logic runs. + let proposed = ProposedEvent::Explore { + object_id: explorer_id, + applied: HashSet::new(), + }; + match replacement::replace_event(state, proposed, events) { + ReplacementResult::Execute(_) => {} + ReplacementResult::Prevented => { + events.push(GameEvent::EffectResolved { + kind: EffectKind::from(&ability.effect), + source_id: ability.source_id, + }); + return Ok(()); + } + ReplacementResult::NeedsChoice(player) => { + state.waiting_for = replacement::replacement_choice_waiting_for(player, state); + return Ok(()); + } + } + + resolve_explore_effect(state, ability, explorer_id, events) +} + +/// CR 701.44a: Run the explore reveal/counter/land pipeline without consulting +/// replacement effects. Used when a replacement effect's "instead" chain +/// already resolved the replacement (nested explores must not re-enter). +pub(crate) fn resolve_explore_effect( + state: &mut GameState, + ability: &ResolvedAbility, + explorer_id: ObjectId, + events: &mut Vec, +) -> Result<(), EffectError> { let controller = state .objects .get(&explorer_id) @@ -362,16 +395,103 @@ 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 explore_scry_prelude_replacement_runs_before_explore() { + let mut state = GameState::new_two_player(42); + + let twists = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Twists and Turns".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&twists) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + + let replacement = ReplacementDefinition::new(ReplacementEvent::Explore) + .execute( + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Scry { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ) + .sub_ability(AbilityDefinition::new(AbilityKind::Spell, Effect::Explore)), + ) + .valid_card(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + )); + state + .objects + .get_mut(&twists) + .unwrap() + .replacement_definitions + .push(replacement); + + 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); + + 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); + + let ability = make_explore_ability(explorer); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + + assert!( + state.objects[&explorer] + .counters + .iter() + .any(|(ct, _)| *ct == CounterType::Plus1Plus1), + "replacement scry prelude must still leave the creature exploring (+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..b8f191b442 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 877628f998..500ebc0a8f 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1569,6 +1569,69 @@ fn scry_applier( } } +// --- 4d. Explore (Twists and Turns / Topography Tracker) --- + +// 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 or double explore). +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 { + let ProposedEvent::Explore { object_id, applied } = event else { + return ApplyResult::Modified(event); + }; + + let Some(source) = state.objects.get(&rid.source) else { + return ApplyResult::Modified(ProposedEvent::Explore { object_id, applied }); + }; + let Some(execute) = source + .replacement_definitions + .get(rid.index) + .and_then(|def| def.execute.clone()) + else { + return ApplyResult::Modified(ProposedEvent::Explore { object_id, applied }); + }; + + use crate::game::ability_utils::build_resolved_from_def; + use crate::types::ability::TargetRef; + + let controller = source.controller; + let mut current = Some(execute.as_ref()); + while let Some(def) = current { + match &*def.effect { + Effect::Scry { .. } => { + let ability = build_resolved_from_def(def, rid.source, controller); + let _ = crate::game::effects::scry::resolve(state, &ability, events); + } + Effect::Explore => { + let ability = ResolvedAbility::new( + Effect::Explore, + vec![TargetRef::Object(object_id)], + rid.source, + controller, + ); + let _ = crate::game::effects::explore::resolve_explore_effect( + state, &ability, object_id, events, + ); + } + _ => { + let mut ability = build_resolved_from_def(def, rid.source, controller); + ability.targets = vec![TargetRef::Object(object_id)]; + let _ = crate::game::effects::resolve_ability_chain(state, &ability, events, 1); + } + } + current = def.sub_ability.as_deref(); + } + + ApplyResult::Prevented +} + // --- 4c. CoinFlip (Krark's Thumb) --- // CR 705.1 + CR 614.1a: A coin flip is about to happen. Krark's Thumb replaces @@ -2665,6 +2728,13 @@ pub fn build_replacement_registry() -> IndexMap Option Option Option { + if !nom_primitives::scan_contains(lower, "if a creature you control would explore") { + return None; + } + let (_, execute_text) = split_once_on_lower(original_text, lower, "instead ")?; + let execute_text = execute_text.trim().trim_end_matches('.'); + + Some( + ReplacementDefinition::new(ReplacementEvent::Explore) + .valid_card(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + )) + .execute(parse_effect_chain(execute_text, AbilityKind::Spell)) + .description(original_text.to_string()), + ) +} + #[derive(Clone, Copy)] enum ScryReplacementAction { Draw, @@ -12408,6 +12436,34 @@ mod tests { assert_eq!(def.valid_player, Some(ReplacementPlayerScope::Opponent)); } + #[test] + fn parses_explore_replacement_scry_prelude() { + let def = parse_replacement_line( + "If a creature you control would explore, instead you scry 1, then that creature explores.", + "Twists and Turns", + ) + .expect("Twists and Turns explore replacement must parse"); + assert_eq!(def.event, ReplacementEvent::Explore); + assert!(matches!( + def.valid_card, + Some(TargetFilter::Typed(tf)) + if tf.type_filters == vec![TypeFilter::Creature] + && tf.controller == Some(ControllerRef::You) + )); + assert!(def.execute.is_some()); + } + + #[test] + fn parses_explore_replacement_double_explore() { + let def = parse_replacement_line( + "If a creature you control would explore, instead it explores, then it explores again.", + "Topography Tracker", + ) + .expect("Topography Tracker explore replacement must parse"); + assert_eq!(def.event, ReplacementEvent::Explore); + assert!(def.execute.is_some()); + } + #[test] fn parses_halving_season_opponent_token_replacement() { let def = parse_replacement_line( 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,