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
61 changes: 55 additions & 6 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17321,6 +17321,16 @@ pub(crate) fn resolve_non_self_discard_requirement(
player: PlayerId,
source_id: ObjectId,
cost: &AbilityCost,
) -> Result<Option<(usize, Vec<ObjectId>)>, EngineError> {
resolve_non_self_discard_requirement_with_ability(state, player, source_id, cost, None)
}

pub(crate) fn resolve_non_self_discard_requirement_with_ability(
state: &GameState,
player: PlayerId,
source_id: ObjectId,
cost: &AbilityCost,
ability: Option<&ResolvedAbility>,
) -> Result<Option<(usize, Vec<ObjectId>)>, EngineError> {
// The activation/casting path handles ANY `FromHand` discard selection mode; the
// mana-ability path (see `mana_abilities::discard_cost_choice`) is the only caller
Expand All @@ -17334,7 +17344,12 @@ pub(crate) fn resolve_non_self_discard_requirement(
if count == 0 {
return Ok(None);
}
let eligible = find_eligible_discard_targets(state, player, source_id, filter);
let eligible = ability.map_or_else(
|| find_eligible_discard_targets(state, player, source_id, filter),
|ability| {
find_eligible_discard_targets_for_ability(state, player, source_id, filter, ability)
},
);
if eligible.len() < count {
return Err(EngineError::ActionNotAllowed(
"Not enough cards in hand to discard".into(),
Expand Down Expand Up @@ -17628,7 +17643,7 @@ fn find_eligible_hand_cost_targets(
source: ObjectId,
filter: Option<&TargetFilter>,
) -> Vec<ObjectId> {
let effective_filter = super::cost_payability::exile_cost_effective_filter(filter);
let effective_filter = super::cost_payability::cost_filter_before_x_announcement(filter);
let filter_ref = effective_filter.as_ref();
let ctx = super::filter::FilterContext::from_source(state, source);
state
Expand Down Expand Up @@ -17659,6 +17674,36 @@ pub(crate) fn find_eligible_discard_targets(
find_eligible_hand_cost_targets(state, player, source, filter)
}

/// CR 118.3 + CR 602.2b: Select the hand cards that can pay an activated
/// ability's discard cost by excluding the source and applying its optional
/// filter against the announced ability context.
pub(crate) fn find_eligible_discard_targets_for_ability(
state: &GameState,
player: PlayerId,
source: ObjectId,
filter: Option<&TargetFilter>,
ability: &ResolvedAbility,
) -> Vec<ObjectId> {
let ctx = super::filter::FilterContext::from_ability(ability);
state
.players
.get(player.0 as usize)
.map(|player_state| {
player_state
.hand
.iter()
.copied()
.filter(|&id| {
id != source
&& filter.is_none_or(|filter| {
super::filter::matches_target_filter(state, id, filter, &ctx)
})
})
.collect()
})
.unwrap_or_default()
}
Comment thread
matthewevans marked this conversation as resolved.

/// CR 701.20a + CR 601.2b: Eligible cards for an `AbilityCost::Reveal` payment
/// whose `filter` is `Some` (a non-self reveal). The source spell is never a
/// legal choice for its own additional cost, mirroring discard/exile.
Expand All @@ -17683,7 +17728,7 @@ pub(crate) fn find_eligible_exile_for_cost_targets(
zone: ExileCostSourceZone,
filter: Option<&TargetFilter>,
) -> Vec<ObjectId> {
let effective_filter = super::cost_payability::exile_cost_effective_filter(filter);
let effective_filter = super::cost_payability::cost_filter_before_x_announcement(filter);
let filter_ref = effective_filter.as_ref();
match zone {
ExileCostSourceZone::Hand => {
Expand Down Expand Up @@ -19130,9 +19175,13 @@ pub fn handle_activate_ability(
// Courier's "Discard your hand" on an empty hand) is paid by doing nothing — the
// helper returns `Ok(None)` so we FALL THROUGH to the following cost detection
// rather than surfacing a dead `PayCost { count: 0 }`.
if let Some((count, eligible)) =
resolve_non_self_discard_requirement(state, player, source_id, cost)?
{
if let Some((count, eligible)) = resolve_non_self_discard_requirement_with_ability(
state,
player,
source_id,
cost,
Some(&resolved),
)? {
let mut pending_discard =
PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost);
pending_discard.activation_cost = Some(cost.clone());
Expand Down
70 changes: 67 additions & 3 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4431,7 +4431,13 @@ pub(crate) fn surface_next_unpaid_interactive_activation_cost(
// helper returns `Ok(None)` so we FALL THROUGH to the next unpaid leg (the sacrifice arm
// below) rather than surfacing a dead `PayCost { count: 0 }`.
if let Some((count, eligible)) =
super::casting::resolve_non_self_discard_requirement(state, player, source_id, cost)?
super::casting::resolve_non_self_discard_requirement_with_ability(
state,
player,
source_id,
cost,
Some(&pending.ability),
)?
{
return Ok(Some(WaitingFor::PayCost {
player,
Expand Down Expand Up @@ -4607,7 +4613,7 @@ pub(crate) fn surface_next_unpaid_interactive_activation_cost(

if let Some((count, exile_filter)) = super::casting::find_battlefield_exile_cost(cost) {
let effective_filter =
super::cost_payability::exile_cost_effective_filter(Some(exile_filter));
super::cost_payability::cost_filter_before_x_announcement(Some(exile_filter));
let eligible = super::cost_payability::eligible_exile_cost_objects(
state,
player,
Expand Down Expand Up @@ -7425,7 +7431,7 @@ fn pay_additional_cost_with_source(
== Zone::Battlefield =>
{
let effective_filter =
super::cost_payability::exile_cost_effective_filter(filter.as_ref());
super::cost_payability::cost_filter_before_x_announcement(filter.as_ref());
let eligible = super::cost_payability::eligible_exile_cost_objects(
state,
player,
Expand Down Expand Up @@ -7817,6 +7823,17 @@ fn additional_cost_x_max(
AbilityCost::PayEnergy { amount } if amount.contains_x() => {
Some(state.players[player.0 as usize].energy)
}
AbilityCost::Discard {
filter: Some(filter),
..
} if super::cost_payability::target_filter_has_x_mana_value_constraint(filter) => Some(
super::casting::find_eligible_discard_targets(state, player, source_id, Some(filter))
.into_iter()
.filter_map(|object_id| state.objects.get(&object_id))
.map(|object| object.effective_mana_value())
.max()
.unwrap_or(0),
),
AbilityCost::Sacrifice(cost)
if cost.requirement == SacrificeRequirement::Count { count: u32::MAX } =>
{
Expand Down Expand Up @@ -7931,11 +7948,58 @@ fn cost_needs_activation_x_announcement(cost: &AbilityCost) -> bool {
match cost {
AbilityCost::RemoveCounter { count, .. } => is_chosen_remove_counter_cost_count(*count),
AbilityCost::PayEnergy { amount } => amount.contains_x(),
AbilityCost::Discard {
filter: Some(filter),
..
} => super::cost_payability::target_filter_has_x_mana_value_constraint(filter),
AbilityCost::Composite { costs } => costs.iter().any(cost_needs_activation_x_announcement),
_ => false,
}
}

/// CR 107.3a + CR 601.2b: Once X is announced, a discard cost whose card
/// filter references X must have enough matching cards before target selection
/// can proceed. This preserves the all-or-nothing cast proposal when the
/// chosen value is within the numeric maximum but absent from the hand.
pub(crate) fn activation_cost_is_payable_after_x_choice(
state: &GameState,
player: PlayerId,
source_id: ObjectId,
cost: &AbilityCost,
ability: &ResolvedAbility,
) -> bool {
match cost {
AbilityCost::Discard {
count,
filter,
self_scope,
..
} if !self_scope.is_source_card() => {
let count = super::quantity::resolve_quantity_with_targets(state, count, ability).max(0)
as usize;
super::casting::find_eligible_discard_targets_for_ability(
state,
player,
source_id,
filter.as_ref(),
ability,
)
.len()
>= count
}
AbilityCost::Composite { costs } => costs.iter().all(|cost| {
activation_cost_is_payable_after_x_choice(state, player, source_id, cost, ability)
}),
AbilityCost::OneOf { costs } => costs.iter().any(|cost| {
activation_cost_is_payable_after_x_choice(state, player, source_id, cost, ability)
}),
AbilityCost::PerCounter { base, .. } => {
activation_cost_is_payable_after_x_choice(state, player, source_id, base, ability)
}
_ => true,
}
}

fn cost_has_targeted_symbolic_counter_removal(cost: &AbilityCost) -> bool {
match cost {
AbilityCost::RemoveCounter { count, target, .. } => {
Expand Down
46 changes: 25 additions & 21 deletions crates/engine/src/game/cost_payability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use crate::types::GameState;

use super::filter::{matches_target_filter, matches_target_filter_in_owner_zone, FilterContext};

fn is_pitch_bound_cmc_eq_x_prop(prop: &FilterProp) -> bool {
fn is_x_mana_value_constraint(prop: &FilterProp) -> bool {
matches!(
prop,
FilterProp::Cmc {
Expand All @@ -44,16 +44,17 @@ fn is_pitch_bound_cmc_eq_x_prop(prop: &FilterProp) -> bool {
)
}

/// True when a cost filter uses the Shoal pattern: "with mana value X" where X
/// is defined by the card chosen to pay the cost, not by a prior announcement.
pub(crate) fn target_filter_has_pitch_bound_x(filter: &TargetFilter) -> bool {
/// True when a cost filter contains a variable mana-value equality.
pub(crate) fn target_filter_has_x_mana_value_constraint(filter: &TargetFilter) -> bool {
match filter {
TargetFilter::Typed(tf) => tf.properties.iter().any(is_pitch_bound_cmc_eq_x_prop),
TargetFilter::Typed(tf) => tf.properties.iter().any(is_x_mana_value_constraint),
TargetFilter::Or { filters } | TargetFilter::And { filters } => {
filters.iter().any(target_filter_has_pitch_bound_x)
filters
.iter()
.any(target_filter_has_x_mana_value_constraint)
}
TargetFilter::Not { filter } | TargetFilter::TrackedSetFiltered { filter, .. } => {
target_filter_has_pitch_bound_x(filter)
target_filter_has_x_mana_value_constraint(filter)
}
TargetFilter::ExiledCardByIndex { .. }
| TargetFilter::None
Expand Down Expand Up @@ -108,34 +109,34 @@ pub(crate) fn target_filter_has_pitch_bound_x(filter: &TargetFilter) -> bool {
}
}

pub(crate) fn relax_pitch_bound_x_filter(filter: &TargetFilter) -> TargetFilter {
pub(crate) fn relax_x_mana_value_constraint(filter: &TargetFilter) -> TargetFilter {
match filter {
TargetFilter::Typed(tf) => TargetFilter::Typed(TypedFilter {
properties: tf
.properties
.iter()
.filter(|p| !is_pitch_bound_cmc_eq_x_prop(p))
.filter(|p| !is_x_mana_value_constraint(p))
.cloned()
.collect(),
..tf.clone()
}),
TargetFilter::ExiledCardByIndex { .. } => filter.clone(),
TargetFilter::Or { filters } => TargetFilter::Or {
filters: filters.iter().map(relax_pitch_bound_x_filter).collect(),
filters: filters.iter().map(relax_x_mana_value_constraint).collect(),
},
TargetFilter::And { filters } => TargetFilter::And {
filters: filters.iter().map(relax_pitch_bound_x_filter).collect(),
filters: filters.iter().map(relax_x_mana_value_constraint).collect(),
},
TargetFilter::Not { filter } => TargetFilter::Not {
filter: Box::new(relax_pitch_bound_x_filter(filter)),
filter: Box::new(relax_x_mana_value_constraint(filter)),
},
TargetFilter::TrackedSetFiltered {
id,
filter,
caused_by,
} => TargetFilter::TrackedSetFiltered {
id: *id,
filter: Box::new(relax_pitch_bound_x_filter(filter)),
filter: Box::new(relax_x_mana_value_constraint(filter)),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
caused_by: *caused_by,
},
TargetFilter::None
Expand Down Expand Up @@ -190,12 +191,14 @@ pub(crate) fn relax_pitch_bound_x_filter(filter: &TargetFilter) -> TargetFilter
}
}

/// CR 107.3a + CR 118.9: Until the player chooses the pitched card, relax the
/// CMC=X constraint for 601.2b eligibility on Shoal-style exile costs.
pub(crate) fn exile_cost_effective_filter(filter: Option<&TargetFilter>) -> Option<TargetFilter> {
/// CR 107.3a + CR 601.2b: Before X is announced, relax its equality constraint
/// when checking which cards can pay a cost.
pub(crate) fn cost_filter_before_x_announcement(
filter: Option<&TargetFilter>,
) -> Option<TargetFilter> {
filter.map(|f| {
if target_filter_has_pitch_bound_x(f) {
relax_pitch_bound_x_filter(f)
if target_filter_has_x_mana_value_constraint(f) {
relax_x_mana_value_constraint(f)
} else {
f.clone()
}
Expand Down Expand Up @@ -381,12 +384,13 @@ impl AbilityCost {
}
let resolved =
super::quantity::resolve_quantity(state, count, player, source).max(0) as usize;
let effective_filter = cost_filter_before_x_announcement(filter.as_ref());
let ctx = FilterContext::from_source(state, source);
p.hand
.iter()
.filter(|&&id| {
id != source
&& filter
&& effective_filter
.as_ref()
.is_none_or(|f| matches_target_filter(state, id, f, &ctx))
})
Expand Down Expand Up @@ -427,7 +431,7 @@ impl AbilityCost {
};
}
let zone = exile_cost_effective_zone(*zone, filter.as_ref());
let effective_filter = exile_cost_effective_filter(filter.as_ref());
let effective_filter = cost_filter_before_x_announcement(filter.as_ref());
eligible_exile_cost_objects(
state,
player,
Expand Down Expand Up @@ -841,7 +845,7 @@ pub(super) fn eligible_exile_cost_objects(
.collect();
}
};
let effective_filter = exile_cost_effective_filter(filter);
let effective_filter = cost_filter_before_x_announcement(filter);
let filter_ref = effective_filter.as_ref();
let ctx = FilterContext::from_source(state, source);
ids.filter(|&id| {
Expand Down
26 changes: 22 additions & 4 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8958,14 +8958,32 @@ fn apply_action(
let player = *player;
let convoke_mode = *convoke_mode;
if let Some(pending) = state.pending_cast.as_ref() {
// CR 602.2b + CR 601.2b/h: An activation's announced X must
// make its full cost payable before the announcement commits,
// whether or not the ability has deferred targets.
let mut trial = pending.as_ref().clone();
trial.ability.set_chosen_x_recursive(value);
trial.cost.concretize_x(value);
if trial.activation_ability_index.is_some()
&& trial.activation_cost.as_ref().is_some_and(|cost| {
!casting_costs::activation_cost_is_payable_after_x_choice(
state,
player,
trial.object_id,
cost,
&trial.ability,
)
})
{
return Err(EngineError::InvalidAction(format!(
"X={value} cannot pay the activation cost"
)));
}
if pending.deferred_target_selection {
// CR 601.2c: A chosen X that determines target count must
// have a legal target assignment before it is locked into
// the pending cast.
// CR 601.2f: The same X value then determines the total cost.
let mut trial = pending.as_ref().clone();
trial.ability.set_chosen_x_recursive(value);
trial.cost.concretize_x(value);
let mut target_slots = build_target_slots(state, &trial.ability)?;
// CR 601.2c + CR 601.2d: clamp a divided spell's slots to the
// (now-known) pool so the legal-assignment probe matches what
Expand Down Expand Up @@ -16410,7 +16428,7 @@ mod stage2_injector_tests {
//
// SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and
// neither does this branch — total still 37, partition still 5/7/25.
"game/engine.rs:11988".to_string(),
"game/engine.rs:12006".to_string(),
],
"the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \
plus the two repeated-optional-payment drivers, the per-player acceptance cursor \
Expand Down
Loading
Loading