diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 85ff03e2a8..eecc01eedc 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -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; } } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 2cc535af58..8c8dcff886 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -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. 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 7437fa9d2e..7dd223f8f4 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,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; @@ -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, @@ -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 { @@ -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() => { @@ -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, @@ -8298,7 +8370,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 +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, diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index e33ab0e900..1e8a04e682 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -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 /// '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..5bf4d387db --- /dev/null +++ b/crates/engine/tests/integration/doomsday.rs @@ -0,0 +1,151 @@ +//! 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::events::GameEvent; +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" + ); + + 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 { .. }), + "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 84ed9d9f04..64c8103ed2 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;