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
6 changes: 5 additions & 1 deletion crates/engine/src/ai_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ pub use swarm::{
};
#[cfg(feature = "test-support")]
pub use swarm::{adversarial_swarm_witness_with_counters, SwarmWitnessCounters};
pub use targeted_exchange::{targeted_exchange_verdict, TargetedExchangeVerdict};
pub use targeted_exchange::{
root_may_yield_adverse_exchange, targeted_exchange_verdict, TargetedExchangeVerdict,
};
#[cfg(feature = "test-support")]
pub use targeted_exchange::{targeted_exchange_verdict_with_budget, TargetedExchangeBudget};

/// Filter `candidate_actions` down to the actions that are actually legal now.
///
Expand Down
1,469 changes: 1,451 additions & 18 deletions crates/engine/src/ai_support/targeted_exchange.rs

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions crates/engine/src/game/effects/awaken.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ use crate::types::keywords::Keyword;
/// game) — set on the sub-ability's `duration` field because `Effect::Animate`
/// itself carries no duration and `animate::resolve` reads `ability.duration`
/// (defaulting to `UntilEndOfTurn` when absent).
///
/// AI COUPLING — read before changing this payload. The Awaken branch of
/// `casting::prepare_spell_cast_with_variant_override_inner` appends
/// this rider to the bound spine, so it is SYNTHESIZED content that reaches the
/// bind without ever being written to a `GameObject` ability field.
/// `ai_support::targeted_exchange::root_may_yield_adverse_exchange` is a
/// clone-free precondition that reads only the source's STORED ability lists and
/// skips the AI's adverse-exchange preview when it finds no adverse shape there —
/// it therefore cannot see anything this function builds. That is sound ONLY
/// because the payload below is fixed: `Effect::PutCounter` chained to
/// `Effect::Animate`, neither of which either judge (`find_fight_leaf`,
/// `is_target_sourced_self_damage`) can reject on. Introducing `Effect::Fight` or
/// `Effect::DealDamage`/`DamageAll { damage_source: Some(DamageSource::Target) }`
/// here silently drops a `Reject` with no failing test — add a fall-open rail to
/// that guard in the SAME change. See clause (b2)(ii) of its doc comment.
fn build_awaken_rider(count: u32) -> AbilityDefinition {
let land_you_control = TargetFilter::Typed(TypedFilter::land().controller(ControllerRef::You));

Expand Down
617 changes: 66 additions & 551 deletions crates/engine/src/game/printed_cards.rs

Large diffs are not rendered by default.

638 changes: 638 additions & 0 deletions crates/engine/src/types/ability_visit.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/engine/src/types/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod ability;
pub mod ability_visit;
pub mod action_stable_order;
pub mod actions;
pub mod attribution;
Expand Down
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@ mod swans_prevention_followup;
mod swarm_combat_witness;
mod tales_of_the_ancestors_catch_up_draw;
mod talon_gates_from_hand_activation;
mod targeted_exchange_preview_budget;
mod tchaka_venerable_king;
mod teamwork_aggregate_legal_actions;
mod teamwork_origin_composition;
Expand Down
186 changes: 186 additions & 0 deletions crates/engine/tests/integration/targeted_exchange_preview_budget.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! The bounded-witness budget for one `targeted_exchange_verdict` call.
//!
//! R1 is the row that proves the clone-free precondition actually elides work:
//! a root whose source carries no adverse-exchange effect shape must cost zero
//! candidate enumerations and zero reducer replays. P1 and P2 are its permanent
//! positive reach-guards — without them the zero could be a blanket-disabled
//! preview rather than a precondition that fired.

#[cfg(feature = "test-support")]
use engine::ai_support::{
targeted_exchange_verdict_with_budget, validated_candidate_actions_for_semantic_owner,
CandidateAction, TargetedExchangeBudget, TargetedExchangeVerdict,
};
#[cfg(feature = "test-support")]
use engine::game::layers::{evaluate_layers, flush_layers};
#[cfg(feature = "test-support")]
use engine::game::scenario::{GameScenario, P0, P1};
#[cfg(feature = "test-support")]
use engine::types::actions::GameAction;
#[cfg(feature = "test-support")]
use engine::types::game_state::GameState;
#[cfg(feature = "test-support")]
use engine::types::identifiers::ObjectId;
#[cfg(feature = "test-support")]
use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit};
#[cfg(feature = "test-support")]
use engine::types::phase::Phase;

