Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
9 changes: 8 additions & 1 deletion crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5085,7 +5085,14 @@ fn effect_references_tracked_set(effect: &Effect) -> bool {
}
}
if let Effect::ChangeZoneAll { target, .. } = effect {
if filter_references_tracked_set(target) {
// CR 608.2c: a mass zone move can consume the selected set through a
// typed property as well as a bare `TrackedSet` leg. In particular,
// "exile the rest" uses `Not(InTrackedSet)` inside its typed filter;
// the search choice must publish its chosen set before this effect
// resolves or that complement would include the chosen cards too.
if filter_references_tracked_set(target)
|| filter_properties_reference_tracked_membership(target)
{
return true;
}
}
Expand Down
10 changes: 7 additions & 3 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18279,9 +18279,13 @@ mod stage2_injector_tests {
// added to `compute_options`' sibling classifier in this file,
// which sits above all three producers. Nothing added raises a
// `WaitingFor`; the census set is still exactly 5.
"game/effects/mod.rs:6656".to_string(),
"game/effects/mod.rs:6733".to_string(),
"game/effects/mod.rs:9974".to_string(),
// Doomsday selected-search publication: `:6656/:6733/:9974 ⇒
// :6663/:6740/:9981`, uniform +7 from the typed tracked-set
// membership detector and its CR annotation. The producers
// remain byte-identical and the census set remains exactly 5.
"game/effects/mod.rs:6663".to_string(),
"game/effects/mod.rs:6740".to_string(),
"game/effects/mod.rs:9981".to_string(),
// UNMOVED across the rebase, and that is itself evidence the SET did not
// move: a census that had gained or lost a producer would not leave this
// entry both byte-identical AND at the same coordinate.
Expand Down
15 changes: 15 additions & 0 deletions crates/engine/src/game/engine_resolution_choices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,21 @@ fn finalize_standard_search_selection(
.exiled_from_hand_this_resolution
.saturating_add(hand_exiles);
}
// CR 608.2c + CR 701.23a: A search choice produces the selected set for
// any continuation that consumes "the chosen cards" or excludes them from
// a searched-zone remainder. Publish it before the continuation resolves
// so a typed `Not(InTrackedSet)` excludes every selected card.
let continuation_consumes_tracked_set = state
.active_ability_continuation()
.or_else(|| {
state
.outer_ability_continuation_of_active_post_replacement_draw()
.map(|continuation| &continuation.pending)
})
.is_some_and(|continuation| effects::chain_references_tracked_set(&continuation.chain));
if continuation_consumes_tracked_set {
effects::publish_fresh_tracked_set(state, chosen.to_vec());
}
let mut has_delivery = false;
if state.active_ability_continuation().is_some() {
let mut frame = state
Expand Down
145 changes: 141 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,63 @@ pub(super) fn apply_clause_continuation(
);
append_definition_to_sub_chain(previous, put_def);
}
ContinuationAst::ExileSearchRemainder => {
let Some(previous) = defs.last_mut() else {
return;
};
// Recognition only constructs this continuation after
// `SearchLibrary { target_player: None, source_zones.len() >= 2 }`.
// Keep that invariant loud here: this continuation is absorbed, so
// silently returning would otherwise discard "exile the rest"
// without emitting the required zone move.
let Effect::SearchLibrary {
source_zones,
target_player: None,
..
} = &*previous.effect
else {
unreachable!(
"ExileSearchRemainder must immediately follow a self multi-zone SearchLibrary"
);
};
// CR 701.23a + CR 400.3: the preceding search selected from each
// listed zone, so `origin: None` lets this mass move scan both the
// library and graveyard constrained by `InAnyZone` below.
// CR 608.2c: the selected cards are the search's tracked result;
// exclude that set so only the unchosen remainder is exiled.
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 +5377,10 @@ pub(super) fn continuation_absorbs_current(
ContinuationAst::PutChoiceRemainderOnBottom => true,
ContinuationAst::ChoicePartitionDestinations { .. } => true,
ContinuationAst::PutChosenCardsAtLibraryPosition { .. } => true,
// Recognition is gated on a self multi-zone SearchLibrary, and lowering
// appends its `ChangeZoneAll` child. It is therefore safe to absorb the
// clause rather than emit an `Unimplemented` sibling.
ContinuationAst::ExileSearchRemainder => true,
ContinuationAst::BecomesPlotted => true,
ContinuationAst::BecomesForetold => true,
ContinuationAst::EntersTappedAttacking { .. } => true,
Expand Down Expand Up @@ -5389,6 +5453,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 +6992,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 +7019,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 +8370,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 +11911,71 @@ 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));
}

#[test]
fn exile_the_rest_after_single_zone_search_is_not_recognized() {
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::Library],
};
assert_eq!(
parse_followup_continuation_ast(
"Exile the rest.",
&search,
&mut ParseContext::default(),
),
None,
"a single-zone search must not exile its library remainder"
);
}

/// 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