Skip to content
62 changes: 25 additions & 37 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::types::ability::{
is_variable_remove_counter_cost_count, AbilityBlockKind, AbilityBlockReason, AbilityCondition,
AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, ActivationManaPaymentRestriction,
AdditionalCost, CardPlayMode, CardSelectionMode, CastTimingPermission, CastingPermission,
ChoiceType, ContinuousModification, CostObjectCount, CostPaidObjectSnapshot,
AdditionalCost, BoardWideCostModifier, CardPlayMode, CardSelectionMode, CastTimingPermission,
CastingPermission, ChoiceType, ContinuousModification, CostObjectCount, CostPaidObjectSnapshot,
CounterCostSelection, Duration, Effect, EffectKind, FilterProp, GameRestriction,
ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity,
QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope,
Expand Down Expand Up @@ -7184,8 +7184,6 @@ fn collect_battlefield_cost_modifiers(
target_sensitive_only: bool,
casting_variant: Option<CastingVariant>,
) -> Vec<CostModification> {
use crate::types::ability::ControllerRef;

// CR 202.3d + CR 702.102b: a pre-payment `CastingVariant::Fuse` cast presents
// the COMBINED characteristics of both halves to a `ModifyCost` static's
// `spell_filter`. The `fused_split_spell` marker is not yet set at this seam.
Expand Down Expand Up @@ -7219,40 +7217,34 @@ fn collect_battlefield_cost_modifiers(
let source_controller = src_obj.controller;

{
let (amount, spell_filter, dynamic_count, is_raise) = match &def.mode {
StaticMode::ModifyCost {
mode: CostModifyMode::Reduce,
amount,
spell_filter,
dynamic_count,
} => (amount, spell_filter, dynamic_count, false),
StaticMode::ModifyCost {
mode: CostModifyMode::Raise,
amount,
spell_filter,
dynamic_count,
} => (amount, spell_filter, dynamic_count, true),
_ => continue,
// CR 601.2f + CR 113.6: single structural authority for "is this a
// board-wide cost modifier, and what are its terms" — shared with
// deck-time analysis (`phase-ai`'s `features::cost_reduction`) so the
// two cannot drift. It rejects `Minimum` and `SelfRef` (the latter is
// self-cost-reduction, handled by `apply_self_spell_cost_modifiers`
// for the spell being cast and never applied from a battlefield
// permanent to other spells).
let Some(modifier) = def.board_wide_cost_modifier() else {
continue;
};
let BoardWideCostModifier {
mode,
amount,
spell_filter,
dynamic_count,
caster_scope,
condition: _,
} = modifier;
let is_raise = matches!(mode, CostModifyMode::Raise);

let has_target_filter = spell_filter
.as_ref()
.is_some_and(cost_filter_has_target_ref);
let has_target_filter = spell_filter.is_some_and(cost_filter_has_target_ref);
if target_sensitive_only && !has_target_filter {
continue;
}
if selected_ability.is_none() && has_target_filter {
continue;
}

// CR 113.6: SelfRef statics are self-cost-reduction ("this spell costs
// {N} less") — handled by apply_self_spell_cost_modifiers for the spell
// being cast. They must never apply from a battlefield permanent to
// other spells.
if matches!(def.affected, Some(TargetFilter::SelfRef)) {
continue;
}

// CR 113.6 + CR 113.6b: A static functions only in its declared
// zones. Empty `active_zones` means battlefield default; non-empty
// means restrict to the listed zones. Eminence statics list both
Expand All @@ -7267,12 +7259,8 @@ fn collect_battlefield_cost_modifiers(

// CR 601.2f: Check player scope — does this modifier apply to spells the caster casts?
// Must run before condition check so QuantityComparison resolves against the caster.
if let Some(TargetFilter::Typed(ref tf)) = def.affected {
match tf.controller {
Some(ControllerRef::You) if caster != source_controller => continue,
Some(ControllerRef::Opponent) if caster == source_controller => continue,
_ => {} // No controller restriction or matches
}
if !caster_scope.admits(caster, source_controller) {
continue;
}

// CR 601.2f: Check static condition — "as long as" / "during your turn"
Expand All @@ -7291,7 +7279,7 @@ fn collect_battlefield_cost_modifiers(
}

// CR 601.2f: Check spell type filter — does the spell match?
if let Some(ref filter) = spell_filter {
if let Some(filter) = spell_filter {
let matches = if let Some(ability) = selected_ability {
spell_matches_cost_filter_with_selected_targets_for(
state, caster, spell_id, filter, bf_id, ability, fused,
Expand All @@ -7306,7 +7294,7 @@ fn collect_battlefield_cost_modifiers(

// CR 601.2f: Calculate the modification amount.
let base_amount = amount.clone();
let multiplier = if let Some(ref qty_ref) = dynamic_count {
let multiplier = if let Some(qty_ref) = dynamic_count {
let qty_expr = crate::types::ability::QuantityExpr::Ref {
qty: qty_ref.clone(),
};
Expand Down
156 changes: 147 additions & 9 deletions crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1269,35 +1269,173 @@ pub fn matches_target_filter_including_phased_out(
/// face and yields `false`. Use the object-based `matches_target_filter` family
/// instead whenever an `ObjectId` exists.
pub(crate) fn matches_target_filter_against_face(face: &CardFace, filter: &TargetFilter) -> bool {
matches_target_filter_against_face_scoped(face, filter, FaceControllerScope::Reject)
}

/// CR 109.5: how a bare-`CardFace` match treats a filter's controller axis.
///
/// A face has no controller, so a controller-scoped filter is normally
/// unanswerable. Some callers do know the answer out of band — deck analysis
/// asks only about the analyzing player's own list, and a cost modifier's
/// "spells YOU cast" scope is carried on `StaticDefinition.affected` and settled
/// before the spell filter is consulted (see
/// [`crate::types::ability::cost_modifier_caster_scope`]). This names which of
/// those two situations the caller is in instead of leaving it implicit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FaceControllerScope {
/// The controller is genuinely unknown: any controller-scoped filter fails.
Reject,
/// The face is known to be the querying player's own card, so
/// `ControllerRef::You` is satisfied and `ControllerRef::Opponent` is not.
/// Any other `ControllerRef` remains unanswerable and fails.
AssumeOwn,
}

/// CR 205: Evaluate a `TargetFilter`'s STATIC characteristics against a bare
/// `CardFace`, resolving the controller axis per `scope`.
///
/// `pub` so deck-time analysis outside this crate (`phase-ai`'s
/// `features::cost_reduction`) can match a static's `spell_filter` against a
/// bare face through this authority rather than re-deriving CR 205 semantics.
pub fn matches_target_filter_against_face_scoped(
face: &CardFace,
filter: &TargetFilter,
scope: FaceControllerScope,
) -> bool {
match filter {
TargetFilter::Any => true,
TargetFilter::None => false,
TargetFilter::Typed(typed) => {
typed.controller.is_none()
controller_ref_admits_face(typed.controller.as_ref(), scope)
&& typed
.type_filters
.iter()
.all(|type_filter| matches_type_filter_against_face(face, type_filter))
&& typed.properties.iter().all(|property| match property {
FilterProp::HasSupertype { value } => face.card_type.supertypes.contains(value),
_ => false,
})
&& typed
.properties
.iter()
// Fail closed: a property with no context-free reading (live
// combat, counters, zone, chosen-value state) cannot be
// satisfied by a face, so the whole filter does not match.
.all(|property| context_free_prop_matches_face(face, property) == Some(true))
}
TargetFilter::Or { filters } => filters
.iter()
.any(|inner| matches_target_filter_against_face(face, inner)),
.any(|inner| matches_target_filter_against_face_scoped(face, inner, scope)),
TargetFilter::And { filters } => filters
.iter()
.all(|inner| matches_target_filter_against_face(face, inner)),
TargetFilter::Not { filter } => !matches_target_filter_against_face(face, filter),
.all(|inner| matches_target_filter_against_face_scoped(face, inner, scope)),
TargetFilter::Not { filter } => {
!matches_target_filter_against_face_scoped(face, filter, scope)
}
_ => false,
}
}

/// CR 109.5: does this filter's controller scope admit a bare face?
fn controller_ref_admits_face(
controller: Option<&ControllerRef>,
scope: FaceControllerScope,
) -> bool {
matches!(
(controller, scope),
(None, _) | (Some(ControllerRef::You), FaceControllerScope::AssumeOwn)
)
}

/// CR 205 + CR 202: Evaluate one `FilterProp` against a bare `CardFace`.
///
/// `Some(true)` / `Some(false)` = the property has a context-free reading and
/// this is it. `None` = the property needs a live object (battlefield state,
/// counters, combat, zone, a value chosen earlier in a resolution) and therefore
/// has NO answer for a face — callers must fail closed rather than guess.
///
/// Kept as an explicit allowlist, not a wildcard `_ => true`: a newly added
/// `FilterProp` lands in the `None` arm and fails closed until someone decides
/// whether it is context-free, which is the safe direction.
pub fn context_free_prop_matches_face(face: &CardFace, prop: &FilterProp) -> Option<bool> {
match prop {
// CR 205.4a: printed supertype line.
FilterProp::HasSupertype { value } => Some(face.card_type.supertypes.contains(value)),
FilterProp::NotSupertype { value } => Some(!face.card_type.supertypes.contains(value)),
// CR 202.3: mana value is printed on the face. Only a fixed comparison
// is context-free — a dynamic quantity needs game state.
FilterProp::Cmc {
comparator,
value: QuantityExpr::Fixed { value },
} => Some(comparator.evaluate(
i32::try_from(face.mana_cost.mana_value()).unwrap_or(i32::MAX),
*value,
)),
// A dynamic mana-value comparison needs game state to resolve.
FilterProp::Cmc { .. } => None,
// CR 105.2 + CR 202.2: printed color, via the face's own color authority.
FilterProp::HasColor { color } => Some(face_colors(face).contains(color)),
FilterProp::NotColor { color } => Some(!face_colors(face).contains(color)),
FilterProp::ColorCount { comparator, count } => Some(comparator.evaluate(
i32::try_from(face_colors(face).len()).unwrap_or(i32::MAX),
i32::from(*count),
)),
// CR 702: printed keyword line. The keyword authorities in
// `game/keywords.rs` all take a `GameObject`; this function's entire
// contract is that NO object exists (a bare `CardFace` outside the
// game), so there is no zone, no grant and no `off_zone_characteristics`
// to consult — the printed line IS the complete truth here. A caller
// holding an `ObjectId` must use the object-based `matches_target_filter`
// family instead, as the module doc says.
// allow-raw-authority: bare CardFace has no object, so no keyword grant can exist to miss
FilterProp::WithKeyword { value } => Some(face.keywords.contains(value)),
// allow-raw-authority: bare CardFace has no object, so no keyword grant can exist to miss
FilterProp::WithoutKeyword { value } => Some(!face.keywords.contains(value)),
// CR 111.1 + CR 108.2: a bare face is a card definition, never a token.
FilterProp::Token => Some(false),
FilterProp::NonToken | FilterProp::RepresentedByCard => Some(true),
// Recursive combinators inherit their operands' answerability.
FilterProp::Not { prop } => context_free_prop_matches_face(face, prop).map(|m| !m),
FilterProp::AnyOf { props } => props.iter().try_fold(false, |acc, inner| {
context_free_prop_matches_face(face, inner).map(|m| acc || m)
}),
// Everything else reads live state and has no face-level answer.
_ => None,
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// CR 105.2 + CR 202.2: the colors of a bare face — an explicit color-defining
/// override when present (CR 604.3), otherwise the printed mana cost.
fn face_colors(face: &CardFace) -> Vec<ManaColor> {
if let Some(colors) = &face.color_override {
return colors.clone();
}
[
ManaColor::White,
ManaColor::Blue,
ManaColor::Black,
ManaColor::Red,
ManaColor::Green,
]
.into_iter()
.filter(|color| match &face.mana_cost {
ManaCost::Cost { shards, .. } => shards.iter().any(|shard| shard.contributes_to(*color)),
// CR 202.1: no printed mana cost, so no color from one. The `Self*`
// forms are cost REFERENCES resolved against a live object (CR 202.3b),
// not a printed cost, so a bare face has no colors to read from them.
ManaCost::NoCost
| ManaCost::SelfManaCost
| ManaCost::SelfManaValue
| ManaCost::SelfManaCostReduced { .. } => false,
})
.collect()
}

/// CR 205: Evaluate a single `TypeFilter` against a bare `CardFace`'s printed
/// card type line (core types, subtypes, supertypes). Context-free counterpart
/// to the object-based type checks in `filter_inner_for_object`.
pub(crate) fn matches_type_filter_against_face(face: &CardFace, filter: &TypeFilter) -> bool {
///
/// `pub` because deck-time analysis outside this crate (`phase-ai`'s
/// `features::cost_reduction`) classifies bare `CardFace`s against a static's
/// `spell_filter` before any `GameObject` exists, and must use this authority
/// for the type axis rather than re-deriving CR 205 type semantics.
pub fn matches_type_filter_against_face(face: &CardFace, filter: &TypeFilter) -> bool {
match filter {
TypeFilter::Creature => face.card_type.core_types.contains(&CoreType::Creature),
TypeFilter::Land => face.card_type.core_types.contains(&CoreType::Land),
Expand Down
Loading
Loading