/// Verbatim Oracle text, copied from `self_destruct_target_power.rs:18-19`.
#[cfg(feature = "test-support")]
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.";

/// Verbatim Oracle text for a root carrying no adverse-exchange shape.
#[cfg(feature = "test-support")]
const LIGHTNING_BOLT_ORACLE: &str = "Lightning Bolt deals 3 damage to any target.";

/// Verbatim Oracle text for the Fight class (Prey Upon).
#[cfg(feature = "test-support")]
const PREY_UPON_ORACLE: &str =
"Target creature you control fights target creature you don't control.";

/// Build a two-player pre-combat main phase board, put `oracle` into P0's hand
/// as a one-red-mana sorcery, and hand back the state plus the spell's id.
#[cfg(feature = "test-support")]
fn board(
name: &str,
oracle: &str,
ai_pt: (i32, i32),
enemy_pt: (i32, i32),
) -> (GameState, ObjectId) {
let mut scenario = GameScenario::new_n_player(2, 42);
scenario.at_phase(Phase::PreCombatMain);
scenario.add_vanilla(P0, ai_pt.0, ai_pt.1);
scenario.add_vanilla(P1, enemy_pt.0, enemy_pt.1);
let spell = scenario
.add_spell_to_hand_from_oracle(P0, name, false, 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());
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![]));
let mut state = runner.state().clone();
// R1's third reach-guard (and every row's precondition): the guard's first
// rail is `layers_dirty.is_dirty() => return true`, which SPENDS the budget.
// A `GameScenario` that puts permanents onto the battlefield leaves an
// `EnteredObjects` mark, so flush before measuring.
flush_layers(&mut state);
(state, spell)
}

/// Recover the engine-issued root cast candidate for `spell`, exactly as
/// `search.rs` does.
#[cfg(feature = "test-support")]
fn root_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 engine must issue the root cast candidate")
}

/// Assert the three reach-guards R1 depends on, so a zero budget can never be
/// vacuous: `replay_exact_candidate` bails on a missing semantic owner or actor
/// before spending anything, and a dirty lattice spends the budget through the
/// guard's own fall-open rail.
#[cfg(feature = "test-support")]
fn assert_reach_guards(state: &GameState, root: &CandidateAction) {
assert!(
root.metadata.semantic_owner.is_some(),
"reach guard: without a semantic owner `replay_exact_candidate` early-returns and the zero budget is vacuous"
);
assert!(
root.metadata.actor.is_some(),
"reach guard: without an actor `replay_exact_candidate` early-returns and the zero budget is vacuous"
);
assert!(
!state.layers_dirty.is_dirty(),
"reach guard: a dirty lattice makes the guard fall open and SPEND the budget — assumption A must hold in this fixture"
);
}

/// R1 — a root with no adverse-exchange shape costs zero replay and zero
/// enumeration. This is the row that measures the fix.
#[cfg(feature = "test-support")]
#[test]
fn budget_is_zero_for_a_root_without_an_adverse_exchange_shape() {
let (state, spell) = board("Lightning Bolt", LIGHTNING_BOLT_ORACLE, (2, 2), (3, 3));
let root = root_candidate(&state, spell);
assert_reach_guards(&state, &root);

let (verdict, budget) = targeted_exchange_verdict_with_budget(&state, &root);
assert_eq!(verdict, TargetedExchangeVerdict::Indeterminate);
assert_eq!(
budget,
TargetedExchangeBudget::default(),
"the clone-free precondition must elide BOTH the candidate enumeration and the reducer replay for a root that provably cannot be rejected"
);
}

