From f957345999864e4d843d98d6961e1b3a575d80aa Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:26:42 +0200 Subject: [PATCH 1/9] fix(engine): keep full dig set for Expressive Iteration tail (#1162) Publish every looked-at card from DigChoice, forward unkept cards to the bottom/exile sub-chain, and ignore hand-routed cards when placing from a tracked set. Co-authored-by: Cursor --- crates/engine/src/game/effects/mod.rs | 124 ++++++++++++++++++ crates/engine/src/game/effects/put_on_top.rs | 9 ++ .../src/game/engine_resolution_choices.rs | 19 +-- 3 files changed, 143 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 97a222a51d..c7c2e8e585 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -18267,4 +18267,128 @@ mod tests { mid-chain blessing check before the condition is evaluated" ); } + + /// CR 601.2a + CR 608.2c (issue #1162): Expressive Iteration looks at + /// three cards, keeps one in hand, then must still reach the bottom/exile + /// tail on the other looked-at cards. + #[test] + fn expressive_iteration_dig_chain_reaches_library_bottom_and_exile() { + use crate::game::engine; + use crate::types::actions::GameAction; + use crate::types::ability::CastingPermission; + use crate::types::ability::Duration; + + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(100), + PlayerId(0), + "Expressive Iteration".to_string(), + Zone::Stack, + ); + let card_a = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Card A".to_string(), + Zone::Library, + ); + let card_b = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Card B".to_string(), + Zone::Library, + ); + let card_c = create_object( + &mut state, + CardId(3), + PlayerId(0), + "Card C".to_string(), + Zone::Library, + ); + state.players[0].library = vec![card_a, card_b, card_c].into(); + + let def = crate::parser::oracle_effect::parse_effect_chain( + "Look at the top three cards of your library. Put one of them into your hand, put one of them on the bottom of your library, and exile one of them. You may play the exiled card this turn.", + AbilityKind::Spell, + ); + let ability = + crate::game::ability_utils::build_resolved_from_def(&def, source, PlayerId(0)); + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + assert!( + matches!(state.waiting_for, WaitingFor::DigChoice { .. }), + "expected initial dig choice, got {:?}", + state.waiting_for + ); + + engine::apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![card_a], + }, + ) + .unwrap(); + + let tracked: Vec<_> = state + .tracked_object_sets + .values() + .flatten() + .copied() + .collect(); + assert!( + tracked.contains(&card_a) + && tracked.contains(&card_b) + && tracked.contains(&card_c), + "dig must publish all looked-at cards to the tracked set, got {tracked:?}" + ); + + if matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }) { + let WaitingFor::EffectZoneChoice { cards: eligible, .. } = state.waiting_for.clone() + else { + unreachable!(); + }; + assert!( + eligible.contains(&card_b) && eligible.contains(&card_c), + "bottom choice must be among unkept looked-at cards: {eligible:?}" + ); + engine::apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![card_b], + }, + ) + .unwrap(); + } + + assert_eq!(state.objects[&card_a].zone, Zone::Hand); + assert!( + state.objects[&card_c].zone == Zone::Exile + || state.objects[&card_b].zone == Zone::Exile, + "one unkept looked-at card must be exiled (A={:?}, B={:?}, C={:?})", + state.objects[&card_a].zone, + state.objects[&card_b].zone, + state.objects[&card_c].zone, + ); + let exiled = [card_b, card_c] + .into_iter() + .find(|id| state.objects[id].zone == Zone::Exile) + .expect("exiled card"); + assert!( + state.objects[&exiled] + .casting_permissions + .iter() + .any(|p| matches!( + p, + CastingPermission::PlayFromExile { + duration: Duration::UntilEndOfTurn, + granted_to: PlayerId(0), + .. + } + )), + "exiled card must receive play-this-turn permission" + ); + } } diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 04b651bf0f..38ba5996dd 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -58,6 +58,15 @@ pub fn resolve( .collect(); } + if matches!(target_filter, TargetFilter::TrackedSet { .. }) { + collected_targets.retain(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.zone == Zone::Library) + }); + } + // CR 115.1 + CR 400.2: When the filter specifies a private zone (hand/library) // and no targets were pre-selected during casting (because the Oracle text does // not say "target"), present an EffectZoneChoice for resolution-time selection. diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 686e4b2c85..86b70cd186 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1523,15 +1523,13 @@ pub(super) fn handle_resolution_choice( } } } - // CR 701.20b + CR 608.2c: Publish the kept (revealed) cards as a - // tracked set so downstream sub_abilities can route them by type - // via `TargetFilter::TrackedSetFiltered`. Used by Zimone's - // Experiment — "Put all land cards revealed this way onto the - // battlefield tapped and put all creature cards revealed this way - // into your hand" consume this set. Use a fresh tracked set so a - // parent effect's empty pre-choice publish cannot keep the chain + // CR 701.20b + CR 608.2c: Publish every looked-at card as a tracked + // set so downstream sub_abilities can route them (Zimone's + // Experiment land/creature split; Expressive Iteration's bottom/ + // exile tail after keeping one to hand). Use a fresh tracked set so + // a parent effect's empty pre-choice publish cannot keep the chain // sentinel bound to the wrong set. - effects::publish_fresh_tracked_set(state, kept.clone()); + effects::publish_fresh_tracked_set(state, cards.clone()); // None => Graveyard; map to a concrete zone so the rest mover // (shared with the search-split partition) has a single Zone. route_rest_partition( @@ -1541,7 +1539,10 @@ pub(super) fn handle_resolution_choice( events, ); if let Some(cont) = state.pending_continuation.as_mut() { - cont.chain.targets = kept.iter().map(|&id| TargetRef::Object(id)).collect(); + cont.chain.targets = unkept + .iter() + .map(|&id| TargetRef::Object(id)) + .collect(); cont.chain.context.optional_effect_performed = !kept.is_empty(); } ResolutionChoiceOutcome::WaitingFor(finish_with_continuation(state, player, events)) From 35122d81b83a0a6cfd361df57f4f0336e9e102b9 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:15:26 +0200 Subject: [PATCH 2/9] fix(engine): preserve ParentTarget for exile dig continuations (#1162) Publish the full looked-at set for tracked-set routing while binding ParentTarget to kept cards for Hideaway-style exile digs and to unkept cards for hand-routing tails like Expressive Iteration. Co-authored-by: Cursor --- crates/engine/src/game/effects/mod.rs | 10 +++++----- crates/engine/src/game/engine_resolution_choices.rs | 9 ++++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index c7c2e8e585..51d14dbb3c 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -18274,9 +18274,9 @@ mod tests { #[test] fn expressive_iteration_dig_chain_reaches_library_bottom_and_exile() { use crate::game::engine; - use crate::types::actions::GameAction; use crate::types::ability::CastingPermission; use crate::types::ability::Duration; + use crate::types::actions::GameAction; let mut state = GameState::new_two_player(42); let source = create_object( @@ -18339,14 +18339,14 @@ mod tests { .copied() .collect(); assert!( - tracked.contains(&card_a) - && tracked.contains(&card_b) - && tracked.contains(&card_c), + tracked.contains(&card_a) && tracked.contains(&card_b) && tracked.contains(&card_c), "dig must publish all looked-at cards to the tracked set, got {tracked:?}" ); if matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }) { - let WaitingFor::EffectZoneChoice { cards: eligible, .. } = state.waiting_for.clone() + let WaitingFor::EffectZoneChoice { + cards: eligible, .. + } = state.waiting_for.clone() else { unreachable!(); }; diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 86b70cd186..0a7ee21933 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1539,7 +1539,14 @@ pub(super) fn handle_resolution_choice( events, ); if let Some(cont) = state.pending_continuation.as_mut() { - cont.chain.targets = unkept + // CR 608.2c: Hideaway / "exile one face down" sub-abilities bind + // ParentTarget to the kept (exiled) card; hand-routing tails like + // Expressive Iteration bind ParentTarget to the unkept pile. + let continuation_targets = match kept_destination { + Some(Zone::Exile) => kept.clone(), + _ => unkept.clone(), + }; + cont.chain.targets = continuation_targets .iter() .map(|&id| TargetRef::Object(id)) .collect(); From e2a58639dea5ffe8d3be200e5ebedb0a47f8bcd8 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:46:08 +0200 Subject: [PATCH 3/9] fix(engine): keep ParentTarget on dig kept cards for continuations (#1162) Publish the full looked-at set only when cards are kept; bind ParentTarget to the kept selection for Hideaway and dig conditionals while TrackedSet routing handles Expressive Iteration tails. Co-authored-by: Cursor --- client/src-tauri/Cargo.lock | 372 +++++------------- .../src/game/engine_resolution_choices.rs | 25 +- 2 files changed, 110 insertions(+), 287 deletions(-) diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 576901d2c7..7b53144279 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -25,9 +25,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -162,9 +162,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -173,9 +173,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -210,9 +210,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" dependencies = [ "serde", ] @@ -244,9 +244,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" dependencies = [ "serde_core", ] @@ -462,7 +462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -501,7 +501,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -512,7 +512,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -544,7 +544,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -565,7 +565,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -619,7 +619,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -642,7 +642,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -653,7 +653,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash 0.2.0", + "foldhash", "html5ever", "precomputed-hash", "selectors", @@ -851,12 +851,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -881,7 +875,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -939,7 +933,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1104,15 +1098,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -1181,7 +1173,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1260,7 +1252,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1269,15 +1261,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -1525,12 +1508,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1702,7 +1679,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1730,14 +1707,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", @@ -1783,12 +1760,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -1944,9 +1915,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -2045,7 +2016,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2451,7 +2422,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2544,16 +2515,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2733,7 +2694,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2991,7 +2952,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3091,7 +3052,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3102,7 +3063,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3126,7 +3087,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3176,7 +3137,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3198,7 +3159,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3443,7 +3404,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3475,9 +3436,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -3501,7 +3462,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3565,7 +3526,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3587,9 +3548,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.2" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" dependencies = [ "anyhow", "bytes", @@ -3638,9 +3599,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -3659,9 +3620,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -3675,7 +3636,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.117", + "syn 2.0.118", "tauri-utils", "thiserror 2.0.18", "time", @@ -3686,23 +3647,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -3780,9 +3741,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.2" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -3805,9 +3766,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.2" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" dependencies = [ "gtk", "http", @@ -3831,9 +3792,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -3885,7 +3846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3927,7 +3888,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3938,7 +3899,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4227,7 +4188,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4271,9 +4232,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.23.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", "dirs", @@ -4362,12 +4323,6 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "untrusted" version = "0.9.0" @@ -4417,7 +4372,7 @@ version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4488,27 +4443,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -4519,9 +4465,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.73" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -4529,9 +4475,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4539,48 +4485,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -4594,23 +4518,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.100" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4628,9 +4540,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ "phf", "phf_codegen", @@ -4684,9 +4596,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -4713,7 +4625,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4840,7 +4752,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4851,7 +4763,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5195,100 +5107,12 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -5389,7 +5213,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -5410,7 +5234,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5430,15 +5254,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -5470,7 +5294,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 0a7ee21933..e2fc9f380d 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1528,8 +1528,14 @@ pub(super) fn handle_resolution_choice( // Experiment land/creature split; Expressive Iteration's bottom/ // exile tail after keeping one to hand). Use a fresh tracked set so // a parent effect's empty pre-choice publish cannot keep the chain - // sentinel bound to the wrong set. - effects::publish_fresh_tracked_set(state, cards.clone()); + // sentinel bound to the wrong set. When nothing was kept, publish an + // empty set so ParentTarget continuations do not bind stale cards. + let publish_set = if kept.is_empty() { + Vec::new() + } else { + cards.clone() + }; + effects::publish_fresh_tracked_set(state, publish_set); // None => Graveyard; map to a concrete zone so the rest mover // (shared with the search-split partition) has a single Zone. route_rest_partition( @@ -1539,17 +1545,10 @@ pub(super) fn handle_resolution_choice( events, ); if let Some(cont) = state.pending_continuation.as_mut() { - // CR 608.2c: Hideaway / "exile one face down" sub-abilities bind - // ParentTarget to the kept (exiled) card; hand-routing tails like - // Expressive Iteration bind ParentTarget to the unkept pile. - let continuation_targets = match kept_destination { - Some(Zone::Exile) => kept.clone(), - _ => unkept.clone(), - }; - cont.chain.targets = continuation_targets - .iter() - .map(|&id| TargetRef::Object(id)) - .collect(); + // CR 608.2c: ParentTarget continuations (Hideaway conceal, dig + // conditionals on the kept card) bind to the kept selection. + // Hand/bottom/exile tails route via TrackedSetFiltered instead. + cont.chain.targets = kept.iter().map(|&id| TargetRef::Object(id)).collect(); cont.chain.context.optional_effect_performed = !kept.is_empty(); } ResolutionChoiceOutcome::WaitingFor(finish_with_continuation(state, player, events)) From 13d882d2b747db36ec6c386a044417a750db91da Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:10:10 +0200 Subject: [PATCH 4/9] test(engine): expect full dig pile in tracked set publish (#1162) Align dig_choice_publishes_kept_cards_as_tracked_set with the Expressive Iteration routing change that publishes every looked-at card. Co-authored-by: Cursor --- crates/engine/src/game/effects/dig.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/effects/dig.rs b/crates/engine/src/game/effects/dig.rs index 18d78c122f..fd7669ff5f 100644 --- a/crates/engine/src/game/effects/dig.rs +++ b/crates/engine/src/game/effects/dig.rs @@ -594,15 +594,17 @@ mod tests { ); } - // A fresh tracked set must have been inserted with exactly the kept cards. + // A fresh tracked set must publish every looked-at card so downstream + // TrackedSetFiltered routing (Zimone land/creature split, Expressive + // Iteration bottom/exile tail) can see the full dig pile. let tracked_id = TrackedSetId(next_id_before); let set = state .tracked_object_sets .get(&tracked_id) - .expect("tracked set must be inserted for the kept cards"); + .expect("tracked set must be inserted for the looked-at cards"); assert_eq!( - *set, kept, - "tracked set must contain exactly the kept cards" + *set, cards_on_top, + "tracked set must contain every looked-at card, not only the kept subset" ); assert_eq!( state.next_tracked_set_id, From 34f1218173dbbbf880ee5be258e7419abef4b896 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:50:33 +0200 Subject: [PATCH 5/9] fix(engine): resolve Expressive Iteration dig hand/bottom/exile chain (#1162) Scope tracked-set bottom and exile steps to library cards after the kept card reaches hand, and skip bulk rest routing when a continuation owns the unkept pile. Co-authored-by: Cursor --- crates/engine/src/game/effects/change_zone.rs | 75 +++++++++++++++---- crates/engine/src/game/effects/put_on_top.rs | 24 ++++++ .../src/game/engine_resolution_choices.rs | 16 ++-- .../src/parser/oracle_effect/sequence.rs | 7 +- 4 files changed, 101 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index ea0aa1af8d..ffaae141f4 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -3,7 +3,7 @@ use rand::Rng; use crate::game::zones; use crate::types::ability::{ ControllerRef, Duration, Effect, EffectError, EffectKind, FilterProp, LibraryPosition, - ResolvedAbility, TargetChoiceTiming, TargetFilter, TargetSelectionMode, TypedFilter, + ResolvedAbility, TargetChoiceTiming, TargetFilter, TargetRef, TargetSelectionMode, TypedFilter, }; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; @@ -212,19 +212,49 @@ pub fn resolve( let mut origin = origin; + let parsed_target = match &ability.effect { + Effect::ChangeZone { target, .. } => target.clone(), + _ => TargetFilter::Any, + }; // CR 603.7: Resolve the `TrackedSetId(0)` sentinel emitted by the parser // for "from among the milled cards" / "X cards revealed this way" // continuations to the most recent non-empty tracked set. Done up front so // every downstream path (interactive scan, `matches_target_filter`, // `tracked_set_member_zones`) sees the bound id — `matches_target_filter` // looks the set up by exact id and would otherwise miss the sentinel. - let target_filter: TargetFilter = match &ability.effect { - Effect::ChangeZone { target, .. } => { - crate::game::targeting::resolve_tracked_set_sentinel(state, target.clone()) + let mut effective_target_filter = + crate::game::targeting::resolve_tracked_set_sentinel(state, parsed_target); + // CR 608.2c: After a dig that already routed ParentTarget to hand, a chained + // "exile one of them" must pick from the remaining looked-at cards in the + // tracked set — not re-exile the card already in hand (Expressive Iteration). + let mut exile_tracked_set_library_only = false; + if let Effect::ChangeZone { + destination: Zone::Exile, + .. + } = &ability.effect + { + if matches!(effective_target_filter, TargetFilter::ParentTarget) { + if let Some(parent) = ability.targets.iter().find_map(|t| match t { + TargetRef::Object(id) => Some(*id), + _ => None, + }) { + if state + .objects + .get(&parent) + .is_some_and(|obj| obj.zone == Zone::Hand) + { + exile_tracked_set_library_only = true; + effective_target_filter = crate::game::targeting::resolve_tracked_set_sentinel( + state, + TargetFilter::TrackedSet { + id: crate::types::identifiers::TrackedSetId(0), + }, + ); + } + } } - _ => TargetFilter::Any, - }; - let target_filter = &target_filter; + } + let target_filter = &effective_target_filter; if origin.is_none() && matches!(target_filter, TargetFilter::TriggeringSource) { origin = state .current_trigger_event @@ -256,6 +286,19 @@ pub fn resolve( targeted_objects, target_filter, ); + let targeted_objects: Vec = if exile_tracked_set_library_only { + targeted_objects + .into_iter() + .filter(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.zone == Zone::Library) + }) + .collect() + } else { + targeted_objects + }; if targeted_objects.is_empty() { // CR 115.6: "Up to one target" — if the player chose zero targets during @@ -338,13 +381,17 @@ pub fn resolve( // there is no fixed `InZone` constraint to extract — so derive the scan // zone from the members' actual zone rather than defaulting to the // battlefield. - let scan_zone = origin - .or_else(|| target_filter.extract_in_zone()) - .or_else(|| { - tracked_set_member_zones(state, target_filter) - .and_then(|zones| zones.into_iter().next()) - }) - .unwrap_or(Zone::Battlefield); + let scan_zone = if exile_tracked_set_library_only { + Zone::Library + } else { + origin + .or_else(|| target_filter.extract_in_zone()) + .or_else(|| { + tracked_set_member_zones(state, target_filter) + .and_then(|zones| zones.into_iter().next()) + }) + .unwrap_or(Zone::Battlefield) + }; // Filter-controller override is primary here: when a filter like // "creature you control" needs "you" to resolve to the *target* player // (not the caster), we pass `filter_controller` explicitly. Use diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 38ba5996dd..1c26870fad 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -57,6 +57,30 @@ pub fn resolve( .map(|(id, _)| *id) .collect(); } + if collected_targets.is_empty() + && matches!( + target_filter, + TargetFilter::TrackedSet { .. } | TargetFilter::TrackedSetFiltered { .. } + ) + { + let effective_filter = + crate::game::targeting::resolve_tracked_set_sentinel(state, target_filter.clone()); + let ctx = crate::game::filter::FilterContext::from_ability(ability); + collected_targets = state + .objects + .iter() + .filter(|(id, obj)| { + obj.zone == Zone::Library + && crate::game::filter::matches_target_filter( + state, + **id, + &effective_filter, + &ctx, + ) + }) + .map(|(id, _)| *id) + .collect(); + } if matches!(target_filter, TargetFilter::TrackedSet { .. }) { collected_targets.retain(|id| { diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index e2fc9f380d..77e3d9cdd2 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1538,12 +1538,16 @@ pub(super) fn handle_resolution_choice( effects::publish_fresh_tracked_set(state, publish_set); // None => Graveyard; map to a concrete zone so the rest mover // (shared with the search-split partition) has a single Zone. - route_rest_partition( - state, - &unkept, - rest_destination.unwrap_or(Zone::Graveyard), - events, - ); + // When a continuation owns the unkept pile (Expressive Iteration + // bottom/exile tail), do not pre-route here. + if state.pending_continuation.is_none() { + route_rest_partition( + state, + &unkept, + rest_destination.unwrap_or(Zone::Graveyard), + events, + ); + } if let Some(cont) = state.pending_continuation.as_mut() { // CR 608.2c: ParentTarget continuations (Hideaway conceal, dig // conditionals on the kept card) bind to the kept selection. diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 5e84e23bc6..e403d0e65b 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -2741,7 +2741,12 @@ pub(super) fn apply_clause_continuation( .. } = &mut *previous.effect { - *destination = Some(Zone::Library); + // Preserve an explicit kept destination (Hand, Battlefield, etc.) + // from an earlier "put one into your hand" clause; only default + // destination to Library for reveal-only digs. + if destination.is_none() { + *destination = Some(Zone::Library); + } *rest_destination = Some(Zone::Library); } let put_def = AbilityDefinition::new( From e35fa742fc10a6f99352d0ac6b2048b503543bcd Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:27:47 +0200 Subject: [PATCH 6/9] fix(engine): complete Expressive Iteration dig tail routing (#1162) Scope chained exile to library members after a hand keep, publish the full looked-at pile for hand-keep dig continuations, carry library placement through EffectZoneChoice, and restore Zimone-style kept-only tracked-set publish. Co-authored-by: Cursor --- crates/engine/src/ai_support/candidates.rs | 1 + crates/engine/src/game/costs.rs | 1 + crates/engine/src/game/effects/blight.rs | 1 + crates/engine/src/game/effects/bounce.rs | 3 + .../engine/src/game/effects/cast_from_zone.rs | 1 + crates/engine/src/game/effects/change_zone.rs | 2 + crates/engine/src/game/effects/dig.rs | 12 +-- crates/engine/src/game/effects/mod.rs | 54 +++++----- crates/engine/src/game/effects/put_on_top.rs | 102 +++++++++++++++--- crates/engine/src/game/effects/sacrifice.rs | 1 + crates/engine/src/game/effects/tap_untap.rs | 1 + crates/engine/src/game/engine.rs | 3 + .../src/game/engine_resolution_choices.rs | 71 +++++++++--- crates/engine/src/game/replacement.rs | 1 + crates/engine/src/game/triggers.rs | 1 + crates/engine/src/game/visibility.rs | 2 + crates/engine/src/types/game_state.rs | 8 ++ .../mimeoplasm_interactive_exile.rs | 1 + 18 files changed, 202 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index fb61aa59de..34a8a1afe4 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -4968,6 +4968,7 @@ mod tests { track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; diff --git a/crates/engine/src/game/costs.rs b/crates/engine/src/game/costs.rs index 96f0eacad4..8a6e6f88fe 100644 --- a/crates/engine/src/game/costs.rs +++ b/crates/engine/src/game/costs.rs @@ -688,6 +688,7 @@ fn pay_ability_cost_inner( track_exiled_by_source: true, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: true, }; return Ok(PaymentOutcome::Paused { diff --git a/crates/engine/src/game/effects/blight.rs b/crates/engine/src/game/effects/blight.rs index de5cebef99..ef1ae96878 100644 --- a/crates/engine/src/game/effects/blight.rs +++ b/crates/engine/src/game/effects/blight.rs @@ -76,6 +76,7 @@ pub fn resolve( // CR 708.2a: Blight places -1/-1 counters; no face-down entry. face_down_profile: None, count_param: count, + library_position: None, is_cost_payment: false, }; diff --git a/crates/engine/src/game/effects/bounce.rs b/crates/engine/src/game/effects/bounce.rs index 19b7a5c9f8..2f9d2f9fee 100644 --- a/crates/engine/src/game/effects/bounce.rs +++ b/crates/engine/src/game/effects/bounce.rs @@ -233,6 +233,7 @@ pub fn resolve( // CR 708.2a: bounce returns cards face up; no face-down entry. face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; return Ok(()); @@ -322,6 +323,7 @@ pub fn resolve( // CR 708.2a: bounce returns cards face up; no face-down entry. face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; return Ok(()); @@ -494,6 +496,7 @@ pub fn resolve_all( // CR 708.2a: bounce returns cards face up; no face-down entry. face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; return Ok(()); diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index 77179efce9..d92a97cc11 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -63,6 +63,7 @@ fn open_private_zone_cast_selection( // CR 708.2a: cast-from-zone selection is not a face-down entry. face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; Ok(()) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index ffaae141f4..e84579fc11 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -602,6 +602,7 @@ pub fn resolve( // resolves the choice. face_down_profile: face_down_profile.clone(), count_param: 0, + library_position: None, is_cost_payment: false, }; // EffectResolved is emitted by the EffectZoneChoice handler after the player chooses @@ -6569,6 +6570,7 @@ mod tests { track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; diff --git a/crates/engine/src/game/effects/dig.rs b/crates/engine/src/game/effects/dig.rs index fd7669ff5f..ee51320051 100644 --- a/crates/engine/src/game/effects/dig.rs +++ b/crates/engine/src/game/effects/dig.rs @@ -594,17 +594,17 @@ mod tests { ); } - // A fresh tracked set must publish every looked-at card so downstream - // TrackedSetFiltered routing (Zimone land/creature split, Expressive - // Iteration bottom/exile tail) can see the full dig pile. + // A fresh tracked set must publish the kept/revealed selection so + // downstream TrackedSetFiltered routing (Zimone land/creature split) + // resolves against the cards the player chose to keep. let tracked_id = TrackedSetId(next_id_before); let set = state .tracked_object_sets .get(&tracked_id) - .expect("tracked set must be inserted for the looked-at cards"); + .expect("tracked set must be inserted for the kept cards"); assert_eq!( - *set, cards_on_top, - "tracked set must contain every looked-at card, not only the kept subset" + *set, kept, + "tracked set must contain exactly the kept cards" ); assert_eq!( state.next_tracked_set_id, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 51d14dbb3c..bb2b154e61 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -7916,6 +7916,7 @@ mod tests { track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; @@ -12141,6 +12142,7 @@ mod tests { track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; state.pending_continuation = @@ -12178,6 +12180,7 @@ mod tests { track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }, GameAction::SelectCards { @@ -18343,39 +18346,34 @@ mod tests { "dig must publish all looked-at cards to the tracked set, got {tracked:?}" ); - if matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }) { - let WaitingFor::EffectZoneChoice { - cards: eligible, .. - } = state.waiting_for.clone() - else { - unreachable!(); - }; - assert!( - eligible.contains(&card_b) && eligible.contains(&card_c), - "bottom choice must be among unkept looked-at cards: {eligible:?}" - ); - engine::apply_as_current( - &mut state, - GameAction::SelectCards { - cards: vec![card_b], - }, - ) - .unwrap(); - } - - assert_eq!(state.objects[&card_a].zone, Zone::Hand); assert!( - state.objects[&card_c].zone == Zone::Exile - || state.objects[&card_b].zone == Zone::Exile, - "one unkept looked-at card must be exiled (A={:?}, B={:?}, C={:?})", - state.objects[&card_a].zone, - state.objects[&card_b].zone, - state.objects[&card_c].zone, + matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }), + "expected bottom-of-library choice after keeping to hand, got {:?}", + state.waiting_for + ); + let WaitingFor::EffectZoneChoice { + cards: eligible, .. + } = state.waiting_for.clone() + else { + unreachable!(); + }; + assert!( + eligible.contains(&card_b) && eligible.contains(&card_c), + "choice must be among unkept looked-at cards: {eligible:?}" ); + engine::apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![card_b], + }, + ) + .unwrap(); + + assert_eq!(state.objects[&card_a].zone, Zone::Hand); let exiled = [card_b, card_c] .into_iter() .find(|id| state.objects[id].zone == Zone::Exile) - .expect("exiled card"); + .expect("one unkept looked-at card must be exiled"); assert!( state.objects[&exiled] .casting_permissions diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 1c26870fad..8b2c5cede0 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -63,25 +63,47 @@ pub fn resolve( TargetFilter::TrackedSet { .. } | TargetFilter::TrackedSetFiltered { .. } ) { - let effective_filter = - crate::game::targeting::resolve_tracked_set_sentinel(state, target_filter.clone()); - let ctx = crate::game::filter::FilterContext::from_ability(ability); - collected_targets = state - .objects - .iter() - .filter(|(id, obj)| { - obj.zone == Zone::Library - && crate::game::filter::matches_target_filter( - state, - **id, - &effective_filter, - &ctx, - ) - }) - .map(|(id, _)| *id) - .collect(); + // CR 608.2c + CR 401.2: Tracked-set continuations after a dig keep may + // still reference cards left in the library (Expressive Iteration's + // "put one on the bottom" step). Only those library members are legal + // picks — cards already routed to hand must not re-enter this choice. + if let Some(set_id) = state.chain_tracked_set_id { + if let Some(set) = state.tracked_object_sets.get(&set_id) { + collected_targets = set + .iter() + .filter(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.zone == Zone::Library) + }) + .copied() + .collect(); + } + } + if collected_targets.is_empty() { + let effective_filter = + crate::game::targeting::resolve_tracked_set_sentinel(state, target_filter.clone()); + let ctx = crate::game::filter::FilterContext::from_ability(ability); + collected_targets = state + .objects + .iter() + .filter(|(id, obj)| { + obj.zone == Zone::Library + && crate::game::filter::matches_target_filter( + state, + **id, + &effective_filter, + &ctx, + ) + }) + .map(|(id, _)| *id) + .collect(); + } } + // CR 608.2c: After a hand-routed dig keep, tracked-set members already in + // hand must not be offered again for library-position placement. if matches!(target_filter, TargetFilter::TrackedSet { .. }) { collected_targets.retain(|id| { state @@ -97,6 +119,24 @@ pub fn resolve( // This covers Brainstorm ("put two cards from your hand on top of your library") // and similar cards where the player chooses during resolution. let expected = resolve_quantity_with_targets(state, &count_expr, ability).max(0) as usize; + let expected = if expected == 0 + && matches!( + position, + LibraryPosition::Bottom | LibraryPosition::NthFromTop { .. } + ) + && matches!( + target_filter, + TargetFilter::TrackedSet { .. } | TargetFilter::TrackedSetFiltered { .. } + ) + && !collected_targets.is_empty() + { + // Parser placeholder `count: 0` on dig-tail bottom steps means "one of + // the tracked looked-at cards", not "all of them". + 1 + } else { + expected + }; + if collected_targets.is_empty() { if expected == 0 { events.push(GameEvent::EffectResolved { @@ -147,6 +187,7 @@ pub fn resolve( // CR 708.2a: library-position selection is not a face-down entry. face_down_profile: None, count_param: 0, + library_position: Some(position.clone()), is_cost_payment: false, }; return Ok(()); @@ -171,6 +212,33 @@ pub fn resolve( )); } + // CR 115.1 + CR 608.2c: When more tracked-set library candidates exist + // than the placement count allows, prompt before auto-picking the first. + if collected_targets.len() > expected && expected > 0 { + state.waiting_for = WaitingFor::EffectZoneChoice { + player: ability.controller, + cards: collected_targets, + count: expected, + min_count: 0, + up_to: false, + source_id: ability.source_id, + effect_kind: EffectKind::PutAtLibraryPosition, + zone: Zone::Library, + destination: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enter_transformed: false, + enters_under_player: None, + enters_attacking: false, + owner_library: false, + track_exiled_by_source: false, + face_down_profile: None, + count_param: 0, + library_position: Some(position.clone()), + is_cost_payment: false, + }; + return Ok(()); + } + // `count` carries the cardinality of the placement. For multi-card // placement, CR 401.4 lets the owner arrange cards put into the same // library position. The runtime uses target/selection order for "in any diff --git a/crates/engine/src/game/effects/sacrifice.rs b/crates/engine/src/game/effects/sacrifice.rs index aba9db9fc2..fb657d038f 100644 --- a/crates/engine/src/game/effects/sacrifice.rs +++ b/crates/engine/src/game/effects/sacrifice.rs @@ -311,6 +311,7 @@ pub fn resolve( // CR 708.2a: sacrifice selection is not a face-down entry. face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; diff --git a/crates/engine/src/game/effects/tap_untap.rs b/crates/engine/src/game/effects/tap_untap.rs index cc660da8f5..26cc87c243 100644 --- a/crates/engine/src/game/effects/tap_untap.rs +++ b/crates/engine/src/game/effects/tap_untap.rs @@ -249,6 +249,7 @@ fn prompt_resolution_tap_untap_choice( // CR 708.2a: tap/untap selection is not a face-down entry. face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; true diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index ecc0e19822..39e4d1e481 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -17984,6 +17984,7 @@ Echo—Discard a card. (At the beginning of your upkeep, if this came under your track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; state.pending_continuation = Some(crate::types::game_state::PendingContinuation::new( @@ -18054,6 +18055,7 @@ Echo—Discard a card. (At the beginning of your upkeep, if this came under your track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; @@ -18100,6 +18102,7 @@ Echo—Discard a card. (At the beginning of your upkeep, if this came under your track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 77e3d9cdd2..262f99c0fb 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -135,6 +135,28 @@ pub(super) fn handles(waiting_for: &WaitingFor) -> bool { ) } +/// CR 608.2c: Expressive Iteration-style dig tails chain a +/// `PutAtLibraryPosition { TrackedSet }` step before exiling from the same +/// looked-at pile. Those continuations need the full dig pile in the tracked +/// set; generic reveal/keep continuations (Zimone land split) bind only the +/// kept/revealed subset. +fn dig_continuation_needs_full_looked_at_tracked_set(ability: &ResolvedAbility) -> bool { + let mut current = Some(ability); + while let Some(sub) = current { + if matches!( + &sub.effect, + Effect::PutAtLibraryPosition { + target: crate::types::ability::TargetFilter::TrackedSet { .. }, + .. + } + ) { + return true; + } + current = sub.sub_ability.as_deref(); + } + false +} + /// CR 701.20e / CR 701.23a + CR 401.4: Move the "rest" partition of an /// interactive selection (Dig's unkept cards, a search-split's non-primary /// cards) to a concrete destination zone. `Library` routes to the bottom of the @@ -1523,17 +1545,24 @@ pub(super) fn handle_resolution_choice( } } } - // CR 701.20b + CR 608.2c: Publish every looked-at card as a tracked - // set so downstream sub_abilities can route them (Zimone's - // Experiment land/creature split; Expressive Iteration's bottom/ - // exile tail after keeping one to hand). Use a fresh tracked set so - // a parent effect's empty pre-choice publish cannot keep the chain - // sentinel bound to the wrong set. When nothing was kept, publish an - // empty set so ParentTarget continuations do not bind stale cards. + // CR 701.20b + CR 608.2c: Publish a tracked set for downstream + // sub_abilities. Reveal/keep continuations (Zimone land split) bind + // the kept subset; Expressive Iteration's bottom/exile tail needs the + // full looked-at pile when its continuation chains + // `PutAtLibraryPosition { TrackedSet }`. let publish_set = if kept.is_empty() { Vec::new() - } else { + } else if kept_destination == Some(Zone::Hand) && state.pending_continuation.is_some() { + // Expressive Iteration-style hand keep + bottom/exile tail. cards.clone() + } else if state + .pending_continuation + .as_ref() + .is_some_and(|cont| dig_continuation_needs_full_looked_at_tracked_set(&cont.chain)) + { + cards.clone() + } else { + kept.clone() }; effects::publish_fresh_tracked_set(state, publish_set); // None => Graveyard; map to a concrete zone so the rest mover @@ -2452,6 +2481,7 @@ pub(super) fn handle_resolution_choice( track_exiled_by_source, face_down_profile, count_param, + library_position, is_cost_payment, }, GameAction::SelectCards { cards: chosen }, @@ -2729,12 +2759,27 @@ pub(super) fn handle_resolution_choice( // CR 115.1: Resolution-time selection for PutAtLibraryPosition // from a private zone (e.g. Brainstorm's "put two cards from // your hand on top of your library"). Cards are placed in - // selection order (first chosen = top). - EffectKind::PutAtLibraryPosition => { - for &card_id in chosen.iter().rev() { - super::zones::move_to_library_at_index(state, card_id, Some(0), events); + // selection order (first chosen = top). Expressive Iteration's + // tracked-set bottom step chains an exile `ParentTarget` tail — + // detect that continuation shape to honor bottom placement. + EffectKind::PutAtLibraryPosition => match library_position { + Some(LibraryPosition::Bottom) => { + for &card_id in &chosen { + super::zones::move_to_library_position(state, card_id, false, events); + } } - } + Some(LibraryPosition::NthFromTop { n }) => { + let index = Some(n.saturating_sub(1) as usize); + for &card_id in &chosen { + super::zones::move_to_library_at_index(state, card_id, index, events); + } + } + _ => { + for &card_id in chosen.iter().rev() { + super::zones::move_to_library_at_index(state, card_id, Some(0), events); + } + } + }, // CR 601.2c + CR 115.1: Resolution-time hand pick for // `CastFromZone` (Electrodominance, Baral's Expertise). EffectKind::CastFromZone => { diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 1b4bea64b5..9f190c7ab5 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -737,6 +737,7 @@ fn pay_replacement_may_cost( && matches!( state.waiting_for, WaitingFor::EffectZoneChoice { + library_position: None, is_cost_payment: true, .. } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 1757330eab..78c4f5d20a 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -19345,6 +19345,7 @@ pub mod tests { track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 91c7a2db0c..090b2c996a 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -526,6 +526,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState track_exiled_by_source, ref face_down_profile, count_param, + library_position: None, is_cost_payment: _, } = state.waiting_for { @@ -550,6 +551,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState // not private hand info — pass them through the redaction. face_down_profile: face_down_profile.clone(), count_param, + library_position: None, is_cost_payment: false, }; } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 77ad87e40c..4f2967273b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -3119,6 +3119,11 @@ pub enum WaitingFor { /// Zero for all non-blight EffectZoneChoice uses. #[serde(default)] count_param: u32, + /// CR 401.4: Explicit library placement for resolution-time + /// `PutAtLibraryPosition` choices. `None` = top (Brainstorm); `Some` + /// preserves bottom/nth placement across the choice round-trip. + #[serde(default, skip_serializing_if = "Option::is_none")] + library_position: Option, /// CR 118.3: When true, this choice is for a cost payment (e.g., exile cost) /// rather than effect resolution. Cost-payment choices require special /// handling for exile-link tracking (push_exiled_with_source_this_turn). @@ -8198,6 +8203,7 @@ mod tests { track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, })); variants.push(Box::new(WaitingFor::DefilerPayment { @@ -8447,6 +8453,7 @@ mod tests { track_exiled_by_source: false, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: false, }; let json = serde_json::to_string(&wf).unwrap(); @@ -8549,6 +8556,7 @@ mod tests { ward: None, }), count_param: 0, + library_position: None, is_cost_payment: false, }; let json = serde_json::to_string(&wf).expect("serialize"); diff --git a/crates/engine/tests/integration/mimeoplasm_interactive_exile.rs b/crates/engine/tests/integration/mimeoplasm_interactive_exile.rs index e1fb576ce3..53d2648bd7 100644 --- a/crates/engine/tests/integration/mimeoplasm_interactive_exile.rs +++ b/crates/engine/tests/integration/mimeoplasm_interactive_exile.rs @@ -483,6 +483,7 @@ fn paycost_arm_exiles_cards_via_apply_as_current() { track_exiled_by_source: true, face_down_profile: None, count_param: 0, + library_position: None, is_cost_payment: true, }; } From 44d457353b72c8448e67728a87f04a85b590092a Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:58:25 +0200 Subject: [PATCH 7/9] fix(engine): address Expressive Iteration dig tail review (#1162) Publish only unkept cards to the tracked set, skip forwarding kept-card targets into TrackedSet bottom picks, and narrow the set after bottom placement so exile cannot re-select the bottomed card. Co-authored-by: Cursor --- crates/engine/src/game/effects/mod.rs | 63 ++++++++++++------- crates/engine/src/game/effects/put_on_top.rs | 9 ++- .../src/game/engine_resolution_choices.rs | 45 ++++++++----- crates/server-core/src/filter.rs | 1 + 4 files changed, 80 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index bb2b154e61..b474262217 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -18337,30 +18337,40 @@ mod tests { let tracked: Vec<_> = state .tracked_object_sets - .values() - .flatten() - .copied() - .collect(); - assert!( - tracked.contains(&card_a) && tracked.contains(&card_b) && tracked.contains(&card_c), - "dig must publish all looked-at cards to the tracked set, got {tracked:?}" + .get( + &state + .chain_tracked_set_id + .expect("dig tail must publish a tracked set"), + ) + .expect("tracked set must exist") + .clone(); + assert_eq!( + tracked, + vec![card_b, card_c], + "dig must publish only the unkept looked-at cards" ); - assert!( - matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }), - "expected bottom-of-library choice after keeping to hand, got {:?}", - state.waiting_for - ); let WaitingFor::EffectZoneChoice { - cards: eligible, .. + cards: eligible, + effect_kind, + .. } = state.waiting_for.clone() else { - unreachable!(); + panic!( + "expected bottom-of-library choice after keeping to hand, got {:?}", + state.waiting_for + ); }; - assert!( - eligible.contains(&card_b) && eligible.contains(&card_c), - "choice must be among unkept looked-at cards: {eligible:?}" + assert_eq!( + effect_kind, + crate::types::ability::EffectKind::PutAtLibraryPosition ); + assert_eq!( + eligible, + vec![card_b, card_c], + "bottom choice must be among unkept library cards" + ); + engine::apply_as_current( &mut state, GameAction::SelectCards { @@ -18370,12 +18380,21 @@ mod tests { .unwrap(); assert_eq!(state.objects[&card_a].zone, Zone::Hand); - let exiled = [card_b, card_c] - .into_iter() - .find(|id| state.objects[id].zone == Zone::Exile) - .expect("one unkept looked-at card must be exiled"); + assert_eq!(state.objects[&card_b].zone, Zone::Library); + assert_eq!(state.objects[&card_c].zone, Zone::Exile); + assert!( + state.players[0].library.back() == Some(&card_b), + "card B must be on the bottom of the library" + ); + assert!( + !state.objects[&card_b] + .casting_permissions + .iter() + .any(|p| matches!(p, CastingPermission::PlayFromExile { .. })), + "bottomed card must not receive play permission" + ); assert!( - state.objects[&exiled] + state.objects[&card_c] .casting_permissions .iter() .any(|p| matches!( diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 8b2c5cede0..41dbd386dc 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -43,8 +43,13 @@ pub fn resolve( // This is the post-#323 SelfRef short-circuit applied uniformly. let effective_targets = crate::game::targeting::resolved_targets(ability, &target_filter, state); - let mut collected_targets = - crate::game::effects::effect_object_targets(&target_filter, &effective_targets); + // CR 608.2c: `effect_object_targets` forwards `ability.targets` verbatim + // for non-slot filters. A dig hand-keep binds `ParentTarget` on the exile + // tail but must not pre-fill a `TrackedSet` bottom pick with the kept card. + let mut collected_targets = match &target_filter { + TargetFilter::TrackedSet { .. } | TargetFilter::TrackedSetFiltered { .. } => Vec::new(), + _ => crate::game::effects::effect_object_targets(&target_filter, &effective_targets), + }; if collected_targets.is_empty() && matches!(target_filter, TargetFilter::ExiledBySource) { let ctx = crate::game::filter::FilterContext::from_ability(ability); collected_targets = state diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 262f99c0fb..66123ea3c9 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -137,9 +137,9 @@ pub(super) fn handles(waiting_for: &WaitingFor) -> bool { /// CR 608.2c: Expressive Iteration-style dig tails chain a /// `PutAtLibraryPosition { TrackedSet }` step before exiling from the same -/// looked-at pile. Those continuations need the full dig pile in the tracked -/// set; generic reveal/keep continuations (Zimone land split) bind only the -/// kept/revealed subset. +/// looked-at pile. Those continuations publish and route via the **unkept** +/// looked-at cards; generic reveal/keep continuations (Zimone land split) +/// bind only the kept/revealed subset. fn dig_continuation_needs_full_looked_at_tracked_set(ability: &ResolvedAbility) -> bool { let mut current = Some(ability); while let Some(sub) = current { @@ -1547,20 +1547,17 @@ pub(super) fn handle_resolution_choice( } // CR 701.20b + CR 608.2c: Publish a tracked set for downstream // sub_abilities. Reveal/keep continuations (Zimone land split) bind - // the kept subset; Expressive Iteration's bottom/exile tail needs the - // full looked-at pile when its continuation chains + // the kept subset; Expressive Iteration's bottom/exile tail binds the + // unkept looked-at pile when its continuation chains // `PutAtLibraryPosition { TrackedSet }`. let publish_set = if kept.is_empty() { Vec::new() - } else if kept_destination == Some(Zone::Hand) && state.pending_continuation.is_some() { - // Expressive Iteration-style hand keep + bottom/exile tail. - cards.clone() - } else if state - .pending_continuation - .as_ref() - .is_some_and(|cont| dig_continuation_needs_full_looked_at_tracked_set(&cont.chain)) - { - cards.clone() + } else if state.pending_continuation.as_ref().is_some_and(|cont| { + dig_continuation_needs_full_looked_at_tracked_set(&cont.chain) + }) { + // Expressive Iteration-style bottom/exile tail: downstream + // `TrackedSet` steps address the unkept looked-at pile only. + unkept.clone() } else { kept.clone() }; @@ -3011,6 +3008,26 @@ pub(super) fn handle_resolution_choice( _ => None, }) .collect() + } else if matches!(effect_kind, EffectKind::PutAtLibraryPosition) + && matches!(library_position, Some(LibraryPosition::Bottom)) + && state.pending_continuation.is_some() + { + // CR 608.2c: Expressive Iteration's bottom pick narrows the + // tracked set to the remaining looked-at library cards so the + // chained exile step cannot re-select the bottomed card. + state + .chain_tracked_set_id + .and_then(|id| state.tracked_object_sets.get(&id).cloned()) + .unwrap_or_default() + .into_iter() + .filter(|id| !chosen.contains(id)) + .filter(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.zone == Zone::Library) + }) + .collect() } else { chosen.clone() }; diff --git a/crates/server-core/src/filter.rs b/crates/server-core/src/filter.rs index 7ae7628cb6..91a4bc6e00 100644 --- a/crates/server-core/src/filter.rs +++ b/crates/server-core/src/filter.rs @@ -344,6 +344,7 @@ mod tests { face_down_profile: None, count_param: 0, is_cost_payment: false, + library_position: None, }; let filtered = filter_state_for_player(&state, PlayerId(1)); From 28e7d4386a40e5db91fb6f2320d817bd2ef473b2 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:02:27 +0200 Subject: [PATCH 8/9] style(engine): rustfmt dig publish_set block (#1162) Co-authored-by: Cursor --- .../src/game/engine_resolution_choices.rs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 66123ea3c9..f022649f47 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1550,17 +1550,18 @@ pub(super) fn handle_resolution_choice( // the kept subset; Expressive Iteration's bottom/exile tail binds the // unkept looked-at pile when its continuation chains // `PutAtLibraryPosition { TrackedSet }`. - let publish_set = if kept.is_empty() { - Vec::new() - } else if state.pending_continuation.as_ref().is_some_and(|cont| { - dig_continuation_needs_full_looked_at_tracked_set(&cont.chain) - }) { - // Expressive Iteration-style bottom/exile tail: downstream - // `TrackedSet` steps address the unkept looked-at pile only. - unkept.clone() - } else { - kept.clone() - }; + let publish_set = + if kept.is_empty() { + Vec::new() + } else if state.pending_continuation.as_ref().is_some_and(|cont| { + dig_continuation_needs_full_looked_at_tracked_set(&cont.chain) + }) { + // Expressive Iteration-style bottom/exile tail: downstream + // `TrackedSet` steps address the unkept looked-at pile only. + unkept.clone() + } else { + kept.clone() + }; effects::publish_fresh_tracked_set(state, publish_set); // None => Graveyard; map to a concrete zone so the rest mover // (shared with the search-split partition) has a single Zone. From d2ccd1bea10c8cdb5c76e76745c6a6770de8c9db Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:59:29 +0200 Subject: [PATCH 9/9] Fix filtered tracked-set library-position candidate selection (#1162) Resolve TrackedSet/TrackedSetFiltered before collecting library-position candidates so inner filters and explicit set ids are honored instead of offering every chain-set library member. Co-authored-by: Cursor --- crates/engine/src/game/effects/put_on_top.rs | 164 ++++++++++++++----- 1 file changed, 121 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 41dbd386dc..e84b2084b0 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -7,6 +7,7 @@ use crate::types::ability::{ }; use crate::types::events::GameEvent; use crate::types::game_state::{GameState, WaitingFor}; +use crate::types::identifiers::ObjectId; use crate::types::zones::Zone; /// Place target card at a specific position in its owner's library. Unlike @@ -72,50 +73,34 @@ pub fn resolve( // still reference cards left in the library (Expressive Iteration's // "put one on the bottom" step). Only those library members are legal // picks — cards already routed to hand must not re-enter this choice. - if let Some(set_id) = state.chain_tracked_set_id { - if let Some(set) = state.tracked_object_sets.get(&set_id) { - collected_targets = set - .iter() - .filter(|id| { - state - .objects - .get(id) - .is_some_and(|obj| obj.zone == Zone::Library) - }) - .copied() - .collect(); - } - } - if collected_targets.is_empty() { - let effective_filter = - crate::game::targeting::resolve_tracked_set_sentinel(state, target_filter.clone()); - let ctx = crate::game::filter::FilterContext::from_ability(ability); - collected_targets = state - .objects - .iter() - .filter(|(id, obj)| { - obj.zone == Zone::Library - && crate::game::filter::matches_target_filter( - state, - **id, - &effective_filter, - &ctx, - ) - }) - .map(|(id, _)| *id) - .collect(); - } - } - - // CR 608.2c: After a hand-routed dig keep, tracked-set members already in - // hand must not be offered again for library-position placement. - if matches!(target_filter, TargetFilter::TrackedSet { .. }) { - collected_targets.retain(|id| { - state - .objects + // Resolve sentinel/explicit tracked-set identity first, then apply the + // filter predicate instead of blindly reading `chain_tracked_set_id`. + let effective_filter = + crate::game::targeting::resolve_tracked_set_sentinel(state, target_filter.clone()); + let ctx = crate::game::filter::FilterContext::from_ability(ability); + let candidate_ids: Vec = match &effective_filter { + TargetFilter::TrackedSet { id } | TargetFilter::TrackedSetFiltered { id, .. } => state + .tracked_object_sets .get(id) - .is_some_and(|obj| obj.zone == Zone::Library) - }); + .cloned() + .unwrap_or_default(), + _ => state.objects.keys().copied().collect(), + }; + collected_targets = candidate_ids + .into_iter() + .filter(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.zone == Zone::Library) + && crate::game::filter::matches_target_filter( + state, + *id, + &effective_filter, + &ctx, + ) + }) + .collect(); } // CR 115.1 + CR 400.2: When the filter specifies a private zone (hand/library) @@ -814,4 +799,97 @@ mod tests { other => panic!("Expected EffectZoneChoice, got {:?}", other), } } + + /// CR 608.2c (issue #1162): filtered tracked-set library-position + /// continuations must honor `TrackedSetFiltered`, not every library member + /// in the chain set. + #[test] + fn tracked_set_filtered_library_bottom_honors_inner_filter() { + use crate::types::ability::TypeFilter; + use crate::types::card_type::CoreType; + use crate::types::identifiers::TrackedSetId; + + let mut state = GameState::new_two_player(42); + let creature = create_object( + &mut state, + CardId(10), + PlayerId(0), + "Bear".to_string(), + Zone::Library, + ); + let instant_a = create_object( + &mut state, + CardId(11), + PlayerId(0), + "Bolt A".to_string(), + Zone::Library, + ); + let instant_b = create_object( + &mut state, + CardId(12), + PlayerId(0), + "Bolt B".to_string(), + Zone::Library, + ); + state + .objects + .get_mut(&creature) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + state + .objects + .get_mut(&instant_a) + .unwrap() + .card_types + .core_types = vec![CoreType::Instant]; + state + .objects + .get_mut(&instant_b) + .unwrap() + .card_types + .core_types = vec![CoreType::Instant]; + state.players[0].library = vec![creature, instant_a, instant_b].into(); + + let set_id = TrackedSetId(7); + state + .tracked_object_sets + .insert(set_id, vec![creature, instant_a, instant_b]); + state.chain_tracked_set_id = Some(set_id); + + let ability = ResolvedAbility::new( + Effect::PutAtLibraryPosition { + target: TargetFilter::TrackedSetFiltered { + id: set_id, + filter: Box::new(TargetFilter::Typed( + crate::types::ability::TypedFilter::new(TypeFilter::Instant), + )), + caused_by: None, + }, + count: QuantityExpr::Fixed { value: 1 }, + position: LibraryPosition::Bottom, + }, + vec![], + ObjectId(100), + PlayerId(0), + ); + + let mut events = vec![]; + resolve(&mut state, &ability, &mut events).unwrap(); + + match &state.waiting_for { + WaitingFor::EffectZoneChoice { cards, .. } => { + assert_eq!( + cards, + &vec![instant_a, instant_b], + "only instant members of the tracked set may be offered" + ); + assert!( + !cards.contains(&creature), + "creature must not be offered for instant-only filter" + ); + } + other => panic!("expected EffectZoneChoice, got {other:?}"), + } + } }