Skip to content
Merged
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
122 changes: 121 additions & 1 deletion crates/engine/src/game/effects/explore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameEvent>,
) -> Result<(), EffectError> {
let controller = state
.objects
.get(&explorer_id)
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/engine_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameEvent>,
) -> 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
Expand Down Expand Up @@ -2665,6 +2728,13 @@ pub fn build_replacement_registry() -> IndexMap<ReplacementEvent, ReplacementHan
applier: scry_applier,
},
);
registry.insert(
ReplacementEvent::Explore,
ReplacementHandlerEntry {
matcher: explore_matcher,
applier: explore_applier,
},
);
registry.insert(
ReplacementEvent::CoinFlip,
ReplacementHandlerEntry {
Expand Down
56 changes: 56 additions & 0 deletions crates/engine/src/parser/oracle_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,14 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option<Replaceme
return Some(def);
}

// --- Explore replacement: "If a creature you control would explore, instead …"
// (Twists and Turns / Topography Tracker class).
if nom_primitives::scan_contains(&lower, "would explore") {
if let Some(def) = parse_explore_replacement(&lower, &text) {
return Some(def);
}
}

// --- "If you would draw a card, {effect}" ---
if nom_primitives::scan_contains(&lower, "you would draw") {
let effect_text = extract_replacement_effect(&normalized);
Expand Down Expand Up @@ -4622,6 +4630,26 @@ fn parse_scry_count_replacement(lower: &str, original_text: &str) -> Option<Repl
)
}

/// CR 701.44 + CR 614.1a: Parse explore replacement effects such as Twists and
/// Turns ("instead you scry 1, then that creature explores") and Topography
/// Tracker ("instead it explores, then it explores again").
fn parse_explore_replacement(lower: &str, original_text: &str) -> Option<ReplacementDefinition> {
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,
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 12 additions & 2 deletions crates/engine/src/types/proposed_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ pub enum ProposedEvent {
count: u32,
applied: HashSet<ReplacementId>,
},
/// 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<ReplacementId>,
},
LifeGain {
player_id: PlayerId,
amount: u32,
Expand Down Expand Up @@ -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, .. }
Expand All @@ -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, .. }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading