Skip to content
Open
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
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