Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
84 changes: 84 additions & 0 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
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
46 changes: 46 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,52 @@ 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 = matches!(
ability
.multi_target
.as_ref()
.and_then(|spec| spec.max.as_ref()),
Some(QuantityExpr::Ref {
qty: crate::types::ability::QuantityRef::PlayerCount {
filter: crate::types::ability::PlayerFilter::Opponent
}
})
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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()
&& matches!(
graveyard_destination,
None | Some(SpellStackToGraveyardReplacement::Exile)
)
{
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 Exile destination as
// per-cast metadata instead of installing a source-global effect.
exile_instead_of_graveyard: matches!(
graveyard_destination,
Some(SpellStackToGraveyardReplacement::Exile)
),
};
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
Loading
Loading