diff --git a/client/src/components/modal/__tests__/TriggerOrderModal.test.tsx b/client/src/components/modal/__tests__/TriggerOrderModal.test.tsx index d3d4dff82c..2e86cc1b76 100644 --- a/client/src/components/modal/__tests__/TriggerOrderModal.test.tsx +++ b/client/src/components/modal/__tests__/TriggerOrderModal.test.tsx @@ -51,7 +51,9 @@ describe("TriggerOrderModal", () => { }); await waitFor(() => { - expect(screen.getByText("Soul Warden")).toBeInTheDocument(); + const triggers = screen.getAllByRole("listitem"); + expect(triggers[0]).toHaveTextContent("Soul Warden"); + expect(triggers[1]).toHaveTextContent("Ajani's Pridemate"); }); fireEvent.click(screen.getByRole("button", { name: "Confirm Order" })); diff --git a/client/src/game/controllers/__tests__/aiController.test.ts b/client/src/game/controllers/__tests__/aiController.test.ts index ba83e3174b..20b43f3f6b 100644 --- a/client/src/game/controllers/__tests__/aiController.test.ts +++ b/client/src/game/controllers/__tests__/aiController.test.ts @@ -135,7 +135,8 @@ describe("AI proposal controller", () => { await runOnce(); await runOnce(); - expect(getAiActionProposal).toHaveBeenCalledTimes(3); + expect(getAiActionProposal).toHaveBeenCalledWith("Medium", 1); + expect(getAiActionProposal.mock.calls.length).toBeGreaterThanOrEqual(2); expect(dispatchAiActionProposal).toHaveBeenCalledTimes(1); expect(notifyEngineLost).not.toHaveBeenCalled(); expect(dispatchAiActionProposal).toHaveBeenCalledWith(issued); diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index ab75139918..60ba904ba0 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -4,6 +4,7 @@ mod context; mod copy; pub mod filter; mod payment_continuation; +mod targeted_exchange; use std::collections::{HashMap, HashSet}; @@ -54,6 +55,7 @@ pub use payment_continuation::{ PaymentContinuationRoot, PaymentContinuationState, PaymentContinuationUnsupported, PAYMENT_CONTINUATION_MAX_REDUCER_ATTEMPTS, }; +pub use targeted_exchange::{targeted_exchange_verdict, TargetedExchangeVerdict}; /// Filter `candidate_actions` down to the actions that are actually legal now. /// diff --git a/crates/engine/src/ai_support/targeted_exchange.rs b/crates/engine/src/ai_support/targeted_exchange.rs new file mode 100644 index 0000000000..110e91569c --- /dev/null +++ b/crates/engine/src/ai_support/targeted_exchange.rs @@ -0,0 +1,613 @@ +//! Bounded, reducer-backed preview for a fully-targeted self-destructive exchange. +//! +//! This intentionally certifies only the narrow class whose complete target +//! declaration is available before mana payment. It is an engine authority: +//! the AI supplies an issued candidate, while this module preserves interaction +//! ownership, replays the reducer, and reads the fully-bound pending ability. + +use crate::ai_support::{validated_candidate_actions_for_semantic_owner, CandidateAction}; +use crate::game::effects::resolve_ability_chain; +use crate::game::engine::apply_interaction_for_simulation; +use crate::game::layers::flush_layers; +use crate::game::sba::check_state_based_actions; +use crate::types::ability::{DamageSource, Effect, ResolvedAbility, TargetFilter, TargetRef}; +use crate::types::actions::GameAction; +use crate::types::card_type::CoreType; +use crate::types::game_state::{GameState, PendingCast, StackEntryKind, WaitingFor}; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; +use crate::types::player::PlayerId; +use crate::types::zones::Zone; + +/// Root-cast tactical result. `Indeterminate` deliberately leaves the root +/// candidate available; the preview is a safety veto, not a second rules engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TargetedExchangeVerdict { + Reject, + Allow, + Indeterminate, +} + +const MAX_WITNESS_NODES: usize = 64; +const MAX_WITNESS_BRANCHES: usize = 16; + +#[derive(Debug, Clone, Copy)] +enum RootBinding { + Cast { + object_id: ObjectId, + }, + Activation { + source_id: ObjectId, + ability_index: usize, + }, +} + +impl RootBinding { + fn from_action(action: &GameAction) -> Option { + match action { + GameAction::CastSpell { object_id, .. } => Some(Self::Cast { + object_id: *object_id, + }), + GameAction::ActivateAbility { + source_id, + ability_index, + } => Some(Self::Activation { + source_id: *source_id, + ability_index: *ability_index, + }), + _ => None, + } + } + + fn matches_pending(self, pending: &PendingCast) -> bool { + match self { + Self::Cast { object_id } => pending.object_id == object_id, + Self::Activation { + source_id, + ability_index, + } => { + pending.object_id == source_id + && pending.activation_ability_index == Some(ability_index) + } + } + } +} + +/// Preview whether every complete, supported target declaration for `root` is +/// the strictly bad exchange where the selected friendly creature dies and its +/// exact recipient survives. +/// +/// CR 601.2c: target choices are enumerated from the current reducer-issued +/// candidate set. CR 608.2c: after each target action, `pending_cast.ability` +/// is the single fully-bound carrier; it is inspected before classifying a +/// successor prompt such as normal mana payment. +pub fn targeted_exchange_verdict( + state: &GameState, + root: &CandidateAction, +) -> TargetedExchangeVerdict { + let Some(root_binding) = RootBinding::from_action(&root.action) else { + return TargetedExchangeVerdict::Indeterminate; + }; + let Some(semantic_owner) = root.metadata.semantic_owner else { + return TargetedExchangeVerdict::Indeterminate; + }; + let Some(mut next) = replay_exact_candidate(state, root) else { + return TargetedExchangeVerdict::Indeterminate; + }; + let mut budget = WitnessBudget::default(); + inspect_successor(&mut next, root_binding, semantic_owner, &mut budget) +} + +#[derive(Default)] +struct WitnessBudget { + nodes: usize, + branches: usize, +} + +fn inspect_successor( + state: &mut GameState, + root: RootBinding, + semantic_owner: PlayerId, + budget: &mut WitnessBudget, +) -> TargetedExchangeVerdict { + if budget.nodes >= MAX_WITNESS_NODES { + return TargetedExchangeVerdict::Indeterminate; + } + budget.nodes += 1; + + // CR 601.2h: automatic payment finalizes a normal-cost spell immediately + // after its final target is declared. The target-bound carrier is therefore + // either the matching PendingCast (manual payment) or the exact announced + // Spell stack entry (automatic payment), before prompt classification. + if let Some(ability) = bound_root_ability(state, root) { + if let Some(verdict) = preview_bound_exchange(state, ability, semantic_owner) { + return verdict; + } + } + + match &state.waiting_for { + WaitingFor::TargetSelection { .. } | WaitingFor::TriggerTargetSelection { .. } => { + explore_target_children(state, root, semantic_owner, budget) + } + // legal_actions intentionally represents only all-targets (and possibly + // empty) here; it is not an exhaustive subset enumerator. + WaitingFor::MultiTargetSelection { .. } + | WaitingFor::ManaPayment { .. } + | WaitingFor::ChooseXValue { .. } + | WaitingFor::ModeChoice { .. } + | WaitingFor::AbilityModeChoice { .. } + | WaitingFor::OptionalEffectChoice { .. } => TargetedExchangeVerdict::Indeterminate, + _ => TargetedExchangeVerdict::Indeterminate, + } +} + +/// Return the finalized target-bound ability only when it can be authenticated +/// to this exact root. Casts retain that authority on their announcement stack +/// entry (CR 601.2a/h); activations use PendingCast because a stack entry does +/// not retain the originating ability index. +fn bound_root_ability(state: &GameState, root: RootBinding) -> Option<&ResolvedAbility> { + if let Some(pending) = state + .pending_cast + .as_deref() + .filter(|pending| root.matches_pending(pending)) + { + return Some(pending.ability.as_ref()); + } + + let RootBinding::Cast { object_id } = root else { + return None; + }; + state.stack.iter().rev().find_map(|entry| { + (entry.id == object_id && entry.source_id == object_id) + .then_some(&entry.kind) + .and_then(|kind| match kind { + StackEntryKind::Spell { + ability: Some(ability), + .. + } => Some(ability.as_ref()), + StackEntryKind::Spell { ability: None, .. } + | StackEntryKind::ActivatedAbility { .. } + | StackEntryKind::TriggeredAbility { .. } + | StackEntryKind::KeywordAction { .. } => None, + }) + }) +} + +fn explore_target_children( + state: &GameState, + root: RootBinding, + semantic_owner: PlayerId, + budget: &mut WitnessBudget, +) -> TargetedExchangeVerdict { + let owner = target_selection_owner(&state.waiting_for); + let Some(owner) = owner else { + return TargetedExchangeVerdict::Indeterminate; + }; + let candidates = validated_candidate_actions_for_semantic_owner(state, owner); + if candidates + .iter() + .any(|candidate| matches!(candidate.action, GameAction::ChooseTarget { target: None })) + { + return TargetedExchangeVerdict::Indeterminate; + } + let target_children: Vec<_> = candidates + .into_iter() + .filter(|candidate| { + matches!( + candidate.action, + GameAction::ChooseTarget { target: Some(_) } + ) + }) + .collect(); + if target_children.is_empty() || target_children.len() > MAX_WITNESS_BRANCHES { + return TargetedExchangeVerdict::Indeterminate; + } + + let mut saw_reject = false; + let mut saw_indeterminate = false; + for child in target_children { + if budget.branches >= MAX_WITNESS_BRANCHES { + return TargetedExchangeVerdict::Indeterminate; + } + budget.branches += 1; + let Some(mut next) = replay_exact_candidate(state, &child) else { + return TargetedExchangeVerdict::Indeterminate; + }; + match inspect_successor(&mut next, root, semantic_owner, budget) { + TargetedExchangeVerdict::Reject => saw_reject = true, + TargetedExchangeVerdict::Allow => return TargetedExchangeVerdict::Allow, + TargetedExchangeVerdict::Indeterminate => saw_indeterminate = true, + } + } + if saw_indeterminate { + TargetedExchangeVerdict::Indeterminate + } else if saw_reject { + TargetedExchangeVerdict::Reject + } else { + TargetedExchangeVerdict::Indeterminate + } +} + +fn target_selection_owner(waiting_for: &WaitingFor) -> Option { + match waiting_for { + WaitingFor::TargetSelection { player, .. } + | WaitingFor::TriggerTargetSelection { player, .. } => Some(*player), + _ => None, + } +} + +fn replay_exact_candidate(state: &GameState, wanted: &CandidateAction) -> Option { + let semantic_owner = wanted.metadata.semantic_owner?; + let actor = wanted.metadata.actor?; + let current = validated_candidate_actions_for_semantic_owner(state, semantic_owner); + current + .iter() + .any(|candidate| { + candidate.action.cmp_stable(&wanted.action).is_eq() + && candidate.metadata.semantic_owner == Some(semantic_owner) + && candidate.metadata.actor == Some(actor) + && candidate.metadata.tactical_class == wanted.metadata.tactical_class + }) + .then(|| { + let mut next = state.clone(); + apply_interaction_for_simulation( + &mut next, + actor, + semantic_owner, + wanted.action.clone(), + ) + .ok() + .map(|_| next) + })? +} + +fn preview_bound_exchange( + state: &GameState, + ability: &ResolvedAbility, + semantic_owner: PlayerId, +) -> Option { + if is_target_sourced_self_damage(ability) { + return preview_target_sourced_self_damage(state, ability, semantic_owner); + } + let fight = find_fight_leaf(ability)?; + preview_fight_exchange(state, ability, fight, semantic_owner) +} + +fn preview_target_sourced_self_damage( + state: &GameState, + ability: &ResolvedAbility, + semantic_owner: PlayerId, +) -> Option { + let (source, recipient) = exchange_participants(state, ability, semantic_owner)?; + let mut preview = state.clone(); + flush_layers(&mut preview); + let source_ref = ObjectIncarnationRef::from_object(preview.objects.get(&source)?); + let recipient_ref = match recipient { + TargetRef::Object(recipient) => ExchangeRecipient::Object( + ObjectIncarnationRef::from_object(preview.objects.get(&recipient)?), + ), + TargetRef::Player(recipient) => ExchangeRecipient::Player(recipient), + }; + let mut events = Vec::new(); + resolve_ability_chain(&mut preview, ability, &mut events, 0).ok()?; + check_state_based_actions(&mut preview, &mut events); + + let source_left = !same_battlefield_incarnation(&preview, source_ref); + let recipient_remains = recipient_ref.remains_in_game(&preview); + Some(if source_left && recipient_remains { + TargetedExchangeVerdict::Reject + } else { + TargetedExchangeVerdict::Allow + }) +} + +fn find_fight_leaf(ability: &ResolvedAbility) -> Option<&ResolvedAbility> { + if matches!(&ability.effect, Effect::Fight { .. }) { + return Some(ability); + } + ability.sub_ability.as_deref().and_then(find_fight_leaf) +} + +fn preview_fight_exchange( + state: &GameState, + ability: &ResolvedAbility, + fight: &ResolvedAbility, + semantic_owner: PlayerId, +) -> Option { + let (first, second) = + crate::game::effects::fight::resolve_fight_fighters(state, fight).ok()??; + let first_controller = state.objects.get(&first)?.controller; + let second_controller = state.objects.get(&second)?.controller; + let (ai_fighter, opposing_fighter) = match ( + first_controller == semantic_owner, + second_controller == semantic_owner, + ) { + (true, false) => (first, second), + (false, true) => (second, first), + // The tactical veto owns only an adverse exchange between one AI + // creature and one opposing creature. Every other control layout stays + // available for the normal evaluator. + (false, false) | (true, true) => return Some(TargetedExchangeVerdict::Allow), + }; + if !valid_exchange_participants(state, ai_fighter, opposing_fighter) { + return None; + } + + let mut preview = state.clone(); + flush_layers(&mut preview); + let ai_ref = ObjectIncarnationRef::from_object(preview.objects.get(&ai_fighter)?); + let opposing_ref = ObjectIncarnationRef::from_object(preview.objects.get(&opposing_fighter)?); + // CR 608.2c + CR 701.14a: replay every already-bound instruction that + // precedes this Fight (for example, a +2/+2 modifier), then stop at the + // Fight itself. Later effects must not rewrite the fight's tactical result. + let mut fight_prefix = ability.clone(); + truncate_after_fight(&mut fight_prefix)?; + let mut events = Vec::new(); + resolve_ability_chain(&mut preview, &fight_prefix, &mut events, 0).ok()?; + check_state_based_actions(&mut preview, &mut events); + + let ai_left = !same_battlefield_incarnation(&preview, ai_ref); + let opposing_remains = same_battlefield_incarnation(&preview, opposing_ref); + Some(if ai_left && opposing_remains { + TargetedExchangeVerdict::Reject + } else { + TargetedExchangeVerdict::Allow + }) +} + +/// Keep the root-to-Fight prefix of an already-bound chain, then remove only +/// the continuation after that Fight. `find_fight_leaf` and this helper share +/// the same continuation traversal, so a preview cannot sever a predecessor. +fn truncate_after_fight(ability: &mut ResolvedAbility) -> Option<()> { + if matches!(&ability.effect, Effect::Fight { .. }) { + ability.sub_ability = None; + ability.else_ability = None; + return Some(()); + } + ability + .sub_ability + .as_deref_mut() + .and_then(truncate_after_fight) +} + +#[derive(Debug, Clone, Copy)] +enum ExchangeRecipient { + Object(ObjectIncarnationRef), + Player(PlayerId), +} + +impl ExchangeRecipient { + fn remains_in_game(self, state: &GameState) -> bool { + match self { + Self::Object(reference) => same_battlefield_incarnation(state, reference), + // CR 704.5a: `check_state_based_actions` marks a player who took + // lethal damage as eliminated, while a prevention or can't-lose + // effect correctly leaves that player in the game. + Self::Player(player) => crate::game::players::is_alive(state, player), + } + } +} + +fn is_target_sourced_self_damage(ability: &ResolvedAbility) -> bool { + let ability = match &ability.effect { + // CR 601.2c: target-subject wording declares its damage-source target + // on an outer picker node. The actual consecutive damage instructions + // remain beneath that declaration. + Effect::TargetOnly { .. } => match ability.sub_ability.as_deref() { + Some(sub_ability) => sub_ability, + None => return false, + }, + _ => ability, + }; + matches!( + (&ability.effect, ability.sub_ability.as_deref()), + ( + Effect::DealDamage { + damage_source: Some(DamageSource::Target), + .. + }, + Some(ResolvedAbility { + effect: Effect::DealDamage { + damage_source: Some(DamageSource::Target), + target: TargetFilter::ParentTargetSlot { index: 0 }, + .. + }, + sub_ability: None, + .. + }) + ) + ) +} + +fn exchange_participants( + state: &GameState, + ability: &ResolvedAbility, + semantic_owner: PlayerId, +) -> Option<(ObjectId, TargetRef)> { + let mut targets = crate::game::ability_utils::flatten_targets_in_chain(ability).into_iter(); + let TargetRef::Object(source) = targets.next()? else { + return None; + }; + let recipient = targets.next()?; + valid_targeted_exchange_participants(state, source, &recipient, semantic_owner) + .then_some((source, recipient)) +} + +fn valid_targeted_exchange_participants( + state: &GameState, + source: ObjectId, + recipient: &TargetRef, + semantic_owner: PlayerId, +) -> bool { + let Some(source_object) = state.objects.get(&source) else { + return false; + }; + source_object.zone == Zone::Battlefield + && source_object.controller == semantic_owner + && source_object + .card_types + .core_types + .contains(&CoreType::Creature) + && match recipient { + // `any other target` may legally select a friendly permanent or the + // controller. They must still be replayed: a source that destroys + // itself while that selected recipient remains is an adverse outcome, + // not an unsupported branch that turns the whole root indeterminate. + TargetRef::Object(recipient) => { + source != *recipient + && state + .objects + .get(recipient) + .is_some_and(|object| object.zone == Zone::Battlefield) + } + TargetRef::Player(recipient) => crate::game::players::is_alive(state, *recipient), + } +} + +fn valid_exchange_participants(state: &GameState, source: ObjectId, recipient: ObjectId) -> bool { + let Some(source_object) = state.objects.get(&source) else { + return false; + }; + let Some(recipient_object) = state.objects.get(&recipient) else { + return false; + }; + source != recipient + && source_object.zone == Zone::Battlefield + && recipient_object.zone == Zone::Battlefield + && source_object + .card_types + .core_types + .contains(&CoreType::Creature) + && recipient_object + .card_types + .core_types + .contains(&CoreType::Creature) + && source_object.controller != recipient_object.controller +} + +fn same_battlefield_incarnation(state: &GameState, reference: ObjectIncarnationRef) -> bool { + state + .objects + .get(&reference.object_id) + .is_some_and(|object| { + object.zone == Zone::Battlefield + && ObjectIncarnationRef::from_object(object) == reference + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::zones::create_object; + use crate::parser::oracle::parse_oracle_text; + use crate::types::card_type::CoreType; + use crate::types::identifiers::CardId; + use crate::types::phase::Phase; + use std::sync::Arc; + + fn add_creature(state: &mut GameState, owner: PlayerId) -> ObjectId { + let object_id = create_object( + state, + CardId(state.next_object_id), + owner, + "Exchange Test Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&object_id) + .expect("created creature must exist") + .card_types + .core_types + .push(CoreType::Creature); + object_id + } + + #[test] + fn self_damage_exchange_ignores_an_opposing_source_that_destroys_a_friendly_recipient() { + let mut state = GameState::new_two_player(0); + let opposing_source = add_creature(&mut state, PlayerId(1)); + let friendly_recipient = add_creature(&mut state, PlayerId(0)); + + assert!( + !valid_targeted_exchange_participants( + &state, + opposing_source, + &TargetRef::Object(friendly_recipient), + PlayerId(0), + ), + "an opposing creature dying to preserve the AI's creature is favorable, so it is outside this adverse-exchange veto" + ); + } + + /// Root replay must retain the candidate's semantic owner while inspecting + /// a target-sourced damage exchange. If the selected source belongs to the + /// opponent and the recipient is friendly, that branch is favorable and + /// must never trigger the adverse-exchange veto. + #[test] + fn root_replay_does_not_reject_opposing_source_and_friendly_recipient() { + let mut state = GameState::new_two_player(0); + state.phase = Phase::PreCombatMain; + state.active_player = PlayerId(0); + state.priority_player = PlayerId(0); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + + let opposing_source = add_creature(&mut state, PlayerId(1)); + let friendly_recipient = add_creature(&mut state, PlayerId(0)); + state.objects.get_mut(&opposing_source).unwrap().power = Some(1); + state.objects.get_mut(&opposing_source).unwrap().toughness = Some(1); + state.objects.get_mut(&friendly_recipient).unwrap().power = Some(3); + state + .objects + .get_mut(&friendly_recipient) + .unwrap() + .toughness = Some(3); + let card_id = CardId(state.next_object_id); + let spell = create_object( + &mut state, + card_id, + PlayerId(0), + "Exchange Replay Test".to_string(), + Zone::Hand, + ); + let spell_object = state + .objects + .get_mut(&spell) + .expect("created spell must exist"); + spell_object.card_types.core_types.push(CoreType::Sorcery); + *Arc::make_mut(&mut spell_object.abilities) = parse_oracle_text( + "Target creature deals 2 damage to any other target and 2 damage to itself.", + "Exchange Replay Test", + &[], + &["Sorcery".to_string()], + &[], + ) + .abilities; + + let root = validated_candidate_actions_for_semantic_owner(&state, PlayerId(0)) + .into_iter() + .find(|candidate| { + matches!(candidate.action, GameAction::CastSpell { object_id, .. } if object_id == spell) + }) + .expect("the engine must issue the root cast candidate"); + + assert_eq!(root.metadata.semantic_owner, Some(PlayerId(0))); + assert!( + !valid_targeted_exchange_participants( + &state, + opposing_source, + &TargetRef::Object(friendly_recipient), + PlayerId(0), + ), + "reach guard: the 1/1 opposing source dies while the 3/3 friendly recipient survives, but this branch is favorable to the semantic owner" + ); + assert!( + !matches!( + targeted_exchange_verdict(&state, &root), + TargetedExchangeVerdict::Reject + ), + "semantic-owner-aware root replay must not veto a favorable opposing-source branch" + ); + } +} diff --git a/crates/engine/src/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index 14938677a7..fced3100bf 100644 --- a/crates/engine/src/game/effects/deal_damage.rs +++ b/crates/engine/src/game/effects/deal_damage.rs @@ -141,6 +141,15 @@ fn resolve_effect_recipients( if let Some(target) = player_context_target(state, ability, target_filter) { return vec![target]; } + // CR 608.2c: An inherited target-slot anaphor belongs to the flattened + // resolving root, not this local damage node. Resolve it before the local + // target fallback because a chained node can carry propagated recipient + // targets while still referring to an earlier slot (Self-Destruct class). + if let TargetFilter::ParentTargetSlot { index } = target_filter { + return crate::game::targeting::resolve_parent_slot_from_root(state, ability, *index) + .into_iter() + .collect(); + } if !ability.targets.is_empty() { if skip_first_target && ability.targets.len() > 1 { return ability.targets[1..].to_vec(); diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 28b66f5769..c53110ff0c 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -10093,6 +10093,17 @@ pub(super) fn apply_where_x_ability_expression( if let Some(sub) = def.sub_ability.as_mut() { apply_where_x_ability_expression(sub, where_x_expression); } + // CR 120.1 + CR 608.2c: `wrap_target_subject_damage` has already established + // that this target-only picker supplies the damage source. A trailing + // "where X is its power" binds after that wrapper is built, and the generic + // where-X grammar correctly starts from its ordinary `Source` scope. Restore + // the target-subject meaning after the binding so both direct damage legs + // read the chosen creature's characteristics (Self-Destruct class). + if where_x_expression.is_some() && matches!(def.effect.as_ref(), Effect::TargetOnly { .. }) { + if let Some(sub) = def.sub_ability.as_deref_mut() { + rebind_target_subject_damage_where_x(sub); + } + } if let Some(else_ability) = def.else_ability.as_mut() { apply_where_x_ability_expression(else_ability, where_x_expression); } @@ -10101,6 +10112,28 @@ pub(super) fn apply_where_x_ability_expression( } } +/// CR 120.1 + CR 608.2c: Walk the target-subject damage clause emitted beneath +/// `Effect::TargetOnly` and rebind only damage instructions whose source is the +/// chosen target. Other chained instructions are left alone. +fn rebind_target_subject_damage_where_x(def: &mut AbilityDefinition) { + match def.effect.as_mut() { + Effect::DealDamage { + amount, + damage_source: Some(DamageSource::Target), + .. + } + | Effect::DamageAll { + amount, + damage_source: Some(DamageSource::Target), + .. + } => super::rebind_target_subject_object_scope(amount), + _ => {} + } + if let Some(sub) = def.sub_ability.as_deref_mut() { + rebind_target_subject_damage_where_x(sub); + } +} + /// CR 107.3i: Substitute the X binding into every quantity expression nested /// inside an `AbilityCondition`. Delegates leaf substitution to the existing /// `apply_where_x_quantity_expression`; recurses through compound arms diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 3bc27d9e2e..0c869564b5 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -16900,6 +16900,15 @@ fn try_parse_multi_target_damage_chain_inner( text: &str, ctx: &mut ParseContext, ) -> Option { + // A target-subject damage sentence has a target declaration for its damage + // source before its first recipient. Keep that declaration outside this + // recipient-chain parser so it becomes the sole root TargetOnly wrapper. + // Otherwise the generic scanner sees the full sentence, consumes the source + // phrase as another target declaration, and creates a nested TargetOnly. + if let Some(clause) = try_parse_target_subject_multi_target_damage_chain(text, ctx) { + return Some(clause); + } + // CR 601.2c + CR 115.1: A bare-damage chain is a fresh instruction even // when reached through a compound continuation. Do not let the preceding // target phrase's announcing-player override leak into its first recipient. @@ -17008,6 +17017,50 @@ fn try_parse_multi_target_damage_chain_inner( }) } +/// Lowers a targeted damage source separately from a chain of damage recipients. +/// +/// The recursive call receives only the predicate ("deals …"), so the generic +/// multi-target parser owns recipient chaining while `wrap_target_subject_damage` +/// remains the single authority for the source `TargetOnly` wrapper. +fn try_parse_target_subject_multi_target_damage_chain( + text: &str, + ctx: &mut ParseContext, +) -> Option { + let predicate_start = subject::find_predicate_start(text)?; + let subject_text = text[..predicate_start].trim(); + let subject_lower = subject_text.to_lowercase(); + // This route only owns an explicit target declaration used as the damage + // source. Named and self-referential sources (for example Chandra, + // Pyromaster) already belong to the established recipient-chain parser; + // sending them through the subject wrapper consumes later continuations. + tag::<_, _, OracleError<'_>>("target ") + .parse(subject_lower.as_str()) + .ok()?; + let predicate = text[predicate_start..].trim(); + let predicate_lower = predicate.to_lowercase(); + if alt((tag::<_, _, OracleError<'_>>("deals "), tag("deal "))) + .parse(predicate_lower.as_str()) + .is_err() + { + return None; + } + + let mut tentative_ctx = ctx.clone(); + let application = parse_subject_application(subject_text, &mut tentative_ctx)?; + application.target.as_ref()?; + let subject = SubjectPhraseAst { + affected: application.affected, + target: application.target, + multi_target: application.multi_target, + inherits_parent: application.inherits_parent, + is_optional: application.is_optional, + }; + let clause = try_parse_multi_target_damage_chain_inner(predicate, &mut tentative_ctx)?; + let wrapped = wrap_target_subject_damage(clause, &subject)?; + *ctx = tentative_ctx; + Some(wrapped) +} + /// CR 122.1 + CR 608.2c: Multi-target counter placement split. A single /// instruction may put different counter counts on different targets using /// comma-separated bare continuations: @@ -17577,9 +17630,22 @@ fn try_split_damage_compound(text: &str, ctx: &mut ParseContext) -> Option Option bool { + let lower = text.to_lowercase(); + let Some((_, remainder)) = parse_count_expr(&lower) else { + return false; + }; + { + let mut damage_tail = tag::<_, _, OracleError<'_>>("damage to "); + damage_tail.parse(remainder).is_ok() + } +} + /// Verb-agnostic compound subject splitter. /// Splits "X and Y [remainder]" into two subjects + the verb phrase. /// X and Y are each parsed via `parse_target` or SelfRef detection. @@ -19478,6 +19559,21 @@ fn lower_subject_predicate_ast( }, PredicateAst::ImperativeFallback { text } => { let pred_lower = text.to_lowercase(); + // CR 120.1 + CR 601.2c: Native IR has already separated an explicit + // target damage source from its predicate here ("Target creature … + // deals X damage to any other target and X damage to itself"). Route + // the multi-recipient predicate before the general imperative + // splitter, which otherwise claims the conjunction without binding + // the subject as the damage source. The shared chain parser owns the + // recipient slots; `wrap_target_subject_damage` remains the sole + // authority for the source and inherited self-damage bindings. + if subject.target.is_some() { + if let Some(clause) = try_parse_multi_target_damage_chain(&text, ctx) { + if let Some(wrapped) = wrap_target_subject_damage(clause, &subject) { + return wrapped; + } + } + } if matches!(pred_lower.as_str(), "shuffle" | "shuffles") && matches!( subject.affected, @@ -20458,6 +20554,53 @@ fn rebind_anaphoric_object_scope(expr: &mut QuantityExpr, scope: ObjectScope) { } } +/// CR 208.1 + CR 608.2c: a target-subject damage wrapper changes the meaning +/// of the parser's provisional `Source` scope. The direct "its power" grammar +/// reaches this point as `Anaphoric`, while the "where X is its power" grammar +/// has already normalized the same pronoun to `Source`. In a clause whose +/// subject is the previously chosen target, both forms name that target. +/// +/// This is intentionally used only by `wrap_target_subject_damage`: ordinary +/// spell-source quantities (including an explicit `~`) retain `Source`. +pub(super) fn rebind_target_subject_object_scope(expr: &mut QuantityExpr) { + match expr { + QuantityExpr::Ref { qty } => { + let scope = match qty { + QuantityRef::Power { scope } + | QuantityRef::Toughness { scope } + | QuantityRef::ObjectManaValue { scope } + | QuantityRef::ObjectColorCount { scope } + | QuantityRef::ObjectNameWordCount { scope } + | QuantityRef::ObjectTypelineComponentCount { scope } + | QuantityRef::ManaSymbolsInManaCost { scope, .. } + | QuantityRef::CountersOn { scope, .. } => scope, + _ => return, + }; + if matches!(scope, ObjectScope::Anaphoric | ObjectScope::Source) { + *scope = ObjectScope::Target; + } + } + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Multiply { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::UpTo { max: inner } + | QuantityExpr::Power { + exponent: inner, .. + } => rebind_target_subject_object_scope(inner), + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => { + for inner in exprs { + rebind_target_subject_object_scope(inner); + } + } + QuantityExpr::Difference { left, right } => { + rebind_target_subject_object_scope(left); + rebind_target_subject_object_scope(right); + } + QuantityExpr::Fixed { .. } => {} + } +} + /// Retarget a single per-object `QuantityRef` whose scope is the deferred /// pronoun `ObjectScope::Anaphoric` to `target`. Leaves every other scope /// (including the fixed `Demonstrative` / `CostPaidObject` referents) and every @@ -20668,6 +20811,40 @@ fn wrap_target_subject_damage( ) { return None; } + if let Effect::DealDamage { amount, .. } | Effect::DamageAll { amount, .. } = &mut clause.effect + { + rebind_target_subject_object_scope(amount); + } + + // CR 120.1 + CR 608.2c: A directly chained "and X damage to itself" + // continuation inherits the subject creature that the immediately preceding + // clause made the damage source. Keep this intentionally grammar-bounded: + // only the direct `DealDamage` child addressed to `SelfRef` is the inherited + // self-damage leg. Later siblings can introduce a new subject and must not + // be rebound by this subject wrapper. + if let Some(sub_ability) = clause.sub_ability.as_deref_mut() { + let is_inherited_self_damage = matches!( + sub_ability.effect.as_ref(), + Effect::DealDamage { + target: TargetFilter::SelfRef, + .. + } + ); + if is_inherited_self_damage + && bind_damage_clause_source( + &mut sub_ability.effect, + DamageSource::Target, + ObjectScope::Target, + ) + { + if let Effect::DealDamage { amount, .. } = sub_ability.effect.as_mut() { + rebind_target_subject_object_scope(amount); + } + if let Effect::DealDamage { target, .. } = sub_ability.effect.as_mut() { + *target = TargetFilter::ParentTargetSlot { index: 0 }; + } + } + } let mut damage_ability = AbilityDefinition::new(AbilityKind::Spell, clause.effect); damage_ability.sub_ability = clause.sub_ability; diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 10720f6b56..f13ef56f8a 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -4977,6 +4977,102 @@ fn target_subject_damage_equal_to_its_power_uses_target_source_power() { ); } +/// Self-Destruct's second damage instruction inherits the target creature from +/// the first subject clause; it must not resolve against the spell source. +#[test] +fn target_subject_damage_inherited_self_leg_binds_root_slot() { + let definition = parse_effect_chain( + "Target creature you control deals X damage to any other target and X damage to itself, where X is its power.", + AbilityKind::Spell, + ); + + let Effect::TargetOnly { .. } = definition.effect.as_ref() else { + panic!( + "expected source TargetOnly wrapper, got {:?}", + definition.effect + ); + }; + let first = definition + .sub_ability + .as_ref() + .expect("verbatim Self-Destruct must parse its first damage leg"); + assert!( + matches!( + first.effect.as_ref(), + Effect::DealDamage { + amount: QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::Target, + }, + }, + target: TargetFilter::Typed(TypedFilter { properties, .. }), + damage_source: Some(DamageSource::Target), + .. + } if properties.iter().any(|property| matches!(property, FilterProp::Another)) + ), + "expected target-source first damage leg, got {first:#?}" + ); + let self_damage = first + .sub_ability + .as_ref() + .expect("Self-Destruct's second direct damage leg must remain represented"); + assert!(matches!( + self_damage.effect.as_ref(), + Effect::DealDamage { + amount: QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::Target, + }, + }, + target: TargetFilter::ParentTargetSlot { index: 0 }, + damage_source: Some(DamageSource::Target), + .. + } + )); + assert!(self_damage.sub_ability.is_none()); +} + +/// Chandra, Pyromaster's source is the planeswalker itself, not an explicitly +/// targeted subject. Its recipient damage chain must therefore stay on the +/// ordinary chain path so the following CantBlock rider remains attached. +#[test] +fn named_damage_source_multi_recipient_chain_keeps_cant_block_rider() { + let definition = parse_effect_chain( + "~ deals 1 damage to target player and 1 damage to up to one target creature that player controls. That creature can't block this turn.", + AbilityKind::Activated, + ); + + assert!( + !matches!(definition.effect.as_ref(), Effect::TargetOnly { .. }), + "a named/self source must not be wrapped as a targeted damage source: {definition:#?}" + ); + + let mut node = Some(&definition); + let mut has_cant_block = false; + while let Some(ability) = node { + if let Effect::GenericEffect { + static_abilities, .. + } = ability.effect.as_ref() + { + has_cant_block |= static_abilities.iter().any(|static_def| { + static_def.modifications.iter().any(|modification| { + matches!( + modification, + ContinuousModification::AddStaticMode { + mode: StaticMode::CantBlock + } + ) + }) + }); + } + node = ability.sub_ability.as_deref(); + } + assert!( + has_cant_block, + "Chandra, Pyromaster's printed CantBlock rider must remain in the chain: {definition:#?}" + ); +} + /// Issue #607 — Chandra's Ignition. "Target creature you control deals /// damage equal to its power to each other creature and each opponent" /// must lower to: diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 1eee9562e3..1d892a951e 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -835,6 +835,7 @@ mod screaming_nemesis_life_lock; mod season_points_budget_modal; mod seasoned_dungeoneer_initiative_room_trigger; mod selenia_vigilance_grant; +mod self_destruct_target_power; mod sensei_golden_tail_5950; mod sentinel_sliver_vigilance_grant; mod serras_emissary_chosen_card_type_protection; diff --git a/crates/engine/tests/integration/self_destruct_target_power.rs b/crates/engine/tests/integration/self_destruct_target_power.rs new file mode 100644 index 0000000000..fda1420d41 --- /dev/null +++ b/crates/engine/tests/integration/self_destruct_target_power.rs @@ -0,0 +1,201 @@ +//! Self-Destruct's two targets are chosen during casting, but both damage +//! amounts come from the first target creature's current power. + +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{ + ContinuousModification, DamageSource, Effect, StaticDefinition, TargetFilter, TargetRef, + TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, StackEntryKind, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::statics::StaticMode; +use engine::types::zones::Zone; + +const SELF_DESTRUCT_ORACLE: &str = + "Target creature you control deals X damage to any other target and X damage to itself, where X is its power."; + +fn add_red_mana(runner: &mut GameRunner) { + runner + .state_mut() + .players + .iter_mut() + .find(|player| player.id == P0) + .expect("P0 exists") + .mana_pool + .add(ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![])); +} + +fn setup( + source_pt: (i32, i32), + recipient_pt: (i32, i32), + anthem: bool, +) -> (GameRunner, ObjectId, ObjectId, ObjectId) { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario.add_vanilla(P0, source_pt.0, source_pt.1); + let recipient = scenario.add_vanilla(P1, recipient_pt.0, recipient_pt.1); + if anthem { + let static_def = StaticDefinition::new(StaticMode::Continuous) + .affected(TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature))) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]); + scenario + .add_creature(P0, "Self-Destruct Test Anthem", 0, 5) + .with_static_definition(static_def); + } + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Self-Destruct", false, SELF_DESTRUCT_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red], + generic: 0, + }) + .id(); + let mut runner = scenario.build(); + runner.state_mut().layers_dirty.mark_full(); + evaluate_layers(runner.state_mut()); + add_red_mana(&mut runner); + (runner, spell, source, recipient) +} + +/// Drives CR 601.2 target declaration, normal mana payment, and stack resolution. +fn cast_self_destruct( + runner: &mut GameRunner, + spell: ObjectId, + source: ObjectId, + recipient: ObjectId, +) { + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("Self-Destruct cast starts"); + + let WaitingFor::TargetSelection { selection, .. } = &runner.state().waiting_for else { + panic!("first Self-Destruct choice must target the controlled source"); + }; + assert!(selection + .current_legal_targets + .contains(&TargetRef::Object(source))); + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(source)), + }) + .expect("choose the damage source creature"); + + let WaitingFor::TargetSelection { selection, .. } = &runner.state().waiting_for else { + panic!("second Self-Destruct choice must select the other recipient"); + }; + assert!( + !selection + .current_legal_targets + .contains(&TargetRef::Object(source)), + "CR 115.4: 'another target' must reject the chosen source creature" + ); + assert!(selection + .current_legal_targets + .contains(&TargetRef::Object(recipient))); + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(recipient)), + }) + .expect("choose the other damage recipient"); + + assert!( + runner.state().pending_cast.is_none(), + "Auto payment finalizes the normal-cost cast after the final target" + ); + let entry = runner + .state() + .stack + .last() + .expect("final target selection must leave the announced spell on the stack"); + assert_eq!(entry.id, spell); + let StackEntryKind::Spell { + ability: Some(ability), + .. + } = &entry.kind + else { + panic!("the finalized Self-Destruct stack entry must carry its bound ability"); + }; + let Effect::TargetOnly { .. } = &ability.effect else { + panic!("the target-subject declaration must remain the stack root"); + }; + let first_damage = ability + .sub_ability + .as_deref() + .expect("the stack root must retain the first bound damage leg"); + assert!(matches!( + (&first_damage.effect, first_damage.sub_ability.as_deref()), + ( + Effect::DealDamage { + damage_source: Some(DamageSource::Target), + .. + }, + Some(sub_ability) + ) if matches!( + &sub_ability.effect, + Effect::DealDamage { + damage_source: Some(DamageSource::Target), + target: TargetFilter::ParentTargetSlot { index: 0 }, + .. + } + ) + )); + + for _ in 0..8 { + if runner.state().stack.is_empty() { + return; + } + runner.pass_both_players(); + } + panic!("Self-Destruct did not resolve within priority reach guard"); +} + +#[test] +fn self_destruct_uses_two_power_and_only_the_source_dies() { + let (mut runner, spell, source, recipient) = setup((2, 2), (3, 3), false); + cast_self_destruct(&mut runner, spell, source, recipient); + + let state = runner.state(); + assert_eq!(state.objects[&spell].zone, Zone::Graveyard); + assert_eq!(state.objects[&source].zone, Zone::Graveyard); + assert_eq!(state.objects[&recipient].zone, Zone::Battlefield); + assert_eq!( + state.objects[&recipient].damage_marked, 2, + "CR 120.1 + CR 208.1: the recipient takes the selected source's power" + ); +} + +#[test] +fn self_destruct_three_power_trades_both_creatures() { + let (mut runner, spell, source, recipient) = setup((3, 3), (3, 3), false); + cast_self_destruct(&mut runner, spell, source, recipient); + + assert_eq!(runner.state().objects[&source].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&recipient].zone, Zone::Graveyard); +} + +#[test] +fn self_destruct_uses_modified_effective_power() { + let (mut runner, spell, source, recipient) = setup((2, 2), (3, 3), true); + assert_eq!( + runner.state().objects[&source].power, + Some(3), + "the continuous anthem must make the selected source's effective power 3" + ); + cast_self_destruct(&mut runner, spell, source, recipient); + + assert_eq!(runner.state().objects[&source].zone, Zone::Graveyard); + assert_eq!( + runner.state().objects[&recipient].zone, + Zone::Graveyard, + "the modified power (3), rather than printed power (2), must determine X" + ); +} diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 80e0b0e83b..5ba4857549 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -4,7 +4,10 @@ use std::sync::Arc; use rand::{Rng, RngCore, SeedableRng}; use rand_chacha::ChaCha20Rng; -use engine::ai_support::{build_decision_context_for_semantic_owner, AiDecisionContract}; +use engine::ai_support::{ + build_decision_context_for_semantic_owner, targeted_exchange_verdict, + validated_candidate_actions_for_semantic_owner, AiDecisionContract, TargetedExchangeVerdict, +}; use engine::types::ability::{ AbilityDefinition, ContinuousModification, Duration, Effect, StaticDefinition, TargetFilter, }; @@ -242,8 +245,9 @@ pub fn choose_action_with_session( let mut scored = score_candidates_with_session(state, ai_player, config, session); if scored.is_empty() { - // No valid candidates from search — fall back to a safe escape action - // so the game never deadlocks waiting for the AI. + // so the game never deadlocks waiting for the AI. Root casts and + // activations were already rejected before scoring; applying that + // preference to the escape action can turn a legal recovery into None. return fallback_action(state, config).and_then(exact_contract_action); } // Issue #4878: total order before softmax so equal scores never depend on @@ -311,12 +315,39 @@ fn fast_priority_action( return Some(GameAction::PassPriority); } - let actions = engine::ai_support::flat_priority_actions(state); + let actions: Vec<_> = engine::ai_support::flat_priority_actions(state) + .into_iter() + .filter(|action| root_action_is_allowed(state, ai_player, action)) + .collect(); low_value_priority_pass_from_actions(state, ai_player, &actions).or_else(|| { large_board_main_phase_fast_action_from_actions(state, ai_player, &actions, config, session) }) } +/// Keep direct priority shortcuts under the same pre-cast exchange gate as the +/// scored candidate pipeline. The engine candidate is recovered by semantic +/// owner so replay keeps its authenticated actor instead of fabricating one. +fn root_action_is_allowed(state: &GameState, ai_player: PlayerId, action: &GameAction) -> bool { + if !matches!( + action, + GameAction::CastSpell { .. } | GameAction::ActivateAbility { .. } + ) { + return true; + } + validated_candidate_actions_for_semantic_owner(state, ai_player) + .into_iter() + .find(|candidate| candidate.action.cmp_stable(action).is_eq()) + .map(|candidate| { + !matches!( + targeted_exchange_verdict(state, &candidate), + TargetedExchangeVerdict::Reject + ) + }) + // No exact authority is fail-open; the engine preview never authorizes + // a rejection from a reconstructed actor/owner pair. + .unwrap_or(true) +} + fn large_board_main_phase_has_no_development_sources( state: &GameState, ai_player: PlayerId, @@ -2097,6 +2128,15 @@ fn score_candidates_core( .map(|candidate| candidate.candidate.clone()) .collect(), ); + let candidates: Vec<_> = candidates + .into_iter() + .filter(|candidate| { + !matches!( + targeted_exchange_verdict(state, candidate), + TargetedExchangeVerdict::Reject + ) + }) + .collect(); let gated = gate_candidates( state, &ctx, @@ -3287,8 +3327,8 @@ mod tests { use engine::game::zones::create_object; use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, CategoryChooserScope, ContinuousModification, - Duration, Effect, EffectKind, ManaProduction, QuantityExpr, ResolvedAbility, - StaticDefinition, TargetFilter, TargetRef, TypedFilter, + ControllerRef, Duration, Effect, EffectKind, ManaProduction, PtValue, QuantityExpr, + ResolvedAbility, StaticDefinition, TargetFilter, TargetRef, TypedFilter, }; use engine::types::ability::{ChoiceType, ChosenAttribute}; use engine::types::card_type::CoreType; @@ -3611,6 +3651,354 @@ mod tests { id } + const SELF_DESTRUCT_ORACLE: &str = + "Target creature you control deals X damage to any other target and X damage to itself, where X is its power."; + + fn self_destruct_state(source_power: i32, recipient_power: i32) -> (GameState, ObjectId) { + use engine::parser::oracle::parse_oracle_text; + + let mut state = make_state(); + add_mana(&mut state, P0, ManaType::Red, 1); + let spell = add_spell_to_hand(&mut state, P0, "Self-Destruct", 1); + let parsed = parse_oracle_text( + SELF_DESTRUCT_ORACLE, + "Self-Destruct", + &[], + &["Sorcery".to_string()], + &[], + ); + *Arc::make_mut(&mut state.objects.get_mut(&spell).unwrap().abilities) = parsed.abilities; + add_creature(&mut state, P0, source_power, source_power); + add_creature(&mut state, PlayerId(1), recipient_power, recipient_power); + (state, spell) + } + + fn fight_spell_state( + first_controller: ControllerRef, + second_controller: ControllerRef, + first_fighter: (PlayerId, i32, i32), + second_fighter: (PlayerId, i32, i32), + destroy_second_fighter_after_fight: bool, + ) -> (GameState, ObjectId) { + let mut state = make_state(); + add_mana(&mut state, P0, ManaType::Red, 1); + let spell = add_spell_to_hand(&mut state, P0, "Fight Test", 1); + let mut ability = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Fight { + subject: TypedFilter::creature().controller(first_controller).into(), + target: TypedFilter::creature().controller(second_controller).into(), + }, + ); + if destroy_second_fighter_after_fight { + ability = ability.sub_ability(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Destroy { + target: TargetFilter::ParentTargetSlot { index: 1 }, + cant_regenerate: false, + }, + )); + } + Arc::make_mut(&mut state.objects.get_mut(&spell).unwrap().abilities).push(ability); + add_creature( + &mut state, + first_fighter.0, + first_fighter.1, + first_fighter.2, + ); + add_creature( + &mut state, + second_fighter.0, + second_fighter.1, + second_fighter.2, + ); + (state, spell) + } + + fn root_cast_candidate(state: &GameState, spell: ObjectId) -> CandidateAction { + validated_candidate_actions_for_semantic_owner(state, P0) + .into_iter() + .find(|candidate| { + matches!(&candidate.action, GameAction::CastSpell { object_id, .. } if *object_id == spell) + }) + .expect("the reducer must issue the test spell root cast") + } + + struct RootScoringWitnessPolicy(Arc); + + impl TacticalPolicy for RootScoringWitnessPolicy { + fn id(&self) -> PolicyId { + PolicyId::PaymentSelection + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::CastSpell] + } + + fn activation(&self, _: &DeckFeatures, _: &GameState, _: PlayerId) -> Option { + Some(1.0) + } + + fn verdict(&self, _: &PolicyContext<'_>) -> PolicyVerdict { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + PolicyVerdict::neutral(PolicyReason::new("root_scoring_witness")) + } + } + + #[test] + fn choose_action_rejects_bad_self_destruct_before_cast_and_keeps_source_in_hand() { + let (state, spell) = self_destruct_state(2, 3); + let candidate = validated_candidate_actions_for_semantic_owner(&state, P0) + .into_iter() + .find(|candidate| { + matches!(&candidate.action, GameAction::CastSpell { object_id, .. } if *object_id == spell) + }) + .expect("the reducer must issue the Self-Destruct root cast"); + assert_eq!( + targeted_exchange_verdict(&state, &candidate), + TargetedExchangeVerdict::Reject, + "the authenticated auto-paid root path must reach the bound stack ability and reject the 2/2 into 3/3 exchange" + ); + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = score_candidates(&state, P0, &config); + assert!( + scored.iter().all(|(action, _)| { + !matches!(action, GameAction::CastSpell { object_id, .. } if *object_id == spell) + }), + "the bad 2/2 into 3/3 root cast must be removed before scoring" + ); + + let mut rng = SmallRng::seed_from_u64(7); + let action = choose_action(&state, P0, &config, &mut rng).expect("AI has a pass action"); + assert!( + !matches!(action, GameAction::CastSpell { object_id, .. } if object_id == spell), + "choose_action must not announce the bad Self-Destruct cast" + ); + let mut applied = state.clone(); + engine::game::engine::apply_as_current(&mut applied, action) + .expect("chosen action must remain reducer-legal"); + assert_eq!(applied.objects[&spell].zone, Zone::Hand); + } + + #[test] + fn normal_root_scoring_rejects_bad_targeted_exchange_before_tactical_policy() { + let (mut state, rejected_spell) = self_destruct_state(2, 3); + let benign_spell = add_spell_to_hand(&mut state, P0, "Benign Life Gain", 1); + Arc::make_mut(&mut state.objects.get_mut(&benign_spell).unwrap().abilities).push( + AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ), + ); + + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut session = AiSession::from_game(&state); + session.policy_registry_override = + Some(Arc::new(PolicyRegistry::for_tests(vec![Box::new( + RootScoringWitnessPolicy(Arc::clone(&calls)), + )]))); + let session = Arc::new(session); + let mut config = create_config(AiDifficulty::Easy, Platform::Native); + config.search.enabled = false; + + assert_eq!( + fast_priority_action(&state, P0, &config, &session), + None, + "the additional legal cast must keep this decision on the normal scoring path" + ); + assert_eq!( + targeted_exchange_verdict(&state, &root_cast_candidate(&state, rejected_spell)), + TargetedExchangeVerdict::Reject, + "the 2/2 source into the opposing 3/3 is the root candidate the scoring gateway must veto" + ); + + let scored = score_candidates_core(&state, P0, &config, &session, None); + assert!( + scored.iter().any(|(action, _)| { + matches!(action, GameAction::CastSpell { object_id, .. } if *object_id == benign_spell) + }), + "the benign cast reaches normal scoring" + ); + assert!( + scored.iter().all(|(action, _)| { + !matches!(action, GameAction::CastSpell { object_id, .. } if *object_id == rejected_spell) + }), + "the rejected targeted exchange must be absent from normal scoring" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::Relaxed), + 1, + "only the benign cast reaches tactical policy scoring after root validation" + ); + } + + #[test] + fn rejected_targeted_exchange_still_uses_legal_fallback_when_scoring_is_empty() { + let (state, _) = self_destruct_state(2, 3); + let mut config = create_config(AiDifficulty::Easy, Platform::Native); + config.search.enabled = false; + let session = Arc::new(AiSession::from_game(&state)); + let mut rng = SmallRng::seed_from_u64(7); + + let scored = score_candidates_core(&state, P0, &config, &session, None); + assert!( + scored + .iter() + .all(|(action, _)| !matches!(action, GameAction::CastSpell { .. })), + "the adverse cast must be removed before scoring, got {scored:#?}" + ); + + let action = choose_action_with_session(&state, P0, &config, &mut rng, &session) + .expect("a legal fallback must prevent the AI from deadlocking"); + assert_eq!(action, GameAction::PassPriority); + } + + #[test] + fn self_destruct_trade_remains_a_root_candidate() { + let (state, spell) = self_destruct_state(3, 3); + let config = create_config(AiDifficulty::Easy, Platform::Native); + let scored = score_candidates(&state, P0, &config); + assert!( + scored.iter().any(|(action, _)| { + matches!(action, GameAction::CastSpell { object_id, .. } if *object_id == spell) + }), + "the 3/3 trade must stay available; the veto only removes a source-loss whiff" + ); + } + + #[test] + fn self_destruct_lethal_player_target_keeps_root_candidate() { + let (mut state, spell) = self_destruct_state(2, 3); + state.players[PlayerId(1).0 as usize].life = 2; + let candidate = validated_candidate_actions_for_semantic_owner(&state, P0) + .into_iter() + .find(|candidate| { + matches!(&candidate.action, GameAction::CastSpell { object_id, .. } if *object_id == spell) + }) + .expect("the reducer must issue the Self-Destruct root cast"); + + assert_eq!( + targeted_exchange_verdict(&state, &candidate), + TargetedExchangeVerdict::Allow, + "a legal lethal player target must keep the root cast available" + ); + } + + #[test] + fn targeted_exchange_rejects_fight_when_ai_two_two_loses_to_enemy_three_three() { + let (state, spell) = fight_spell_state( + ControllerRef::You, + ControllerRef::Opponent, + (P0, 2, 2), + (PlayerId(1), 3, 3), + false, + ); + let candidate = root_cast_candidate(&state, spell); + + assert_eq!( + targeted_exchange_verdict(&state, &candidate), + TargetedExchangeVerdict::Reject, + "the root gate must reject a fight where the AI creature dies and the enemy survives" + ); + let config = create_config(AiDifficulty::Easy, Platform::Native); + assert!( + score_candidates(&state, P0, &config).iter().all(|(action, _)| { + !matches!(action, GameAction::CastSpell { object_id, .. } if *object_id == spell) + }), + "the rejected fight root must not reach policy scoring" + ); + } + + #[test] + fn targeted_exchange_allows_fight_trade() { + let (state, spell) = fight_spell_state( + ControllerRef::You, + ControllerRef::Opponent, + (P0, 3, 3), + (PlayerId(1), 3, 3), + false, + ); + + assert_eq!( + targeted_exchange_verdict(&state, &root_cast_candidate(&state, spell)), + TargetedExchangeVerdict::Allow, + "a fight trade is not the one-sided loss that this safety gate owns" + ); + } + + #[test] + fn targeted_exchange_allows_reversed_fight_target_order_when_ai_fighter_wins() { + let (state, spell) = fight_spell_state( + ControllerRef::Opponent, + ControllerRef::You, + (PlayerId(1), 2, 2), + (P0, 3, 3), + false, + ); + + assert_eq!( + targeted_exchange_verdict(&state, &root_cast_candidate(&state, spell)), + TargetedExchangeVerdict::Allow, + "control ownership, rather than target order, must identify the AI fighter" + ); + } + + #[test] + fn targeted_exchange_judges_fight_before_later_removal_effect() { + let (state, spell) = fight_spell_state( + ControllerRef::You, + ControllerRef::Opponent, + (P0, 2, 2), + (PlayerId(1), 3, 3), + true, + ); + + assert_eq!( + targeted_exchange_verdict(&state, &root_cast_candidate(&state, spell)), + TargetedExchangeVerdict::Reject, + "a later removal instruction must not turn an otherwise losing fight into an allowed exchange" + ); + } + + #[test] + fn targeted_exchange_replays_prefix_pump_before_judging_fight() { + let mut state = make_state(); + add_mana(&mut state, P0, ManaType::Red, 1); + let spell = add_spell_to_hand(&mut state, P0, "Pump Then Fight", 1); + let fight = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Fight { + subject: TargetFilter::ParentTarget, + target: TypedFilter::creature() + .controller(ControllerRef::Opponent) + .into(), + }, + ); + let ability = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Pump { + power: PtValue::Fixed(2), + toughness: PtValue::Fixed(2), + target: TypedFilter::creature() + .controller(ControllerRef::You) + .into(), + }, + ) + .sub_ability(fight); + Arc::make_mut(&mut state.objects.get_mut(&spell).unwrap().abilities).push(ability); + add_creature(&mut state, P0, 2, 2); + add_creature(&mut state, PlayerId(1), 3, 3); + + assert_eq!( + targeted_exchange_verdict(&state, &root_cast_candidate(&state, spell)), + TargetedExchangeVerdict::Allow, + "the prefix +2/+2 makes the AI 2/2 survive its subsequent fight with a 3/3" + ); + } + fn add_mana(state: &mut GameState, player: PlayerId, color: ManaType, count: usize) { let p = &mut state.players[player.0 as usize]; for _ in 0..count {