diff --git a/crates/phase-ai/src/policies/anti_self_harm.rs b/crates/phase-ai/src/policies/anti_self_harm.rs index 7a09e0caf3..bf2cce0ce6 100644 --- a/crates/phase-ai/src/policies/anti_self_harm.rs +++ b/crates/phase-ai/src/policies/anti_self_harm.rs @@ -46,6 +46,7 @@ use super::effect_classify::{ use super::registry::{ DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy, CRITICAL_MAX, }; +use super::removal_lethality; use super::strategy_helpers::can_pay_ward_cost; use crate::features::DeckFeatures; #[cfg(test)] @@ -392,8 +393,34 @@ fn score_pre_cast(ctx: &PolicyContext<'_>) -> f64 { penalty += ctx.penalties().wasted_cast_penalty; } - // Harmful creature-only spell (e.g. Murder) but no targetable opponent creatures. - if has_harmful_creature_only_target && !has_targetable_opponent_creature { + // Harmful creature-only spell (e.g. Murder) but no targetable opponent + // creatures. A MIXED spell carrying a useful wipe line (`DestroyAll`, + // CR 701.8) is NOT a whiff even when every opposing creature is + // hexproof/protected: the wipe is NON-targeted and hits the population + // (CR 115.10a), so consult the resolver-mirroring mass seam before + // charging the no-target penalty. + if has_harmful_creature_only_target + && !has_targetable_opponent_creature + && !ctx.has_opposing_mass_population() + { + penalty += ctx.penalties().wasted_cast_penalty; + } + + // Harmful creature-only spell whose damage is provably non-lethal against + // EVERY legal target (CR 704.5g): committing a whiff burns the card. The + // existing `lethal_to_creature` branch above (is_useful_removal_target) + // only detects provable non-lethality for FIXED damage amounts; for a + // dynamic amount (Slash of Light's "number of creatures you control + + // number of Equipment you control") it fails open as `None` -> "useful", + // so it never fires. `can_kill_any_legal_target` resolves the amount live + // (CR 120.3 / CR 701) via the `removal_lethality` damage model and vetoes + // (soft) only the total whiff. Soft penalty (NOT a hard reject): it mirrors + // the sibling whiff branches, and synergy / prowess / storm-type + // spellslinger policies may still prefer to cast for cast-triggers. + if has_harmful_creature_only_target + && has_targetable_opponent_creature + && !removal_lethality::can_kill_any_legal_target(ctx) + { penalty += ctx.penalties().wasted_cast_penalty; } @@ -5474,4 +5501,182 @@ mod tests { if reason.kind == "anti_self_harm_lethal_life_cost" )); } + + // Verbatim production shape of the Slash-of-Light gap: a targeted + // creature-only DealDamage whose amount is dynamic (ObjectCount-based, not + // a literal constant). `lethal_to_creature` returns `None` for a non-Fixed + // amount, so `is_useful_removal_target` fails open as "useful" and the + // sibling no-targetable-opponent-creature branch never fires. The + // `removal_lethality::can_kill_any_legal_target` gate must penalise + // committing this when 1 damage is non-lethal to every legal opponent + // creature. + #[test] + fn pre_cast_penalises_dynamic_damage_whiff_that_kills_no_opponent_creature() { + let mut state = make_state(); + // AI's single creature makes "number of creatures you control" resolve + // to 1. + add_creature(&mut state, PlayerId(0), "My Bear", 2, 1); + // Opponent's 3/3 that 1 damage cannot kill (CR 704.5g). + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + let spell_id = create_object( + &mut state, + CardId(90_000), + PlayerId(0), + "Slash of Light".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + let mut my_filter = TypedFilter::creature(); + my_filter.controller = Some(ControllerRef::You); + let amount = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(my_filter), + }, + }; + obj.abilities = Arc::new(vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + )]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_000), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + let score = AntiSelfHarmPolicy.score(&ctx); + assert!( + score < -5.0, + "Casting a dynamic burn whose 1 damage kills no opponent creature \ + should be penalised, got {score}" + ); + } + + /// Cast-commit seam regression: a MIXED spell coupling a creature-only + /// damage half with a `DestroyAll` wipe (CR 701.8) must NOT be charged the + /// -8 no-target penalty when its ONLY opposing creature is HEXPROOF. + /// Hexproof (`Keyword::Hexproof`) gates TARGETING only (CR 702.11b) — an + /// affected object is not a target — while the wipe is NON-targeted and + /// hits the battlefield POPULATION regardless (CR 115.10a). So + /// `has_targetable_opponent_creature` is false here, but + /// `removal_lethality::has_opposing_mass_population` is true, and + /// `score_pre_cast` must consult the mass seam before charging the + /// no-target penalty. Pre-fix, the ordering charged the -8 penalty — a + /// false positive for a spell whose wipe genuinely clears the hexproof + /// 3/3. The AI's OWN bear exists solely so the damage half is announceable + /// (CR 601.2c); this test pins the PENALTY question, not castability. + #[test] + fn pre_cast_does_not_penalise_mixed_wipe_when_only_population_is_hexproof() { + let mut state = make_state(); + // AI's own creature makes the dynamic ObjectCount amount resolve to 1. + add_creature(&mut state, PlayerId(0), "My Bear", 2, 1); + // The ONLY opposing creature is hexproof (un-targetable, CR 702.11b) + // but is in the wipe's NON-targeted population (CR 115.10a). + let hexproof_bear = add_creature(&mut state, PlayerId(1), "Hexproof Bear", 3, 3); + state + .objects + .get_mut(&hexproof_bear) + .unwrap() + .keywords + .push(Keyword::Hexproof); + + let spell_id = create_object( + &mut state, + CardId(90_001), + PlayerId(0), + "Hexproof-Proof Judgement".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + let mut my_filter = TypedFilter::creature(); + my_filter.controller = Some(ControllerRef::You); + let amount = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(my_filter), + }, + }; + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + // `None` is the serde default for `DestroyAll.target`; construct it + // explicitly so the resolver's `None` -> all-creatures population + // (destroy.rs `resolve_all`) is the point under test. + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DestroyAll { + target: TargetFilter::None, + cant_regenerate: false, + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_001), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + let score = AntiSelfHarmPolicy.score(&ctx); + assert!( + score > -5.0, + "A mixed deal-1 + destroy-all vs a hexproof-only opposing board \ + ({score:.3}) must NOT be charged the no-target penalty: the wipe is \ + NON-targeted (CR 115.10a) and hits the hexproof 3/3's population \ + (hexproof gates targeting only, CR 702.11b), so the mass seam \ + rescues the mixed spell from the wasted-cast penalty" + ); + } } diff --git a/crates/phase-ai/src/policies/context.rs b/crates/phase-ai/src/policies/context.rs index 9a17410cc7..32eb383f13 100644 --- a/crates/phase-ai/src/policies/context.rs +++ b/crates/phase-ai/src/policies/context.rs @@ -1,5 +1,6 @@ use engine::ai_support::{AiDecisionContext, CandidateAction}; use engine::game::game_object::GameObject; +use engine::game::players::is_opponent; use engine::game::targeting::find_legal_targets; use engine::types::ability::{AbilityDefinition, Effect, ResolvedAbility, TargetFilter, TargetRef}; use engine::types::actions::GameAction; @@ -230,13 +231,24 @@ impl<'a> PolicyContext<'a> { .into_iter() .any(|target| match target { TargetRef::Object(id) => self.state.objects.get(&id).is_some_and(|object| { - object.controller != self.ai_player + is_opponent(self.state, self.ai_player, object.controller) && object.card_types.core_types.contains(&CoreType::Creature) && is_relevant(id) }), TargetRef::Player(_) => false, }) } + + /// Does the pending spell carry an inherently-mass effect (`DestroyAll`, + /// CR 701.8) with a non-empty OPPONENT population under the resolver's + /// NON-targeted semantics (CR 115.10a; team-aware via `is_opponent`)? The + /// engine's tactical gate (redundant-removal suppression) and the + /// cast-commit anti-whiff scoring both consult this BEFORE any + /// target-legality gate: a wipe line that clears an un-targetable + /// (hexproof/protected) population is a real removal line, not a whiff. + pub(crate) fn has_opposing_mass_population(&self) -> bool { + super::removal_lethality::has_opposing_mass_population(self) + } } /// Walk a ResolvedAbility's sub_ability chain, collecting all effects. @@ -269,7 +281,9 @@ mod tests { use engine::game::zones::create_object; use engine::types::ability::{ AbilityDefinition, AbilityKind, EffectKind, PtValue, QuantityExpr, TargetFilter, + TypedFilter, }; + use engine::types::format::FormatConfig; use engine::types::game_state::{PendingCast, TargetEffectDetail, TargetSelectionSlot}; use engine::types::identifiers::{CardId, ObjectId}; use engine::types::mana::ManaCost; @@ -530,6 +544,98 @@ mod tests { assert!(facts.has_direct_removal_text()); } + #[test] + fn legal_opponent_creature_target_is_team_aware() { + let mut state = GameState::new(FormatConfig::two_headed_giant(), 4, 42); + let source_id = create_object( + &mut state, + CardId(10), + PlayerId(0), + "Test Spell".to_string(), + Zone::Hand, + ); + let teammate_id = create_object( + &mut state, + CardId(11), + PlayerId(1), + "Teammate Bear".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&teammate_id) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: source_id, + card_id: CardId(10), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ai_context = crate::context::AiContext::empty(&config.weights); + let creature_filter = TargetFilter::Typed(TypedFilter::creature()); + + { + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &ai_context, + cast_facts: None, + search_depth: SearchDepth::Root, + }; + assert!( + !ctx.has_legal_opponent_creature_target(&creature_filter, source_id, |_| true), + "P1's legal creature target is P0's teammate in 2HG, not an opponent" + ); + } + + let opponent_id = create_object( + &mut state, + CardId(12), + PlayerId(2), + "Opponent Bear".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&opponent_id) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &ai_context, + cast_facts: None, + search_depth: SearchDepth::Root, + }; + assert!( + ctx.has_legal_opponent_creature_target(&creature_filter, source_id, |_| true), + "P2's legal creature target is P0's opponent in 2HG" + ); + } + fn deadline_test_ctx<'a>( state: &'a GameState, decision: &'a AiDecisionContext, diff --git a/crates/phase-ai/src/policies/removal_lethality.rs b/crates/phase-ai/src/policies/removal_lethality.rs index 4404502959..a5d51e89ac 100644 --- a/crates/phase-ai/src/policies/removal_lethality.rs +++ b/crates/phase-ai/src/policies/removal_lethality.rs @@ -44,17 +44,25 @@ //! modelled damage to the target, so `-X/-X`, destroy, and exile removal are //! untouched. +use engine::game::filter::{matches_target_filter, FilterContext}; use engine::game::game_object::GameObject; use engine::game::keywords::object_has_effective_keyword_kind; +use engine::game::players::is_opponent; use engine::game::quantity::{resolve_quantity, resolve_quantity_with_targets_slice}; -use engine::types::ability::{DamageSource, Effect, TargetRef}; +use engine::game::targeting::find_legal_targets; +use engine::types::ability::{ + ControllerRef, DamageSource, Effect, TargetFilter, TargetRef, TypeFilter, TypedFilter, +}; use engine::types::card_type::CoreType; use engine::types::game_state::WaitingFor; use engine::types::identifiers::ObjectId; use engine::types::keywords::{Keyword, KeywordKind}; use super::context::PolicyContext; -use super::effect_classify::effect_targets_object; +use super::effect_classify::{ + effect_polarity, effect_targets_object, extract_target_filter, targets_creatures_only, + EffectPolarity, +}; /// Reward for a target the removal spell actually kills — a clean kill is worth /// more than the marginal threat-value ranking that lured the AI to an @@ -369,3 +377,1571 @@ pub(crate) fn lethality_bonus( let survived = reduced_toughness(target, &outcome).max(0); -(f64::from(survived) * WASTE_PENALTY_MULT).min(WASTE_PENALTY_MAX) } + +/// Cast-commit lethality guard: does the pending spell's targeted creature +/// damage have ANY legal target it can actually kill? +/// +/// The cast-commit dual of [`lethality_bonus`] (which ranks targets during +/// selection). Prevents cases where a burn spell whose damage is provably +/// non-lethal against every legal target gets cast and pointed at the biggest +/// body, wasting the card. This gate tells the cast-commit whiff check +/// ([`super::anti_self_harm::score_pre_cast`]) whether committing +/// is ever worthwhile against the board. +/// +/// **Conservative no-veto contract** — returns `true` (do not veto) whenever it +/// cannot *prove* a total whiff: +/// * no source object / no usable filter (cannot reason); +/// * ANY `Harmful` or `Contextual` non-`DealDamage` effect that has at least one +/// legal target/population under an OPPONENT's control (e.g. a mixed "deal +/// damage + destroy" spell — the Destroy half is an independent, useful +/// removal line, CR 701.8a; "deal damage + gain control" — stealing a +/// planeswalker or artifact is an independent control-changing line, +/// CR 613.1b Layer 2; or a mass wipe, CR 701.8). The population is resolved +/// with the COMPLETE typed filter — including `TypedFilter.controller` +/// (CR 108.4 / CR 109.5) — so an own-controller-constrained line ("gain +/// control of target creature you control") credits nothing, so the spell is +/// never a total *damage* whiff). A wipe's population is evaluated +/// resolver-mirroring (CR 115.10a: it is NON-targeted, so hexproof/protected +/// creatures still count and `TargetFilter::None` means the resolver's +/// default all-creatures population, destroy.rs `resolve_all`); +/// * ANY `DealDamage` amount references `X` (CR 107.3a — the caster chooses the +/// value at announcement, so it is unknowable at cast-commit), including a +/// `DealDamage` whose target filter is not creature-only; +/// * any legal target yields [`PendingDamage::Unresolved`] (damage source not +/// knowable at cast-commit, CR 120.3) or [`PendingDamage::None`] (non-damage +/// removal like Destroy/Exile — never a damage whiff); +/// * there are zero legal object targets (empty set — never conclude a veto +/// from a vacuous "all survived"). +/// +/// It returns `false` (veto) **only** when at least one legal object target was +/// fully modelled as [`PendingDamage::Dealt`] and **every** modelled target is +/// provably non-lethal per [`outcome_is_lethal`] (CR 704.5f / 704.5g / 704.5h). +pub(crate) fn can_kill_any_legal_target(ctx: &PolicyContext<'_>) -> bool { + // CR 120.3: to resolve a damage source the caster's source object must be + // known. Without it, no filter controller and no resolvable source — fail + // open rather than veto on something we cannot reason about. + let Some(source) = ctx.source_object() else { + return true; + }; + let effects = ctx.effects(); + + // FAIL OPEN when a `Harmful`/`Contextual` non-`DealDamage` effect has at + // least one legal target/population under an OPPONENT's control — e.g. a + // mixed "deal 1 damage to target creature; destroy target creature" spell, + // a control-changing line like "deal 1 damage; gain control of target + // permanent" (`GainControl`, CR 613.1b Layer 2), or a mass wipe + // (`DestroyAll`, CR 701.8). The decision applies the COMPLETE typed filter + // — including `TypedFilter.controller` (CR 108.4 / CR 109.5) — through + // the engine's `find_legal_targets`: an own-controller-constrained line + // ("gain control of target creature you control") credits nothing, and a + // wipe's real population is resolved, not guessed from filter shape. + // Wipes take a resolver-mirroring population path instead of target + // legality: `DestroyAll` is NON-targeted (CR 115.10a), so hexproof/protected + // creatures count toward the population and `TargetFilter::None` means the + // resolver's default all-creatures population (destroy.rs `resolve_all`). + if effects.iter().any(|effect| { + matches!( + effect_polarity(effect), + EffectPolarity::Harmful | EffectPolarity::Contextual + ) && !matches!(effect, Effect::DealDamage { .. }) + && effect_has_legal_opposing_line(ctx, effect) + }) { + return true; + } + + // FAIL OPEN when ANY `DealDamage` effect on the spell references a + // variable-X — including one whose target filter is not creature-only. + // CR 107.3a: X is chosen by the caster at announcement and cannot be + // known at the commit decision. Therefore, no damage-only X spell is + // ever a provable total whiff. Scan every `DealDamage`. + if effects + .iter() + .any(|effect| matches!(effect, Effect::DealDamage { amount, .. } if amount.contains_x())) + { + return true; + } + + let mut modelled_any_target = false; + + for effect in effects.iter().copied().filter(|effect| { + matches!(effect_polarity(effect), EffectPolarity::Harmful) && targets_creatures_only(effect) + }) { + // No usable target filter (or a filter this policy can't analyse) — fail + // open, mirroring `harmful_effect_has_opponent_creature_target`. + let Some(filter) = extract_target_filter(effect) else { + return true; + }; + for target in find_legal_targets(ctx.state, filter, ctx.ai_player, source.id) { + let TargetRef::Object(object_id) = target else { + continue; + }; + // A harmful removal spell is only useful against an OPPONENT's + // creature — a target the caster controls would be self-targeting + // (anti-self-harm, handled separately). Mirror the gating + // `has_targetable_opponent_creature` via `players::is_opponent` + // (CR 102.2 / CR 102.3: team-aware — a teammate is not an opponent). + let Some(object) = ctx.state.objects.get(&object_id).filter(|object| { + is_opponent(ctx.state, ctx.ai_player, object.controller) + && object.card_types.core_types.contains(&CoreType::Creature) + }) else { + continue; + }; + match pending_damage_to_object(ctx, object_id, object) { + // CR 120.3: source not resolvable at cast-commit, or this is + // non-damage removal — inconclusive, no veto. + PendingDamage::Unresolved | PendingDamage::None => return true, + PendingDamage::Dealt(outcome) => { + modelled_any_target = true; + // CR 704.5f/g/h: even one legal target this spell can kill + // means the cast is not a total whiff. + if outcome_is_lethal(object, &outcome) { + return true; + } + } + } + } + } + + // Veto only when we fully modelled at least one legal target and every + // modelled target survived — i.e. `model_any_target && !any_escape`. The + // empty-set case (`!modelled_any_target`) fails open by contract. + !modelled_any_target +} + +/// Cast-commit seam query: does ANY inherently-mass non-`DealDamage` effect +/// on the pending spell (currently `DestroyAll`, CR 701.8) have a non-empty +/// opposing population under the resolver's semantics — NON-targeted (CR +/// 115.10a), team-aware (`is_opponent`, CR 102.2/102.3), indestructible +/// skipped? Independent of target legality: consulted by +/// `anti_self_harm::score_pre_cast` BEFORE the `has_targetable_opponent_creature` +/// gate so a useful wipe line rescues a mixed spell whose only opposing +/// creatures are hexproof/protected (un-targetable, but wiped). +/// Returns `true` for an UNKNOWN population too (an unbound player-relative +/// wipe, e.g. a companion `TargetOpponent` controller scope — CR 109.4 / +/// CR 115.1): only a provably-empty population (`Some(false)`) reads false. +/// This threads the fail-open to BOTH `anti_self_harm::score_pre_cast`'s +/// rescue and `tactical_gate::is_redundant_creature_only_removal`'s +/// suppression, so an unresolvable-at-commit wipe can never apply a whiff +/// penalty or hard-reject the cast. +pub(crate) fn has_opposing_mass_population(ctx: &PolicyContext<'_>) -> bool { + let Some(source) = ctx.source_object() else { + return false; + }; + ctx.effects().iter().any(|effect| { + matches!( + effect, + Effect::DestroyAll { target, .. } + if mass_effect_has_opposing_population(ctx, source, target) != Some(false) + ) + }) +} + +/// Does this non-`DealDamage` effect currently have a legal target or +/// population under an OPPONENT's control? Applies the full typed filter — +/// including `TypedFilter.controller` (CR 108.4 / CR 109.5) — via the +/// engine's `find_legal_targets`; never a filter-shape proxy. Effects with no +/// extractable filter (or no source object) resolve to false (no line). +/// +/// [`Effect::DestroyAll`] bypasses the targeting path entirely: it is +/// NON-targeted, so the engine resolver (`destroy::resolve_all`) matches a +/// battlefield POPULATION with no hexproof/shroud/protection exemptions and a +/// default all-creatures population for `TargetFilter::None` (CR 115.10a). +/// `find_legal_targets` would wrongly gate that population on target legality +/// and read `None` as an empty set, so wipes take the resolver-mirroring +/// [`mass_effect_has_opposing_population`] path instead. +fn effect_has_legal_opposing_line(ctx: &PolicyContext<'_>, effect: &Effect) -> bool { + let Some(source) = ctx.source_object() else { + return false; + }; + // CR 115.10a: inherently-mass effects (`DestroyAll`) are NON-targeted — + // the resolver matches a battlefield POPULATION (engine destroy.rs + // `resolve_all`) with no target-legality exemptions and a default + // population when the filter is `None`. Evaluate those resolver-mirroring; + // `find_legal_targets` would wrongly apply hexproof/shroud/protection and + // read `None` as an empty set. + if let Effect::DestroyAll { target, .. } = effect { + // `None` (unbound player-relative controller, e.g. a companion + // `TargetOpponent` wipe) is UNKNOWN — FAIL OPEN as useful, only a + // provably-empty population (`Some(false)`) is not worth a line. + return mass_effect_has_opposing_population(ctx, source, target) != Some(false); + } + let Some(filter) = extract_target_filter(effect) else { + return false; + }; + find_legal_targets(ctx.state, filter, ctx.ai_player, source.id) + .into_iter() + .any(|target| { + matches!( + target, + TargetRef::Object(id) + if ctx + .state + .objects + .get(&id) + .is_some_and(|o| is_opponent(ctx.state, ctx.ai_player, o.controller)) + ) + }) +} + +/// Resolver-mirroring population evaluation for a non-targeted mass effect +/// (CR 115.10a): iterate the battlefield and match the effect's population +/// exactly as `engine::game::effects::destroy::resolve_all` does — +/// indestructible objects are skipped (CR 702.12b: they can't be destroyed) +/// and `TargetFilter::None` means the resolver's default population (all +/// creatures). Unlike `find_legal_targets`, NO hexproof / shroud / protection +/// targets-exemption applies: those gate targeting only (CR 115.10a) and +/// never a wipe's population. +/// +/// Tri-state result, conservative by construction: +/// * `Some(true)` — an opposing population exists (the wipe is useful); +/// * `Some(false)` — the opposing population is provably empty; +/// * `None` — UNKNOWN: the population filter carries a player-RELATIVE +/// controller scope (`TargetPlayer` / `TargetOpponent`, `ScopedPlayer`, +/// `ParentTarget*`, `Chosen*`, `TriggeringPlayer`, ...) whose companion +/// player target is not bound at cast-commit. The engine reads the +/// companion from `ability.targets` (filter.rs +/// `ControllerRef::TargetPlayer|TargetOpponent` arm) and FAILS CLOSED +/// without it, while `destroy::resolve_all` resolves it later via +/// `FilterContext::from_ability` AFTER the companion player is announced +/// (CR 601.2c / CR 603.3d). We cannot know the population now, so consumers +/// must FAIL OPEN on `None` (treat `!= Some(false)` as useful). +fn mass_effect_has_opposing_population( + ctx: &PolicyContext<'_>, + source: &GameObject, + target: &TargetFilter, +) -> Option { + // Mirror destroy.rs `resolve_all`'s `None` -> default creature population. + let default_population = TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)); + let effective = if matches!(target, TargetFilter::None) { + &default_population + } else { + target + }; + // CR 109.4 + CR 115.1: a player-RELATIVE population filter — `TargetPlayer` / + // `TargetOpponent` companion wipes ("destroy all creatures target player + // controls"), or any other context-bound controller scope — is NOT resolvable at + // cast-commit: the engine reads the companion from `ability.targets` + // (filter.rs `ControllerRef::TargetPlayer|TargetOpponent` arm) and FAILS CLOSED + // without it, while `destroy::resolve_all` resolves it later via + // `FilterContext::from_ability` AFTER the companion player is announced + // (CR 601.2c / CR 603.3d). We cannot know the population now → `None` + // (UNKNOWN = explicit fail-open); consumers treat `!= Some(false)` as useful. + if filter_has_unbound_player_controller(effective) { + return None; + } + let filter_ctx = FilterContext::from_source_with_controller(source.id, source.controller); + Some(ctx.state.battlefield.iter().any(|&id| { + let Some(obj) = ctx.state.objects.get(&id) else { + return false; + }; + is_opponent(ctx.state, ctx.ai_player, obj.controller) + && !obj.has_keyword(&Keyword::Indestructible) + && matches_target_filter(ctx.state, id, effective, &filter_ctx) + })) +} + +/// Does the filter's controller scope resolve from the casting source alone at +/// cast-commit? Only `ControllerRef::You` / `ControllerRef::Opponent` are +/// statically derivable from the pending spell object. Every other scope — +/// `TargetPlayer`/`TargetOpponent` companion wipes, `ScopedPlayer`, +/// `ParentTarget*`, `Chosen*`, `TriggeringPlayer` — depends on an announced +/// target or resolution context (CR 109.4 / CR 115.1 / CR 608.2c) that does not +/// exist yet, so we can never provably empty the population now. The remaining +/// source/global-state-readable variants (`ActivePlayer`, resolved from +/// `state.active_player` in `filter.rs`; `EnchantedPlayer`, from +/// `source.attached_to`; `SourceChosenPlayer`, from `source.chosen_attributes`) +/// would resolve at cast-commit in isolation, but a pending spell object carries +/// no `attached_to` / `chosen_attributes`, and coupling their resolution here +/// would beg the very scope question — so for the purpose of this guard they are +/// classified UNBOUND BY CONSERVATIVE DESIGN (an over-approximation: a wipe of +/// such a scope is treated as possibly-non-empty unless its scope is statically +/// derivable, so a conservative wipe is never vetoed at cast-commit). +/// +/// Boundary: the unbound check covers only the controller-scope positions the +/// parser emits for wipe populations — `TypedFilter.controller`, including +/// nested `Or`/`And`/`Not` filters. It does NOT recurse into `FilterProp` +/// payloads that themselves embed `ControllerRef`s (`Owned { controller }`, +/// `Attacking { defender }`, `ProtectorMatches { controller }`, +/// `HasAttachment { controller }`, `HasAnyAttachmentOf { controller }`, +/// `MostPrevalentCreatureTypeIn { scope }`, or the nested `TargetFilter` inside +/// `CanEnchant`). No parser-emittable wipe population uses these today, so the +/// gap is latent; if one ever appeared it would be conservatively non-fail-open +/// and would need revisiting. Conservative by construction: any future +/// `ControllerRef` variant falls to `true` (unknown → fail open). +fn filter_has_unbound_player_controller(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::Typed(typed) => typed + .controller + .as_ref() + .is_some_and(|ctrl| !matches!(ctrl, ControllerRef::You | ControllerRef::Opponent)), + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + filters.iter().any(filter_has_unbound_player_controller) + } + TargetFilter::Not { filter: inner } => filter_has_unbound_player_controller(inner), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::AiConfig; + use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; + use engine::game::zones::create_object; + use engine::types::ability::{ + AbilityDefinition, AbilityKind, ControllerRef, QuantityExpr, QuantityRef, TargetFilter, + TypeFilter, TypedFilter, + }; + use engine::types::actions::GameAction; + use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; + use engine::types::identifiers::CardId; + use engine::types::player::PlayerId; + use engine::types::zones::Zone; + + fn make_state() -> GameState { + let mut state = GameState::new_two_player(42); + state.turn_number = 2; + state + } + + fn add_creature( + state: &mut GameState, + owner: PlayerId, + name: &str, + power: i32, + toughness: i32, + ) -> ObjectId { + let id = create_object( + state, + CardId(state.next_object_id), + owner, + name.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.power = Some(power); + obj.toughness = Some(toughness); + id + } + + /// Add a bare non-creature permanent (artifact) to the battlefield — a + /// legal, useful "gain control of target permanent" target that is + /// invisible to creature-only filters. + fn add_artifact(state: &mut GameState, owner: PlayerId, name: &str) -> ObjectId { + let id = create_object( + state, + CardId(state.next_object_id), + owner, + name.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + id + } + + /// A damage spell that deals the given `amount` to "target creature", then + /// runs `f` with a `PolicyContext` for casting it. Constructed inline so the + /// borrowed temporaries (`decision`/`candidate`/`context`) live for the + /// duration of `f`. + fn with_damage_spell( + state: &mut GameState, + amount: QuantityExpr, + f: impl FnOnce(&PolicyContext<'_>) -> R, + ) -> R { + let spell_id = create_object( + state, + CardId(90_000), + PlayerId(0), + "Predictable Burn".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + )]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let context = crate::context::AiContext::empty(&config.weights); + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_000), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + f(&ctx) + } + + /// Slash-of-Light-shaped whiff: 1 damage, an opponent 3/3 that survives. + /// Nothing to kill → `can_kill_any_legal_target` must return false (veto). + #[test] + fn can_kill_vetoes_when_every_legal_target_survives() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(0), "My Bear", 2, 1); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + // 1 damage, undamaged 3/3 survives (CR 704.5g) → veto. + with_damage_spell(&mut state, QuantityExpr::Fixed { value: 1 }, |ctx| { + assert!( + !can_kill_any_legal_target(ctx), + "1 damage with no lethal legal opponent target must veto the whiff" + ); + }); + } + + /// Positive reach-guard: burn that kills a legal opponent target must NOT + /// veto. Model 4 damage against a 3/3 (lethal via CR 704.5g). + #[test] + fn can_kill_does_not_veto_when_a_legal_target_is_lethal() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + with_damage_spell(&mut state, QuantityExpr::Fixed { value: 4 }, |ctx| { + assert!( + can_kill_any_legal_target(ctx), + "4 damage killing the 3/3 must NOT veto the cast" + ); + }); + } + + /// Multi-authority hostile fixture: opponent has a 2/2 and a 3/3, burn + /// deals 2. The 3/3 survives but the 2/2 is a legal lethal target → the + /// cast is NOT a total whiff, so no veto. Partial-whiff target choice is + /// deferred to the target-selection `lethality_bonus`. + #[test] + fn can_kill_does_not_veto_when_any_single_target_is_lethal() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Small Bear", 2, 2); + add_creature(&mut state, PlayerId(1), "Big Bear", 3, 3); + + with_damage_spell(&mut state, QuantityExpr::Fixed { value: 2 }, |ctx| { + assert!( + can_kill_any_legal_target(ctx), + "2 damage that can kill the opponent 2/2 must NOT veto (partial whiff)" + ); + }); + } + + /// Variable-X damage is chosen by the caster at announcement — never veto. + #[test] + fn can_kill_never_vetoes_variable_x_damage() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + let amount = QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }; + with_damage_spell(&mut state, amount, |ctx| { + assert!(can_kill_any_legal_target(ctx)); + }); + } + + /// Self-controlled targets are not "useful" removal targets. The + /// empty/self-only target set is covered by the sibling branch in + /// `anti_self_harm::score_pre_cast` (no targetable opponent creature), + /// which fires before this gate is consulted — so this gate's contract + /// fails open on it (never veto from a vacuous "all survived"). + #[test] + fn can_kill_fails_open_on_self_only_targets() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(0), "My Bear", 2, 1); + + with_damage_spell(&mut state, QuantityExpr::Fixed { value: 1 }, |ctx| { + // Empty opponent-target set → fail open (no veto); the whiff is + // handled by the sibling no-opponent-target branch instead. + assert!(can_kill_any_legal_target(ctx)); + }); + } + + /// Non-damage removal (e.g. Destroy) is never a damage whiff — never veto. + #[test] + fn can_kill_never_vetoes_non_damage_removal() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + // Build a `Destroy` spell — no DealDamage in the effect set, so + // `pending_damage_to_object` returns `PendingDamage::None` (never a + // damage whiff). Override the ability to a Destroy. + let spell_id = create_object( + &mut state, + CardId(90_001), + PlayerId(0), + "Murder".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::creature()), + cant_regenerate: false, + }, + )]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_001), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert!(can_kill_any_legal_target(&ctx)); + } + + /// Mixed removal spell: "deal 1 damage to target creature; destroy target + /// creature". The 1-damage half is a whiff on the 3/3, but the Destroy half + /// is independently useful (CR 701.8a). The cast-commit gate must FAIL OPEN + /// (not veto), otherwise it reports a false *damage* whiff for a spell that + /// still has a genuine removal line. + /// + /// `pending_damage_to_object` aggregates the spell's `DealDamage` halves + /// only. Without the non-`DealDamage` fail-open guard, the Destroy half + /// reads as a surviving 1-damage target and the whole spell is wrongly vetoed. + #[test] + fn can_kill_fails_open_on_mixed_damage_and_destroy() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + let spell_id = create_object( + &mut state, + CardId(90_002), + PlayerId(0), + "Charred Murder".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::creature()), + cant_regenerate: false, + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_002), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert!( + can_kill_any_legal_target(&ctx), + "mixed deal-1 + destroy must fail open: the Destroy half is a useful \ + removal line, so the spell is not a total damage whiff even though \ + 1 damage alone cannot kill the 3/3" + ); + } + + /// Mixed removal spell with a wipe line: "deal 1 damage to target creature; + /// destroy all creatures". The 1-damage half is a whiff on the 3/3, but the + /// `DestroyAll` half (CR 701.8) is an independent, useful mass-removal + /// line. `Effect::DestroyAll` is dispatched DIRECTLY (it bypasses the + /// target-only `extract_target_filter`) and the cast-commit gate resolves + /// it against the real opposing population (the 3/3) through the + /// resolver-mirroring mass path + /// (`mass_effect_has_opposing_population` — battlefield population matched + /// with `matches_target_filter`, CR 115.10a; DestroyAll is non-targeted), + /// not via `find_legal_targets`, which gates target legality. + #[test] + fn can_kill_fails_open_on_mixed_damage_and_destroy_all() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + let spell_id = create_object( + &mut state, + CardId(90_006), + PlayerId(0), + "Charred Cataclysm".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DestroyAll { + target: TargetFilter::Typed(TypedFilter::creature()), + cant_regenerate: false, + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_006), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert!( + can_kill_any_legal_target(&ctx), + "mixed deal-1 + destroy-all must fail open: `DestroyAll` (CR 701.8) \ + resolves a real opposing population (the 3/3) through the \ + resolver-mirroring mass population path \ + (`mass_effect_has_opposing_population`, CR 115.10a), so the spell \ + is not a total whiff even though 1 damage alone cannot kill the 3/3" + ); + } + + /// A mixed damage + wipe spell whose `DestroyAll` population carries a + /// player-RELATIVE controller scope (`ControllerRef::TargetOpponent`, a + /// companion "destroy all creatures target opponent controls" wipe) with + /// the companion player target NOT bound at cast-commit. The engine + /// resolves that scope by reading the first `TargetRef::Player` from + /// `ability.targets` and FAILS CLOSED without it (CR 109.4 / CR 115.1), + /// while `destroy::resolve_all` resolves it later via + /// `FilterContext::from_ability` after the companion is announced + /// (CR 601.2c). The population is therefore UNKNOWABLE at cast-commit: + /// `mass_effect_has_opposing_population` must report `None` (unknown), + /// the seam must fail open (`has_opposing_mass_population == true`), and + /// the mixed spell must not be vetoed. Pre-fix the population read as + /// empty → the 1-damage half vetoed the whole cast. + #[test] + fn mass_population_unknown_for_unbound_player_controller() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + let wipe_filter = + TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::TargetOpponent)); + + let spell_id = create_object( + &mut state, + CardId(90_020), + PlayerId(0), + "Player-targeted Cataclysm".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DestroyAll { + target: wipe_filter.clone(), + cant_regenerate: false, + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_020), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + + let source = ctx.source_object().unwrap(); + // (a) The helper's own discriminating seam: unbound player-relative + // controller → UNKNOWN (`None`), not a provable empty (`Some(false)`). + assert_eq!( + mass_effect_has_opposing_population(&ctx, source, &wipe_filter), + None, + "an unbound player-relative controller scope (TargetOpponent) must read UNKNOWN" + ); + // (b) The seam threads the fail-open: an UNKNOWN population is useful. + assert!( + has_opposing_mass_population(&ctx), + "an unknown (unbound player-relative) mass population must fail open through the seam" + ); + // (c) The mixed spell is not vetoed (the unknown wipe rescues the + // non-lethal damage half). + assert!( + can_kill_any_legal_target(&ctx), + "mixed deal-1 + player-relative wipe must not be vetoed when the \ + population is unknown at cast-commit" + ); + } + + /// Mixed removal spell with a DEFAULT-population wipe line: "deal 1 damage + /// to target creature; destroy all permanents" where the `DestroyAll` half + /// declares `TargetFilter::None`. The engine resolver (`destroy.rs` + /// `resolve_all`) treats `None` as its DEFAULT population — all creatures — + /// so the 3/3 is a wipe target even though the spell declares no filter + /// (CR 701.8). Pre-fix, the gate fed the raw `None` through + /// `find_legal_targets` (the extraction-as-target error), which reads an + /// empty set: the wipe half was not credited and the 1-damage half vetoed + /// the whole cast. Post-fix the dispatch is direct — `Effect::DestroyAll` + /// bypasses the target-only `extract_target_filter` and is resolved + /// resolver-mirroring, so the gate must fail open via the + /// mass-population path (CR 115.10a). + #[test] + fn can_kill_fails_open_on_default_population_destroy_all() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + let spell_id = create_object( + &mut state, + CardId(90_007), + PlayerId(0), + "Charred Judgement".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + // `None` is the serde default for `DestroyAll.target`, but construct + // it explicitly: the resolver's `None` -> all-creatures default + // population is the whole point of this test. + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DestroyAll { + target: TargetFilter::None, + cant_regenerate: false, + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_007), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert!( + can_kill_any_legal_target(&ctx), + "mixed deal-1 + default-population destroy-all must fail open: the \ + resolver's default population (`None` -> all creatures, destroy.rs \ + `resolve_all`) makes the 3/3 a wipe target (CR 701.8) even though \ + the spell declares no filter, so the spell is not a total damage \ + whiff even though 1 damage alone cannot kill the 3/3" + ); + } + + /// Wipe population counts HEXPROOF opponent creatures: "destroy all + /// creatures" against a board whose only opposing creature is hexproof. + /// Hexproof gates TARGETING only (CR 115.10a) — an affected object is not a + /// target — so it never protects anything from a non-targeted wipe's + /// population, exactly as the resolver matches it (`destroy.rs` + /// `resolve_all`). Pre-fix, `find_legal_targets` excluded the hexproof + /// creature on target legality, so the wipe half credited nothing. The + /// helper-level assert pins the resolver-semantics seam directly; the + /// can_kill-level empty-set clause would fail open even pre-fix, so it is a + /// secondary guard. + #[test] + fn mass_population_counts_protected_opponent_creatures() { + let mut state = make_state(); + let hexproof_bear = add_creature(&mut state, PlayerId(1), "Hexproof Bear", 3, 3); + state + .objects + .get_mut(&hexproof_bear) + .unwrap() + .keywords + .push(Keyword::Hexproof); + + let spell_id = create_object( + &mut state, + CardId(90_008), + PlayerId(0), + "Hexproof-Proof Wipe".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::DestroyAll { + target: TargetFilter::Typed(TypedFilter::creature()), + cant_regenerate: false, + }, + )]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_008), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + + let filter = TargetFilter::Typed(TypedFilter::creature()); + // THE discriminating seam: pre-fix, `find_legal_targets` returned false + // for this hexproof-only board (target legality), so the wipe half + // credited nothing even though the resolver destroys the hexproof bear. + assert!( + mass_effect_has_opposing_population(&ctx, ctx.source_object().unwrap(), &filter) + == Some(true), + "the wipe's resolver-mirroring population must count hexproof \ + opponent creatures: hexproof gates targeting only (CR 115.10a), \ + and the resolver (destroy.rs `resolve_all`) matches the population \ + with no target-legality exemptions" + ); + assert!( + can_kill_any_legal_target(&ctx), + "destroy-all vs a hexproof-only opposing board must fail open: the \ + wipe is non-targeted (CR 115.10a), so hexproof does not protect \ + the 3/3 from the mass-removal line" + ); + } + + /// Own-controller-constrained control line: "deal 1 damage to target + /// creature; gain control of target creature YOU control". `GainControl` is + /// `Contextual`, but its `TypedFilter.controller` is `ControllerRef::You` + /// (CR 108.4 / CR 109.5), so `find_legal_targets` names only the caster's + /// own creatures — there is no legal OPPOSING population for the control + /// line. Only the whiff 1-damage half remains, so the gate vetoes. + #[test] + fn can_kill_vetoes_when_control_line_is_own_controller_constrained() { + let mut state = make_state(); + // The AI's own bear keeps the You-constrained population NON-empty — + // the veto must come from the controller axis, not the empty set. + add_creature(&mut state, PlayerId(0), "My Bear", 2, 1); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + with_mixed_gain_control_spell( + &mut state, + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: Some(ControllerRef::You), + ..Default::default() + }), + |ctx| { + assert!( + !can_kill_any_legal_target(ctx), + "an own-controller-constrained control line (CR 108.4/109.5) must \ + NOT be credited as opposing removal: the You filter names only the \ + caster's own bear, so only the whiff 1-damage half remains and \ + the gate vetoes the total damage whiff" + ); + }, + ); + } + + /// Mixed spell: "deal 1 damage to target creature; gain control of target + /// creature". The 1-damage half is a whiff on the 3/3, but `GainControl` + /// (CR 613.1b, Layer 2) is an independent, useful control line. `GainControl` + /// is classified `EffectPolarity::Contextual`, so the fail-open guard must + /// cover Contextual non-`DealDamage` effects — not just `Harmful` ones. + /// Without that extension this spell is wrongly vetoed as a total *damage* + /// whiff. + #[test] + fn can_kill_fails_open_on_mixed_damage_and_gain_control_creature() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + let spell_id = create_object( + &mut state, + CardId(90_003), + PlayerId(0), + "Charmed Lightning".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainControl { + target: TargetFilter::Typed(TypedFilter::creature()), + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_003), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert!( + can_kill_any_legal_target(&ctx), + "mixed deal-1 + gain control of a creature must fail open: the \ + control half (CR 613.1b, Layer 2) is a useful line even though \ + 1 damage alone cannot kill the 3/3" + ); + } + + /// Same mixed-shape spell, but the control half targets ANY permanent + /// ("gain control of target permanent" — planeswalkers, artifacts, lands, + /// enchantments, CR 613.1b) instead of only creatures. The fail-open must + /// not require a creature filter: `permanent()` here targets the + /// opponent's artifact, a legal and useful control line invisible to + /// creature-only filters. + #[test] + fn can_kill_fails_open_on_mixed_damage_and_gain_control_of_permanent() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + add_artifact(&mut state, PlayerId(1), "Opponent Rock"); + + let spell_id = create_object( + &mut state, + CardId(90_004), + PlayerId(0), + "Charmed Heist".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainControl { + target: TargetFilter::Typed(TypedFilter::permanent()), + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_004), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert!( + can_kill_any_legal_target(&ctx), + "mixed deal-1 + gain control of a permanent must fail open: the \ + permanent-stealing half (CR 613.1b, Layer 2) is useful against the \ + opponent's artifact even though 1 damage alone cannot kill the 3/3" + ); + } + + /// Mixed spell with a parameterized `GainControl` filter: "deal 1 damage to + /// target creature" (a whiff on a 3/3) plus "gain control of [filter]". + /// The `GainControl` half is `EffectPolarity::Contextual`, so the fail-open + /// guard must recognize whichever target-filter shape `filter` names; a + /// filter the guard cannot analyse wrongly vetoes the control line + /// (CR 613.1b, Layer 2). + fn with_mixed_gain_control_spell( + state: &mut GameState, + control_filter: TargetFilter, + f: impl FnOnce(&PolicyContext<'_>) -> R, + ) -> R { + let spell_id = create_object( + state, + CardId(90_005), + PlayerId(0), + "Charmed Heist Shapes".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainControl { + target: control_filter, + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let context = crate::context::AiContext::empty(&config.weights); + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_005), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + f(&ctx) + } + + /// The control half targets "artifact or creature" via a NESTED + /// TypeFilter-level `AnyOf(AnyOf(artifact, creature))` disjunction — the + /// opponent's artifact is a legal control target (CR 613.1b, Layer 2). The + /// pre-commit `AnyOf` arm only matched a single level of plain + /// permanent-type inners, so this nested disjunction fell through to the + /// catch-all and vetoed the mixed spell; the recursive + /// helper must descend into nested `AnyOf`. A sibling + /// `AnyOf(Non(Land), Non(Creature))` case pins the same recursion over + /// negated inners. + #[test] + fn can_kill_fails_open_on_anyof_gain_control_target() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + add_artifact(&mut state, PlayerId(1), "Opponent Rock"); + + with_mixed_gain_control_spell( + &mut state, + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::AnyOf(vec![TypeFilter::AnyOf(vec![ + TypeFilter::Artifact, + TypeFilter::Creature, + ])])], + ..Default::default() + }), + |ctx| { + assert!( + can_kill_any_legal_target(ctx), + "nested AnyOf(AnyOf(artifact, creature)) control half must fail \ + open: the outer disjunction wraps a disjunction naming the \ + opponent's artifact (CR 613.1b, Layer 2), a useful control line \ + the recursive matcher must descend to find" + ); + }, + ); + + // Negated inners: "nonland, noncreature" is `AnyOf(Non(Land), Non(Creature))` + // — the opponent's artifact satisfies the Non(Land) alternative, so the + // recursive helper must descend through the disjunction into the negation. + with_mixed_gain_control_spell( + &mut state, + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::AnyOf(vec![ + TypeFilter::Non(Box::new(TypeFilter::Land)), + TypeFilter::Non(Box::new(TypeFilter::Creature)), + ])], + ..Default::default() + }), + |ctx| { + assert!( + can_kill_any_legal_target(ctx), + "AnyOf(Non(Land), Non(Creature)) control half must fail open: \ + the Non(Land) alternative matches the opponent's artifact \ + (CR 613.1b, Layer 2), a useful control line the recursive \ + matcher must descend to find" + ); + }, + ); + } + + /// The control half targets "nonland" via `TypeFilter::Non` — the opponent's + /// artifact matches ("nonland, noncreature permanent" filters are the + /// canonical Non shape), a legal control target (CR 613.1b, Layer 2). The + /// guard must treat `Non(Land)` as able to match a permanent; the catch-all + /// vetoed every Non shape, including "noncreature permanent" control lines. + #[test] + fn can_kill_fails_open_on_non_land_gain_control_target() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + add_artifact(&mut state, PlayerId(1), "Opponent Rock"); + + with_mixed_gain_control_spell( + &mut state, + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Non(Box::new(TypeFilter::Land))], + ..Default::default() + }), + |ctx| { + assert!( + can_kill_any_legal_target(ctx), + "Non(Land) control half must fail open: the negation matches \ + the opponent's artifact (CR 613.1b, Layer 2), so the mixed \ + spell is not a total whiff" + ); + }, + ); + } + + /// The control half targets "a permanent, or a player" via a TargetFilter + /// level `Or` — the opponent's artifact satisfies the permanent alternative + /// (CR 613.1b, Layer 2). The guard must walk `TargetFilter::Or` branches; + /// the `let Some(TargetFilter::Typed(..))` destructure returned `false` + /// for any non-Typed shape and vetoed the mixed spell. + #[test] + fn can_kill_fails_open_on_or_filter_gain_control_target() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + add_artifact(&mut state, PlayerId(1), "Opponent Rock"); + + with_mixed_gain_control_spell( + &mut state, + TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter::permanent()), + TargetFilter::Player, + ], + }, + |ctx| { + assert!( + can_kill_any_legal_target(ctx), + "Or(permanent, player) control half must fail open: the \ + permanent branch is a useful control line against the \ + opponent's artifact (CR 613.1b, Layer 2)" + ); + }, + ); + } + + /// Variable-X damage found anywhere in the spell — including a `DealDamage` + /// whose target filter is NOT creature-only — must fail open. The X-scan + /// covers every `DealDamage`, not just the creature-only ones. Here, a + /// creature-only fixed-damage spell carries a sibling `DealDamage` to "any + /// target" with X. The caster could choose X at announcement (CR 107.3a) to + /// make the spell lethal, so even though the creature-only half is a provable + /// whiff on the 3/3, the spell as a whole must not be vetoed. + #[test] + fn can_kill_fails_open_when_non_creature_deal_x_present() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3); + + let x_amount = QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }; + let spell_id = create_object( + &mut state, + CardId(90_003), + PlayerId(0), + "X-Blast".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: x_amount, + // ANY target (player/planeswalker/creature) — NOT creature-only. + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_003), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert!( + can_kill_any_legal_target(&ctx), + "a sibling non-creature-only deal-X means X is castable-lethal; the \ + spell must fail open despite the creature-only half being a whiff" + ); + } + + /// Two-Headed Giant teammate regression: a teammate's creature is a LEGAL + /// target (in `find_legal_targets`) and sits in any battlefield population, + /// but is NOT an opponent under the team-aware relation (CR 102.2 / + /// CR 102.3 + 2HG topology: P0/P1 are teammates, P2/P3 are P0's opponents). + /// Pre-fix, the removal-line predicates used `controller != ai_player`, + /// which wrongly credited a teammate-only creature/population as an + /// opposing removal line. `players::is_opponent` is the authority. + #[test] + fn opposing_lines_are_team_aware_in_two_headed_giant() { + let mut state = GameState::new( + engine::types::format::FormatConfig::two_headed_giant(), + 4, + 42, + ); + // P1 is P0's teammate in 2HG (topology: team_id = player.0 / team_size). + add_creature(&mut state, PlayerId(1), "Teammate Bear", 2, 2); + + // Teammate only — a legal target, but no opposing removal line. + with_mixed_gain_control_spell( + &mut state, + TargetFilter::Typed(TypedFilter::creature()), + |ctx| { + let gc = Effect::GainControl { + target: TargetFilter::Typed(TypedFilter::creature()), + }; + assert!( + !effect_has_legal_opposing_line(ctx, &gc), + "a teammate's creature must NOT be an opposing removal line: it is a \ + legal target but not an opponent (CR 102.2/102.3 + 2HG topology)" + ); + assert!( + mass_effect_has_opposing_population( + ctx, + ctx.source_object().unwrap(), + &TargetFilter::Typed(TypedFilter::creature()) + ) == Some(false), + "a teammate's creature must NOT be an opposing wipe population \ + (CR 102.2/102.3 + 2HG topology)" + ); + }, + ); + + // P2 IS P0's opponent in 2HG — the same shapes must now credit it. + add_creature(&mut state, PlayerId(2), "Enemy Bear", 3, 3); + with_mixed_gain_control_spell( + &mut state, + TargetFilter::Typed(TypedFilter::creature()), + |ctx| { + let gc = Effect::GainControl { + target: TargetFilter::Typed(TypedFilter::creature()), + }; + assert!( + effect_has_legal_opposing_line(ctx, &gc), + "an enemy (P2) creature must be an opposing removal line \ + (CR 102.2/102.3 + 2HG topology)" + ); + assert!( + mass_effect_has_opposing_population( + ctx, + ctx.source_object().unwrap(), + &TargetFilter::Typed(TypedFilter::creature()) + ) == Some(true), + "an enemy (P2) creature must be an opposing wipe population \ + (CR 102.2/102.3 + 2HG topology)" + ); + }, + ); + } + + /// Direct seam test: `has_opposing_mass_population` — the cast-commit seam + /// consulted by `anti_self_harm::score_pre_cast` BEFORE the target-legality + /// gate — must report TRUE for a mixed damage+wipe spell whose only opposing + /// creature is HEXPROOF. The wipe is NON-targeted (CR 115.10a), so the + /// hexproof 3/3 is in its resolver population even though it has no legal + /// target (hexproof gates targeting only, CR 702.11b). This is the + /// population truth that rescues the mixed spell from the no-target + /// penalty in `anti_self_harm::score_pre_cast`. + #[test] + fn seam_has_opposing_mass_population_counts_hexproof_opponent() { + let mut state = make_state(); + add_creature(&mut state, PlayerId(0), "My Bear", 2, 1); + let hexproof_bear = add_creature(&mut state, PlayerId(1), "Hexproof Bear", 3, 3); + state + .objects + .get_mut(&hexproof_bear) + .unwrap() + .keywords + .push(Keyword::Hexproof); + + let spell_id = create_object( + &mut state, + CardId(90_009), + PlayerId(0), + "Wipe Plus Damage".to_string(), + Zone::Hand, + ); + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.abilities = Arc::new(vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DestroyAll { + target: TargetFilter::None, + cant_regenerate: false, + }, + ), + ]); + + let config = AiConfig::default(); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { + player: PlayerId(0), + }, + candidates: Vec::new(), + }; + let candidate = CandidateAction { + action: GameAction::CastSpell { + object_id: spell_id, + card_id: CardId(90_009), + targets: Vec::new(), + payment_mode: CastPaymentMode::Auto, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell), + }; + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: PlayerId(0), + config: &config, + context: &crate::context::AiContext::empty(&config.weights), + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert!( + has_opposing_mass_population(&ctx), + "the mixed wipe's resolver-mirroring population must include the hexproof 3/3 \ + (CR 115.10a: the wipe is NON-targeted, so hexproof gates targeting only, \ + CR 702.11b)" + ); + } +} diff --git a/crates/phase-ai/src/policies/self_protection_classify.rs b/crates/phase-ai/src/policies/self_protection_classify.rs index f228d8b311..a3e532ce95 100644 --- a/crates/phase-ai/src/policies/self_protection_classify.rs +++ b/crates/phase-ai/src/policies/self_protection_classify.rs @@ -379,7 +379,7 @@ fn grant_from_keyword(keyword: &Keyword) -> Vec { } } -/// CR 702.18a / CR 702.11a: targeting immunity answers only harmful effects +/// CR 702.18a / CR 702.11b: targeting immunity answers only harmful effects /// that select the protected permanent as a target — not player burn, beneficial /// buffs, or untargeted mass removal. fn any_stack_harmful_answerable_by_grants( @@ -456,8 +456,14 @@ fn grant_answers_harmful_effect( } } -/// Harmful single-target effects that select a permanent (answered by shroud / -/// hexproof / protection when the source is not exempt). +/// Harmful SINGLE-TARGET effects that select a permanent (answered by shroud / +/// hexproof / protection when the source is not exempt). Mass/untargeted +/// effects — `DestroyAll` (CR 115.10a: an affected object is not a target) — +/// are excluded: targeting immunity grants (CR 702.11/702.16/702.18) save +/// nothing from a wipe. `extract_target_filter` is target-only, so it already +/// returns `None` for `DestroyAll` (a wipe has no selectable target), making +/// the `.is_some()` check below false — mass effects are thus excluded by the +/// extraction itself, with no separate match needed. fn harmful_effect_uses_object_targeting(effect: &Effect) -> bool { !matches!(extract_target_filter(effect), Some(TargetFilter::Player)) && extract_target_filter(effect).is_some() diff --git a/crates/phase-ai/src/tactical_gate.rs b/crates/phase-ai/src/tactical_gate.rs index 054e50bba7..9b8fdf001e 100644 --- a/crates/phase-ai/src/tactical_gate.rs +++ b/crates/phase-ai/src/tactical_gate.rs @@ -434,6 +434,16 @@ fn is_redundant_creature_only_removal(ctx: &PolicyContext<'_>, effects: &[&Effec return false; }; + // A MIXED spell carrying a useful MASS wipe is never "redundant creature-only + // removal": the wipe's NON-targeted population (CR 115.10a) is an independent + // line that can clear creatures hexproof/protected FROM TARGETING (CR 702.11b) + // — so a creature-only half with no live opponent TARGET must not suppress the + // cast. Consult the resolver-mirroring mass seam (`ctx.has_opposing_mass_population`) + // before declaring redundancy. + if ctx.has_opposing_mass_population() { + return false; + } + let mut saw_creature_only_harm = false; for effect in effects { if !(matches!(effect_polarity(effect), EffectPolarity::Harmful) diff --git a/crates/phase-ai/tests/ai_quality.rs b/crates/phase-ai/tests/ai_quality.rs index 9ec6f46ace..b9115a09c3 100644 --- a/crates/phase-ai/tests/ai_quality.rs +++ b/crates/phase-ai/tests/ai_quality.rs @@ -9,16 +9,25 @@ use std::collections::{HashMap, HashSet}; use engine::game::combat::{AttackTarget, AttackerInfo, CombatState}; use engine::game::deck_loading::DeckEntry; use engine::game::scenario::{GameScenario, P0, P1}; -use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, QuantityExpr, TargetFilter}; +use engine::types::ability::TargetRef; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, ControllerRef, Effect, QuantityExpr, QuantityRef, TargetFilter, + TypedFilter, +}; use engine::types::actions::GameAction; use engine::types::card::CardFace; use engine::types::card_type::{CardType, CoreType}; use engine::types::game_state::CastPaymentMode; use engine::types::game_state::{PlayerDeckPool, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; use engine::types::mana::ManaCost; +use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; use engine::types::player::PlayerId; -use phase_ai::auto_play::{driver_step, run_ai_actions, AiActionsBreakReason}; +use phase_ai::auto_play::{ + driver_step, run_ai_actions, run_ai_actions_bounded, AiActionsBreakReason, +}; use phase_ai::choose_action; use phase_ai::config::{create_config, AiDifficulty, Platform}; use phase_ai::score_candidates; @@ -204,6 +213,954 @@ fn prefers_removing_larger_creature() { ); } +/// With a single 2/1 creature and no Equipment on board, Slash of Light deals 1 +/// damage — non-lethal on a 3/3 — so casting it at a 3/3 wastes the removal +/// spell. The AI should NOT commit the cast in this situation. +/// +/// Slash of Light's Oracle text: +/// "Slash of Light deals damage equal to the number of creatures you control +/// plus the number of Equipment you control to target creature." +/// +/// CR 120.3: damage equal to the number of creatures you control (1) plus the +/// number of Equipment you control (0) = 1 damage. CR 704.5g: 1 marked damage +/// on an undamaged 3/3 does not reach its 3 toughness, so it survives. +#[test] +fn does_not_cast_slash_of_light_for_nonlethal_damage() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // AI's single 2/1 creature (1 creature, 0 Equipment → Slash deals 1). + scenario.add_creature(P0, "My Bear", 2, 1); + // Opponent's 3/3 that 1 damage cannot kill. + scenario.add_creature(P1, "Opponent Bear", 3, 3); + + scenario + .add_spell_to_hand_from_oracle( + P0, + "Slash of Light", + true, + "Slash of Light deals damage equal to the number of creatures you control plus the number of Equipment you control to target creature.", + ) + .id(); + + // Fund {1}{W} so the cast is affordable — passing must reflect the waste, + // not an unpayable cost. + let mut mana = vec![ManaUnit::new( + ManaType::White, + ObjectId(9_999), + false, + vec![], + )]; + mana.push(ManaUnit::new( + ManaType::Colorless, + ObjectId(9_999), + false, + vec![], + )); + scenario.with_mana_pool(P0, mana); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + // Easy, Hard, and Very Hard: the whiff guard deterministically ranks the + // wasteful cast BELOW passing its priority. Assert via `score_candidates` + // (the deterministic policy-registry ranking) rather than `choose_action` + // (which applies softmax temperature + search pre-emptions and is therefore + // difficulty-stochastic at the argmax boundary). The discriminating signal + // for the cast-commit gate is that the whiff burn is deprioritized below + // passing. + // + // Medium is deliberately NOT asserted here: at difficulty Medium the cast + // decision goes through the search path which projects casting Slash of Light + // as a ~WIN_SCORE (10000) line whether or not the whiff penalty is applied — + // a search/terminal-eval artifact orthogonal to the cast-commit whiff guard. + for diff in [ + AiDifficulty::Easy, + AiDifficulty::Hard, + AiDifficulty::VeryHard, + ] { + let config = create_config(diff, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + let cast = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { .. })) + .map(|(_, s)| *s); + let pass = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::PassPriority)) + .map(|(_, s)| *s); + let (Some(cast), Some(pass)) = (cast, pass) else { + panic!("{diff:?}: expected both CastSpell and PassPriority candidates, got {scored:?}"); + }; + assert!( + cast < pass, + "{diff:?}: the wasteful Slash of Light cast ({cast:.3}) must rank below \ + passing ({pass:.3}) — the whiff guard did not deprioritize the burn" + ); + } +} + +/// Drives the full Very Hard pipeline after a wasteful Slash of Light cast would +/// be committed: confirms the AI does NOT commit the cast (and therefore never +/// points its non-lethal 1 damage at the opponent's 3/3). The cast-commit +/// whiff guard (the `removal_lethality::can_kill_any_legal_target` gate behind +/// `AntiSelfHarm::score_pre_cast`) deprioritizes a burn whose damage kills no +/// legal target, so the Very Hard AI passes instead of pinging the 3/3 for a +/// wasted 1 point. +/// +/// Two-tier reach-guard: (1) the scorer must offer the exact Slash of Light +/// `CastSpell` candidate at the cast-commit step — proving the gate is in the +/// decision path rather than the test passing vacuously on an unrelated +/// action — and (2) the bounded pipeline must still produce at least one +/// decision. Only with both tiers does "no CastSpell in the results" prove the +/// whiff guard deprioritized the cast. +#[test] +fn very_hard_slash_of_light_does_not_commit_or_ping_the_3_3() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let _mine = scenario.add_creature(P0, "My Bear", 2, 1).id(); + let _theirs = scenario.add_creature(P1, "Opponent Bear", 3, 3).id(); + + let slash = scenario + .add_spell_to_hand_from_oracle( + P0, + "Slash of Light", + true, + "Slash of Light deals damage equal to the number of creatures you control plus the number of Equipment you control to target creature.", + ) + .id(); + + let mut mana = vec![ManaUnit::new( + ManaType::White, + ObjectId(9_999), + false, + vec![], + )]; + mana.push(ManaUnit::new( + ManaType::Colorless, + ObjectId(9_999), + false, + vec![], + )); + scenario.with_mana_pool(P0, mana); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + // Reach-guard (tier 1): the exact Slash of Light cast must be offered as a + // candidate to the Very Hard scorer. A vacuous "no candidates" pass would + // satisfy the outcome asserts below for the wrong reason. Read-only — no + // borrow survives past this block. + let config = create_config(AiDifficulty::VeryHard, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + assert!( + scored.iter().any(|(a, _)| matches!( + a, + GameAction::CastSpell { object_id, .. } if *object_id == slash + )), + "Slash of Light must be offered as a CastSpell candidate, got {scored:?}" + ); + + let ai_players = HashSet::from([P0]); + let ai_configs = HashMap::from([(P0, config)]); + let mut ai_rng = SmallRng::seed_from_u64(42); + let ai_session = phase_ai::session::AiSession::arc_from_game(runner.state()); + + let results = run_ai_actions_bounded( + runner.state_mut(), + &ai_players, + &ai_configs, + &mut ai_rng, + &ai_session, + 4, + ); + + // The Very Hard AI must NOT commit the wasteful cast, so it must not choose + // a target either — the first (and only) action should be a priority pass. + let cast = results + .iter() + .find(|r| matches!(r.action, GameAction::CastSpell { .. })); + assert!( + cast.is_none(), + "AI must NOT commit the non-lethal Slash of Light cast — actions: {:?}", + results.iter().map(|r| &r.action).collect::>() + ); + let target = results.iter().find_map(|r| match r.action { + GameAction::ChooseTarget { + target: Some(TargetRef::Object(id)), + } => Some(id), + _ => None, + }); + assert!( + target.is_none(), + "AI must not aim Slash of Light at any creature — got target {target:?}" + ); + // Reach-guard: the engine's full Very Hard action pipeline gave the AI a + // chance to act (at least one decision was produced), proving the test did + // not short-circuit before the cast-commit gate could be evaluated. A pass + // (or any decision) reaching the arms above means the whiff guard fired. + assert!( + !results.is_empty(), + "Very Hard AI must produce at least one decision at the cast-commit step" + ); +} + +/// Hostile sibling for the whiff gate: the opponent has a 3/3 that survives +/// AND a 1/1 that Slash's 1 damage KILLS. The cast is a *partial* whiff, not a +/// total one — `can_kill_any_legal_target` must NOT veto. This proves the gate +/// blocks only *total* whiffs (target choice for a partial whiff is deferred to +/// the existing target-selection `lethality_bonus`). +/// +/// Asserted via `score_candidates` (deterministic policy-registry ranking) at +/// Easy, exactly as the total-whiff guard test does, so the two are directly +/// comparable: total whiff → cast ranks below pass; partial whiff → cast ranks +/// above pass. +#[test] +fn slash_of_light_commits_when_a_legal_target_is_lethal() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // AI's single 2/1 creature → Slash deals 1. + scenario.add_creature(P0, "My Bear", 2, 1); + // Opponent's 1/1 that 1 damage kills (CR 704.5g), and a 3/3 that it doesn't. + scenario.add_creature(P1, "Small Opponent", 1, 1); + scenario.add_creature(P1, "Big Opponent", 3, 3); + + scenario + .add_spell_to_hand_from_oracle( + P0, + "Slash of Light", + true, + "Slash of Light deals damage equal to the number of creatures you control plus the number of Equipment you control to target creature.", + ) + .id(); + + let mut mana = vec![ManaUnit::new( + ManaType::White, + ObjectId(9_999), + false, + vec![], + )]; + mana.push(ManaUnit::new( + ManaType::Colorless, + ObjectId(9_999), + false, + vec![], + )); + scenario.with_mana_pool(P0, mana); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + let cast = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { .. })) + .map(|(_, s)| *s); + let pass = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::PassPriority)) + .map(|(_, s)| *s); + let (Some(cast), Some(pass)) = (cast, pass) else { + panic!("expected both CastSpell and PassPriority candidates, got {scored:?}"); + }; + assert!( + cast > pass, + "partial whiff must NOT be vetoed: a legal lethal target (the opponent 1/1) \ + exists, so the cast ({cast:.3}) should rank above passing ({pass:.3}) — \ + the gate must only block total whiffs" + ); +} + +/// Differential test for mixed-removal spells: a spell with TWO creature-only +/// effects — "deal 1 damage to target creature; destroy target creature" — +/// must NOT be penalized as a damage whiff when it still has a useful Destroy +/// line. Without the non-`DealDamage` fail-open guard, `can_kill_any_legal_target` +/// aggregates only the spell's `DealDamage` half, sees the 1 damage survive the +/// 3/3, and wrongly vetoes the cast. +/// +/// The damage amount is DYNAMIC (ObjectCount of the AI's creatures → 1), matching +/// spells where `lethal_to_creature` fails open and the `can_kill_any_legal_target` +/// gate determines lethality. +/// +/// This test isolates the whiff penalty. A second, otherwise-identical spell +/// deals only the dynamic 1 damage ("pure burn"): on the same board it is a +/// provable total damage whiff and receives the -8 `wasted_cast_penalty`, +/// while the mixed spell must not. Both are driven through the real cast +/// pipeline to ensure the cast-commit gate is fully evaluated. +#[test] +fn mixed_damage_and_destroy_is_not_penalized_as_a_damage_whiff() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // AI's single creature makes the dynamic ObjectCount amount resolve to 1 + // (Slash-of-Light-shaped); the opponent's 3/3 survives 1 damage but Destroy + // kills it (CR 701.8a). + scenario.add_creature(P0, "My Bear", 2, 1); + scenario.add_creature(P1, "Opponent Bear", 3, 3); + + let mut my_filter = TypedFilter::creature(); + my_filter.controller = Some(ControllerRef::You); + let amount = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(my_filter), + }, + }; + + // Mixed "deal 1 damage + destroy target creature". + let mixed = scenario + .add_spell_to_hand(P0, "Charred Murder", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .with_ability(Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::creature()), + cant_regenerate: false, + }) + .id(); + + // Pure "deal 1 damage to target creature" — a total damage whiff here + // (1 cannot kill the 3/3). + let pure = scenario + .add_spell_to_hand(P0, "Pure Burn", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .id(); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + + // Reach-guard: BOTH spells must actually be offered as CastSpell + // candidates to prevent a vacuous pass that never reaches the cast-commit gate. + let mixed_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == mixed)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!("mixed spell {mixed:?} must be offered as CastSpell, got {scored:?}") + }); + let pure_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == pure)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!("pure whiff spell {pure:?} must be offered as CastSpell, got {scored:?}") + }); + + // The mixed spell's Destroy line makes it strictly more castable than the + // identical pure-damage whiff. Without the non-`DealDamage` fail-open guard, + // `can_kill_any_legal_target` penalizes the mixed spell with the same -8 + // whiff penalty, collapsing this inequality. + assert!( + mixed_score > pure_score + config.policy_penalties.wasted_cast_penalty.abs() * 0.5, + "mixed deal-1 + destroy ({mixed_score:.3}) must outrank the identical pure \ + burn whiff ({pure_score:.3}): Destroy is a useful removal line the gate \ + must not penalize as a damage whiff" + ); +} + +/// Differential test for mixed control spells: a spell with a creature-damage +/// effect AND a "gain control of target permanent" effect must NOT be +/// penalized as a damage whiff when the control half is independently useful. +/// `Effect::GainControl` is classified `EffectPolarity::Contextual`, so the +/// fail-open requires the gate to cover Contextual non-`DealDamage` effects +/// with a creature-or-permanent target (CR 613.1b, Layer 2). Without it, +/// `can_kill_any_legal_target` aggregates only the `DealDamage` half, sees the +/// 1 damage survive the 3/3, and wrongly vetoes the cast. +/// +/// The damage amount is DYNAMIC (ObjectCount of the AI's creatures → 1), +/// Slash-of-Light-shaped; the opponent also controls a non-creature permanent +/// (Island) so the control line is legal and genuinely useful. On this board the +/// pure burn is a provable total damage whiff (-8 `wasted_cast_penalty`) while +/// the mixed control spell must not be penalized. Both are driven through the +/// real cast pipeline so the cast-commit gate is fully evaluated. +#[test] +fn mixed_damage_and_gain_control_is_not_penalized_as_a_damage_whiff() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // AI's single creature makes the dynamic ObjectCount amount resolve to 1 + // (Slash-of-Light-shaped); the opponent's 3/3 survives 1 damage but the + // Island is a legal, useful GainControl permanent target (CR 613.1b). + scenario.add_creature(P0, "My Bear", 2, 1); + scenario.add_creature(P1, "Opponent Bear", 3, 3); + scenario.add_basic_land(P1, engine::types::mana::ManaColor::Blue); + + let mut my_filter = TypedFilter::creature(); + my_filter.controller = Some(ControllerRef::You); + let amount = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(my_filter), + }, + }; + + // Mixed "deal 1 damage to target creature + gain control of target permanent". + let mixed = scenario + .add_spell_to_hand(P0, "Charmed Heist", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .with_ability(Effect::GainControl { + target: TargetFilter::Typed(TypedFilter::permanent()), + }) + .id(); + + // Pure "deal 1 damage to target creature" — a total damage whiff here + // (1 cannot kill the 3/3). + let pure = scenario + .add_spell_to_hand(P0, "Pure Burn", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .id(); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + + // Reach-guard: BOTH spells must actually be offered as CastSpell + // candidates to prevent a vacuous pass that never reaches the cast-commit gate. + let mixed_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == mixed)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!("mixed spell {mixed:?} must be offered as CastSpell, got {scored:?}") + }); + let pure_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == pure)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!("pure whiff spell {pure:?} must be offered as CastSpell, got {scored:?}") + }); + + // The mixed spell's permanent-control line makes it strictly more castable + // than the identical pure-damage whiff. Without the Contextual/permanent + // fail-open, `can_kill_any_legal_target` penalizes the mixed spell with the + // same -8 whiff penalty, collapsing this inequality. + assert!( + mixed_score > pure_score + config.policy_penalties.wasted_cast_penalty.abs() * 0.5, + "mixed deal-1 + gain control of a permanent ({mixed_score:.3}) must outrank \ + the identical pure burn whiff ({pure_score:.3}): stealing the opponent's \ + permanent is a useful control line (CR 613.1b) the gate must not penalize \ + as a damage whiff" + ); +} + +/// A mixed spell carrying a DEFAULT-population `DestroyAll` (declaring +/// `TargetFilter::None`) plus a "deal 1 damage to target creature" effect must +/// NOT be penalized as a damage whiff when its wipe half is independently +/// useful. `TargetFilter::None` means the engine resolver's default population +/// — all creatures (destroy.rs `resolve_all`, CR 701.8) — so the opponent's +/// 3/3 is a wipe target even though the spell declares no filter. Pre-fix, the +/// cast-commit gate fed the raw `None` into `find_legal_targets` (an empty +/// set), the wipe half credited nothing, and the 1-damage half vetoed the +/// whole spell as a whiff. The wipe is also NON-targeted (CR 115.10a): target +/// legality never gates its population. +/// +/// The damage amount is DYNAMIC (ObjectCount of the AI's creatures → 1), +/// Slash-of-Light-shaped. On this board the pure burn is a provable total +/// damage whiff (-8 `wasted_cast_penalty`) while the mixed wipe spell must not +/// be penalized. Both are driven through the real cast pipeline so the +/// cast-commit gate is fully evaluated. +#[test] +fn mixed_damage_and_destroy_all_is_not_penalized_as_a_damage_whiff() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // AI's single creature makes the dynamic ObjectCount amount resolve to 1 + // (Slash-of-Light-shaped); the opponent's 3/3 survives 1 damage but is in + // the wipe's default all-creatures population (CR 701.8, destroy.rs + // `resolve_all`). + scenario.add_creature(P0, "My Bear", 2, 1); + scenario.add_creature(P1, "Opponent Bear", 3, 3); + + let mut my_filter = TypedFilter::creature(); + my_filter.controller = Some(ControllerRef::You); + let amount = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(my_filter), + }, + }; + + // Mixed "deal 1 damage to target creature + destroy all permanents" — the + // wipe declares NO filter, so its population is the resolver's default + // (all creatures). + let mixed = scenario + .add_spell_to_hand(P0, "Charred Judgement", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .with_ability(Effect::DestroyAll { + target: TargetFilter::None, + cant_regenerate: false, + }) + .id(); + + // Pure "deal 1 damage to target creature" — a total damage whiff here + // (1 cannot kill the 3/3). + let pure = scenario + .add_spell_to_hand(P0, "Pure Burn", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .id(); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + + // Reach-guard: BOTH spells must actually be offered as CastSpell + // candidates to prevent a vacuous pass that never reaches the cast-commit gate. + let mixed_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == mixed)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!("mixed spell {mixed:?} must be offered as CastSpell, got {scored:?}") + }); + let pure_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == pure)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!("pure whiff spell {pure:?} must be offered as CastSpell, got {scored:?}") + }); + + // The mixed spell's default-population wipe line (the 3/3 is in the + // resolver's all-creatures population, CR 701.8 / destroy.rs `resolve_all`; + // the wipe is non-targeted, CR 115.10a) makes it strictly more castable + // than the identical pure-damage whiff. Without the resolver-mirroring mass + // path, `can_kill_any_legal_target` penalizes the mixed spell with the same + // -8 whiff penalty, collapsing this inequality. + assert!( + mixed_score > pure_score + config.policy_penalties.wasted_cast_penalty.abs() * 0.5, + "mixed deal-1 + default-population destroy-all ({mixed_score:.3}) must outrank \ + the identical pure burn whiff ({pure_score:.3}): the wipe's default \ + all-creatures population (CR 701.8 / destroy.rs `resolve_all`) makes the \ + 3/3 a wipe target and the wipe is non-targeted (CR 115.10a), so the gate \ + must not penalize the spell as a damage whiff" + ); +} + +/// Production-pipeline differential pinning BOTH seams restored by the +/// whiff-gate fix, for a MIXED wipe spell on a board whose only opposing +/// creature is HEXPROOF: +/// +/// * **Targeting is gated, the wipe population is not.** Hexproof (CR 702.11b) +/// prevents the creature being *targeted* by the spell's "deal 1 damage to +/// target creature" half — but `DestroyAll` is NON-targeted (CR 115.10a), so +/// hexproof never answers it. With `TargetFilter::None` (CR 701.8) the +/// resolver's population defaults to ALL creatures, so the hexproof 3/3 is a +/// genuine wipe target. +/// * **Own bear gives the damage half a legal ANNOUNCE target (CR 601.2c).** +/// The AI's own 2/1 means the DealDamage half has a legal target to name when +/// the spell is cast, so the PENDING spell is valid and the cast pipeline +/// reaches the cast-commit scoring. +/// +/// The reference R is a PURE `DestroyAll{None}` wipe (NOT pure burn): on a +/// board with no targetable opponent creature, pure burn is hard-REJECTED by +/// `is_redundant_creature_only_removal` (whose creature-only half has no live +/// opponent target), so it is never offered and cannot be a comparable +/// reference. The pure wipe R has no creature-only half, is offered, and is +/// the honest baseline: the mixed spell M (DealDamage half + wipe) must carry +/// the SAME cast-commit score as R (modulo the small margin), because both +/// clear the hexproof population and M's dead damage half adds a no-target +/// whiff only if the rescue fails. +/// +/// This pins TWO fixes: +/// 1. **Tactical gate mass-awareness** (tactical_gate.rs) — pre-fix M is +/// hard-REJECTED by `is_redundant_creature_only_removal` (its creature-only +/// half has no live opponent target on a hexproof board), so the +/// `CastSpell` reach-guard on M fails (only `PassPriority` is offered). +/// 2. **anti_self_harm no-target rescue** — pre-rescue M carries the -8 +/// `wasted_cast_penalty` no-target penalty (M ≈ R − 8), failing the +/// differential; post-rescue M ≈ R. +#[test] +fn mixed_destroy_all_not_penalized_when_only_population_is_hexproof() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // The AI's own 2/1 gives the DealDamage half a legal ANNOUNCE target so the + // pending spell is valid (CR 601.2c), and resolves the dynamic ObjectCount + // amount to 1. The opponent's ONLY creature is hexproof (CR 702.11b): an + // illegal TARGET for the damage half, but in the wipe's resolver population + // (CR 115.10a non-targeted; CR 701.8 default all-creatures for `None`). + scenario.add_creature(P0, "My Bear", 2, 1); + scenario + .add_creature(P1, "Hexproof Bear", 3, 3) + .with_keyword(Keyword::Hexproof); + + let mut my_filter = TypedFilter::creature(); + my_filter.controller = Some(ControllerRef::You); + let amount = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(my_filter), + }, + }; + + // Mixed "deal 1 damage to target creature + destroy all creatures" — the + // damage half is announceable (own bear) but NOT lethal/useful by target; + // the DestroyAll half clears the hexproof population. + let mixed = scenario + .add_spell_to_hand(P0, "Charred Judgement", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .with_ability(Effect::DestroyAll { + target: TargetFilter::None, + cant_regenerate: false, + }) + .id(); + + // Reference: PURE wipe (CR 701.8, CR 115.10a) — the honest comparable. Pure + // burn would be hard-rejected by `is_redundant_creature_only_removal` on this + // board (no live opponent target), so it is NOT a valid reference. + let reference = scenario + .add_spell_to_hand(P0, "Pure Wipe", true) + .with_ability(Effect::DestroyAll { + target: TargetFilter::None, + cant_regenerate: false, + }) + .id(); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + + // Reach-guard: BOTH must be offered as CastSpell. The M reach-guard is the + // DISCRIMINATING guard for the tactical-gate fix: pre-fix M is hard-rejected + // (only PassPriority), so this unwrap panics. + let mixed_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == mixed)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!( + "mixed spell {mixed:?} must be offered as CastSpell (tactical gate must be \ + mass-aware), got {scored:?}" + ) + }); + let ref_score = scored + .iter() + .find(|(a, _)| { + matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == reference) + }) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!("reference pure wipe {reference:?} must be offered as CastSpell, got {scored:?}") + }); + + // M's wipe clears the hexproof population exactly like R, so M must NOT be + // meaningfully below R. Pre-rescue M carried the -8 no-target penalty + // (M ≈ R − 8 < R − 4); post-rescue M ≈ R. The dead damage half is harmless + // once the wipe line rescues the spell, so the allowed gap is only the + // half-penalty margin. + assert!( + mixed_score > ref_score - config.policy_penalties.wasted_cast_penalty.abs() * 0.5, + "mixed deal-1 + destroy-all on a hexproof-only board ({mixed_score:.3}) must not be \ + penalized below the pure wipe ({ref_score:.3}): the DestroyAll population is \ + NON-targeted (CR 115.10a) and clears the hexproof 3/3 (CR 702.11b gates \ + targeting only), so M is a real removal line, not a whiff" + ); +} + +/// Production-pipeline differential pinning the player-relative-wipe fix for +/// the **anti_self_harm** thread: a mixed spell whose `DestroyAll` population +/// carries a companion `ControllerRef::TargetOpponent` scope ("destroy all +/// creatures target opponent controls") with a LIVE opponent creature on board +/// and the companion player target LEFT UNBOUND, exactly as at cast-commit. +/// +/// * **Why UNKNOWN.** The engine resolves `ControllerRef::TargetOpponent` by +/// reading the first `TargetRef::Player` from `ability.targets` (filter.rs +/// `ControllerRef::TargetPlayer|TargetOpponent` arm) and FAILS CLOSED without +/// it, while `destroy::resolve_all` resolves the same population later via +/// `FilterContext::from_ability` AFTER the companion player is announced +/// (CR 601.2c). At cast-commit the companion slot is not yet bound, so the +/// population is UNKNOWABLE — the mass helper must fail open (`None`), NOT +/// read it as empty (CR 109.4 / CR 115.1). +/// * **The wipe is non-targeted** (CR 115.10a): population members are not +/// "targets" (CR 701.8), so the unbound companion player is a +/// target-declaration bookkeeping gap, not a legality problem for the wipe. +/// * **Reference R** is the identical target-player wipe ALONE — the +/// apples-to-apples baseline: both M and R carry the same +/// `TargetOpponent` wipe; only M adds the non-lethal damage half. So M must +/// score ≈ R (within the half-penalty margin). Pre-fix the mass population +/// read as empty → `can_kill_any_legal_target` did not credit the wipe → M's +/// 1-damage half was vetoed as a whiff (`wasted_cast_penalty`, +/// anti_self_harm) → M ≈ R − 8, failing this assert. +#[test] +fn mixed_target_opponent_wipe_is_not_penalized_when_player_unbound() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // AI's 2/1 also lets the dynamic ObjectCount amount resolve to 1; the + // opponent's LIVE 3/3 is both a legal target for the damage half and a + // member of the TargetOpponent wipe population (CR 115.10a). + scenario.add_creature(P0, "My Bear", 2, 1); + scenario.add_creature(P1, "Opponent Bear", 3, 3); + + let mut my_filter = TypedFilter::creature(); + my_filter.controller = Some(ControllerRef::You); + let amount = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(my_filter), + }, + }; + + // Companion `TargetPlayer`/`TargetOpponent` wipe filter — its player target + // slot is left unbound, as it is at cast-commit. + let opponent_wipe = + || TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::TargetOpponent)); + + // Mixed "deal 1 damage to target creature + destroy all creatures target + // opponent controls". + let mixed = scenario + .add_spell_to_hand(P0, "Targeted Cataclysm", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .with_ability(Effect::DestroyAll { + target: opponent_wipe(), + cant_regenerate: false, + }) + .id(); + + // Reference: the SAME TargetOpponent wipe alone — the honest + // baseline (same wipe, no damage half). + let reference = scenario + .add_spell_to_hand(P0, "Pure Player Wipe", true) + .with_ability(Effect::DestroyAll { + target: opponent_wipe(), + cant_regenerate: false, + }) + .id(); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + + // Reach-guard: BOTH must be offered as CastSpell. + let mixed_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == mixed)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!("mixed spell {mixed:?} must be offered as CastSpell, got {scored:?}") + }); + let ref_score = scored + .iter() + .find(|(a, _)| { + matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == reference) + }) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!( + "reference target-player wipe {reference:?} must be offered as CastSpell, \ + got {scored:?}" + ) + }); + + assert!( + mixed_score > ref_score - config.policy_penalties.wasted_cast_penalty.abs() * 0.5, + "mixed deal-1 + target-opponent wipe ({mixed_score:.3}) must not be penalized below \ + the target-player wipe baseline ({ref_score:.3}): the wipe's population is UNKNOWN \ + (companion player unbound at cast-commit, CR 109.4 / CR 115.1), so it must fail open \ + (CR 115.10a non-targeted; CR 701.8) and rescue the non-lethal damage half" + ); +} + +/// Production-pipeline differential pinning the player-relative-wipe fix for +/// the **tactical-gate** thread on a board whose only opposing creature is +/// HEXPROOF. Pre-fix, an unbound player-relative wipe read as an EMPTY +/// population, so `is_redundant_creature_only_removal` (consulting +/// `has_opposing_mass_population`) saw no useful wipe and HARD-REJECTED the +/// mixed spell — only `PassPriority` was offered, so the M reach-guard below +/// panics. Post-fix the seam is UNKNOWN → not redundant → M is offered. +/// +/// * The hexproof 3/3 (CR 702.11b) is an illegal TARGET for the damage half, +/// but the wipe's `TargetOpponent` population is NON-targeted (CR 115.10a) +/// and UNKNOWABLE at cast-commit (companion player unbound, CR 109.4 / +/// CR 115.1; resolved later via `FilterContext::from_ability`, CR 601.2c). +/// * The AI's own 2/1 gives the damage half a legal ANNOUNCE target so the +/// pending spell is valid (CR 601.2c). +/// * R is the same TargetOpponent wipe alone — the honest baseline: both M and +/// R carry the unknown-population wipe, so M must score ≈ R (half-penalty +/// margin), with the M-offered reach-guard as the DISCRIMINATING assert. +#[test] +fn target_opponent_wipe_offered_when_only_population_is_hexproof() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // The AI's 2/1 resolves the ObjectCount amount to 1 and gives the damage + // half a legal announce target; the opponent's only creature is hexproof. + scenario.add_creature(P0, "My Bear", 2, 1); + scenario + .add_creature(P1, "Hexproof Bear", 3, 3) + .with_keyword(Keyword::Hexproof); + + let mut my_filter = TypedFilter::creature(); + my_filter.controller = Some(ControllerRef::You); + let amount = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(my_filter), + }, + }; + + let opponent_wipe = + || TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::TargetOpponent)); + + // Mixed "deal 1 damage to target creature + destroy all creatures target + // opponent controls" (companion player unbound). + let mixed = scenario + .add_spell_to_hand(P0, "Targeted Hexproof Cataclysm", true) + .with_ability(Effect::DealDamage { + amount: amount.clone(), + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }) + .with_ability(Effect::DestroyAll { + target: opponent_wipe(), + cant_regenerate: false, + }) + .id(); + + // Reference: the SAME TargetOpponent wipe alone. + let reference = scenario + .add_spell_to_hand(P0, "Pure Player Wipe Hexproof", true) + .with_ability(Effect::DestroyAll { + target: opponent_wipe(), + cant_regenerate: false, + }) + .id(); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = phase_ai::score_candidates(runner.state(), P0, &config); + + // DISCRIMINATING reach-guard: M must be offered. Pre-fix + // `is_redundant_creature_only_removal` hard-rejected M (empty population + // read → not a useful wipe) so only PassPriority was offered — this panics. + let mixed_score = scored + .iter() + .find(|(a, _)| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == mixed)) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!( + "mixed spell {mixed:?} must be offered as CastSpell (the TargetOpponent wipe \ + must be UNKNOWN, not empty, so the tactical gate must not hard-reject), \ + got {scored:?}" + ) + }); + let ref_score = scored + .iter() + .find(|(a, _)| { + matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == reference) + }) + .map(|(_, s)| *s) + .unwrap_or_else(|| { + panic!( + "reference pure wipe {reference:?} must be offered as CastSpell, got {scored:?}" + ) + }); + + assert!( + mixed_score > ref_score - config.policy_penalties.wasted_cast_penalty.abs() * 0.5, + "mixed deal-1 + target-opponent wipe on a hexproof-only board ({mixed_score:.3}) must \ + not be penalized below the pure wipe ({ref_score:.3}): the TargetOpponent population \ + is UNKNOWN at cast-commit (CR 109.4 / CR 115.1) and the wipe is NON-targeted \ + (CR 115.10a), so the seam must rescue the spell from both the tactical gate and the \ + whiff penalty" + ); +} + // ── Full Game Completion ───────────────────────────────────────────────── #[test]