From 6bfca3fc24cb09a6ded6e83592e938db47f1fbde Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 15:33:01 -0700 Subject: [PATCH 1/2] fix(ai): filter target-dependent optional costs --- crates/engine/src/ai_support/context.rs | 156 +++++++++++++++++++++++- 1 file changed, 152 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/ai_support/context.rs b/crates/engine/src/ai_support/context.rs index c0d145b00a..36bddc4d0f 100644 --- a/crates/engine/src/ai_support/context.rs +++ b/crates/engine/src/ai_support/context.rs @@ -39,14 +39,14 @@ impl AiDecisionContract { state_revision: state.state_revision, // The engine's candidate enumerator is the authoritative finite // domain for this prompt. Combat and search continuations remain - // reducer-owned. A target choice crosses the reducer only when the - // pending spell's mana obligation can still change with its final - // target set. Submission still performs the public action-boundary + // reducer-owned. Choices that can change either a pending spell's + // target requirements or its final mana obligation cross the reducer + // before issue. Submission still performs the public action-boundary // apply after exact-membership and owner checks. candidates: { let mut candidates = candidate_actions_for_semantic_owner_with_probe(state, semantic_owner, None); - if target_selection_requires_reducer_validation(state) { + if decision_contract_requires_reducer_validation(state) { candidates = FilterPipeline::default_pipeline().apply(state, candidates); } candidates.sort_by(|left, right| left.action.cmp_stable(&right.action)); @@ -125,6 +125,23 @@ pub(crate) fn target_selection_requires_reducer_validation(state: &GameState) -> ) } +/// Whether a decision can alter the target requirements of an in-progress cast. +/// +/// CR 601.2b-c: a kicker declaration precedes target selection and may replace +/// the spell's target requirements. The capability contract must therefore +/// simulate each such payment decision before issuing it; otherwise an AI can +/// decline the only target-enabling kicker and receive a targetless cast. +fn decision_contract_requires_reducer_validation(state: &GameState) -> bool { + target_selection_requires_reducer_validation(state) + || matches!( + &state.waiting_for, + WaitingFor::OptionalCostChoice { + pending_cast, + .. + } if pending_cast.deferred_target_selection + ) +} + fn candidate_action_matches(issued: &GameAction, submitted: &GameAction) -> bool { match (issued, submitted) { ( @@ -212,13 +229,20 @@ mod tests { use super::*; use crate::game::zones::create_object; use crate::types::{ + ability::{ + AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AdditionalCost, + AdditionalCostRepeatability, Effect, TargetFilter, TypeFilter, TypedFilter, + }, actions::GameAction, card_type::CoreType, + game_state::CastPaymentMode, identifiers::{CardId, ObjectId}, + mana::{ManaCost, ManaType, ManaUnit}, player::PlayerId, zones::Zone, Phase, }; + use std::sync::Arc; /// Issue #4878: the decision context is consumed directly by phase-ai, so /// it must canonicalize candidate enumeration order before trajectories @@ -344,4 +368,128 @@ mod tests { }, )); } + + /// Issue #7109: the decision contract must not offer an optional payment + /// whose resulting deferred target set has no legal assignment. + #[test] + fn decision_contract_filters_optional_cost_that_leaves_no_legal_targets() { + let player = PlayerId(0); + let mut state = GameState::new_two_player(42); + state.phase = Phase::PreCombatMain; + state.active_player = player; + state.priority_player = player; + state.waiting_for = WaitingFor::Priority { player }; + + let spell = create_object( + &mut state, + CardId(7109), + player, + "Kicker Target Spell".to_string(), + Zone::Hand, + ); + let creature = create_object( + &mut state, + CardId(7110), + PlayerId(1), + "Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&creature) + .expect("created creature must exist") + .card_types + .core_types + .push(CoreType::Creature); + { + let spell_object = state + .objects + .get_mut(&spell) + .expect("created spell must exist"); + spell_object.card_types.core_types.push(CoreType::Instant); + spell_object.mana_cost = ManaCost::generic(0); + spell_object.additional_cost = Some(AdditionalCost::Kicker { + costs: vec![AbilityCost::Mana { + cost: ManaCost::generic(1), + }], + repeatability: AdditionalCostRepeatability::Once, + }); + Arc::make_mut(&mut spell_object.abilities).push( + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + cant_regenerate: false, + }, + ) + .sub_ability( + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), + cant_regenerate: false, + }, + ) + .condition(AbilityCondition::AdditionalCostPaidInstead), + ), + ); + } + state.players[0].mana_pool.add(ManaUnit::new( + ManaType::Green, + ObjectId(7109), + false, + vec![], + )); + + crate::game::engine::apply_as_current( + &mut state, + GameAction::CastSpell { + object_id: spell, + card_id: CardId(7109), + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }, + ) + .expect("the cast must reach its target-dependent kicker choice"); + + assert!( + matches!( + &state.waiting_for, + WaitingFor::OptionalCostChoice { pending_cast, .. } + if pending_cast.deferred_target_selection + ), + "reach-guard: the production cast must defer targets until the kicker choice" + ); + assert!( + matches!( + crate::game::engine::apply_as_current( + &mut state.clone(), + GameAction::DecideOptionalCost { pay: false }, + ), + Err(crate::game::engine::EngineError::ActionNotAllowed(message)) + if message == "No legal targets available" + ), + "reach-guard: declining kicker must reproduce the rejected targetless cast" + ); + + let contract = AiDecisionContract::issue(&state, player); + assert!( + contract.candidates.iter().any(|candidate| { + matches!( + candidate.action, + GameAction::DecideOptionalCost { pay: true } + ) + }), + "the target-enabling kicker payment must be issued" + ); + assert!( + !contract.candidates.iter().any(|candidate| { + matches!( + candidate.action, + GameAction::DecideOptionalCost { pay: false } + ) + }), + "the targetless declining choice must not be issued" + ); + } } From 252133ae569453efe491e4f06d718107be30fbfe Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 15:39:36 -0700 Subject: [PATCH 2/2] test(engine): cover optional-cost AI contract integration --- crates/engine/src/ai_support/context.rs | 131 ---------------- .../tests/integration/ai_decision_contract.rs | 140 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 3 files changed, 141 insertions(+), 131 deletions(-) create mode 100644 crates/engine/tests/integration/ai_decision_contract.rs diff --git a/crates/engine/src/ai_support/context.rs b/crates/engine/src/ai_support/context.rs index 36bddc4d0f..5c28a489e6 100644 --- a/crates/engine/src/ai_support/context.rs +++ b/crates/engine/src/ai_support/context.rs @@ -229,20 +229,13 @@ mod tests { use super::*; use crate::game::zones::create_object; use crate::types::{ - ability::{ - AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AdditionalCost, - AdditionalCostRepeatability, Effect, TargetFilter, TypeFilter, TypedFilter, - }, actions::GameAction, card_type::CoreType, - game_state::CastPaymentMode, identifiers::{CardId, ObjectId}, - mana::{ManaCost, ManaType, ManaUnit}, player::PlayerId, zones::Zone, Phase, }; - use std::sync::Arc; /// Issue #4878: the decision context is consumed directly by phase-ai, so /// it must canonicalize candidate enumeration order before trajectories @@ -368,128 +361,4 @@ mod tests { }, )); } - - /// Issue #7109: the decision contract must not offer an optional payment - /// whose resulting deferred target set has no legal assignment. - #[test] - fn decision_contract_filters_optional_cost_that_leaves_no_legal_targets() { - let player = PlayerId(0); - let mut state = GameState::new_two_player(42); - state.phase = Phase::PreCombatMain; - state.active_player = player; - state.priority_player = player; - state.waiting_for = WaitingFor::Priority { player }; - - let spell = create_object( - &mut state, - CardId(7109), - player, - "Kicker Target Spell".to_string(), - Zone::Hand, - ); - let creature = create_object( - &mut state, - CardId(7110), - PlayerId(1), - "Creature".to_string(), - Zone::Battlefield, - ); - state - .objects - .get_mut(&creature) - .expect("created creature must exist") - .card_types - .core_types - .push(CoreType::Creature); - { - let spell_object = state - .objects - .get_mut(&spell) - .expect("created spell must exist"); - spell_object.card_types.core_types.push(CoreType::Instant); - spell_object.mana_cost = ManaCost::generic(0); - spell_object.additional_cost = Some(AdditionalCost::Kicker { - costs: vec![AbilityCost::Mana { - cost: ManaCost::generic(1), - }], - repeatability: AdditionalCostRepeatability::Once, - }); - Arc::make_mut(&mut spell_object.abilities).push( - AbilityDefinition::new( - AbilityKind::Spell, - Effect::Destroy { - target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), - cant_regenerate: false, - }, - ) - .sub_ability( - AbilityDefinition::new( - AbilityKind::Spell, - Effect::Destroy { - target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), - cant_regenerate: false, - }, - ) - .condition(AbilityCondition::AdditionalCostPaidInstead), - ), - ); - } - state.players[0].mana_pool.add(ManaUnit::new( - ManaType::Green, - ObjectId(7109), - false, - vec![], - )); - - crate::game::engine::apply_as_current( - &mut state, - GameAction::CastSpell { - object_id: spell, - card_id: CardId(7109), - targets: vec![], - payment_mode: CastPaymentMode::Auto, - }, - ) - .expect("the cast must reach its target-dependent kicker choice"); - - assert!( - matches!( - &state.waiting_for, - WaitingFor::OptionalCostChoice { pending_cast, .. } - if pending_cast.deferred_target_selection - ), - "reach-guard: the production cast must defer targets until the kicker choice" - ); - assert!( - matches!( - crate::game::engine::apply_as_current( - &mut state.clone(), - GameAction::DecideOptionalCost { pay: false }, - ), - Err(crate::game::engine::EngineError::ActionNotAllowed(message)) - if message == "No legal targets available" - ), - "reach-guard: declining kicker must reproduce the rejected targetless cast" - ); - - let contract = AiDecisionContract::issue(&state, player); - assert!( - contract.candidates.iter().any(|candidate| { - matches!( - candidate.action, - GameAction::DecideOptionalCost { pay: true } - ) - }), - "the target-enabling kicker payment must be issued" - ); - assert!( - !contract.candidates.iter().any(|candidate| { - matches!( - candidate.action, - GameAction::DecideOptionalCost { pay: false } - ) - }), - "the targetless declining choice must not be issued" - ); - } } diff --git a/crates/engine/tests/integration/ai_decision_contract.rs b/crates/engine/tests/integration/ai_decision_contract.rs new file mode 100644 index 0000000000..2beb1050c0 --- /dev/null +++ b/crates/engine/tests/integration/ai_decision_contract.rs @@ -0,0 +1,140 @@ +use engine::ai_support::AiDecisionContract; +use engine::game::engine::{apply_as_current, EngineError}; +use engine::game::zones::create_object; +use engine::types::ability::{ + AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AdditionalCost, + AdditionalCostRepeatability, Effect, TargetFilter, TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; +use std::sync::Arc; + +/// Issue #7109: the decision contract must not offer an optional payment +/// whose resulting deferred target set has no legal assignment. +#[test] +fn decision_contract_filters_optional_cost_that_leaves_no_legal_targets() { + let player = PlayerId(0); + let mut state = GameState::new_two_player(42); + state.phase = Phase::PreCombatMain; + state.active_player = player; + state.priority_player = player; + state.waiting_for = WaitingFor::Priority { player }; + + let spell = create_object( + &mut state, + CardId(7109), + player, + "Kicker Target Spell".to_string(), + Zone::Hand, + ); + let creature = create_object( + &mut state, + CardId(7110), + PlayerId(1), + "Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&creature) + .expect("created creature must exist") + .card_types + .core_types + .push(CoreType::Creature); + { + let spell_object = state + .objects + .get_mut(&spell) + .expect("created spell must exist"); + spell_object.card_types.core_types.push(CoreType::Instant); + spell_object.mana_cost = ManaCost::generic(0); + spell_object.additional_cost = Some(AdditionalCost::Kicker { + costs: vec![AbilityCost::Mana { + cost: ManaCost::generic(1), + }], + repeatability: AdditionalCostRepeatability::Once, + }); + Arc::make_mut(&mut spell_object.abilities).push( + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + cant_regenerate: false, + }, + ) + .sub_ability( + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), + cant_regenerate: false, + }, + ) + .condition(AbilityCondition::AdditionalCostPaidInstead), + ), + ); + } + state.players[0].mana_pool.add(ManaUnit::new( + ManaType::Green, + ObjectId(7109), + false, + vec![], + )); + + apply_as_current( + &mut state, + GameAction::CastSpell { + object_id: spell, + card_id: CardId(7109), + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }, + ) + .expect("the cast must reach its target-dependent kicker choice"); + + assert!( + matches!( + &state.waiting_for, + WaitingFor::OptionalCostChoice { pending_cast, .. } + if pending_cast.deferred_target_selection + ), + "reach-guard: the production cast must defer targets until the kicker choice" + ); + assert!( + matches!( + apply_as_current( + &mut state.clone(), + GameAction::DecideOptionalCost { pay: false }, + ), + Err(EngineError::ActionNotAllowed(message)) + if message == "No legal targets available" + ), + "reach-guard: declining kicker must reproduce the rejected targetless cast" + ); + + let contract = AiDecisionContract::issue(&state, player); + assert!( + contract.candidates.iter().any(|candidate| { + matches!( + candidate.action, + GameAction::DecideOptionalCost { pay: true } + ) + }), + "the target-enabling kicker payment must be issued" + ); + assert!( + !contract.candidates.iter().any(|candidate| { + matches!( + candidate.action, + GameAction::DecideOptionalCost { pay: false } + ) + }), + "the targetless declining choice must not be issued" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 0f787ef76f..9050ceea7e 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -4,6 +4,7 @@ mod ad_nauseam_repeat; mod adamant_enters_with_leading_if_gate; mod adapter_contract_fixtures; mod advanced_reconstruction_regression; +mod ai_decision_contract; mod ajani_nacatl_pariah_co_departure_6427; mod ajani_nacatl_pariah_sacrifice_outlet_6018; mod ajani_nacatl_pariah_transform;