From 81d6b35387290f1222545d254cd55f04de734f26 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 17:44:17 -0700 Subject: [PATCH 1/3] fix(engine): randomize Dig library remainders --- crates/engine/src/database/hideaway.rs | 1 + crates/engine/src/game/ability_rw.rs | 1 + crates/engine/src/game/ability_scan.rs | 1 + crates/engine/src/game/effects/choose_card.rs | 1 + crates/engine/src/game/effects/dig.rs | 54 ++++- crates/engine/src/game/effects/explore.rs | 1 + crates/engine/src/game/effects/mod.rs | 2 + .../engine/src/game/effects/reveal_until.rs | 5 + .../src/game/engine_resolution_choices.rs | 190 +++++++++++++++++- crates/engine/src/game/mana_abilities.rs | 1 + crates/engine/src/game/visibility.rs | 2 + .../src/parser/oracle_effect/conditions.rs | 3 + .../src/parser/oracle_effect/imperative.rs | 1 + .../engine/src/parser/oracle_effect/lower.rs | 1 + .../src/parser/oracle_effect/sequence.rs | 105 ++++++++-- .../engine/src/parser/oracle_effect/tests.rs | 1 + crates/engine/src/parser/oracle_ir/ast.rs | 16 +- ...napshot_tests__follow_the_lumarets_ir.snap | 2 + crates/engine/src/parser/oracle_tests.rs | 36 ++++ crates/engine/src/types/ability.rs | 22 ++ crates/engine/src/types/game_state.rs | 21 +- .../tests/integration/cost_zone_pipeline.rs | 17 +- .../integration/dig_impossible_keep_count.rs | 1 + .../dig_rest_pile_stranding_on_etb_pause.rs | 1 + .../issue_5996_planetarium_look_cast.rs | 1 + .../integration/issue_6367_thassas_oracle.rs | 118 +++++++++++ crates/engine/tests/integration/main.rs | 1 + .../integration/metamorphic_alteration.rs | 1 + crates/mtgish-import/src/convert/action.rs | 43 +++- crates/phase-ai/src/determinize.rs | 1 + crates/phase-ai/src/features/control.rs | 2 + .../src/features/spellslinger_prowess.rs | 2 + .../src/features/tests/graveyard_types.rs | 2 + crates/phase-ai/src/search.rs | 2 + crates/server-core/src/session.rs | 1 + 35 files changed, 610 insertions(+), 50 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6367_thassas_oracle.rs diff --git a/crates/engine/src/database/hideaway.rs b/crates/engine/src/database/hideaway.rs index e5b0ce8e8f..dcc298d110 100644 --- a/crates/engine/src/database/hideaway.rs +++ b/crates/engine/src/database/hideaway.rs @@ -85,6 +85,7 @@ fn hideaway_trigger(n: u32) -> TriggerDefinition { filter: TargetFilter::Any, // CR 702.75a: "put the rest on the bottom of your library." rest_destination: Some(Zone::Library), + rest_order: crate::types::ability::DigRestOrder::Random, // CR 701.20e: the cards are looked at privately, not revealed. reveal: false, enter_tapped: false, diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 3d6b8a59b6..a5578a07be 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -4675,6 +4675,7 @@ fn rw_effect( keep_count: _, up_to: _, rest_destination: _, + rest_order: _, reveal: _, enter_tapped: _, source: _, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 15d91485f9..dec21a96a1 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -709,6 +709,7 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { keep_count: _, up_to: _, rest_destination: _, + rest_order: _, reveal: _, enter_tapped: _, source: _, diff --git a/crates/engine/src/game/effects/choose_card.rs b/crates/engine/src/game/effects/choose_card.rs index 212c69a6ac..bcc32752c4 100644 --- a/crates/engine/src/game/effects/choose_card.rs +++ b/crates/engine/src/game/effects/choose_card.rs @@ -86,6 +86,7 @@ pub fn resolve( up_to: false, kept_destination: None, rest_destination: None, + rest_order: crate::types::ability::DigRestOrder::Preserve, source_id: Some(ability.source_id), enter_tapped: false, }; diff --git a/crates/engine/src/game/effects/dig.rs b/crates/engine/src/game/effects/dig.rs index 806c1f1a21..26a0708627 100644 --- a/crates/engine/src/game/effects/dig.rs +++ b/crates/engine/src/game/effects/dig.rs @@ -1,8 +1,8 @@ use crate::game::filter::{matches_target_filter, FilterContext}; use crate::game::quantity::resolve_quantity_with_targets; use crate::types::ability::{ - DigSource, Effect, EffectError, EffectKind, ParentTargetMissingReason, ResolvedAbility, - TargetFilter, + DigRestOrder, DigSource, Effect, EffectError, EffectKind, ParentTargetMissingReason, + ResolvedAbility, TargetFilter, }; use crate::types::events::GameEvent; use crate::types::game_state::{BatchCompletion, GameState, WaitingFor}; @@ -23,6 +23,7 @@ pub fn resolve( filter, kept_dest, rest_dest, + rest_order, is_reveal, enter_tapped, dig_source, @@ -36,6 +37,7 @@ pub fn resolve( filter, destination, rest_destination, + rest_order, reveal, enter_tapped, source, @@ -63,6 +65,7 @@ pub fn resolve( filter.clone(), *destination, *rest_destination, + *rest_order, *reveal, *enter_tapped, *source, @@ -76,6 +79,7 @@ pub fn resolve( TargetFilter::Any, None, None, + DigRestOrder::Preserve, false, false, DigSource::Library, @@ -107,6 +111,7 @@ pub fn resolve( filter, kept_dest, rest_dest, + rest_order, enter_tapped, ); } @@ -241,6 +246,7 @@ pub fn resolve( &selectable_cards, dest, rest_dest, + rest_order, enter_tapped, events, ); @@ -263,6 +269,7 @@ pub fn resolve( up_to: is_up_to, kept_destination: kept_dest, rest_destination: rest_dest, + rest_order, source_id: Some(ability.source_id), enter_tapped, }; @@ -300,6 +307,7 @@ fn resolve_from_prior_look( filter: TargetFilter, kept_dest: Option, rest_dest: Option, + rest_order: DigRestOrder, enter_tapped: bool, ) -> Result<(), EffectError> { let cards = state.private_look_ids.clone(); @@ -327,6 +335,7 @@ fn resolve_from_prior_look( state, &cards, dest, + rest_order, Some(ability.source_id), events, ) { @@ -374,6 +383,7 @@ fn resolve_from_prior_look( state, &cards, dest, + rest_order, Some(ability.source_id), events, ) { @@ -417,6 +427,7 @@ fn resolve_from_prior_look( up_to: is_up_to, kept_destination: kept_dest, rest_destination: rest_dest, + rest_order, source_id: Some(ability.source_id), enter_tapped, }; @@ -450,6 +461,7 @@ fn resolve_mass_put_all( selectable: &[crate::types::identifiers::ObjectId], dest: Zone, rest_destination: Option, + rest_order: DigRestOrder, enter_tapped: bool, events: &mut Vec, ) { @@ -465,6 +477,7 @@ fn resolve_mass_put_all( state, &rest, rest_destination.unwrap_or(Zone::Library), + rest_order, Some(ability.source_id), events, ) { @@ -550,6 +563,7 @@ mod tests { use crate::types::mana::{ManaCost, ManaCostShard}; use crate::types::player::PlayerId; use crate::types::zones::Zone; + use rand::seq::SliceRandom; fn make_dig_ability(dig_num: u32) -> ResolvedAbility { ResolvedAbility::new( @@ -564,6 +578,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -658,6 +673,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -711,6 +727,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -764,6 +781,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -834,6 +852,7 @@ mod tests { up_to: true, kept_destination: None, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -915,6 +934,7 @@ mod tests { up_to: true, kept_destination: None, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -975,6 +995,7 @@ mod tests { up_to: false, kept_destination: Some(Zone::Library), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -1046,6 +1067,7 @@ mod tests { up_to: false, kept_destination: Some(Zone::Library), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -1109,6 +1131,7 @@ mod tests { up_to: true, kept_destination: Some(Zone::Hand), rest_destination: Some(Zone::Graveyard), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -1169,6 +1192,7 @@ mod tests { up_to: true, kept_destination: Some(Zone::Hand), rest_destination: Some(Zone::Graveyard), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -1238,6 +1262,7 @@ mod tests { up_to: true, kept_destination: Some(Zone::Hand), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -1310,6 +1335,7 @@ mod tests { up_to: true, kept_destination: Some(Zone::Hand), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -1376,6 +1402,7 @@ mod tests { up_to: true, kept_destination: Some(Zone::Hand), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -1453,6 +1480,7 @@ mod tests { up_to: false, filter, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -1529,6 +1557,7 @@ mod tests { up_to: false, filter: TargetFilter::Typed(TypedFilter::creature()), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -1834,6 +1863,7 @@ mod tests { up_to: true, filter: filter.clone(), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -1890,6 +1920,7 @@ mod tests { up_to: true, filter: filter_you, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -1957,6 +1988,7 @@ mod tests { up_to: true, kept_destination: Some(Zone::Battlefield), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, }; @@ -2026,6 +2058,7 @@ mod tests { up_to, destination, rest_destination, + rest_order, .. } => { assert_eq!( @@ -2041,6 +2074,11 @@ mod tests { "the in-clause 'and the rest on the bottom' rider must set rest=Library, \ not fall through to the graveyard default" ); + assert_eq!( + *rest_order, + DigRestOrder::Random, + "Muxus's exact random-order rider must reach the mass Dig resolver" + ); } other => panic!("expected a Dig effect, got {other:?}"), } @@ -2083,6 +2121,9 @@ mod tests { let ability = ResolvedAbility::new((*def.effect).clone(), vec![], ObjectId(100), PlayerId(0)); + let mut expected_rest = rest.clone(); + let mut expected_rng = state.rng.clone(); + expected_rest.shuffle(&mut expected_rng); let mut events = Vec::new(); resolve(&mut state, &ability, &mut events).unwrap(); @@ -2115,6 +2156,15 @@ mod tests { "non-matching card {id:?} must remain in the library" ); } + assert_eq!( + library, expected_rest, + "Muxus's random-order rest pile must use the seeded shuffle before bottom placement" + ); + assert_eq!( + state.rng.get_word_pos(), + expected_rng.get_word_pos(), + "the deterministic mass path must consume exactly its rest-pile shuffle" + ); let bottom: Vec<_> = library .iter() .rev() diff --git a/crates/engine/src/game/effects/explore.rs b/crates/engine/src/game/effects/explore.rs index ac46bd144e..bfd05f2024 100644 --- a/crates/engine/src/game/effects/explore.rs +++ b/crates/engine/src/game/effects/explore.rs @@ -352,6 +352,7 @@ pub(crate) fn resolve_explore_effect( up_to: true, kept_destination: Some(crate::types::zones::Zone::Library), rest_destination: Some(crate::types::zones::Zone::Graveyard), + rest_order: crate::types::ability::DigRestOrder::Preserve, source_id: Some(ability.source_id), enter_tapped: false, }; diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 03c7d6aaf8..35a097a7ff 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -27352,6 +27352,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -27986,6 +27987,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, diff --git a/crates/engine/src/game/effects/reveal_until.rs b/crates/engine/src/game/effects/reveal_until.rs index 861c3742dd..2ac0790d19 100644 --- a/crates/engine/src/game/effects/reveal_until.rs +++ b/crates/engine/src/game/effects/reveal_until.rs @@ -260,6 +260,7 @@ pub fn resolve( source_id: Some(ability.source_id), rest_cards: revealed_misses, rest_destination, + rest_order: crate::types::ability::DigRestOrder::Preserve, clear_markers, publish_tracked_set: None, emit_reveal_until_resolved: Some(ability.source_id), @@ -310,6 +311,7 @@ pub fn resolve( source_id: Some(ability.source_id), rest_cards: revealed_misses, rest_destination, + rest_order: crate::types::ability::DigRestOrder::Preserve, clear_markers, publish_tracked_set: None, emit_reveal_until_resolved: Some(ability.source_id), @@ -343,6 +345,7 @@ pub fn resolve( source_id: Some(ability.source_id), rest_cards: revealed_misses, rest_destination, + rest_order: crate::types::ability::DigRestOrder::Preserve, clear_markers, publish_tracked_set: None, emit_reveal_until_resolved: Some(ability.source_id), @@ -383,6 +386,7 @@ pub fn resolve( source_id: Some(ability.source_id), rest_cards: Vec::new(), rest_destination, + rest_order: crate::types::ability::DigRestOrder::Preserve, clear_markers, publish_tracked_set: None, emit_reveal_until_resolved: Some(ability.source_id), @@ -517,6 +521,7 @@ fn resolve_choose_any_number( selectable_cards: matched, kept_destination: Some(kept_destination), rest_destination: Some(rest_destination), + rest_order: crate::types::ability::DigRestOrder::Preserve, source_id: Some(ability.source_id), enter_tapped: enter_tapped.is_tapped(), }; diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 74bdab9971..9dd8ee5e32 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1,9 +1,11 @@ // engine-citation-gate: symbol anchors only use std::collections::{HashMap, HashSet}; +use rand::seq::SliceRandom; + use crate::types::ability::{ - AbilityCost, ChoiceType, ChosenAttribute, Effect, EffectKind, GuessOutcome, LibraryPosition, - QuantityExpr, QuantityRef, ResolvedAbility, TargetRef, ThisWayCause, + AbilityCost, ChoiceType, ChosenAttribute, DigRestOrder, Effect, EffectKind, GuessOutcome, + LibraryPosition, QuantityExpr, QuantityRef, ResolvedAbility, TargetRef, ThisWayCause, }; use crate::types::actions::{GameAction, LearnOption, OutsideGameSelection}; use crate::types::events::GameEvent; @@ -759,10 +761,18 @@ pub(crate) fn route_rest_partition( state: &mut GameState, rest_ids: &[ObjectId], rest_zone: Zone, + rest_order: DigRestOrder, source_id: Option, events: &mut Vec, ) -> crate::game::zone_pipeline::BatchMoveResult { - route_rest_partition_then(state, rest_ids, rest_zone, source_id, None, events) + let mut ordered_ids = rest_ids.to_vec(); + if rest_zone == Zone::Library && rest_order == DigRestOrder::Random { + // CR 400.5 + CR 608.2c: Exact Oracle text requires a randomized + // remainder; only this rest pile, not the remainder of the library, + // consumes entropy. + ordered_ids.shuffle(&mut state.rng); + } + route_rest_partition_then(state, &ordered_ids, rest_zone, source_id, None, events) } fn route_rest_partition_then( @@ -1754,6 +1764,7 @@ pub(super) fn handle_resolution_choice( source_id: Some(source_id), rest_cards: graveyard_cards, rest_destination: Zone::Graveyard, + rest_order: DigRestOrder::Preserve, clear_markers: cards.clone(), publish_tracked_set: None, emit_reveal_until_resolved: None, @@ -1992,6 +2003,7 @@ pub(super) fn handle_resolution_choice( source_id: Some(source_id), rest_cards: misses, rest_destination, + rest_order: DigRestOrder::Preserve, clear_markers, publish_tracked_set: None, emit_reveal_until_resolved: None, @@ -2068,6 +2080,7 @@ pub(super) fn handle_resolution_choice( source_id: Some(source_id), rest_cards: Vec::new(), rest_destination, + rest_order: DigRestOrder::Preserve, clear_markers, publish_tracked_set: None, emit_reveal_until_resolved: None, @@ -3303,6 +3316,7 @@ pub(super) fn handle_resolution_choice( selectable_cards, kept_destination, rest_destination, + rest_order, enter_tapped, source_id: dig_source_id, .. @@ -3345,7 +3359,7 @@ pub(super) fn handle_resolution_choice( // filtered dig that matched nothing accepted arbitrary object ids. validate_dig_selection(&kept, &cards, &selectable_cards)?; - let unkept: Vec<_> = cards + let mut unkept: Vec<_> = cards .iter() .filter(|id| !kept.contains(id)) .copied() @@ -3365,6 +3379,11 @@ pub(super) fn handle_resolution_choice( } match rest_destination { Some(Zone::Library) => { + if rest_order == DigRestOrder::Random { + // CR 400.5 + CR 608.2c: Randomize exactly the + // unchosen pile immediately before bottom placement. + unkept.shuffle(&mut state.rng); + } for &obj_id in &unkept { // allow-raw-zone: looked-at cards remain library objects until a keep decision (CR 701.20b/e). player_state.library.push_back(obj_id); @@ -3419,6 +3438,7 @@ pub(super) fn handle_resolution_choice( source_id: dig_source_id, rest_cards: Vec::new(), rest_destination: zone, + rest_order: DigRestOrder::Preserve, clear_markers: Vec::new(), publish_tracked_set: None, emit_reveal_until_resolved: None, @@ -3477,6 +3497,7 @@ pub(super) fn handle_resolution_choice( unkept }, rest_destination: rest_destination.unwrap_or(Zone::Graveyard), + rest_order, publish_tracked_set: publish_set, continuation_targets: kept.clone(), }, @@ -3543,6 +3564,7 @@ pub(super) fn handle_resolution_choice( source_id: dig_source_id, rest_cards: unkept.clone(), rest_destination: rest_destination.unwrap_or(Zone::Graveyard), + rest_order, clear_markers: Vec::new(), publish_tracked_set: Some(kept.clone()), emit_reveal_until_resolved: None, @@ -3587,6 +3609,7 @@ pub(super) fn handle_resolution_choice( state, &unkept, rest_destination.unwrap_or(Zone::Graveyard), + rest_order, dig_source_id, events, ) { @@ -3603,6 +3626,7 @@ pub(super) fn handle_resolution_choice( source_id: dig_source_id, rest_cards: Vec::new(), rest_destination: rest_destination.unwrap_or(Zone::Graveyard), + rest_order, clear_markers: Vec::new(), publish_tracked_set: Some(publish_set), emit_reveal_until_resolved: None, @@ -7151,6 +7175,7 @@ fn route_kept_card_or_defer( source_id: Some(source_id), rest_cards: misses.to_vec(), rest_destination, + rest_order: DigRestOrder::Preserve, clear_markers, publish_tracked_set: None, emit_reveal_until_resolved: None, @@ -7723,12 +7748,19 @@ pub(crate) fn run_batch_completion( source_id, rest_cards, rest_destination, + rest_order, publish_tracked_set, continuation_targets, } => { if !rest_cards.is_empty() { - match route_rest_partition(state, &rest_cards, rest_destination, source_id, events) - { + match route_rest_partition( + state, + &rest_cards, + rest_destination, + rest_order, + source_id, + events, + ) { crate::game::zone_pipeline::BatchMoveResult::Done => {} crate::game::zone_pipeline::BatchMoveResult::NeedsChoice => { crate::game::zone_pipeline::defer_completion_on_pause( @@ -7738,6 +7770,7 @@ pub(crate) fn run_batch_completion( source_id, rest_cards: Vec::new(), rest_destination, + rest_order, publish_tracked_set, continuation_targets, }, @@ -7814,6 +7847,7 @@ pub(crate) fn run_batch_completion( source_id, rest_cards, rest_destination, + rest_order, clear_markers, publish_tracked_set, emit_reveal_until_resolved, @@ -7824,8 +7858,14 @@ pub(crate) fn run_batch_completion( // Library-bottom placement and any CR 616.1 pause. Dispatch on the // dig-only payload so each site keeps its synchronous semantics. if publish_tracked_set.is_some() { - match route_rest_partition(state, &rest_cards, rest_destination, source_id, events) - { + match route_rest_partition( + state, + &rest_cards, + rest_destination, + rest_order, + source_id, + events, + ) { crate::game::zone_pipeline::BatchMoveResult::Done => {} crate::game::zone_pipeline::BatchMoveResult::NeedsChoice => { crate::game::zone_pipeline::defer_completion_on_pause( @@ -7835,6 +7875,7 @@ pub(crate) fn run_batch_completion( source_id, rest_cards: Vec::new(), rest_destination, + rest_order, clear_markers, publish_tracked_set, emit_reveal_until_resolved, @@ -7854,6 +7895,7 @@ pub(crate) fn run_batch_completion( source_id, rest_cards: Vec::new(), rest_destination, + rest_order: DigRestOrder::Preserve, clear_markers, publish_tracked_set: None, emit_reveal_until_resolved, @@ -9776,6 +9818,7 @@ mod tests { selectable_cards: vec![black, white], kept_destination: Some(Zone::Library), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, source_id: None, enter_tapped: false, }, @@ -9790,6 +9833,137 @@ mod tests { ); } + /// CR 400.5 + CR 608.2c: a Dig's explicit random-bottom instruction draws + /// exactly once from the game's seeded RNG immediately before the unkept + /// cards are appended. Its Preserve sibling must retain the look order and + /// leave the RNG untouched. + #[test] + fn dig_choice_library_rest_honors_random_and_preserve_order() { + fn state_with_looked_at_cards() -> (GameState, [ObjectId; 3], ObjectId) { + let mut state = GameState::new_two_player(0x6367); + let _keep = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Keep".to_string(), + Zone::Library, + ); + let rest = [ + create_object( + &mut state, + CardId(2), + PlayerId(0), + "Rest One".to_string(), + Zone::Library, + ), + create_object( + &mut state, + CardId(3), + PlayerId(0), + "Rest Two".to_string(), + Zone::Library, + ), + create_object( + &mut state, + CardId(4), + PlayerId(0), + "Rest Three".to_string(), + Zone::Library, + ), + ]; + let below = create_object( + &mut state, + CardId(5), + PlayerId(0), + "Below Look Window".to_string(), + Zone::Library, + ); + // `create_object` appends to the library, making this exact + // top-to-bottom window `[keep, rest..., below]`. + (state, rest, below) + } + + let (mut random_state, rest, below) = state_with_looked_at_cards(); + let keep = random_state.players[0].library[0]; + let mut expected_rest = rest.to_vec(); + let mut expected_rng = random_state.rng.clone(); + expected_rest.shuffle(&mut expected_rng); + let mut events = Vec::new(); + handle_resolution_choice( + &mut random_state, + WaitingFor::DigChoice { + player: PlayerId(0), + library_owner: PlayerId(0), + cards: vec![keep, rest[0], rest[1], rest[2]], + keep_count: 1, + up_to: false, + selectable_cards: vec![keep, rest[0], rest[1], rest[2]], + kept_destination: Some(Zone::Library), + rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Random, + source_id: None, + enter_tapped: false, + }, + GameAction::SelectCards { cards: vec![keep] }, + &mut events, + ) + .expect("random-bottom Dig choice resolves"); + + assert_eq!( + random_state.players[0] + .library + .iter() + .copied() + .collect::>(), + [vec![keep, below], expected_rest].concat(), + "random rest must occupy the bottom in the seeded permutation" + ); + assert_eq!( + random_state.rng.get_word_pos(), + expected_rng.get_word_pos(), + "random rest must consume exactly the seeded shuffle stream" + ); + + let (mut preserve_state, rest, below) = state_with_looked_at_cards(); + let keep = preserve_state.players[0].library[0]; + let before_rng = preserve_state.rng.get_word_pos(); + let mut events = Vec::new(); + handle_resolution_choice( + &mut preserve_state, + WaitingFor::DigChoice { + player: PlayerId(0), + library_owner: PlayerId(0), + cards: vec![keep, rest[0], rest[1], rest[2]], + keep_count: 1, + up_to: false, + selectable_cards: vec![keep, rest[0], rest[1], rest[2]], + kept_destination: Some(Zone::Library), + rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, + source_id: None, + enter_tapped: false, + }, + GameAction::SelectCards { cards: vec![keep] }, + &mut events, + ) + .expect("preserve-bottom Dig choice resolves"); + + assert_eq!( + preserve_state.players[0] + .library + .iter() + .copied() + .collect::>(), + vec![keep, below, rest[0], rest[1], rest[2]], + "legacy/non-random Dig rest must preserve the looked-at order" + ); + assert_eq!( + preserve_state.rng.get_word_pos(), + before_rng, + "preserved rest must not consume RNG" + ); + } + #[test] fn land_nonland_guess_logs_without_persisting_a_source_label() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index fb13b79703..15bdc8fceb 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -5776,6 +5776,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source, diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index eb87e158a3..c4b9090aea 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -849,6 +849,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState ref selectable_cards, kept_destination, rest_destination, + rest_order, source_id, enter_tapped, } = state.waiting_for @@ -863,6 +864,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState selectable_cards: selectable_cards.iter().map(|_| ObjectId(0)).collect(), kept_destination, rest_destination, + rest_order, source_id, enter_tapped, }; diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 808243b40f..dc89004731 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -4235,6 +4235,7 @@ pub(crate) fn try_parse_dig_instead_alternative( player: prev_player, count: prev_count, rest_destination: prev_rest, + rest_order: prev_rest_order, reveal: prev_reveal, .. } = &*prev.effect @@ -4312,6 +4313,7 @@ pub(crate) fn try_parse_dig_instead_alternative( filter: alt_filter, destination: alt_destination, rest_destination: alt_rest, + rest_order: alt_rest_order, enter_tapped: alt_enter_tapped, .. } = alt_continuation @@ -4348,6 +4350,7 @@ pub(crate) fn try_parse_dig_instead_alternative( up_to: alt_up_to, filter: alt_filter, rest_destination: alt_rest.or(*prev_rest), + rest_order: alt_rest.map_or(*prev_rest_order, |_| alt_rest_order), reveal: *prev_reveal, enter_tapped: alt_enter_tapped, source: DigSource::Library, diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 7278021a5d..6c90e024cf 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -3382,6 +3382,7 @@ pub(super) fn lower_search_and_creation_ast(ast: SearchCreationImperativeAst) -> up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: crate::types::ability::DigRestOrder::Preserve, reveal, enter_tapped: false, source: DigSource::Library, diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index a1655d74ad..43b539c1fa 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -12427,6 +12427,7 @@ mod where_x_tests { ]), ), rest_destination: Some(Zone::Library), + rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 12e3980cae..398443ca71 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -24,12 +24,12 @@ use crate::parser::oracle_quantity::{ }; use crate::types::ability::{ AbilityCondition, AbilityDefinition, AbilityKind, CastingPermission, ChoiceType, Chooser, - ContinuousModification, ControllerRef, CopyRetargetPermission, CounterSourceRider, DigSource, - Duration, Effect, EffectScope, ExcessRecipient, FaceDownBody, FaceDownProfile, FilterProp, - ForEachCategoryAction, LibraryPosition, ManaSpendRestriction, MultiTargetSpec, ObjectScope, - PermissionGrantee, PlayerFilter, PtValue, QuantityExpr, QuantityRef, RevealUntilDisposition, - SpellStackToGraveyardReplacement, StaticDefinition, TargetChoiceTiming, TargetFilter, - TypeFilter, TypedFilter, + ContinuousModification, ControllerRef, CopyRetargetPermission, CounterSourceRider, + DigRestOrder, DigSource, Duration, Effect, EffectScope, ExcessRecipient, FaceDownBody, + FaceDownProfile, FilterProp, ForEachCategoryAction, LibraryPosition, ManaSpendRestriction, + MultiTargetSpec, ObjectScope, PermissionGrantee, PlayerFilter, PtValue, QuantityExpr, + QuantityRef, RevealUntilDisposition, SpellStackToGraveyardReplacement, StaticDefinition, + TargetChoiceTiming, TargetFilter, TypeFilter, TypedFilter, }; use crate::types::card_type::CoreType; use crate::types::counter::CounterType; @@ -817,21 +817,33 @@ fn parse_put_all_back_in_any_order(lower: &str) -> bool { .is_ok() } -fn parse_put_one_dig_card_on_top(lower: &str) -> bool { - ( +fn parse_put_one_dig_card_on_top(lower: &str) -> Option { + let (rest, _) = ( alt(( tag::<_, _, OracleError<'_>>("you may put "), tag("may put "), tag("put "), )), + opt(tag("up to ")), alt((tag("one of those cards"), tag("one of them"))), - tag(" back "), + opt(tag(" back")), + tag(" "), alt((tag("on top of your library"), tag("on top"))), - opt(tag(".")), - eof, ) .parse(lower.trim()) - .is_ok() + .ok()?; + let (rest, order) = opt(value( + DigRestOrder::Random, + tag::<_, _, OracleError<'_>>( + " and the rest on the bottom of your library in a random order", + ), + )) + .parse(rest) + .ok()?; + terminated(opt(tag::<_, _, OracleError<'_>>(".")), eof) + .parse(rest) + .ok()?; + Some(order.unwrap_or(DigRestOrder::Preserve)) } fn parse_exile_rest_after_dig(lower: &str) -> bool { @@ -4204,6 +4216,7 @@ pub(super) fn apply_clause_continuation( filter: card_filter, destination: kept_dest, rest_destination: rest_dest, + rest_order: continuation_rest_order, enters_under, face_down_profile, enter_tapped, @@ -4298,6 +4311,7 @@ pub(super) fn apply_clause_continuation( filter: TargetFilter::Any, destination: None, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::PriorLook, @@ -4319,6 +4333,7 @@ pub(super) fn apply_clause_continuation( filter: card_filter, destination: kept_dest, rest_destination: Some(rest_dest.unwrap_or(Zone::Library)), + rest_order: continuation_rest_order, reveal: false, enter_tapped, source: DigSource::PriorLook, @@ -4361,6 +4376,7 @@ pub(super) fn apply_clause_continuation( filter, destination, rest_destination, + rest_order, reveal, enter_tapped: dig_enter_tapped, .. @@ -4411,6 +4427,7 @@ pub(super) fn apply_clause_continuation( if let Some(rd) = rest_dest { *rest_destination = Some(rd); } + *rest_order = continuation_rest_order; *dig_enter_tapped = enter_tapped; } else if let Effect::Mill { destination: mill_destination, @@ -5585,6 +5602,7 @@ pub(super) fn parse_dig_from_among( filter, destination, rest_destination: None, + rest_order: DigRestOrder::Preserve, enters_under, face_down_profile, enter_tapped, @@ -5678,13 +5696,17 @@ pub(super) fn parse_dig_from_among( // shared rest-anaphor matcher so the rest pile is routed correctly instead of // falling through to the `None`→Graveyard default. A genuinely separate // "Put the rest ..." sentence still patches via its own PutRest continuation. - let rest_destination = parse_of_them_rest_destination(lower); + let (rest_destination, rest_order) = parse_of_them_rest_destination(lower) + .map_or((None, DigRestOrder::Preserve), |(destination, order)| { + (Some(destination), order) + }); return Some(ContinuationAst::DigFromAmong { quantity, filter, destination, rest_destination, + rest_order, enters_under, face_down_profile, enter_tapped, @@ -5737,13 +5759,17 @@ pub(super) fn parse_dig_from_among( PutCount::up(1) }; - let rest_destination = parse_of_them_rest_destination(lower); + let (rest_destination, rest_order) = parse_of_them_rest_destination(lower) + .map_or((None, DigRestOrder::Preserve), |(destination, order)| { + (Some(destination), order) + }); return Some(ContinuationAst::DigFromAmong { quantity, filter: TargetFilter::Any, destination, rest_destination, + rest_order, enters_under: None, face_down_profile: None, enter_tapped, @@ -6116,18 +6142,33 @@ pub(super) fn parse_its_face_down_profile(lower: &str) -> Option Option { +fn parse_of_them_rest_destination(lower: &str) -> Option<(Zone, DigRestOrder)> { let (_, (_, after_rest)) = nom_primitives::split_once_on(lower, " and the rest") .or_else(|_| nom_primitives::split_once_on(lower, " and the other")) .ok()?; - if contains_possessive(after_rest, "into", "graveyard") { - Some(Zone::Graveyard) + let destination = if contains_possessive(after_rest, "into", "graveyard") { + Zone::Graveyard } else if contains_possessive(after_rest, "into", "hand") { - Some(Zone::Hand) + Zone::Hand } else { // Default: bottom of library ("on the bottom", "in any order", etc.) - Some(Zone::Library) - } + Zone::Library + }; + let random = opt(preceded( + take_until::<_, _, OracleError<'_>>(" in a random order"), + tag(" in a random order"), + )) + .parse(after_rest) + .ok() + .is_some_and(|(_, matched)| matched.is_some()); + Some(( + destination, + if random { + DigRestOrder::Random + } else { + DigRestOrder::Preserve + }, + )) } /// CR 608.2c: The controller follows a card's instructions in written order; @@ -6781,6 +6822,7 @@ pub(super) fn parse_followup_continuation_ast( ])), destination: Some(Zone::Battlefield), rest_destination: None, + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -6791,12 +6833,18 @@ pub(super) fn parse_followup_continuation_ast( // "You may put one of those cards back on top of your library" after // Dig — keep up to one looked-at card on top, leaving the remainder // for a following rest-placement clause. - Effect::Dig { .. } if parse_put_one_dig_card_on_top(&lower) => { + Effect::Dig { .. } if parse_put_one_dig_card_on_top(&lower).is_some() => { Some(ContinuationAst::DigFromAmong { quantity: PutCount::up(1), filter: TargetFilter::Any, destination: Some(Zone::Library), - rest_destination: None, + rest_destination: matches!( + parse_put_one_dig_card_on_top(&lower), + Some(DigRestOrder::Random) + ) + .then_some(Zone::Library), + rest_order: parse_put_one_dig_card_on_top(&lower) + .expect("continuation guard just matched"), enters_under: None, face_down_profile: None, enter_tapped: false, @@ -9785,6 +9833,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -9966,6 +10015,7 @@ mod tests { filter: TargetFilter::Any, destination: Some(Zone::Hand), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -9990,6 +10040,7 @@ mod tests { filter: TargetFilter::Any, destination: Some(Zone::Hand), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -10019,6 +10070,7 @@ mod tests { filter: TargetFilter::Any, destination: Some(Zone::Hand), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -10042,6 +10094,7 @@ mod tests { filter: TargetFilter::Any, destination: Some(Zone::Hand), rest_destination: Some(Zone::Graveyard), + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -10065,6 +10118,7 @@ mod tests { filter: TargetFilter::Any, destination: Some(Zone::Hand), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -10090,6 +10144,7 @@ mod tests { filter: TargetFilter::Any, destination: Some(Zone::Hand), rest_destination: None, + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -10347,6 +10402,7 @@ mod tests { filter: TargetFilter::Any, destination: Some(Zone::Hand), rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -10619,6 +10675,7 @@ mod tests { filter: TargetFilter::Typed(TypedFilter::creature()), destination: Some(Zone::Hand), rest_destination: None, + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -10667,6 +10724,7 @@ mod tests { filter: TargetFilter::Typed(TypedFilter::creature()), destination: Some(Zone::Hand), rest_destination: None, + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -10733,6 +10791,7 @@ mod tests { filter: or_filter.clone(), destination: Some(Zone::Hand), rest_destination: None, + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -11695,6 +11754,7 @@ mod tests { ])), destination: Some(Zone::Battlefield), rest_destination: None, + rest_order: DigRestOrder::Preserve, enters_under: None, face_down_profile: None, enter_tapped: false, @@ -12995,6 +13055,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 6487731100..202788e614 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -22646,6 +22646,7 @@ fn exiled_cause_publishers_all_stamp_exiled_at_runtime() { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: crate::types::ability::DigSource::default(), diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index d1b571e0dc..b185eeba48 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -5,12 +5,12 @@ use crate::types::ability::MultiTargetSpec; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, ActivationRestriction, BounceSelection, CastingPermission, ChosenCounterCountCondition, ControlWindow, ControllerRef, - CopyRetargetPermission, CounterAdjustment, CounterSourceRider, DoorLockOp, Duration, Effect, - EffectScope, FaceDownProfile, ForceBlockAttackerRef, LibraryPosition, ManaProduction, - ManaSpendRestriction, ManaTargetRole, ModalSelectionConstraint, OutsideGameSourcePool, - PlayerFilter, PtStat, PtValue, QuantityExpr, SearchDestinationSplit, SearchSelectionConstraint, - SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, SubAbilityLink, - TargetFilter, + CopyRetargetPermission, CounterAdjustment, CounterSourceRider, DigRestOrder, DoorLockOp, + Duration, Effect, EffectScope, FaceDownProfile, ForceBlockAttackerRef, LibraryPosition, + ManaProduction, ManaSpendRestriction, ManaTargetRole, ModalSelectionConstraint, + OutsideGameSourcePool, PlayerFilter, PtStat, PtValue, QuantityExpr, SearchDestinationSplit, + SearchSelectionConstraint, SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, + SubAbilityLink, TargetFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::CounterType; @@ -420,6 +420,10 @@ pub(crate) enum ContinuationAst { /// "put two of them into your hand and the rest on the bottom of your library". /// When None, a subsequent PutRest continuation handles rest_destination. rest_destination: Option, + /// CR 400.5 + CR 608.2c: Only exact "in a random order" text sets + /// `Random`; every other accepted form preserves existing behavior. + #[serde(default)] + rest_order: DigRestOrder, /// CR 110.2a: Controller override for the kept cards' battlefield entry /// ("... onto the battlefield ... under your control"). `None` leaves /// them under their owner's control. diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap index d261e879a2..153c0d0a8c 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap @@ -1,5 +1,6 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +assertion_line: 1742 expression: "&ir" --- { @@ -140,6 +141,7 @@ expression: "&ir" }, "destination": "Hand", "rest_destination": null, + "rest_order": "preserve", "enter_tapped": false, "reveal_verb": true } diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 7c6c1a3154..eb131e7beb 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -6813,6 +6813,30 @@ fn thassas_oracle_win_condition_gated_by_devotion_vs_library() { .execute .as_ref() .expect("trigger should have execute body"); + match &*exec.effect { + crate::types::ability::Effect::Dig { + keep_count, + up_to, + destination, + rest_destination, + rest_order, + .. + } => { + assert_eq!(*keep_count, Some(1)); + assert!(*up_to); + assert_eq!(*destination, Some(crate::types::zones::Zone::Library)); + assert_eq!(*rest_destination, Some(crate::types::zones::Zone::Library)); + assert_eq!(*rest_order, crate::types::ability::DigRestOrder::Random); + } + other => panic!("Thassa's selection must fuse into Dig, got {other:?}"), + } + assert!( + !matches!( + exec.sub_ability.as_deref().map(|ability| &*ability.effect), + Some(crate::types::ability::Effect::PutAtLibraryPosition { .. }) + ), + "Thassa's top-card instruction must not remain a separate ParentTarget move" + ); // Walk to the innermost SequentialSibling chain — the WinTheGame node. let mut node = exec; while let Some(sub) = node.sub_ability.as_ref() { @@ -18491,6 +18515,7 @@ fn muxus_put_all_from_among_sets_rest_to_library() { up_to, filter, rest_destination, + rest_order, .. } => { assert_eq!(*count, QuantityExpr::Fixed { value: 6 }, "dig six"); @@ -18516,6 +18541,11 @@ fn muxus_put_all_from_among_sets_rest_to_library() { Some(Zone::Library), "the in-clause 'and the rest on the bottom' rider must route the rest to the library, not the graveyard", ); + assert_eq!( + *rest_order, + crate::types::ability::DigRestOrder::Random, + "only the exact random-order rider must request a shuffled rest pile" + ); } other => panic!( "Expected Dig effect, got {:?}", @@ -18542,6 +18572,7 @@ fn commune_with_nature_dig_from_among() { up_to, filter, rest_destination, + rest_order, .. } => { assert_eq!(*count, QuantityExpr::Fixed { value: 5 }); @@ -18554,6 +18585,11 @@ fn commune_with_nature_dig_from_among() { "filter should require creatures", ); assert_eq!(*rest_destination, Some(Zone::Library)); + assert_eq!( + *rest_order, + crate::types::ability::DigRestOrder::Preserve, + "'in any order' must not be misrepresented as a random instruction" + ); } other => { panic!( diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index db49f3f1ff..f1c1c5b351 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10633,6 +10633,24 @@ pub enum PerpetualModification { }, } +/// CR 400.5 + CR 608.2c: The required placement order for a Dig's unkept +/// cards when its rest destination is a library. `Preserve` retains the +/// pre-existing encounter order; `Random` consumes engine RNG immediately +/// before placing the rest pile on the library bottom. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DigRestOrder { + #[default] + Preserve, + Random, +} + +impl DigRestOrder { + pub fn is_preserve(&self) -> bool { + matches!(self, DigRestOrder::Preserve) + } +} + /// CR 701.20e + CR 608.2c: Discriminates where `Effect::Dig` reads its /// card set from. `Library` (the default) reads from the top of the library; /// `PriorLook` reads from `GameState::private_look_ids`, which was populated @@ -11324,6 +11342,10 @@ pub enum Effect { /// Where unchosen cards go (None = Graveyard, Some(Library) = bottom). #[serde(default)] rest_destination: Option, + /// CR 400.5 + CR 608.2c: Ordering instruction for an unchosen + /// library rest pile. `Random` is only set by exact Oracle text. + #[serde(default, skip_serializing_if = "DigRestOrder::is_preserve")] + rest_order: DigRestOrder, /// CR 701.20a vs CR 701.20e: True = cards are revealed (public), false = looked at (private). #[serde(default)] reveal: bool, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 5db8d5e62a..1312281cbf 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -11,11 +11,12 @@ use super::ability::{ CastTimingPermission, CastVariantPaid, CategoryChooserScope, ChoiceType, ChoiceValue, ChooseFromZoneConstraint, ChosenAttribute, CoinFlipResult, Comparator, ContinuousModification, ControlWindow, CopiableValues, CopyChooseScope, CopyScale, CopyTargetPurpose, - CostPaidObjectSnapshot, CounterCostSelection, DelayedTriggerCondition, Duration, EffectKind, - FaceDownProfile, GameRestriction, KeywordAction, KickerVariant, LibraryPosition, ModalChoice, - PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility, SearchDestinationSplit, - SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate, TargetFilter, TargetRef, - ThisWayCause, TriggerCondition, TriggerDefinition, TriggerDefinitionRef, TriggerEntry, + CostPaidObjectSnapshot, CounterCostSelection, DelayedTriggerCondition, DigRestOrder, Duration, + EffectKind, FaceDownProfile, GameRestriction, KeywordAction, KickerVariant, LibraryPosition, + ModalChoice, PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility, + SearchDestinationSplit, SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate, + TargetFilter, TargetRef, ThisWayCause, TriggerCondition, TriggerDefinition, + TriggerDefinitionRef, TriggerEntry, }; use super::attribution::ObjectAttribution; use super::card::{CardFace, PrintedCardRef, TokenImageRef}; @@ -4999,6 +5000,8 @@ pub enum BatchCompletion { source_id: Option, rest_cards: Vec, rest_destination: Zone, + #[serde(default)] + rest_order: DigRestOrder, publish_tracked_set: Vec, continuation_targets: Vec, }, @@ -5051,6 +5054,10 @@ pub enum BatchCompletion { /// Where the rest pile goes (`Library` => bottom in a reposition, else /// the destination zone). rest_destination: Zone, + /// Dig-only rest piles preserve this policy across a replacement pause. + /// Reveal-until uses the serde-default Preserve value. + #[serde(default)] + rest_order: DigRestOrder, /// CR 701.20b: reveal markers to clear once the cards have moved (the /// kept card plus the misses). clear_markers: Vec, @@ -10212,6 +10219,9 @@ pub enum WaitingFor { /// Where unchosen cards go (None = Graveyard, Some(Library) = bottom). #[serde(default)] rest_destination: Option, + /// CR 400.5 + CR 608.2c: Ordering instruction for a library rest pile. + #[serde(default)] + rest_order: DigRestOrder, /// Source ability's object ID for filter context. #[serde(default)] source_id: Option, @@ -27151,6 +27161,7 @@ mod tests { selectable_cards: vec![ObjectId(1)], kept_destination: None, rest_destination: None, + rest_order: crate::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, })); diff --git a/crates/engine/tests/integration/cost_zone_pipeline.rs b/crates/engine/tests/integration/cost_zone_pipeline.rs index 7cd816f315..2da2c23299 100644 --- a/crates/engine/tests/integration/cost_zone_pipeline.rs +++ b/crates/engine/tests/integration/cost_zone_pipeline.rs @@ -7,11 +7,11 @@ use engine::parser::oracle_cost::parse_oracle_cost; use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, BounceSelection, CardPlayMode, CardSelectionMode, CastFromZoneDriver, CastingPermission, CategoryChooserScope, ChoiceType, Chooser, - ContinuousModification, DigSource, DiscardSelfScope, Effect, EffectKind, FilterProp, - ForEachCategoryAction, IterationCategory, ManaContribution, ManaProduction, ModalChoice, - QuantityExpr, QuantityRef, ReplacementDefinition, ReplacementMode, ResolvedAbility, - SacrificeCost, SpellCastingOption, TargetFilter, TargetRef, TargetSelectionMode, - TriggerDefinition, TypeFilter, TypedFilter, + ContinuousModification, DigRestOrder, DigSource, DiscardSelfScope, Effect, EffectKind, + FilterProp, ForEachCategoryAction, IterationCategory, ManaContribution, ManaProduction, + ModalChoice, QuantityExpr, QuantityRef, ReplacementDefinition, ReplacementMode, + ResolvedAbility, SacrificeCost, SpellCastingOption, TargetFilter, TargetRef, + TargetSelectionMode, TriggerDefinition, TypeFilter, TypedFilter, }; use engine::types::actions::GameAction; use engine::types::card::CardFace; @@ -100,6 +100,7 @@ fn dig_rest_pile_library_redirect_pauses_before_tracked_set_publish() { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -229,6 +230,7 @@ fn dig_mass_put_all_nonbattlefield_redirect_publishes_only_delivered_set() { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -387,6 +389,7 @@ fn uninterrupted_dig_rest_and_mass_put_all_complete_synchronously() { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -443,6 +446,7 @@ fn uninterrupted_dig_rest_and_mass_put_all_complete_synchronously() { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -527,6 +531,7 @@ fn dig_deferred_reveal_rest_pile_repauses_and_completes_once() { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Library), + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -8686,6 +8691,7 @@ fn dig_kept_nonbattlefield_redirect_pauses_before_tail() { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Graveyard), + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -8826,6 +8832,7 @@ fn r2_effect_zone_moves_stay_synchronous_without_redirects() { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Graveyard), + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, diff --git a/crates/engine/tests/integration/dig_impossible_keep_count.rs b/crates/engine/tests/integration/dig_impossible_keep_count.rs index d63e01c6db..fbac257cc0 100644 --- a/crates/engine/tests/integration/dig_impossible_keep_count.rs +++ b/crates/engine/tests/integration/dig_impossible_keep_count.rs @@ -47,6 +47,7 @@ fn filtered_dig_runner() -> (engine::game::scenario::GameRunner, Vec) selectable_cards: vec![looked_at[0]], kept_destination: Some(Zone::Hand), rest_destination: Some(Zone::Graveyard), + rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, }; diff --git a/crates/engine/tests/integration/dig_rest_pile_stranding_on_etb_pause.rs b/crates/engine/tests/integration/dig_rest_pile_stranding_on_etb_pause.rs index 80b990cf07..e01235b5e7 100644 --- a/crates/engine/tests/integration/dig_rest_pile_stranding_on_etb_pause.rs +++ b/crates/engine/tests/integration/dig_rest_pile_stranding_on_etb_pause.rs @@ -64,6 +64,7 @@ fn dig_rest_pile_not_stranded_when_kept_aura_pauses_on_attachment_choice() { selectable_cards: vec![kept, rest0, rest1], kept_destination: Some(Zone::Battlefield), rest_destination: Some(Zone::Graveyard), + rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, }; diff --git a/crates/engine/tests/integration/issue_5996_planetarium_look_cast.rs b/crates/engine/tests/integration/issue_5996_planetarium_look_cast.rs index bb1178304e..dac024bb93 100644 --- a/crates/engine/tests/integration/issue_5996_planetarium_look_cast.rs +++ b/crates/engine/tests/integration/issue_5996_planetarium_look_cast.rs @@ -505,6 +505,7 @@ fn missing_look_referent_does_not_play_inherited_unrelated_object() { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, diff --git a/crates/engine/tests/integration/issue_6367_thassas_oracle.rs b/crates/engine/tests/integration/issue_6367_thassas_oracle.rs new file mode 100644 index 0000000000..4a46ba3ff5 --- /dev/null +++ b/crates/engine/tests/integration/issue_6367_thassas_oracle.rs @@ -0,0 +1,118 @@ +//! Thassa's Oracle regression (#6367): its ETB Dig keeps at most one looked-at +//! card on top and randomizes every unchosen looked-at card at the bottom. + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; +use rand::seq::SliceRandom; + +const THASSAS_ORACLE: &str = "When Thassa's Oracle enters, look at the top X cards of your library, where X is your devotion to blue. Put up to one of them on top of your library and the rest on the bottom of your library in a random order. If X is greater than or equal to the number of cards in your library, you win the game."; + +fn thassa_runner() -> (GameRunner, ObjectId, [ObjectId; 3], ObjectId) { + let mut scenario = GameScenario::new_n_player(2, 0x6367); + scenario.at_phase(Phase::PreCombatMain); + + // Three blue pips make X=3 independently of the Oracle's own printed + // characteristics, so the test observes one chosen and two unchosen cards. + for name in [ + "Blue Devotion One", + "Blue Devotion Two", + "Blue Devotion Three", + ] { + scenario + .add_creature(P0, name, 1, 1) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 0, + }); + } + + // `add_card_to_library_top` inserts at index zero. Build bottom-first so + // the three-card look window is `[look_one, look_two, look_three]`. + let below = scenario.add_card_to_library_top(P0, "Below Look Window"); + let look_three = scenario.add_card_to_library_top(P0, "Look Three"); + let look_two = scenario.add_card_to_library_top(P0, "Look Two"); + let look_one = scenario.add_card_to_library_top(P0, "Look One"); + let thassa = scenario + .add_creature_to_hand_from_oracle(P0, "Thassa's Oracle", 1, 3, THASSAS_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + + ( + scenario.build(), + thassa, + [look_one, look_two, look_three], + below, + ) +} + +#[test] +fn thassas_oracle_keeps_selected_card_on_top_and_randomizes_rest() { + let (mut runner, thassa, looked_at, below) = thassa_runner(); + let outcome = runner.cast(thassa).resolve(); + let WaitingFor::DigChoice { cards, .. } = outcome.final_waiting_for() else { + panic!( + "Thassa's Oracle ETB must reach a DigChoice, got {:?}", + outcome.final_waiting_for() + ); + }; + assert_eq!( + cards, &looked_at, + "X=3 must look at exactly the top three cards" + ); + + let chosen = looked_at[1]; + let mut expected_rest = vec![looked_at[0], looked_at[2]]; + let mut expected_rng = runner.state().rng.clone(); + expected_rest.shuffle(&mut expected_rng); + runner + .act(GameAction::SelectCards { + cards: vec![chosen], + }) + .expect("Thassa's Oracle selection resolves"); + runner.advance_until_stack_empty(); + + let state = runner.state(); + assert_eq!(state.objects[&thassa].zone, Zone::Battlefield); + assert_eq!( + state.players[0].library.iter().copied().collect::>(), + [vec![chosen, below], expected_rest].concat(), + "selected card goes to the top and the unchosen cards occupy the bottom in seeded random order" + ); + assert_eq!( + state.rng.get_word_pos(), + expected_rng.get_word_pos(), + "the random bottom rider consumes the seeded RNG exactly for its rest pile" + ); +} + +#[test] +fn thassas_oracle_allows_no_top_card_and_randomizes_every_looked_at_card() { + let (mut runner, thassa, looked_at, below) = thassa_runner(); + let outcome = runner.cast(thassa).resolve(); + assert!( + matches!(outcome.final_waiting_for(), WaitingFor::DigChoice { .. }), + "reach guard: the real ETB must expose its optional up-to-one choice" + ); + + let mut expected_rest = looked_at.to_vec(); + let mut expected_rng = runner.state().rng.clone(); + expected_rest.shuffle(&mut expected_rng); + runner + .act(GameAction::SelectCards { cards: vec![] }) + .expect("declining Thassa's optional top card resolves"); + runner.advance_until_stack_empty(); + + let state = runner.state(); + assert_eq!(state.objects[&thassa].zone, Zone::Battlefield); + assert_eq!( + state.players[0].library.iter().copied().collect::>(), + [vec![below], expected_rest].concat(), + "declining the optional choice sends every looked-at card to the random bottom pile" + ); + assert_eq!(state.rng.get_word_pos(), expected_rng.get_word_pos()); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 98b53a2153..6f00683496 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1149,6 +1149,7 @@ mod issue_4937_iona_chosen_color; mod issue_4945_zada_hedron_grinder; mod issue_4948_samwise_gamgee_sacrifice_target_order; mod issue_5263_chaos_warp; +mod issue_6367_thassas_oracle; mod kang_dynasty_until_next_turn_rider; mod karplusan_yeti_fight_back; mod kav_landseeker_delayed_sacrifice; diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 67ac53a4d2..efc27a53be 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -616,6 +616,7 @@ fn non_spell_aura_entry_copies_chosen_creature() { selectable_cards: vec![aura], kept_destination: Some(Zone::Battlefield), rest_destination: Some(Zone::Graveyard), + rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, }; diff --git a/crates/mtgish-import/src/convert/action.rs b/crates/mtgish-import/src/convert/action.rs index 323f1d54c3..269d9b2df3 100644 --- a/crates/mtgish-import/src/convert/action.rs +++ b/crates/mtgish-import/src/convert/action.rs @@ -10,8 +10,8 @@ use std::collections::BTreeSet; use engine::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, BounceSelection, ChoiceType, - ContinuousModification, ControllerRef, DamageSource, DelayedTriggerCondition, DigSource, - Duration, Effect, EffectScope, FilterProp, LibraryPosition, ManaProduction, + ContinuousModification, ControllerRef, DamageSource, DelayedTriggerCondition, DigRestOrder, + DigSource, Duration, Effect, EffectScope, FilterProp, LibraryPosition, ManaProduction, ManaSpendRestriction, ModalSelectionConstraint, MultiTargetSpec, PileSource, PlayerFilter, PlayerScope, PtValue, QuantityExpr, QuantityRef, SearchSelectionConstraint, SharedQuality, StaticDefinition, TapStateChange, TargetFilter, TriggerDefinition, TypedFilter, VoterScope, @@ -3239,6 +3239,7 @@ pub fn convert(a: &Action) -> ConvResult { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -4902,6 +4903,7 @@ fn convert_look_at_top( up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -4922,6 +4924,14 @@ fn convert_look_at_top( up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Library), + rest_order: if matches!( + dispositions.last(), + Some(L::PutTheRemainingCardsOnTheBottomOfLibraryInARandomOrder) + ) { + DigRestOrder::Random + } else { + DigRestOrder::Preserve + }, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -4941,6 +4951,7 @@ fn convert_look_at_top( up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -4963,6 +4974,14 @@ fn convert_look_at_top( up_to: true, filter: filter_mod::cards_to_filter(cards)?, rest_destination: Some(Zone::Library), + rest_order: if matches!( + dispositions.last(), + Some(L::PutTheRemainingCardsOnTheBottomOfLibraryInARandomOrder) + ) { + DigRestOrder::Random + } else { + DigRestOrder::Preserve + }, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -4981,6 +5000,7 @@ fn convert_look_at_top( up_to: true, filter: filter_mod::cards_to_filter(cards)?, rest_destination: Some(Zone::Graveyard), + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -5042,6 +5062,7 @@ fn convert_reveal_top_dig( up_to: true, filter: filter_mod::cards_to_filter(cards)?, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -5057,6 +5078,7 @@ fn convert_reveal_top_dig( up_to: false, filter: filter_mod::cards_to_filter(cards)?, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -5072,6 +5094,7 @@ fn convert_reveal_top_dig( up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -5088,6 +5111,14 @@ fn convert_reveal_top_dig( up_to: true, filter: filter_mod::cards_to_filter(cards)?, rest_destination: Some(Zone::Library), + rest_order: if matches!( + dispositions.last(), + Some(RevealTheTopNumberCardsOfLibraryAction::PutTheRemainingCardsOnTheBottomOfLibraryInARandomOrder) + ) { + DigRestOrder::Random + } else { + DigRestOrder::Preserve + }, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -5104,6 +5135,14 @@ fn convert_reveal_top_dig( up_to: false, filter: filter_mod::cards_to_filter(cards)?, rest_destination: Some(Zone::Library), + rest_order: if matches!( + dispositions.last(), + Some(RevealTheTopNumberCardsOfLibraryAction::PutTheRemainingCardsOnTheBottomOfLibraryInARandomOrder) + ) { + DigRestOrder::Random + } else { + DigRestOrder::Preserve + }, reveal: true, enter_tapped: false, source: DigSource::Library, diff --git a/crates/phase-ai/src/determinize.rs b/crates/phase-ai/src/determinize.rs index ddeb342855..dfc376c126 100644 --- a/crates/phase-ai/src/determinize.rs +++ b/crates/phase-ai/src/determinize.rs @@ -416,6 +416,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, diff --git a/crates/phase-ai/src/features/control.rs b/crates/phase-ai/src/features/control.rs index 7743cc87b2..c7fd817c87 100644 --- a/crates/phase-ai/src/features/control.rs +++ b/crates/phase-ai/src/features/control.rs @@ -526,6 +526,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -554,6 +555,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, diff --git a/crates/phase-ai/src/features/spellslinger_prowess.rs b/crates/phase-ai/src/features/spellslinger_prowess.rs index c5f9eda918..bec579d7cb 100644 --- a/crates/phase-ai/src/features/spellslinger_prowess.rs +++ b/crates/phase-ai/src/features/spellslinger_prowess.rs @@ -652,6 +652,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, @@ -675,6 +676,7 @@ mod tests { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, diff --git a/crates/phase-ai/src/features/tests/graveyard_types.rs b/crates/phase-ai/src/features/tests/graveyard_types.rs index ffea6ef714..7f9411ff20 100644 --- a/crates/phase-ai/src/features/tests/graveyard_types.rs +++ b/crates/phase-ai/src/features/tests/graveyard_types.rs @@ -138,6 +138,7 @@ fn dig_to_graveyard_enabler(name: &str, in_trigger: bool) -> CardFace { up_to: false, filter: TargetFilter::Any, rest_destination: Some(Zone::Graveyard), + rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: true, enter_tapped: false, source: DigSource::Library, @@ -647,6 +648,7 @@ fn dig_without_graveyard_rest_is_not_an_enabler() { up_to: false, filter: TargetFilter::Any, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, source: DigSource::Library, diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 19df3651a9..33c02493cb 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -11835,6 +11835,7 @@ mod tests { selectable_cards: pool, kept_destination: None, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, }; @@ -11959,6 +11960,7 @@ mod tests { selectable_cards: vec![pool[0]], kept_destination: None, rest_destination: None, + rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, } diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index 61c2e2248f..bb76e482a8 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -5266,6 +5266,7 @@ mod tests { selectable_cards: top_three.clone(), kept_destination: Some(Zone::Library), rest_destination: Some(Zone::Library), + rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, }; From 02750b635710ca39559b244e95e6bcf9ece553d9 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 18:25:46 -0700 Subject: [PATCH 2/3] fix(parser): preserve random Dig rest continuations --- .../src/parser/oracle_effect/sequence.rs | 50 +++++++++++++------ .../engine/src/parser/oracle_effect/tests.rs | 12 ++++- crates/engine/src/parser/oracle_ir/ast.rs | 4 ++ ...napshot_tests__follow_the_lumarets_ir.snap | 3 +- ...ot_tests__follow_the_lumarets_lowered.snap | 2 + 5 files changed, 52 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 398443ca71..9558a61548 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -404,20 +404,17 @@ fn parse_reveal_until_rest_zone(lower: &str) -> Option { /// Whole-line dig continuation "put the rest on the bottom of your library /// [in a random order | in any order]" following a `ChooseFromZone`. /// -/// CR 401.4: multi-card library placement defaults to owner-arranged order, -/// so "in any order" restates the default. The random-order variant is -/// currently collapsed into the same continuation (randomization is not yet -/// modeled at this seam) — the suffix axis is one `opt(alt(...))`, extended -/// there when it is. -fn parse_put_rest_on_bottom_line(input: &str) -> OracleResult<'_, ()> { - value( - (), - all_consuming(( - tag("put the rest on the bottom of your library"), - opt(alt((tag(" in a random order"), tag(" in any order")))), - )), - ) - .parse(input) +/// CR 400.5 + CR 608.2c: "in a random order" is an instruction distinct from +/// the player-chosen ordering in "in any order". +fn parse_put_rest_on_bottom_line(input: &str) -> OracleResult<'_, DigRestOrder> { + let (input, _) = tag("put the rest on the bottom of your library").parse(input)?; + let (input, rest_order) = opt(alt(( + value(DigRestOrder::Random, tag(" in a random order")), + value(DigRestOrder::Preserve, tag(" in any order")), + ))) + .parse(input)?; + let (input, _) = eof(input)?; + Ok((input, rest_order.unwrap_or(DigRestOrder::Preserve))) } /// Whole-line dig continuation matcher — delegates to [`parse_put_rest_on_bottom_line`] @@ -4122,6 +4119,7 @@ pub(super) fn apply_clause_continuation( ContinuationAst::PutRest { destination, reorder_all, + rest_order, } => { // CR 608.2c + CR 701.20b (Portent of Calamity): After a per-category // exile from among revealed cards, "put the rest into " moves @@ -4199,7 +4197,7 @@ pub(super) fn apply_clause_continuation( enter_with_counters: vec![], face_down_profile: None, library_position, - random_order: false, + random_order: matches!(rest_order, DigRestOrder::Random), }, )); return; @@ -4208,6 +4206,7 @@ pub(super) fn apply_clause_continuation( &mut defs[bound_index], destination, reorder_all, + rest_order, ); } } @@ -5199,11 +5198,13 @@ fn patch_rest_destination_recursively( def: &mut AbilityDefinition, destination: Zone, reorder_all: bool, + rest_order: DigRestOrder, ) { match &mut *def.effect { Effect::Dig { destination: kept_destination, rest_destination, + rest_order: dig_rest_order, .. } => { if reorder_all { @@ -5212,6 +5213,7 @@ fn patch_rest_destination_recursively( } else if rest_destination.is_none() { *rest_destination = Some(destination); } + *dig_rest_order = rest_order; } Effect::RevealUntil { rest_destination, .. @@ -5221,7 +5223,7 @@ fn patch_rest_destination_recursively( _ => {} } if let Some(else_def) = def.else_ability.as_deref_mut() { - patch_rest_destination_recursively(else_def, destination, reorder_all); + patch_rest_destination_recursively(else_def, destination, reorder_all, rest_order); } } @@ -6859,6 +6861,7 @@ pub(super) fn parse_followup_continuation_ast( Some(ContinuationAst::PutRest { destination: Zone::Library, reorder_all: true, + rest_order: DigRestOrder::Preserve, }) } Effect::SearchLibrary { .. } | Effect::Shuffle { .. } | Effect::Dig { .. } @@ -6885,6 +6888,7 @@ pub(super) fn parse_followup_continuation_ast( Some(ContinuationAst::PutRest { destination: Zone::Exile, reorder_all: false, + rest_order: DigRestOrder::Preserve, }) } // CR 406.3 + CR 701.20e: "[then] exile it/them [face down]" after a @@ -6943,9 +6947,13 @@ pub(super) fn parse_followup_continuation_ast( // Default: bottom of library (covers "on the bottom", "back in any order", etc.) Zone::Library }; + let rest_order = parse_put_rest_on_bottom_line(&lower) + .map(|(_, order)| order) + .unwrap_or(DigRestOrder::Preserve); Some(ContinuationAst::PutRest { destination, reorder_all: false, + rest_order, }) } // CR 608.2c + CR 701.20b (Portent of Calamity / Sanar class): "Put the @@ -6972,6 +6980,7 @@ pub(super) fn parse_followup_continuation_ast( Some(ContinuationAst::PutRest { destination, reorder_all: false, + rest_order: DigRestOrder::Preserve, }) } // CR 701.20a + CR 608.2c: A reveal-until rest-pile clause may be @@ -6984,6 +6993,7 @@ pub(super) fn parse_followup_continuation_ast( Some(ContinuationAst::PutRest { destination: parse_reveal_until_rest_zone(&lower).unwrap_or(Zone::Library), reorder_all: false, + rest_order: DigRestOrder::Preserve, }) } // CR 701.20a + CR 608.2c: "Put any number of those [filter] cards onto the @@ -7148,6 +7158,7 @@ pub(super) fn parse_followup_continuation_ast( Some(ContinuationAst::PutRest { destination, reorder_all: false, + rest_order: DigRestOrder::Preserve, }) } // "create a ... token and suspect it" → chain suspect on last created token @@ -9891,6 +9902,7 @@ mod tests { Some(ContinuationAst::PutRest { destination: Zone::Library, reorder_all: false, + rest_order: DigRestOrder::Preserve, }) ); } @@ -9908,6 +9920,7 @@ mod tests { Some(ContinuationAst::PutRest { destination: Zone::Library, reorder_all: false, + rest_order: DigRestOrder::Preserve, }) ); } @@ -9925,6 +9938,7 @@ mod tests { Some(ContinuationAst::PutRest { destination: Zone::Graveyard, reorder_all: false, + rest_order: DigRestOrder::Preserve, }) ); } @@ -9942,6 +9956,7 @@ mod tests { Some(ContinuationAst::PutRest { destination: Zone::Library, reorder_all: false, + rest_order: DigRestOrder::Random, }) ); } @@ -9959,6 +9974,7 @@ mod tests { Some(ContinuationAst::PutRest { destination: Zone::Library, reorder_all: true, + rest_order: DigRestOrder::Preserve, }) ); } @@ -9976,6 +9992,7 @@ mod tests { Some(ContinuationAst::PutRest { destination: Zone::Hand, reorder_all: false, + rest_order: DigRestOrder::Preserve, }) ); } @@ -9993,6 +10010,7 @@ mod tests { Some(ContinuationAst::PutRest { destination: Zone::Library, reorder_all: false, + rest_order: DigRestOrder::Preserve, }) ); } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 202788e614..f9c726f325 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -10,8 +10,8 @@ use crate::parser::parse_oracle_text; use crate::types::ability::CardPlayMode::{Cast, Play}; use crate::types::ability::CastFromZoneDriver::{DuringResolution, LingeringPermission}; use crate::types::ability::{ - AttachmentKind, CardSelectionMode, CastManaObjectScope, CastManaSpentMetric, ExcessRecipient, - ForEachCategoryAction, ModalChoice, PerpetualModification, SeatDirection, + AttachmentKind, CardSelectionMode, CastManaObjectScope, CastManaSpentMetric, DigRestOrder, + ExcessRecipient, ForEachCategoryAction, ModalChoice, PerpetualModification, SeatDirection, }; use crate::types::card_type::CoreType; use crate::types::mana::{ManaCost, ManaCostShard}; @@ -3632,6 +3632,7 @@ fn dig_conditional_instead_alternative_preserves_both_branches() { up_to, filter, rest_destination, + rest_order, destination, .. } = &*def.effect @@ -3646,6 +3647,7 @@ fn dig_conditional_instead_alternative_preserves_both_branches() { assert!(!*up_to, "alt branch is exact-2 (not up-to)"); assert_eq!(*destination, Some(Zone::Hand)); assert_eq!(*rest_destination, Some(Zone::Library)); + assert_eq!(*rest_order, DigRestOrder::Random); let TargetFilter::Or { filters } = filter else { panic!("alt filter should be Or, got {:?}", filter); }; @@ -3681,6 +3683,7 @@ fn dig_conditional_instead_alternative_preserves_both_branches() { up_to: base_up_to, filter: base_filter, rest_destination: base_rest, + rest_order: base_rest_order, .. } = &*base.effect else { @@ -3690,6 +3693,11 @@ fn dig_conditional_instead_alternative_preserves_both_branches() { assert_eq!(*base_keep, Some(1)); assert!(*base_up_to, "base branch is up-to 1"); assert_eq!(*base_rest, Some(Zone::Library), "PutRest must recurse"); + assert_eq!( + *base_rest_order, + DigRestOrder::Random, + "PutRest order must recurse" + ); let TargetFilter::Or { filters: base_filters, } = base_filter diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index b185eeba48..e58aaa593f 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -402,6 +402,10 @@ pub(crate) enum ContinuationAst { PutRest { destination: Zone, reorder_all: bool, + /// CR 400.5 + CR 608.2c: Only exact "in a random order" text + /// randomizes the unchosen library remainder. + #[serde(default, skip_serializing_if = "DigRestOrder::is_preserve")] + rest_order: DigRestOrder, }, /// CR 701.20e + CR 608.2c: "Put up to N [filter] from among them onto the battlefield/into /// your hand" after Dig — patches the Dig's keep_count, filter, destination, and rest_destination. diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap index 153c0d0a8c..751ded6b74 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap @@ -350,7 +350,8 @@ expression: "&ir" "continuation": { "PutRest": { "destination": "Library", - "reorder_all": false + "reorder_all": false, + "rest_order": "random" } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_lowered.snap index 0f5d1f850c..68b56e0e5d 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_lowered.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_lowered.snap @@ -40,6 +40,7 @@ expression: "&lowered" ] }, "rest_destination": "Library", + "rest_order": "random", "reveal": false, "enter_tapped": false }, @@ -81,6 +82,7 @@ expression: "&lowered" ] }, "rest_destination": "Library", + "rest_order": "random", "reveal": true, "enter_tapped": false }, From 918682ff2aabce7443c55934ecea66304b403682 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 18:42:49 -0700 Subject: [PATCH 3/3] fix(parser): accept punctuated Dig rest continuations --- crates/engine/src/parser/oracle_effect/sequence.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 9558a61548..9a41a81a71 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -413,6 +413,7 @@ fn parse_put_rest_on_bottom_line(input: &str) -> OracleResult<'_, DigRestOrder> value(DigRestOrder::Preserve, tag(" in any order")), ))) .parse(input)?; + let (input, _) = opt(tag(".")).parse(input)?; let (input, _) = eof(input)?; Ok((input, rest_order.unwrap_or(DigRestOrder::Preserve))) }