/// P1 — positive reach-guard for R1: the guard did not blanket-disable the
/// preview. Target-sourced self damage, 2/2 source against a 3/3 recipient.
#[cfg(feature = "test-support")]
#[test]
fn budget_is_spent_and_verdict_rejects_for_a_target_sourced_self_damage_root() {
let (state, spell) = board("Self-Destruct", SELF_DESTRUCT_ORACLE, (2, 2), (3, 3));
let root = root_candidate(&state, spell);
assert_reach_guards(&state, &root);

let (verdict, budget) = targeted_exchange_verdict_with_budget(&state, &root);
assert_eq!(
verdict,
TargetedExchangeVerdict::Reject,
"the 2/2 source dies to the 3/3 recipient's damage while the recipient survives"
);
assert!(
budget.candidate_enumerations >= 1,
"the preview must still run its candidate enumeration for a shape-bearing root"
);
assert!(
budget.replay_clone_applies >= 1,
"the preview must still clone-and-apply for a shape-bearing root"
);
assert!(
budget.preview_clone_resolves >= 1,
"the preview must still resolve the bound exchange for a shape-bearing root"
);
assert!(budget.nodes >= 1, "C2: the node cap must still be charged");
assert!(
budget.branches <= 16,
"C2: the branch cap must still bound exploration"
);
}

/// P2 — positive reach-guard, Fight class. AI 2/2 against an enemy 3/3.
#[cfg(feature = "test-support")]
#[test]
fn budget_is_spent_for_a_fight_root() {
let (state, spell) = board("Prey Upon", PREY_UPON_ORACLE, (2, 2), (3, 3));
let root = root_candidate(&state, spell);
assert_reach_guards(&state, &root);

let (verdict, budget) = targeted_exchange_verdict_with_budget(&state, &root);
assert_eq!(
verdict,
TargetedExchangeVerdict::Reject,
"the AI's 2/2 dies to the 3/3 it fights while the 3/3 survives"
);
assert!(
budget.replay_clone_applies >= 1,
"the Fight arm of the leaf shape test must let the preview run"
);
assert!(budget.nodes >= 1, "C2: the node cap must still be charged");
}
24 changes: 19 additions & 5 deletions crates/phase-ai/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use rand_chacha::ChaCha20Rng;

use engine::ai_support::{
build_decision_context, certify_fetch_then_cast, certify_pact_plan, is_pact_payment_cast,
targeted_exchange_verdict, validated_candidate_actions_for_semantic_owner, AiDecisionContract,
TargetedExchangeVerdict,
root_may_yield_adverse_exchange, targeted_exchange_verdict,
validated_candidate_actions_for_semantic_owner, AiDecisionContract, TargetedExchangeVerdict,
};
use engine::types::ability::{
AbilityDefinition, ContinuousModification, Duration, Effect, ResolvedAbility, StaticDefinition,
Expand Down Expand Up @@ -529,16 +529,30 @@ fn fast_priority_action(
action.filter(|_| !has_certified_fetch_then_cast_route(state, ai_player))
}

/// 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.
/// Keep the direct priority shortcuts under the pre-cast exchange gate. The
/// engine candidate is recovered by semantic owner so replay keeps its
/// authenticated actor instead of fabricating one.
///
/// The engine's clone-free precondition runs FIRST: a root whose source carries
/// no adverse-exchange effect shape can never be rejected, so recovering its
/// candidate — a full `validated_candidate_actions_for_semantic_owner` pass,
/// with a `GameState` clone per candidate the cheap filters decline — is pure
/// cost. This gate is invoked once per action from a filter over the whole
/// priority list, so the recovery must stay behind the precondition or the pass
/// count is quadratic in the number of castable roots. The reordering is
/// behavior-identical: with the same precondition inside
/// `targeted_exchange_verdict`, the old path returned `true` on every root this
/// one short-circuits.
fn root_action_is_allowed(state: &GameState, ai_player: PlayerId, action: &GameAction) -> bool {
if !matches!(
action,
GameAction::CastSpell { .. } | GameAction::ActivateAbility { .. }
) {
return true;
}
if !root_may_yield_adverse_exchange(state, action) {
return true;
}
validated_candidate_actions_for_semantic_owner(state, ai_player)
.into_iter()
.find(|candidate| candidate.action.cmp_stable(action).is_eq())
Expand Down
Loading