From a11265062607a3bd8d16e75b294114140c011020 Mon Sep 17 00:00:00 2001 From: traemyn Date: Fri, 14 Aug 2026 17:26:27 -0500 Subject: [PATCH 1/3] Fix Doomsday --- .../src/parser/oracle_effect/sequence.rs | 108 +++++++++++++- .../engine/src/parser/oracle_effect/tests.rs | 72 ++++++++++ crates/engine/src/parser/oracle_ir/ast.rs | 7 +- crates/engine/tests/integration/doomsday.rs | 136 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/integration/doomsday.rs diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 7437fa9d2e..201d113fe5 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -280,7 +280,10 @@ fn parse_put_chosen_cards_at_library_position(lower: &str) -> Option>("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"), @@ -844,7 +847,7 @@ fn parse_put_one_dig_card_on_top(lower: &str) -> Option { 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(".")), @@ -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; @@ -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, @@ -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 { @@ -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() => { @@ -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, @@ -8298,7 +8355,7 @@ pub(super) fn try_parse_scoped_does_the_same(text: &str) -> Option #[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() { @@ -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, diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 9193d96e8a..bf8c997819 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -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 /// 's life total with ~'s power/toughness" parses to /// `ExchangeLifeWithStat` with the right player filter and stat, not the diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 6838a0c517..0153e39645 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -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. diff --git a/crates/engine/tests/integration/doomsday.rs b/crates/engine/tests/integration/doomsday.rs new file mode 100644 index 0000000000..ef37eab924 --- /dev/null +++ b/crates/engine/tests/integration/doomsday.rs @@ -0,0 +1,136 @@ +//! Runtime regression coverage for Doomsday's multi-zone search, remainder +//! exile, and five-card library ordering. + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const DOOMSDAY_ORACLE: &str = "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."; + +fn named_id(runner: &GameRunner, name: &str) -> engine::types::identifiers::ObjectId { + runner + .state() + .objects + .iter() + .find_map(|(id, object)| (object.name == name).then_some(*id)) + .unwrap_or_else(|| panic!("missing scenario card {name:?}")) +} + +/// CR 401.4 + CR 701.23a + CR 608.2c: drive the real cast/apply pipeline, +/// choose five cards from the library/graveyard in a deliberate order, and +/// verify that only the unchosen searched-zone cards are exiled. The selected +/// order is the order submitted through the production SearchChoice action; +/// `PutAtLibraryPosition` preserves it when it resolves. Reverting either +/// parser continuation leaves the selected cards in their original zones or +/// sends them through the wrong destination. +#[test] +fn doomsday_exiles_search_remainder_and_orders_five_chosen_cards() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_library_top( + P0, + &[ + "Library A", + "Library B", + "Library C", + "Library D", + "Library E", + "Library F", + "Library G", + ], + ); + scenario.with_graveyard(P0, &["Graveyard A", "Graveyard B", "Graveyard C"]); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Doomsday", false, DOOMSDAY_ORACLE) + .id(); + let mut runner = scenario.build(); + + let library_a = named_id(&runner, "Library A"); + let library_c = named_id(&runner, "Library C"); + let library_e = named_id(&runner, "Library E"); + let graveyard_a = named_id(&runner, "Graveyard A"); + let graveyard_b = named_id(&runner, "Graveyard B"); + let graveyard_c = named_id(&runner, "Graveyard C"); + let unchosen = [ + named_id(&runner, "Library B"), + named_id(&runner, "Library D"), + named_id(&runner, "Library F"), + named_id(&runner, "Library G"), + graveyard_c, + ]; + let chosen = vec![library_c, graveyard_a, library_e, graveyard_b, library_a]; + let expected_top_order = chosen.clone(); + + let mut cast = runner.cast(spell).commit(); + for _ in 0..2 { + assert!(matches!( + cast.state().waiting_for, + WaitingFor::Priority { .. } + )); + cast.act(GameAction::PassPriority) + .expect("passing priority should resolve Doomsday"); + if !matches!(cast.state().waiting_for, WaitingFor::Priority { .. }) { + break; + } + } + + let search_cards = match &cast.state().waiting_for { + WaitingFor::SearchChoice { cards, count, .. } => { + assert_eq!(*count, 5, "Doomsday must require five selected cards"); + assert!(chosen.iter().all(|id| cards.contains(id))); + cards.clone() + } + other => panic!("expected SearchChoice for Doomsday, got {other:?}"), + }; + assert_eq!( + search_cards.len(), + 10, + "library + graveyard search candidates" + ); + + cast.act(GameAction::SelectCards { + cards: chosen.clone(), + }) + .expect("selecting Doomsday's five cards should be legal"); + + assert!( + matches!(cast.state().waiting_for, WaitingFor::Priority { .. }), + "Doomsday should finish its resolution after the ordered search choice, got {:?}", + cast.state().waiting_for + ); + assert_eq!( + cast.state().players[P0.0 as usize].life, + 10, + "the verbatim Doomsday text must also resolve its half-life loss" + ); + + let actual_top_order: Vec<_> = cast.state().players[P0.0 as usize] + .library + .iter() + .take(5) + .copied() + .collect(); + assert_eq!( + actual_top_order, expected_top_order, + "the selected cards must be placed on top in the submitted order" + ); + for id in unchosen { + assert_eq!( + cast.state().objects[&id].zone, + Zone::Exile, + "unchosen library cards must be exiled" + ); + } + assert_eq!( + cast.state().objects[&graveyard_a].zone, + Zone::Library, + "chosen graveyard cards must be ordered into the library" + ); + assert_eq!( + cast.state().objects[&graveyard_b].zone, + Zone::Library, + "chosen graveyard cards must be ordered into the library" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index e59c831416..ac23b422d5 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -191,6 +191,7 @@ mod disjunctive_state_change_head_coverage_honesty; mod disorder_in_the_court_5955; mod divine_visitation_token_substitution; mod doom_s_time_platform_exile_with_time_counters; +mod doomsday; mod doran_attack_block_pump; mod double_strike_first_strike_trigger_removes_attacker; mod dragon_man_reformed_robot_graveyard_discard_cost; From 5337abbf857f995b60fb9eef9755314f970b71ec Mon Sep 17 00:00:00 2001 From: traemyn Date: Fri, 14 Aug 2026 20:13:54 -0500 Subject: [PATCH 2/3] Fix Doomsday selected search set publication --- crates/engine/src/game/effects/mod.rs | 9 ++++- .../src/game/engine_resolution_choices.rs | 15 +++++++ .../src/parser/oracle_effect/sequence.rs | 39 ++++++++++++++++++- crates/engine/tests/integration/doomsday.rs | 23 +++++++++-- 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 20521d54e3..f20c8ce56e 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -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; } } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 92b2158bf0..79d8e8cdf1 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -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 diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 201d113fe5..7dd223f8f4 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -4771,14 +4771,26 @@ pub(super) fn apply_clause_continuation( 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 { - return; + 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) @@ -5365,6 +5377,9 @@ 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, @@ -11939,6 +11954,28 @@ mod tests { 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, diff --git a/crates/engine/tests/integration/doomsday.rs b/crates/engine/tests/integration/doomsday.rs index ef37eab924..5bf4d387db 100644 --- a/crates/engine/tests/integration/doomsday.rs +++ b/crates/engine/tests/integration/doomsday.rs @@ -3,6 +3,7 @@ use engine::game::scenario::{GameRunner, GameScenario, P0}; use engine::types::actions::GameAction; +use engine::types::events::GameEvent; use engine::types::game_state::WaitingFor; use engine::types::phase::Phase; use engine::types::zones::Zone; @@ -90,10 +91,24 @@ fn doomsday_exiles_search_remainder_and_orders_five_chosen_cards() { "library + graveyard search candidates" ); - cast.act(GameAction::SelectCards { - cards: chosen.clone(), - }) - .expect("selecting Doomsday's five cards should be legal"); + let resolution = cast + .act(GameAction::SelectCards { + cards: chosen.clone(), + }) + .expect("selecting Doomsday's five cards should be legal"); + + assert!( + !resolution.events.iter().any(|event| matches!( + event, + GameEvent::ZoneChanged { + object_id, + to: Zone::Exile, + .. + } if chosen.contains(object_id) + )), + "chosen cards must never undergo the remainder's exile move; events={:?}", + resolution.events + ); assert!( matches!(cast.state().waiting_for, WaitingFor::Priority { .. }), From 69b8cce4cc32039fcea65b33885cb595d7cf14ab Mon Sep 17 00:00:00 2001 From: traemyn Date: Fri, 14 Aug 2026 20:25:03 -0500 Subject: [PATCH 3/3] Update engine prompt census pins --- crates/engine/src/game/engine.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 6c1e9575f9..d734096a43 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -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.