Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
108 changes: 104 additions & 4 deletions crates/engine/src/parser/oracle_effect/sequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,10 @@ fn parse_put_chosen_cards_at_library_position(lower: &str) -> Option<LibraryPosi
value(
LibraryPosition::Top,
all_consuming((
tag::<_, _, OracleError<'_>>("put those cards on top"),
alt((
tag::<_, _, OracleError<'_>>("put those cards on top"),
tag("put the chosen cards on top"),
)),
opt(alt((
tag(" of your library"),
tag(" of their owner's library"),
Expand Down Expand Up @@ -844,7 +847,7 @@ fn parse_put_one_dig_card_on_top(lower: &str) -> Option<DigRestOrder> {
Some(order.unwrap_or(DigRestOrder::Preserve))
}

fn parse_exile_rest_after_dig(lower: &str) -> bool {
fn parse_exile_rest_clause(lower: &str) -> bool {
(
tag::<_, _, OracleError<'_>>("exile the rest"),
opt(tag(".")),
Expand Down Expand Up @@ -4764,6 +4767,51 @@ pub(super) fn apply_clause_continuation(
);
append_definition_to_sub_chain(previous, put_def);
}
ContinuationAst::ExileSearchRemainder => {
let Some(previous) = defs.last_mut() else {
return;
};
let Effect::SearchLibrary {
source_zones,
target_player: None,
..
} = &*previous.effect
else {
return;
};
let target = TargetFilter::Typed(
TypedFilter::default()
.controller(ControllerRef::You)
.properties(vec![
FilterProp::InAnyZone {
zones: source_zones.clone(),
},
FilterProp::Not {
prop: Box::new(FilterProp::InTrackedSet {
id: crate::types::identifiers::TrackedSetId(0),
}),
},
]),
);
append_definition_to_sub_chain(
previous,
AbilityDefinition::new(
kind,
Effect::ChangeZoneAll {
origin: None,
destination: Zone::Exile,
target,
enters_under: None,
enter_tapped: crate::types::zones::EtbTapState::Unspecified,
enters_attacking: false,
enter_with_counters: vec![],
face_down_profile: None,
library_position: None,
random_order: false,
},
),
);
}
ContinuationAst::BecomesPlotted => {
let Some(previous) = defs.last_mut() else {
return;
Expand Down Expand Up @@ -5317,6 +5365,7 @@ pub(super) fn continuation_absorbs_current(
ContinuationAst::PutChoiceRemainderOnBottom => true,
ContinuationAst::ChoicePartitionDestinations { .. } => true,
ContinuationAst::PutChosenCardsAtLibraryPosition { .. } => true,
ContinuationAst::ExileSearchRemainder => true,
ContinuationAst::BecomesPlotted => true,
ContinuationAst::BecomesForetold => true,
ContinuationAst::EntersTappedAttacking { .. } => true,
Expand Down Expand Up @@ -5389,6 +5438,7 @@ pub(super) fn parse_intrinsic_continuation_ast(
|| nom_primitives::scan_contains(&full_lower, "put the card on top")
|| nom_primitives::scan_contains(&full_lower, "put them on top")
|| nom_primitives::scan_contains(&full_lower, "put those cards on top")
|| nom_primitives::scan_contains(&full_lower, "put the chosen cards on top")
|| (nom_primitives::scan_contains(&full_lower, "put that card")
&& nom_primitives::scan_contains(&full_lower, "from the top"));
if has_positional_put {
Expand Down Expand Up @@ -6927,6 +6977,13 @@ pub(super) fn parse_followup_continuation_ast(
rest_order: DigRestOrder::Preserve,
})
}
Effect::SearchLibrary {
source_zones,
target_player: None,
..
} if source_zones.len() >= 2 && parse_exile_rest_clause(&lower) => {
Some(ContinuationAst::ExileSearchRemainder)
}
Effect::SearchLibrary { .. } | Effect::Shuffle { .. } | Effect::Dig { .. }
if parse_put_chosen_cards_at_library_position(&lower).is_some() =>
{
Expand All @@ -6947,7 +7004,7 @@ pub(super) fn parse_followup_continuation_ast(
}
// "Exile the rest" after Dig — sets rest_destination on the preceding
// looked-at pile while preserving any prior kept-card destination.
Effect::Dig { .. } if parse_exile_rest_after_dig(&lower) => {
Effect::Dig { .. } if parse_exile_rest_clause(&lower) => {
Some(ContinuationAst::PutRest {
destination: Zone::Exile,
reorder_all: false,
Expand Down Expand Up @@ -8298,7 +8355,7 @@ pub(super) fn try_parse_scoped_does_the_same(text: &str) -> Option<PlayerFilter>
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ability::QuantityExpr;
use crate::types::ability::{QuantityExpr, SearchSelectionConstraint};

#[test]
fn face_down_pile_is_dig_lookback_transparent() {
Expand Down Expand Up @@ -11839,6 +11896,49 @@ mod tests {
);
}

#[test]
fn put_the_chosen_cards_on_top_parses_as_library_position_continuation() {
let search = Effect::SearchLibrary {
filter: TargetFilter::Any,
count: QuantityExpr::Fixed { value: 5 },
reveal: false,
target_player: None,
selection_constraint: SearchSelectionConstraint::None,
split: None,
source_zones: vec![Zone::Graveyard, Zone::Library],
};
let result = parse_followup_continuation_ast(
"Put the chosen cards on top of your library in any order.",
&search,
&mut ParseContext::default(),
);
assert_eq!(
result,
Some(ContinuationAst::PutChosenCardsAtLibraryPosition {
position: LibraryPosition::Top,
})
);
}

#[test]
fn exile_the_rest_after_multi_zone_search_excludes_selected_set() {
let search = Effect::SearchLibrary {
filter: TargetFilter::Any,
count: QuantityExpr::Fixed { value: 5 },
reveal: false,
target_player: None,
selection_constraint: SearchSelectionConstraint::None,
split: None,
source_zones: vec![Zone::Graveyard, Zone::Library],
};
let result = parse_followup_continuation_ast(
"Exile the rest.",
&search,
&mut ParseContext::default(),
);
assert_eq!(result, Some(ContinuationAst::ExileSearchRemainder));
}

/// CR 201.2 + CR 608.2c: Mitotic-Manipulation-style name-match selection
/// after a Dig emits a `DigFromAmong` continuation that patches the
/// preceding Dig with destination = Battlefield, keep_count = 1,
Expand Down
72 changes: 72 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41616,6 +41616,78 @@ fn multi_zone_player_exile_matcher_recognizes_zone_union() {
);
}

/// CR 701.23a + CR 608.2c: Doomsday searches the controller's library and
/// graveyard, exiles the searched-zone complement of the selected cards, then
/// leaves the selected cards in the library for the explicit ordering step.
#[test]
fn doomsday_search_exiles_rest_and_orders_chosen_cards() {
let def = parse_effect_chain(
"Search your library and graveyard for five cards and exile the rest. Put the chosen cards on top of your library in any order. You lose half your life, rounded up.",
AbilityKind::Spell,
);

let Effect::SearchLibrary {
count,
source_zones,
target_player,
..
} = def.effect.as_ref()
else {
panic!("expected SearchLibrary root, got {:?}", def.effect);
};
assert_eq!(*count, QuantityExpr::Fixed { value: 5 });
assert_eq!(source_zones, &vec![Zone::Graveyard, Zone::Library]);
assert_eq!(*target_player, None);

let exile = def
.sub_ability
.as_deref()
.expect("expected an exile-rest continuation");
let Effect::ChangeZoneAll {
target,
destination,
..
} = exile.effect.as_ref()
else {
panic!(
"expected ChangeZoneAll exile-rest step, got {:?}",
exile.effect
);
};
assert_eq!(*destination, Zone::Exile);
assert_eq!(
*target,
TargetFilter::Typed(
TypedFilter::default()
.controller(ControllerRef::You)
.properties(vec![
FilterProp::InAnyZone {
zones: vec![Zone::Graveyard, Zone::Library],
},
FilterProp::Not {
prop: Box::new(FilterProp::InTrackedSet {
id: TrackedSetId(0),
}),
},
]),
)
);

let put = exile
.sub_ability
.as_deref()
.expect("expected chosen-card ordering continuation");
assert!(matches!(
put.effect.as_ref(),
Effect::PutAtLibraryPosition {
target: TargetFilter::Any,
count: QuantityExpr::Fixed { value: 0 },
position: LibraryPosition::Top,
}
));
assert!(!ability_chain_has_unimplemented(&def));
}

/// CR 701.12a: Tree of Perdition / Tree of Redemption / Evra — "exchange
/// <player>'s life total with ~'s power/toughness" parses to
/// `ExchangeLifeWithStat` with the right player filter and stat, not the
Expand Down
7 changes: 6 additions & 1 deletion crates/engine/src/parser/oracle_ir/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,9 +386,14 @@ pub(crate) enum ContinuationAst {
chosen_destination: Zone,
rest_destination: Zone,
},
/// "Put those cards on top ..." after a search/dig/choice producer.
/// "Put those cards/the chosen cards on top ..." after a search/dig/choice
/// producer.
/// Count is supplied by the already-selected target set.
PutChosenCardsAtLibraryPosition { position: LibraryPosition },
/// CR 701.23a + CR 608.2c: "exile the rest" after a multi-zone search.
/// The searched player's cards in the searched zones, excluding the cards
/// selected by the SearchLibrary choice, are moved to exile.
ExileSearchRemainder,
/// CR 702.170c-d: "It/that card/they become plotted" after an exile effect.
BecomesPlotted,
/// CR 702.143d: "It/that card/they become foretold" after an exile effect.
Expand Down
Loading
Loading