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
125 changes: 124 additions & 1 deletion crates/engine/src/game/effects/explore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,30 @@ fn resolve_single_explorer(
remaining: Vec<ObjectId>,
events: &mut Vec<GameEvent>,
) -> 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(_) => {}
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(());
}
}
Comment thread
Kelvinchen03 marked this conversation as resolved.

// Continue with existing explore logic (reveal, counter, land/hand)
let mut single = ResolvedAbility::new(
Effect::Explore,
vec![TargetRef::Object(explorer_id)],
Expand Down Expand Up @@ -362,16 +386,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);
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
40 changes: 40 additions & 0 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,39 @@ 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<GameEvent>,
) -> ApplyResult {
// 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)
}
Comment thread
Kelvinchen03 marked this conversation as resolved.

// --- 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 @@ -2662,6 +2695,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
80 changes: 80 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,10 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option<Replaceme
return Some(def);
}

if let Some(def) = parse_explore_scry_prelude_replacement(&norm_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 @@ -4556,6 +4560,52 @@ fn parse_mill_count_replacement(lower: &str, original_text: &str) -> Option<Repl
Some(def)
}

fn parse_explore_scry_prelude_replacement(
lower: &str,
original_text: &str,
) -> Option<ReplacementDefinition> {
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 ").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)?;
let count = count_str.parse::<u32>().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 N (main effect), explore is implicit (the event proceeds)
let scry_effect = Effect::Scry {
count: QuantityExpr::Fixed {
value: scry_value as i32,
},
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)
}
Comment thread
Kelvinchen03 marked this conversation as resolved.

fn parse_mill_replacement_count(input: &str) -> nom::IResult<&str, QuantityExpr, OracleError<'_>> {
alt((
value(
Expand Down Expand Up @@ -12379,6 +12429,36 @@ 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);
}

#[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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading