Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
744622c
fix(engine): verify offered casts with the auto-payment authority
nishu-builder Aug 4, 2026
b0e7db4
fix(engine): candidate-specific payment modes and review-hardened fix…
nishu-builder Aug 5, 2026
deef850
fix(engine): preserve manual mana paths for sneak and web-slinging casts
nishu-builder Aug 5, 2026
cbf0461
fix(engine): preserve alternative-face casts in priority preflight
nishu-builder Aug 5, 2026
276cd22
Merge remote-tracking branch 'origin/main' into pr/7007
matthewevans Aug 7, 2026
714c9d3
fix(engine): harden offer-side payment classification
matthewevans Aug 7, 2026
ea3f822
fix(ai): include offer-payment counters in perf reports
matthewevans Aug 7, 2026
0c820b7
fix(ai): version offer-payment perf baseline
matthewevans Aug 7, 2026
c6181b1
Merge remote-tracking branch 'origin/main' into pr/7007
matthewevans Aug 7, 2026
db2ee61
fix(ai): preserve free-cast simulation legality
matthewevans Aug 7, 2026
052056f
fix(ai): limit free-cast payment bypass
matthewevans Aug 7, 2026
3a8be1d
fix(engine): preserve mandatory sacrificial mana choices
matthewevans Aug 8, 2026
a678010
fix(ai): recognize prepared free-cast costs
matthewevans Aug 8, 2026
e5a3f33
Merge remote-tracking branch 'origin/main' into pr/7007
matthewevans Aug 8, 2026
bdf4d34
perf(ai): refresh offer-payment baseline
matthewevans Aug 8, 2026
d4c025f
Merge remote-tracking branch 'origin/main' into pr/7007
matthewevans Aug 8, 2026
b9f7fd0
fix(ai): preserve optional free-cast offers
matthewevans Aug 8, 2026
f49ab14
fix(ai): retain named free-cast candidates
matthewevans Aug 8, 2026
dd859e3
test(engine): remove unsupported free-cast parser fixture
matthewevans Aug 8, 2026
3974168
test(PR-7007): restore free-cast offer regression
matthewevans Aug 8, 2026
a1750ad
fix(ai): retain ordinary alternative-cost cast offers
matthewevans Aug 8, 2026
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
80 changes: 60 additions & 20 deletions crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::cell::OnceCell;
use std::collections::{BTreeMap, HashSet};

