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
117 changes: 114 additions & 3 deletions crates/phase-ai/src/policies/evasion_removal_priority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@ impl EvasionRemovalPriorityPolicy {
let target_quality_bonus = removal_target_quality_score(target_value);
let evasion_bonus = evasion_score(ctx, target, *target_id);
let velocity_bonus = velocity_score(ctx, target, *target_id);
// #6582: threat value alone rewards the biggest body, so a damage spell
// gets pointed at a creature it can't kill. Fold in whether the pending
// damage is actually lethal (CR 704.5g) so a clean kill outranks a whiff.
let lethality_bonus = super::removal_lethality::lethality_bonus(ctx, *target_id, target);

target_quality_bonus + evasion_bonus + velocity_bonus
target_quality_bonus + evasion_bonus + velocity_bonus + lethality_bonus
}
}

Expand Down Expand Up @@ -211,8 +215,8 @@ mod tests {
use engine::game::scenario::{GameScenario, P0};
use engine::game::zones::create_object;
use engine::types::ability::{
AbilityDefinition, AbilityKind, Effect, EffectKind, PtValue, ResolvedAbility, TargetFilter,
TargetRef, TypedFilter,
AbilityDefinition, AbilityKind, Effect, EffectKind, PtValue, QuantityExpr, ResolvedAbility,
TargetFilter, TargetRef, TypedFilter,
};
use engine::types::format::FormatConfig;
use engine::types::game_state::{
Expand All @@ -230,6 +234,7 @@ mod tests {

const BEAST_WITHIN_ORACLE: &str =
"Destroy target permanent. Its controller creates a 3/3 green Beast creature token.";
const LIGHTNING_BOLT_ORACLE: &str = "Lightning Bolt deals 3 damage to any target.";

fn add_creature(
state: &mut GameState,
Expand Down Expand Up @@ -462,6 +467,112 @@ mod tests {
);
}

/// #6582 end-to-end: a real 3-damage burn spell must be aimed at the body it
/// can actually kill, not at the biggest threat on the board. This drives the
/// PRODUCTION path — a cast that reaches `TargetSelection`, the registered
/// `EvasionRemovalPriorityPolicy` verdict, and the full `score_candidates`
/// ranking — so the lethality term is proven to survive registry wiring, not
/// just to compute the right number in isolation.
#[test]
fn burn_prefers_the_killable_body_over_the_bigger_unkillable_threat() {
let mut scenario = GameScenario::new_n_player(2, 42);
scenario.at_phase(Phase::PreCombatMain);
let bolt = scenario
.add_spell_to_hand_from_oracle(P0, "Lightning Bolt", true, LIGHTNING_BOLT_ORACLE)
.with_mana_cost(ManaCost::Cost {
shards: vec![ManaCostShard::Red],
generic: 0,
})
.id();
// The killable body is deliberately the LOWER threat, so threat value
// alone (the pre-#6582 ranking) would pick the 7/7 the Bolt can't kill.
let killable = scenario
.add_creature(PlayerId(1), "Scrappy Skirmisher", 2, 2)
.id();
let unkillable = scenario
.add_creature(PlayerId(1), "Looming Colossus", 7, 7)
.id();
scenario.with_mana_pool(
P0,
vec![ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![])],
);

let mut runner = scenario.build();
let card_id = runner.state().objects[&bolt].card_id;
runner
.act(GameAction::CastSpell {
object_id: bolt,
card_id,
targets: Vec::new(),
payment_mode: CastPaymentMode::Auto,
})
.expect("the real Lightning Bolt fixture should reach target selection");

let (pending_cast, target_slots) = match &runner.state().waiting_for {
WaitingFor::TargetSelection {
pending_cast,
target_slots,
..
} => (pending_cast, target_slots),
other => panic!("expected Lightning Bolt target selection, got {other:?}"),
};
let effects = crate::policies::context::collect_ability_effects(&pending_cast.ability);
assert!(
effects.iter().any(|effect| matches!(
effect,
Effect::DealDamage {
amount: QuantityExpr::Fixed { value: 3 },
damage_source: None,
..
}
)),
"reach guard: Lightning Bolt must parse as 3 default-sourced damage"
);
assert!(
effects
.iter()
.all(|effect| !matches!(effect, Effect::Unimplemented { .. })),
"the regression fixture must not silently drop an unsupported clause"
);
assert!(target_slots[0]
.legal_targets
.contains(&TargetRef::Object(killable)));
assert!(target_slots[0]
.legal_targets
.contains(&TargetRef::Object(unkillable)));

let state = runner.state();
let decision = build_decision_context(state);
let config = create_config(AiDifficulty::VeryHard, Platform::Native).into_measurement(42);

let killable_delta = registry_delta(state, &decision, killable, &config);
let unkillable_delta = registry_delta(state, &decision, unkillable, &config);
assert!(
killable_delta > unkillable_delta,
"the registered removal policy must prefer the body the Bolt kills: \
killable 2/2={killable_delta}, unkillable 7/7={unkillable_delta}"
);

let scores = crate::search::score_candidates(state, P0, &config);
assert!(
full_score_for_target(&scores, killable) > full_score_for_target(&scores, unkillable),
"the complete Very Hard scorer must carry the lethality preference through to the \
final target ranking"
);

// The #6582 misplay itself: whatever else Very Hard does with a Bolt
// ("any target" keeps the opponent's face on the table), it must not
// burn it on a 7/7 that survives.
let mut rng = SmallRng::seed_from_u64(42);
assert_ne!(
crate::choose_action(state, P0, &config, &mut rng),
Some(GameAction::ChooseTarget {
target: Some(TargetRef::Object(unkillable)),
}),
"Very Hard must not spend a 3-damage burn spell on a 7/7 it cannot kill"
);
}

#[test]
fn activated_removal_weights_controller_threat_but_beneficial_activation_is_neutral() {
let destroy = Effect::Destroy {
Expand Down
1 change: 1 addition & 0 deletions crates/phase-ai/src/policies/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ mod reactive_self_protection;
mod recursion_awareness;
mod redundancy_avoidance;
pub mod registry;
mod removal_lethality;
mod sacrifice_land_protection;
mod sacrifice_value;
mod self_cost;
Expand Down
Loading
Loading