Skip to content
Merged
216 changes: 199 additions & 17 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,7 @@ fn drain_active_repeat_for(state: &mut GameState, events: &mut Vec<GameEvent>) {
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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// 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
Expand Down Expand Up @@ -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))
{
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
Expand Down Expand Up @@ -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(),
};
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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, .. }) =
Expand Down Expand Up @@ -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();
}
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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};
Expand Down
11 changes: 6 additions & 5 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:#?}"
Expand Down
Loading
Loading