diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 13f6116928..b649049d30 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -1498,7 +1498,7 @@ fn drain_active_repeat_for(state: &mut GameState, events: &mut Vec) { let iter_effective: &ResolvedAbility = if member.is_some() || kind.is_some() { iter_ability = (*ability).clone(); if let Some(member) = member { - rebind_first_object_target(&mut iter_ability.targets, member); + rebind_member_driven_parent_target(&mut iter_ability, member); } if let Some(kind) = kind { rebind_iterated_counter_kind(&mut iter_ability, kind); @@ -2465,6 +2465,10 @@ fn apply_parent_chain_context( state: &mut GameState, ) { child.context = parent.context.clone(); + // CR 701.20e + CR 608.2c: Look-result membership is owned by precisely + // one immediate looping child. Ordinary hand-offs must not let it leak to + // a later grandchild with a different instruction scope. + child.context.parent_target_iteration_members = None; // CR 701.9a + CR 608.2c: A discard result is visible only to the direct // contingent child. Every ordinary hand-off clears it, preventing a later // grandchild (or an unrelated chain branch) from reading stale provenance. @@ -3087,6 +3091,26 @@ fn inject_last_revealed_targets( .collect() } +/// Stamps the exact forwarded result collection onto an immediate executable +/// member-driven child. Call only after `apply_parent_chain_context`, which +/// intentionally clears resolution-local direct-child provenance. +fn stamp_parent_target_iteration_members(child: &mut ResolvedAbility) { + if child.targets.is_empty() || !has_member_driven_repeat(child) { + return; + } + + child.context.parent_target_iteration_members = Some( + child + .targets + .iter() + .filter_map(|target| match target { + TargetRef::Object(id) => Some(*id), + TargetRef::Player(_) => None, + }) + .collect(), + ); +} + /// CR 608.2c: Locate the `Not(OptionalEffectPerformed)` decline clause anywhere /// in an `IfYouDo` head's accept-body sub-chain. A parent optional ability has a /// single `sub_ability` link, so the accept body ("If you do, A and B") and the @@ -5868,6 +5892,30 @@ fn rebind_first_object_target( } } +/// CR 701.20e + CR 608.2c: A carried look-result collection owns every object +/// target on its immediate looping child, so each iteration must replace the +/// complete object list with its one current member while retaining independent +/// player targets. Ordinary member loops retain the established first-slot +/// binding for their independent target slots. +fn rebind_member_driven_parent_target(ability: &mut ResolvedAbility, member: ObjectId) { + if ability.context.parent_target_iteration_members.is_some() { + let insertion_index = ability + .targets + .iter() + .position(|target| matches!(target, TargetRef::Object(_))) + .unwrap_or(ability.targets.len()); + ability + .targets + .retain(|target| !matches!(target, TargetRef::Object(_))); + ability.targets.insert( + insertion_index.min(ability.targets.len()), + TargetRef::Object(member), + ); + } else { + rebind_first_object_target(&mut ability.targets, member); + } +} + /// CR 122.1 + CR 608.2c: Rebind a counter-kind-driven `ChooseOneOf` to the /// current iteration's counter kind. For each branch tagged /// `iteration_kind_binding == Some(RebindToIteratedKind)`, rewrites that @@ -9541,7 +9589,15 @@ fn resolve_chain_body( // CR 118.12 + CR 118.12a: "Effect unless [player] pays {cost}" — // intercepted here for both tax triggers and counter-target-spell unless // costs. Post-fold, the cost is the unified `AbilityCost` taxonomy. - if let Some(ref unless_pay) = ability.unless_pay { + // CR 608.2c + CR 118.12a: A member-driven "for each" loop offers its + // unless payment once for each bound member, not once against the full + // parent target collection. Defer interception until the loop below + // re-enters this chain with its singleton iteration ability. + if let Some(unless_pay) = ability + .unless_pay + .as_ref() + .filter(|_| !has_member_driven_repeat_after_hydration(state, ability)) + { // CR 603.2 + CR 118.12a: Hydrate event-context targets before payer // resolution so trigger unless-costs ("that player ... unless they pay") // do not silently fall through when `ability.targets` is still empty @@ -9913,12 +9969,24 @@ fn resolve_chain_body( // the same `effective` ability, so members and count match // (including `OtherThanTriggerObject` handling). let ctx = filter::FilterContext::from_ability(effective); - crate::game::quantity::object_count_matching_ids( - state, - filter, - &ctx, - effective.source_id, - ) + if let Some(candidate_ids) = + effective.context.parent_target_iteration_members.clone() + { + crate::game::quantity::object_count_matching_candidate_ids( + state, + candidate_ids, + filter, + &ctx, + effective.source_id, + ) + } else { + crate::game::quantity::object_count_matching_ids( + state, + filter, + &ctx, + effective.source_id, + ) + } } _ => Vec::new(), }; @@ -10015,9 +10083,13 @@ fn resolve_chain_body( // exiled cards…" — Disorder in the Court) and runs exactly once AFTER // the loop — it falls through to the generic sub tail below. let repeated_full_chain = ability.repeat_for.is_some() - && effective.sub_ability.as_deref().is_some_and(|sub| { + && (effective.sub_ability.as_deref().is_some_and(|sub| { member_driven || kind_driven || sub.sub_link == SubAbilityLink::ContinuationStep - }); + }) + // CR 118.12a: a per-member/per-kind unless payment must run + // through resolve_ability_chain so its individual bound + // target reaches the payment gate before the effect resolves. + || ((member_driven || kind_driven) && effective.unless_pay.is_some())); while iteration < iterations { // Snapshot per-iteration ability with parent-target rebinding when // applicable. CR 109.5: the rebind is SINGLE-slot — every reachable @@ -10036,7 +10108,7 @@ fn resolve_chain_body( if member.is_some() || is_replacement_added_copy || kind_driven { iter_ability = effective.clone(); if let Some(member) = member { - rebind_first_object_target(&mut iter_ability.targets, member); + rebind_member_driven_parent_target(&mut iter_ability, member); } // CR 122.1 + CR 608.2c: rebind this iteration's dynamic // ChooseOneOf branch to the current counter kind. @@ -10052,7 +10124,7 @@ fn resolve_chain_body( // so each iteration fires its own `OptionalEffectChoice`. // Clear `repeat_for` on the clone so the inner chain does // not re-enter this outer loop. - if kind_driven || (member_driven && iter_ability.optional) { + if kind_driven || member_driven { iter_ability.repeat_for = None; } if let (true, Effect::CopySpell { retarget, .. }) = @@ -10793,11 +10865,8 @@ fn resolve_chain_body( && !state.last_revealed_ids.is_empty() && effect_writes_last_revealed_ids(&ability.effect) { - else_resolved.targets = state - .last_revealed_ids - .iter() - .map(|&id| TargetRef::Object(id)) - .collect(); + else_resolved.targets = + inject_last_revealed_targets(state, ability, else_branch.as_ref()); } else if should_propagate_parent_targets(ability, &else_resolved) { else_resolved.targets = ability.targets.clone(); } @@ -10807,6 +10876,7 @@ fn resolve_chain_body( effect_context_object.as_ref(), state, ); + stamp_parent_target_iteration_members(&mut else_resolved); if try_begin_deferred_else_branch_target_selection( state, &mut else_resolved, @@ -11284,6 +11354,7 @@ fn resolve_chain_body( effect_context_object.as_ref(), state, ); + stamp_parent_target_iteration_members(&mut sub_with_targets); resolve_ability_chain(state, &sub_with_targets, events, depth + 1)?; } else if sub.targets.is_empty() && !state.last_zone_changed_ids.is_empty() @@ -12851,6 +12922,117 @@ mod tests { ); } + #[test] + fn carried_member_rebind_retains_independent_player_target() { + let mut ability = ResolvedAbility::new( + Effect::Unimplemented { + name: "member rebind probe".to_string(), + description: None, + }, + vec![ + TargetRef::Object(ObjectId(1)), + TargetRef::Player(PlayerId(2)), + TargetRef::Object(ObjectId(3)), + ], + ObjectId(10), + PlayerId(0), + ); + ability.context.parent_target_iteration_members = Some(vec![ObjectId(1), ObjectId(3)]); + + rebind_member_driven_parent_target(&mut ability, ObjectId(4)); + + assert_eq!( + ability.targets, + vec![TargetRef::Object(ObjectId(4)), TargetRef::Player(PlayerId(2))], + "a carried collection replaces all of its object members but retains an independent selected player target" + ); + } + + /// CR 608.2c + CR 118.12a: Every member-driven iteration must re-enter the + /// unless-payment gate with exactly one bound parent target. In particular, + /// a non-optional loop with no sub-ability must re-enter + /// `resolve_ability_chain`, because that chain owns the unless-payment gate. + #[test] + fn member_driven_unless_loop_prompts_for_its_first_bound_member() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Loop source".to_string(), + Zone::Battlefield, + ); + let first = create_object( + &mut state, + CardId(2), + PlayerId(0), + "First creature".to_string(), + Zone::Battlefield, + ); + let second = create_object( + &mut state, + CardId(3), + PlayerId(0), + "Second creature".to_string(), + Zone::Battlefield, + ); + for object_id in [first, second] { + state + .objects + .get_mut(&object_id) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + } + + let creature_filter = TargetFilter::Typed(TypedFilter::creature()); + let mut ability = ResolvedAbility::new( + Effect::Destroy { + target: TargetFilter::ParentTarget, + cant_regenerate: false, + }, + vec![], + source, + PlayerId(0), + ); + ability.repeat_for = Some(QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: creature_filter, + }, + }); + ability.unless_pay = Some(UnlessPayModifier { + cost: AbilityCost::PayLife { + amount: QuantityExpr::Fixed { value: 1 }, + }, + payer: TargetFilter::Controller, + }); + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0) + .expect("member-driven unless loop should arm its first payment prompt"); + + let WaitingFor::UnlessPayment { pending_effect, .. } = &state.waiting_for else { + panic!( + "expected an unless-payment prompt, got {:?}", + state.waiting_for + ); + }; + assert!( + pending_effect.repeat_for.is_none(), + "the bound iteration must not re-enter the outer repeat loop" + ); + assert_eq!( + pending_effect.targets.len(), + 1, + "the prompt must be bound to one loop member" + ); + assert!(matches!( + pending_effect.targets.as_slice(), + [TargetRef::Object(id)] if *id == first || *id == second + )); + } + #[test] fn volcanic_vision_deals_returned_cards_mana_value_after_return_to_hand() { use crate::game::scenario::{GameScenario, P0, P1}; diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index b7ec95079d..276f2590d7 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16035,9 +16035,9 @@ mod stage2_injector_tests { // shifts combine with #6958's paid-cast outcome exclusion and // #6976's conditional-branch exclusions. None creates an // `OptionalEffect` prompt. Re-pinned against the merged source. - "game/effects/mod.rs:6252".to_string(), - "game/effects/mod.rs:6329".to_string(), - "game/effects/mod.rs:9522".to_string(), + "game/effects/mod.rs:6300".to_string(), + "game/effects/mod.rs:6377".to_string(), + "game/effects/mod.rs:9570".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. @@ -18665,7 +18665,7 @@ mod bounded_offer_conjunct_tests { (format!("has_kind_driven{}repeat(", '_'), 2), ( format!("has_member_driven_repeat_after{}hydration(", '_'), - 2, + 3, ), (format!("is_repeated_optional{}payment(", '_'), 2), (format!("optional_prompt{}player(", '_'), 1), @@ -18741,7 +18741,8 @@ mod bounded_offer_conjunct_tests { "the CR 603.5 conjunct set gained or lost a production consumer. The surviving \ non-authority sites are `repeat_for_outermost_with_scope_or_unless` (does a \ counted repeat wrap scoped/unless-pay instructions), `resolve_chain_body`'s \ - repeat-driver guard and its CR 603.12a driver dispatch, and \ + repeat-driver guard, its per-member unless-payment gate, and its CR 603.12a \ + driver dispatch, and \ `resolve_chain_body`'s `CastFromZone` decline probe — every one of them selects a \ DRIVER rather than opening an up-front window, so a NEW site is a decision to \ adjudicate here and not a number to move.\nsites={sites:#?}" diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index b06587dcdf..f221faf51e 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -2569,7 +2569,31 @@ pub(crate) fn object_count_matching_ids( filter_ctx: &FilterContext<'_>, source_id: ObjectId, ) -> Vec { - let mut ids = matching_object_ids_in_filter_universe(state, filter, filter_ctx); + object_count_matching_candidate_ids( + state, + matching_object_ids_in_filter_universe(state, filter, filter_ctx), + filter, + filter_ctx, + source_id, + ) +} + +/// Filters an ordered object snapshot with the same semantics as +/// [`object_count_matching_ids`]. Callers choose the candidate universe; this +/// helper owns membership checks, stable de-duplication, and "other than the +/// triggering object" exclusion. +pub(crate) fn object_count_matching_candidate_ids( + state: &GameState, + candidate_ids: Vec, + filter: &TargetFilter, + filter_ctx: &FilterContext<'_>, + source_id: ObjectId, +) -> Vec { + let mut seen = HashSet::new(); + let mut ids: Vec = candidate_ids + .into_iter() + .filter(|id| seen.insert(*id) && matches_target_filter(state, *id, filter, filter_ctx)) + .collect(); // Drop the triggering object for an "other than" filter (Valakut's "five // other Mountains" — the newly-entered Mountain matches the per-object filter // as a pass-through and is removed here). The exclusion is the Oracle-text @@ -9413,6 +9437,53 @@ mod tests { assert_eq!(ids, vec![red_a, red_b]); } + #[test] + fn object_count_matching_candidate_ids_filters_and_stably_deduplicates_snapshot() { + let mut state = GameState::new_two_player(46); + let red_a = create_object( + &mut state, + CardId(408), + PlayerId(0), + "Red A".to_string(), + Zone::Graveyard, + ); + let green = create_object( + &mut state, + CardId(409), + PlayerId(0), + "Green".to_string(), + Zone::Graveyard, + ); + let red_b = create_object( + &mut state, + CardId(410), + PlayerId(0), + "Red B".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&red_a).unwrap().color = vec![ManaColor::Red]; + state.objects.get_mut(&green).unwrap().color = vec![ManaColor::Green]; + state.objects.get_mut(&red_b).unwrap().color = vec![ManaColor::Red]; + + let filter = + TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::HasColor { + color: ManaColor::Red, + }])); + let ctx = FilterContext::from_source(&state, ObjectId(0)); + assert_eq!( + object_count_matching_candidate_ids( + &state, + vec![red_b, red_a, red_b, green], + &filter, + &ctx, + ObjectId(0), + ), + vec![red_b, red_a], + "candidate membership preserves its supplied order, rejects nonmatches, and \ + retains only the first occurrence" + ); + } + #[test] fn object_count_matching_ids_or_last_zone_changed_includes_typed_outside_ledger() { let mut state = GameState::new_two_player(46); diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 4902c9b66b..cacc48c849 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -3292,7 +3292,7 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili else_ability, duration, condition, - context: _, + context, optional_targeting, optional, optional_for, @@ -3347,6 +3347,7 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili && else_ability.is_none() && duration.is_none() && condition.is_none() + && *context == SpellContext::default() && !*optional_targeting && !*optional && optional_for.is_none() @@ -3483,7 +3484,7 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility else_ability, duration, condition, - context: _, + context, optional_targeting, optional, optional_for, @@ -3538,6 +3539,7 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility && else_ability.is_none() && duration.is_none() && condition.is_none() + && *context == SpellContext::default() && !*optional_targeting && !*optional && optional_for.is_none() diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index d4cbf49626..e65d6010b6 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -12,6 +12,29 @@ use super::turn_control; const HIDDEN_CARD_NAME: &str = "Hidden Card"; +/// Resolution-only look-result provenance is never part of a viewer snapshot. +/// The engine retains it for the active loop, while clients need only the +/// public prompt and the card identities they are otherwise allowed to see. +fn redact_parent_target_iteration_members(ability: &mut crate::types::ability::ResolvedAbility) { + ability.context.parent_target_iteration_members = None; + if let Some(sub_ability) = ability.sub_ability.as_mut() { + redact_parent_target_iteration_members(sub_ability); + } + if let Some(else_ability) = ability.else_ability.as_mut() { + redact_parent_target_iteration_members(else_ability); + } +} + +fn redact_waiting_for_iteration_members(waiting_for: &mut WaitingFor) { + match waiting_for { + WaitingFor::UnlessPayment { pending_effect, .. } + | WaitingFor::UnlessPaymentChooseCost { pending_effect, .. } => { + redact_parent_target_iteration_members(pending_effect); + } + _ => {} + } +} + pub(crate) fn interaction_object_identity_is_visible(state: &GameState, id: ObjectId) -> bool { state .objects @@ -88,10 +111,13 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState // viewer projection — including the activating player's projection. if let Some(pending) = filtered.pending_cast.as_mut() { pending.activation_trigger_collection = None; + redact_parent_target_iteration_members(&mut pending.ability); } if let Some(pending) = filtered.waiting_for.pending_cast_mut() { pending.activation_trigger_collection = None; + redact_parent_target_iteration_members(&mut pending.ability); } + redact_waiting_for_iteration_members(&mut filtered.waiting_for); // Interaction capability authority is trusted persistence state. Viewer // projections expose only the actor-scoped opaque opportunity IDs produced // by `game::interaction`, never the session/serial/slot minting ledger. @@ -1898,8 +1924,8 @@ mod tests { use crate::game::zones::create_object; use crate::types::ability::EffectKind; use crate::types::ability::{ - AbilityDefinition, AbilityKind, BeholdCostAction, CostPaidObjectSnapshot, Effect, - ReplacementDefinition, ResolvedAbility, TargetFilter, + AbilityCost, AbilityDefinition, AbilityKind, BeholdCostAction, CostPaidObjectSnapshot, + Effect, QuantityExpr, ReplacementDefinition, ResolvedAbility, TargetFilter, }; use crate::types::actions::GameAction; use crate::types::card_type::{CardType, CoreType}; @@ -2001,6 +2027,40 @@ mod tests { }) } + #[test] + fn unless_payment_projection_redacts_private_iteration_members() { + let mut state = GameState::new_two_player(42); + let mut pending = ResolvedAbility::new( + Effect::Unimplemented { + name: "private loop probe".to_string(), + description: None, + }, + vec![], + ObjectId(10), + PlayerId(0), + ); + pending.context.parent_target_iteration_members = Some(vec![ObjectId(1), ObjectId(2)]); + state.waiting_for = WaitingFor::UnlessPayment { + player: PlayerId(0), + cost: AbilityCost::PayLife { + amount: QuantityExpr::Fixed { value: 2 }, + }, + pending_effect: Box::new(pending), + trigger_event: None, + effect_description: None, + remaining: Vec::new(), + }; + + let filtered = filter_state_for_viewer(&state, PlayerId(1)); + let WaitingFor::UnlessPayment { pending_effect, .. } = filtered.waiting_for else { + panic!("the viewer projection must retain the payment prompt"); + }; + assert_eq!( + pending_effect.context.parent_target_iteration_members, None, + "a viewer snapshot must not expose the private look-result object ids" + ); + } + fn dummy_pending_mana_ability( player: PlayerId, source_id: ObjectId, diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 6b1f35b1d3..6ee693ab04 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -20521,6 +20521,11 @@ pub struct SpellContext { /// discard's direct child and clears it on every other hand-off. #[serde(default, skip_serializing_if = "Option::is_none")] pub direct_discard_result: Option, + /// CR 701.20e + CR 608.2c: Exact parent-produced cards forwarded to one + /// immediate "for each" child. The child uses this snapshot as its + /// iteration universe instead of an unqualified battlefield census. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_target_iteration_members: Option>, /// CR 601.2c + CR 115.1: For a target slot announced by "an opponent's /// choice", the opponent the spell's controller chose to make that choice. /// In a multiplayer game the controller picks which opponent announces; diff --git a/crates/engine/tests/integration/issue_7151_moonlight_bargain.rs b/crates/engine/tests/integration/issue_7151_moonlight_bargain.rs new file mode 100644 index 0000000000..1c498e0dd6 --- /dev/null +++ b/crates/engine/tests/integration/issue_7151_moonlight_bargain.rs @@ -0,0 +1,131 @@ +//! Production-path regression for Moonlight Bargain's per-card payment loop. +//! +//! The post-Dig child is repeated once for each of the looked-at cards. Its +//! iteration universe must be the exact five cards supplied by Dig, rather than +//! an unqualified battlefield object census (CR 608.2c, CR 701.20e). + +use std::collections::HashSet; + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const MOONLIGHT_BARGAIN: &str = "Look at the top five cards of your library. For each card, put that card into your graveyard unless you pay 2 life. Then put the rest into your hand."; + +fn pending_member( + runner: &mut GameRunner, + looked_members: &[engine::types::identifiers::ObjectId], +) -> engine::types::identifiers::ObjectId { + for _ in 0..16 { + match runner.state().waiting_for.clone() { + WaitingFor::UnlessPayment { + player, + pending_effect, + .. + } => { + assert_eq!(player, P0, "Moonlight Bargain's controller pays life"); + assert_eq!( + pending_effect + .context + .parent_target_iteration_members + .as_deref(), + Some(looked_members), + "each payment prompt retains Dig's exact five-card universe" + ); + let members: Vec<_> = pending_effect + .targets + .iter() + .filter_map(|target| match target { + TargetRef::Object(id) => Some(*id), + TargetRef::Player(_) => None, + }) + .collect(); + assert_eq!( + members.len(), + 1, + "each iteration has exactly one card target" + ); + return members[0]; + } + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("priority pass must advance Moonlight Bargain"); + } + other => panic!("expected Moonlight Bargain payment prompt, got {other:?}"), + } + } + panic!("Moonlight Bargain never reached its next payment prompt"); +} + +#[test] +fn moonlight_bargain_repeats_only_over_the_cards_it_looked_at() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + let moonlight = scenario + .add_spell_to_hand_from_oracle(P0, "Moonlight Bargain", true, MOONLIGHT_BARGAIN) + .with_mana_cost(ManaCost::zero()) + .id(); + scenario.with_library_top( + P0, + &["Looked A", "Looked B", "Looked C", "Looked D", "Looked E"], + ); + let battlefield_a = scenario.add_creature(P0, "Unrelated A", 2, 2).id(); + let battlefield_b = scenario.add_creature(P0, "Unrelated B", 3, 3).id(); + + let mut runner = scenario.build(); + runner.cast(moonlight).resolve(); + let looked_members = runner.state().last_revealed_ids.clone(); + assert_eq!(looked_members.len(), 5, "Dig looked at exactly five cards"); + + let mut paid_members = Vec::new(); + let mut declined_members = Vec::new(); + for pay in [true, false, true, false, false] { + let member = pending_member(&mut runner, &looked_members); + if pay { + paid_members.push(member); + } else { + declined_members.push(member); + } + runner + .act(GameAction::PayUnlessCost { pay }) + .expect("each Moonlight Bargain payment decision succeeds"); + } + + let looked: HashSet<_> = looked_members.into_iter().collect(); + assert_eq!(looked.len(), 5, "Dig looked at five distinct cards"); + assert_eq!( + paid_members.iter().copied().collect::>().len(), + 2, + "each paid iteration is a distinct looked-at card" + ); + assert_eq!( + declined_members + .iter() + .copied() + .collect::>() + .len(), + 3, + "each declined iteration is a distinct looked-at card" + ); + assert!( + paid_members + .iter() + .chain(&declined_members) + .all(|id| looked.contains(id)), + "no repeat iteration may substitute an unrelated battlefield permanent" + ); + assert_eq!( + runner.state().objects[&battlefield_a].zone, + Zone::Battlefield + ); + assert_eq!( + runner.state().objects[&battlefield_b].zone, + Zone::Battlefield + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 58ddb743f7..b126d9ad48 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -692,6 +692,7 @@ mod issue_6943_faerie_slumber_party; mod issue_7063_library_reorder; mod issue_7087_recruit_discard_provenance; mod issue_709_regression; +mod issue_7151_moonlight_bargain; mod issue_718_dina_sacrifice_draw; mod issue_7212_recruit_sibling_trigger; mod issue_7232_expend_auto_land_payment;