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
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 @@ -5167,7 +5167,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
37 changes: 7 additions & 30 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18423,36 +18423,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.
// THIRD merge with main (this branch × `origin/main` @ 59f5a51e, which
// by now carries Wheel of Misfortune's unbounded-number round). Same rule
// as the two merges logged above, applied a third time: each side's pins
// were local-correct and BOTH are wrong for the merged tree, so the merged
// file was re-measured rather than either side taken. `origin/main`
// carried `:6656/:6733/:9974`; this branch carried `:6722/:6799/:10001`;
// the merged file measures `:6738/:6815/:10053`.
//
// The merged coordinates are PREDICTED, not merely observed, and the
// prediction is what makes this a measurement rather than a fixup:
// `main`'s pins plus this branch's own base-relative offsets — `+82/+82/+79`,
// the figure the row immediately above derives from base `8035813e6` and
// re-derives twice — give `6656+82`/`6733+82`/`9974+79` =
// `:6738`/`:6815`/`:10053`, equal to the observed coordinates exactly.
// That the branch's offsets compose additively onto main's is the evidence
// the merge introduced no new producer and displaced none: a merge that had
// gained or lost one would break the additivity, not just shift a pin.
//
// Set preservation: the assembled needle finds exactly five hits in the
// merged effects/mod.rs (`:6738`, `:6815`, `:10053`, `:14805`, `:15290`);
// the last two fall inside the `#[cfg(test)]` span opening at `:13563` and
// so are the partition's test half, leaving the same three production
// producers this row has always pinned. Total still 37, partition still
// 5/7/25. The merge added no `WaitingFor` producer on either side — main's
// contribution here is the unbounded-range arm in `compute_options`' sibling
// classifier and this branch's is the CR 603.4 delayed-hoist carve-out, both
// pure classification code.
"game/effects/mod.rs:6738".to_string(),
"game/effects/mod.rs:6815".to_string(),
"game/effects/mod.rs:10053".to_string(),
// Current-main port: #7403/#7389 move main's three production pins to
// `:6738/:6815/:10053`; the Doomsday tracked-set publication adds seven
// lines above each. Re-measured in this merged tree: `:6745/:6822/:10060`.
// The three sites remain the existing `OptionalEffectChoice` producers.
"game/effects/mod.rs:6745".to_string(),
"game/effects/mod.rs:6822".to_string(),
"game/effects/mod.rs:10060".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 @@ -41667,6 +41667,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