Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions crates/engine/src/ai_support/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
(
Expand Down
140 changes: 140 additions & 0 deletions crates/engine/tests/integration/ai_decision_contract.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading