Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
372 changes: 98 additions & 274 deletions client/src-tauri/Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/blight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/effects/bounce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down Expand Up @@ -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(());
Expand Down Expand Up @@ -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(());
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/cast_from_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
77 changes: 63 additions & 14 deletions crates/engine/src/game/effects/change_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -256,6 +286,19 @@ pub fn resolve(
targeted_objects,
target_filter,
);
let targeted_objects: Vec<ObjectId> = 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -555,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
Expand Down Expand Up @@ -6522,6 +6570,7 @@ mod tests {
track_exiled_by_source: false,
face_down_profile: None,
count_param: 0,
library_position: None,
is_cost_payment: false,
};

Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/game/effects/dig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,9 @@ mod tests {
);
}

// A fresh tracked set must have been inserted with exactly the kept cards.
// 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
Expand Down
141 changes: 141 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -18267,4 +18270,142 @@ 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::ability::CastingPermission;
use crate::types::ability::Duration;
use crate::types::actions::GameAction;

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
.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"
);

let WaitingFor::EffectZoneChoice {
cards: eligible,
effect_kind,
..
} = state.waiting_for.clone()
else {
panic!(
"expected bottom-of-library choice after keeping to hand, got {:?}",
state.waiting_for
);
};
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 {
cards: vec![card_b],
},
)
.unwrap();

assert_eq!(state.objects[&card_a].zone, Zone::Hand);
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[&card_c]
.casting_permissions
.iter()
.any(|p| matches!(
p,
CastingPermission::PlayFromExile {
duration: Duration::UntilEndOfTurn,
granted_to: PlayerId(0),
..
}
)),
"exiled card must receive play-this-turn permission"
);
}
}
Loading
Loading