use crate::game::casting;
Expand Down Expand Up @@ -3508,6 +3509,7 @@ pub(crate) fn priority_actions_with_probe(
// players can't cast spells or activate non-mana abilities. Special actions
// (PlayLand, Foretell) and mana abilities remain permitted.
let split_second_active = crate::game::keywords::stack_has_split_second(state);
let mana_source_selections = OnceCell::new();

let p = &state.players[player.0 as usize];
let is_main_phase = matches!(state.phase, Phase::PreCombatMain | Phase::PostCombatMain);
Expand Down Expand Up @@ -3610,18 +3612,25 @@ pub(crate) fn priority_actions_with_probe(

// CR 702.61a: Spells and non-mana activated abilities are suppressed by split second.
if !split_second_active {
// CR 601.2g-h: Mana abilities are activated before the total cost is
// paid. When every available capability requires a sacrifice, retain
// the spell offer but stop at that irreversible source choice.
for object_id in casting::spell_objects_available_to_cast(state, player) {
let Some(obj) = state.objects.get(&object_id) else {
continue;
};
if casting::can_cast_object_now_with_probe(state, player, object_id, probe) {
let selections = mana_source_selections
.get_or_init(|| mana_sources::activatable_mana_source_selections(state, player));
if let Some(payment_mode) = casting::castable_spell_payment_mode_with_probe(
state, player, object_id, selections, probe,
) {
actions.push(candidate(
GameAction::CastSpell {
object_id,
card_id: obj.card_id,
targets: Vec::new(),

payment_mode: CastPaymentMode::Auto,
payment_mode,
},
TacticalClass::Spell,
Some(player),
Expand Down Expand Up @@ -4207,31 +4216,47 @@ pub(crate) fn priority_actions_with_probe(
.map(|p| p.hand.iter().copied().collect::<Vec<_>>())
.unwrap_or_default();
for hand_id in hand_ids {
let Some(cost) = keywords::effective_sneak_cost(state, hand_id) else {
continue;
};
// CR 601.2f: Mana-cost affordability must consider mana that
// can be produced by activating mana abilities during the cost
// step, not just mana currently floating in the pool.
// Delegates to the same auto-tap aware check used by the
// normal `CastSpell` emitter (`can_cast_object_now` →
// `can_pay_cost_after_auto_tap`) so a Sneak cast with 0
// floating mana but enough untapped sources is surfaced.
if !crate::game::casting::can_pay_cost_after_auto_tap(state, player, hand_id, &cost)
{
if keywords::effective_sneak_cost(state, hand_id).is_none() {
continue;
}
// CR 601.2g-h: Mana abilities are activated before the total
// cost is paid, so affordability must consider mana that those
// activations can produce, including irreversible manual ones.
let Some(card_id) = state.objects.get(&hand_id).map(|o| o.card_id) else {
continue;
};
for &creature_id in &unblocked {
let Some(prepared_cost) = casting::effective_spell_cost_for_variant(
state,
player,
hand_id,
crate::types::game_state::CastingVariant::Sneak {
returned_creature: creature_id,
placement: None,
},
) else {
continue;
};
let selections = mana_source_selections.get_or_init(|| {
mana_sources::activatable_mana_source_selections(state, player)
});
let Some(payment_mode) = casting::prepared_spell_payment_verdict_with_probe(
state,
player,
hand_id,
&prepared_cost,
selections,
probe,
) else {
continue;
};
actions.push(candidate(
GameAction::CastSpellAsSneak {
hand_object: hand_id,
card_id,
creature_to_return: creature_id,

payment_mode: CastPaymentMode::Auto,
payment_mode,
},
TacticalClass::Ability,
Some(player),
Expand Down Expand Up @@ -4272,21 +4297,36 @@ pub(crate) fn priority_actions_with_probe(
continue;
};
for &creature_id in &tapped_creatures {
if !casting::can_cast_spell_as_web_slinging_now(
let Some(prepared_cost) = casting::effective_spell_cost_for_variant(
state,
player,
hand_id,
creature_id,
) {
crate::types::game_state::CastingVariant::WebSlinging {
returned_creature: creature_id,
},
) else {
continue;
}
};
let selections = mana_source_selections.get_or_init(|| {
mana_sources::activatable_mana_source_selections(state, player)
});
let Some(payment_mode) = casting::prepared_spell_payment_verdict_with_probe(
state,
player,
hand_id,
&prepared_cost,
selections,
probe,
) else {
continue;
};
actions.push(candidate(
GameAction::CastSpellAsWebSlinging {
hand_object: hand_id,
card_id,
creature_to_return: creature_id,

payment_mode: CastPaymentMode::Auto,
payment_mode,
},
TacticalClass::Spell,
Some(player),
Expand Down
69 changes: 66 additions & 3 deletions crates/engine/src/ai_support/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ use crate::types::actions::GameAction;
use crate::types::card_type::CardType;
use crate::types::counter::CounterType;
use crate::types::definitions::Definitions;
use crate::types::game_state::{CastPaymentMode, GameState, WaitingFor};
use crate::types::game_state::{
CastPaymentMode, CastingPermissionIndex, GameState, PendingCast, WaitingFor,
};
use crate::types::identifiers::ObjectId;
use crate::types::keywords::Keyword;
use crate::types::mana::{ManaColor, ManaCost};
Expand Down Expand Up @@ -184,7 +186,12 @@ impl CandidateFilter for SimulationFilter {

impl SimulationFilter {
fn fallback_simulation(&self, state: &GameState, candidate: &CandidateAction) -> bool {
let _phase = crate::game::perf_counters::LegalityClonePhaseGuard::enter(
crate::game::perf_counters::LegalityClonePhase::RawValidation,
);
crate::game::perf_counters::record_state_clone_for_legality();
crate::game::perf_counters::record_phase_owned_state_clone();
let before = pending_spell_root_provenance(state);
let mut sim = state.clone();
// PR-3 Defect-2: mark the entire nested clone-and-apply as a legality probe so
// the top-level-only loop-shortcut detection (`reconcile_terminal_result` §3)
Expand All @@ -205,17 +212,70 @@ impl SimulationFilter {
.or_else(|| turn_control::authorized_submitters(state).first().copied());
actor.is_some_and(|actor| {
let semantic_owner = candidate.metadata.semantic_owner.unwrap_or(actor);
crate::game::engine::apply_interaction_for_simulation(
if crate::game::engine::apply_interaction_for_simulation(
&mut sim,
actor,
semantic_owner,
candidate.action.clone(),
)
.is_ok()
.is_err()
{
return false;
}

let Some((after, pending)) = pending_spell_root(&sim) else {
return true;
};
// CR 601.2b: An optional alternative-cost choice is part of the
// casting process. The announced spell is legal when it reaches
// this decision, even when paying its printed cost would not be.
// The selected free branch will replace that cost before payment.
if matches!(sim.waiting_for, WaitingFor::OptionalCostChoice { .. }) {
return true;
}
// CR 118.6a: A selected free-cast permission is represented by the
// prepared spell's `NoCost`, including silent unlimited permissions
// that arrive through the ordinary `CastSpell` action. The prepared
// cost, rather than the action variant, is authoritative here.
if matches!(pending.cost, ManaCost::NoCost) {
return true;
}
if before == Some(after) {
return true;
}
!matches!(
crate::game::casting_costs::post_origin_auto_payment_verdict(&mut sim, &pending,),
Some(false)
)
})
}
}

type SpellRootProvenance = (ObjectId, Option<CastingPermissionIndex>);

fn pending_spell_root_provenance(state: &GameState) -> Option<SpellRootProvenance> {
state
.waiting_for
.pending_cast_ref()
.or(state.pending_cast.as_deref())
.filter(|pending| pending.activation_ability_index.is_none())
.map(|pending| (pending.object_id, pending.casting_permission_index))
}

fn pending_spell_root(state: &GameState) -> Option<(SpellRootProvenance, PendingCast)> {
state
.waiting_for
.pending_cast_ref()
.or(state.pending_cast.as_deref())
.filter(|pending| pending.activation_ability_index.is_none())
.map(|pending| {
(
(pending.object_id, pending.casting_permission_index),
pending.clone(),
)
})
}

/// CR 117.3d: the holder of a live priority window may always decline to act.
/// The engine's `(Priority, PassPriority)` reducer arm rejects a pass on exactly
/// two conditions (CR 723.5 submitter mismatch, CR 732.2c divergence obligation),
Expand Down Expand Up @@ -295,6 +355,9 @@ fn structurally_valid_priority_cast_with_probe(
action: &GameAction,
probe: Option<&casting::PriorityCastProbe>,
) -> bool {
let _phase = crate::game::perf_counters::LegalityClonePhaseGuard::enter(
crate::game::perf_counters::LegalityClonePhase::StrictFastPath,
);
let (
WaitingFor::Priority { player },
GameAction::CastSpell {
Expand Down
Loading
Loading