Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1249,7 +1249,7 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes {
count: _,
max_total_mv: _,
zones: _,
exile_instead_of_graveyard: _,
graveyard_replacement: _,
} => {
let mut acc = Axes::NONE;
acc = acc.or(scan_target_filter(filter, target_ctx, mode));
Expand Down
86 changes: 85 additions & 1 deletion crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4760,7 +4760,7 @@ fn attach_host_enchant_filter(
Some((filter, attachment_id, controller))
}

fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool {
pub(crate) fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool {
if ability.target_choice_timing != TargetChoiceTiming::Stack {
return false;
}
Expand Down Expand Up @@ -11103,6 +11103,90 @@ mod tests {
);
}

/// CR 115.1a + CR 108.3: The sole nonbattlefield per-opponent fanout class
/// binds each graveyard card to its immediately preceding opponent target.
/// The positive paired cards prove the path is reachable; a wrong-owner card
/// and a battlefield lookalike prove neither owner nor zone is widened.
#[test]
fn per_opponent_graveyard_fanout_pairs_only_each_opponents_typed_card() {
use crate::types::ability::{CardPlayMode, CastFromZoneDriver};

let mut state = GameState::new(FormatConfig::standard(), 3, 42);
let add_instant = |state: &mut GameState, owner, zone, card_id| {
let id = create_object(
state,
CardId(card_id),
owner,
format!("Instant {card_id}"),
zone,
);
state
.objects
.get_mut(&id)
.unwrap()
.card_types
.core_types
.push(CoreType::Instant);
id
};
let p1_graveyard = add_instant(&mut state, PlayerId(1), Zone::Graveyard, 1);
let p2_graveyard = add_instant(&mut state, PlayerId(2), Zone::Graveyard, 2);
let _wrong_owner = add_instant(&mut state, PlayerId(0), Zone::Graveyard, 3);
let _battlefield_lookalike = add_instant(&mut state, PlayerId(1), Zone::Battlefield, 4);

let filter = TargetFilter::Typed(
TypedFilter::new(TypeFilter::Instant)
.controller(ControllerRef::TargetPlayer)
.properties(vec![
FilterProp::Owned {
controller: ControllerRef::TargetPlayer,
},
FilterProp::InZone {
zone: Zone::Graveyard,
},
]),
);
let mut ability = ResolvedAbility::new(
Effect::CastFromZone {
target: filter,
without_paying_mana_cost: true,
mode: CardPlayMode::Cast,
cast_transformed: false,
alt_ability_cost: None,
constraint: None,
duration: None,
driver: CastFromZoneDriver::DuringResolution,
mana_spend_permission: None,
},
vec![],
ObjectId(900),
PlayerId(0),
);
ability.target_choice_timing = TargetChoiceTiming::Stack;
ability.multi_target = Some(MultiTargetSpec::bounded(
0,
QuantityExpr::Ref {
qty: QuantityRef::PlayerCount {
filter: PlayerFilter::Opponent,
},
},
));

let slots = build_target_slots(&state, &ability).expect("paired graveyard slots");
assert_eq!(slots.len(), 4, "one player/object pair per opponent");
assert_eq!(slots[0].legal_targets, vec![TargetRef::Player(PlayerId(1))]);
assert_eq!(
slots[1].legal_targets,
vec![TargetRef::Object(p1_graveyard)],
"P1's object slot excludes the wrong owner and battlefield lookalike"
);
assert_eq!(slots[2].legal_targets, vec![TargetRef::Player(PlayerId(2))]);
assert_eq!(
slots[3].legal_targets,
vec![TargetRef::Object(p2_graveyard)]
);
}

#[test]
fn per_opponent_gain_control_runtime_transfers_all_objects_and_preserves_tail() {
let mut state = GameState::new(FormatConfig::standard(), 3, 42);
Expand Down
14 changes: 5 additions & 9 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9317,17 +9317,13 @@ fn handle_resolution_cast_success(
remaining_mv_budget,
filter,
zones,
exile_instead_of_graveyard,
graveyard_replacement,
source,
member_pool,
} => {
if exile_instead_of_graveyard {
// CR 614.1a: Invoke Calamity's free-cast rider redirects to exile.
apply_spell_graveyard_replacement_rider(
state,
cast_object,
SpellStackToGraveyardReplacement::Exile,
);
if let Some(destination) = graveyard_replacement.clone() {
// CR 614.1a: Carry the exact printed replacement destination.
apply_spell_graveyard_replacement_rider(state, cast_object, destination);
}
let casts_left = remaining_casts.saturating_sub(1);
// CR 202.3: shrink the shared budget by what was actually spent on
Expand Down Expand Up @@ -9362,7 +9358,7 @@ fn handle_resolution_cast_success(
remaining_mv_budget: budget_left,
filter,
zones,
exile_instead_of_graveyard,
graveyard_replacement,
source,
member_pool,
},
Expand Down
6 changes: 3 additions & 3 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3076,7 +3076,7 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
max_total_mv,
filter,
zones,
exile_instead_of_graveyard,
graveyard_replacement,
} => {
d.push(("count".into(), count.to_string()));
if let Some(mv) = max_total_mv {
Expand All @@ -3091,8 +3091,8 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
.collect::<Vec<_>>()
.join("/"),
));
if *exile_instead_of_graveyard {
d.push(("exile instead of graveyard".into(), "yes".into()));
if let Some(destination) = graveyard_replacement {
d.push(("graveyard replacement".into(), format!("{destination:?}")));
}
}
Effect::RollDie {
Expand Down
32 changes: 32 additions & 0 deletions crates/engine/src/game/effects/cast_from_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,38 @@ pub fn resolve(
return Ok(());
}

// CR 608.2g + CR 115.1a: A per-opponent fanout has already chosen its
// player/object pairs as the trigger went on the stack. After resolution
// revalidation only the surviving object ids remain, so hand them to the
// existing free-cast window as an exact pool: do not rescan graveyards and
// do not substitute another card from the same opponent. The window's
// re-offer pipeline casts selected spells one at a time without priority.
let is_per_opponent_fanout = crate::game::ability_utils::is_per_opponent_target_fanout(ability);
let graveyard_destination = cast_from_zone_graveyard_destination(ability);
if driver.is_during_resolution()
&& without_paying
&& alt_ability_cost.is_none()
&& is_per_opponent_fanout
&& !target_ids.is_empty()
{
let mut window = ability.clone();
window.effect = Effect::FreeCastFromZones {
count: target_ids.len().try_into().unwrap_or(u8::MAX),
max_total_mv: None,
filter: target_filter.clone(),
zones: vec![Zone::Graveyard],
// The CastFromZone rider is stored as a sequential ParentTarget
// sub-ability; FreeCastWindow carries its exact destination as
// per-cast metadata instead of installing a source-global effect.
graveyard_replacement: graveyard_destination,
};
// The rider has been translated into the window's per-cast metadata;
// retaining it would run a second destination move after the window.
window.sub_ability = None;
window.targets = target_ids.drain(..).map(TargetRef::Object).collect();
return super::free_cast_from_zones::resolve(state, &window, events);
}

if driver_free_cast || immediate_graveyard_free_cast {
// CR 608.2g: both gates require `alt_ability_cost.is_none()`, so the
// pre-targeted free-cast path never carries a borrowed keyword cost —
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/game/effects/exile_from_top_until.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,7 @@ mod tests {
],
},
zones: vec![Zone::Exile],
exile_instead_of_graveyard: false,
graveyard_replacement: None,
},
vec![],
source,
Expand Down Expand Up @@ -1212,7 +1212,7 @@ mod tests {
],
},
zones: vec![Zone::Exile],
exile_instead_of_graveyard: false,
graveyard_replacement: None,
},
vec![],
source,
Expand Down
Loading
Loading