Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6b0329f
fix(ai): cast-commit whiff for dynamic damage spells
CodeOptimist Aug 8, 2026
3169a94
prevent cast-commit guard from blocking mixed and variable-X removal
CodeOptimist Aug 8, 2026
ff842f9
fix(ai): extend cast-commit guard fail-open to control-changing lines
CodeOptimist Aug 9, 2026
e58b2b6
fix(ai): cover negated/disjunctive control filters in whiff-gate fail…
CodeOptimist Aug 9, 2026
d104717
test(ai): pin nested-AnyOf recursion in whiff-gate fail-open guard
CodeOptimist Aug 9, 2026
5c63806
comments cleanup
CodeOptimist Aug 9, 2026
c073aa2
fix(ai): resolve mixed-removal usefulness from real populations, not …
CodeOptimist Aug 9, 2026
5ffc69f
fix(ai): correct controller CR citations; keep wipes out of targeting…
CodeOptimist Aug 9, 2026
c97eeb6
cleanup comments
CodeOptimist Aug 9, 2026
a30eb9c
fix(ai): evaluate wipes by resolver population, not target legality
CodeOptimist Aug 9, 2026
5ce09cb
test(ai): correct stale DestroyAll mechanism prose in wipe fail-open …
CodeOptimist Aug 9, 2026
d16001e
fix(ai): keep extraction target-only; consult mass population at the …
CodeOptimist Aug 10, 2026
f692171
fix(ai): cite CR 702.11b for hexproof-targeting; drop redundant wipe …
CodeOptimist Aug 10, 2026
8a2cf05
docs(ai): cite CR 702.11b for targeting-immunity in self_protection_c…
CodeOptimist Aug 10, 2026
b548a65
docs(ai): drop out-of-context :397 line refs and blocker phrasing fro…
CodeOptimist Aug 10, 2026
b1005dd
fix(ai): fail open on unbound player-relative wipe populations
CodeOptimist Aug 11, 2026
fe14adf
docs(ai): correct unbound-player-controller justification; document F…
CodeOptimist Aug 11, 2026
7899cd9
docs(ai): drop out-of-context reviewer-session attribution from wipe …
CodeOptimist Aug 11, 2026
6de77a7
fix(ai): respect teams in opponent target checks
matthewevans Aug 11, 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
209 changes: 207 additions & 2 deletions crates/phase-ai/src/policies/anti_self_harm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ use super::effect_classify::{
use super::registry::{
DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy, CRITICAL_MAX,
};
use super::removal_lethality;
use super::strategy_helpers::can_pay_ward_cost;
use crate::features::DeckFeatures;
#[cfg(test)]
Expand Down Expand Up @@ -392,8 +393,34 @@ fn score_pre_cast(ctx: &PolicyContext<'_>) -> f64 {
penalty += ctx.penalties().wasted_cast_penalty;
}

// Harmful creature-only spell (e.g. Murder) but no targetable opponent creatures.
if has_harmful_creature_only_target && !has_targetable_opponent_creature {
// Harmful creature-only spell (e.g. Murder) but no targetable opponent
// creatures. A MIXED spell carrying a useful wipe line (`DestroyAll`,
// CR 701.8) is NOT a whiff even when every opposing creature is
// hexproof/protected: the wipe is NON-targeted and hits the population
// (CR 115.10a), so consult the resolver-mirroring mass seam before
// charging the no-target penalty.
if has_harmful_creature_only_target
&& !has_targetable_opponent_creature
&& !ctx.has_opposing_mass_population()
{
penalty += ctx.penalties().wasted_cast_penalty;
}

// Harmful creature-only spell whose damage is provably non-lethal against
// EVERY legal target (CR 704.5g): committing a whiff burns the card. The
// existing `lethal_to_creature` branch above (is_useful_removal_target)
// only detects provable non-lethality for FIXED damage amounts; for a
// dynamic amount (Slash of Light's "number of creatures you control +
// number of Equipment you control") it fails open as `None` -> "useful",
// so it never fires. `can_kill_any_legal_target` resolves the amount live
// (CR 120.3 / CR 701) via the `removal_lethality` damage model and vetoes
// (soft) only the total whiff. Soft penalty (NOT a hard reject): it mirrors
// the sibling whiff branches, and synergy / prowess / storm-type
// spellslinger policies may still prefer to cast for cast-triggers.
if has_harmful_creature_only_target
&& has_targetable_opponent_creature
&& !removal_lethality::can_kill_any_legal_target(ctx)
{
penalty += ctx.penalties().wasted_cast_penalty;
}

Expand Down Expand Up @@ -5474,4 +5501,182 @@ mod tests {
if reason.kind == "anti_self_harm_lethal_life_cost"
));
}

// Verbatim production shape of the Slash-of-Light gap: a targeted
// creature-only DealDamage whose amount is dynamic (ObjectCount-based, not
// a literal constant). `lethal_to_creature` returns `None` for a non-Fixed
// amount, so `is_useful_removal_target` fails open as "useful" and the
// sibling no-targetable-opponent-creature branch never fires. The
// `removal_lethality::can_kill_any_legal_target` gate must penalise
// committing this when 1 damage is non-lethal to every legal opponent
// creature.
#[test]
fn pre_cast_penalises_dynamic_damage_whiff_that_kills_no_opponent_creature() {
let mut state = make_state();
// AI's single creature makes "number of creatures you control" resolve
// to 1.
add_creature(&mut state, PlayerId(0), "My Bear", 2, 1);
// Opponent's 3/3 that 1 damage cannot kill (CR 704.5g).
add_creature(&mut state, PlayerId(1), "Opponent Bear", 3, 3);

let spell_id = create_object(
&mut state,
CardId(90_000),
PlayerId(0),
"Slash of Light".to_string(),
Zone::Hand,
);
let obj = state.objects.get_mut(&spell_id).unwrap();
let mut my_filter = TypedFilter::creature();
my_filter.controller = Some(ControllerRef::You);
let amount = QuantityExpr::Ref {
qty: QuantityRef::ObjectCount {
filter: TargetFilter::Typed(my_filter),
},
};
obj.abilities = Arc::new(vec![AbilityDefinition::new(
AbilityKind::Spell,
Effect::DealDamage {
amount,
target: TargetFilter::Typed(TypedFilter::creature()),
damage_source: None,
excess: None,
},
)]);

let config = AiConfig::default();
let decision = AiDecisionContext {
waiting_for: WaitingFor::Priority {
player: PlayerId(0),
},
candidates: Vec::new(),
};
let candidate = CandidateAction {
action: GameAction::CastSpell {
object_id: spell_id,
card_id: CardId(90_000),
targets: Vec::new(),
payment_mode: CastPaymentMode::Auto,
},
metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell),
};
let ctx = PolicyContext {
state: &state,
decision: &decision,
candidate: &candidate,
ai_player: PlayerId(0),
config: &config,
context: &crate::context::AiContext::empty(&config.weights),
cast_facts: None,
search_depth: crate::policies::context::SearchDepth::Root,
};
let score = AntiSelfHarmPolicy.score(&ctx);
assert!(
score < -5.0,
"Casting a dynamic burn whose 1 damage kills no opponent creature \
should be penalised, got {score}"
);
}

/// Cast-commit seam regression: a MIXED spell coupling a creature-only
/// damage half with a `DestroyAll` wipe (CR 701.8) must NOT be charged the
/// -8 no-target penalty when its ONLY opposing creature is HEXPROOF.
/// Hexproof (`Keyword::Hexproof`) gates TARGETING only (CR 702.11b) β€” an
/// affected object is not a target β€” while the wipe is NON-targeted and
/// hits the battlefield POPULATION regardless (CR 115.10a). So
/// `has_targetable_opponent_creature` is false here, but
/// `removal_lethality::has_opposing_mass_population` is true, and
/// `score_pre_cast` must consult the mass seam before charging the
/// no-target penalty. Pre-fix, the ordering charged the -8 penalty β€” a
/// false positive for a spell whose wipe genuinely clears the hexproof
/// 3/3. The AI's OWN bear exists solely so the damage half is announceable
/// (CR 601.2c); this test pins the PENALTY question, not castability.
#[test]
fn pre_cast_does_not_penalise_mixed_wipe_when_only_population_is_hexproof() {
let mut state = make_state();
// AI's own creature makes the dynamic ObjectCount amount resolve to 1.
add_creature(&mut state, PlayerId(0), "My Bear", 2, 1);
// The ONLY opposing creature is hexproof (un-targetable, CR 702.11b)
// but is in the wipe's NON-targeted population (CR 115.10a).
let hexproof_bear = add_creature(&mut state, PlayerId(1), "Hexproof Bear", 3, 3);
state
.objects
.get_mut(&hexproof_bear)
.unwrap()
.keywords
.push(Keyword::Hexproof);

let spell_id = create_object(
&mut state,
CardId(90_001),
PlayerId(0),
"Hexproof-Proof Judgement".to_string(),
Zone::Hand,
);
let obj = state.objects.get_mut(&spell_id).unwrap();
let mut my_filter = TypedFilter::creature();
my_filter.controller = Some(ControllerRef::You);
let amount = QuantityExpr::Ref {
qty: QuantityRef::ObjectCount {
filter: TargetFilter::Typed(my_filter),
},
};
obj.abilities = Arc::new(vec![
AbilityDefinition::new(
AbilityKind::Spell,
Effect::DealDamage {
amount,
target: TargetFilter::Typed(TypedFilter::creature()),
damage_source: None,
excess: None,
},
),
// `None` is the serde default for `DestroyAll.target`; construct it
// explicitly so the resolver's `None` -> all-creatures population
// (destroy.rs `resolve_all`) is the point under test.
AbilityDefinition::new(
AbilityKind::Spell,
Effect::DestroyAll {
target: TargetFilter::None,
cant_regenerate: false,
},
),
]);

let config = AiConfig::default();
let decision = AiDecisionContext {
waiting_for: WaitingFor::Priority {
player: PlayerId(0),
},
candidates: Vec::new(),
};
let candidate = CandidateAction {
action: GameAction::CastSpell {
object_id: spell_id,
card_id: CardId(90_001),
targets: Vec::new(),
payment_mode: CastPaymentMode::Auto,
},
metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell),
};
let ctx = PolicyContext {
state: &state,
decision: &decision,
candidate: &candidate,
ai_player: PlayerId(0),
config: &config,
context: &crate::context::AiContext::empty(&config.weights),
cast_facts: None,
search_depth: crate::policies::context::SearchDepth::Root,
};
let score = AntiSelfHarmPolicy.score(&ctx);
assert!(
score > -5.0,
"A mixed deal-1 + destroy-all vs a hexproof-only opposing board \
({score:.3}) must NOT be charged the no-target penalty: the wipe is \
NON-targeted (CR 115.10a) and hits the hexproof 3/3's population \
(hexproof gates targeting only, CR 702.11b), so the mass seam \
rescues the mixed spell from the wasted-cast penalty"
);
}
}
108 changes: 107 additions & 1 deletion crates/phase-ai/src/policies/context.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use engine::ai_support::{AiDecisionContext, CandidateAction};
use engine::game::game_object::GameObject;
use engine::game::players::is_opponent;
use engine::game::targeting::find_legal_targets;
use engine::types::ability::{AbilityDefinition, Effect, ResolvedAbility, TargetFilter, TargetRef};
use engine::types::actions::GameAction;
Expand Down Expand Up @@ -230,13 +231,24 @@ impl<'a> PolicyContext<'a> {
.into_iter()
.any(|target| match target {
TargetRef::Object(id) => self.state.objects.get(&id).is_some_and(|object| {
object.controller != self.ai_player
is_opponent(self.state, self.ai_player, object.controller)
&& object.card_types.core_types.contains(&CoreType::Creature)
&& is_relevant(id)
}),
TargetRef::Player(_) => false,
})
}

/// Does the pending spell carry an inherently-mass effect (`DestroyAll`,
/// CR 701.8) with a non-empty OPPONENT population under the resolver's
/// NON-targeted semantics (CR 115.10a; team-aware via `is_opponent`)? The
/// engine's tactical gate (redundant-removal suppression) and the
/// cast-commit anti-whiff scoring both consult this BEFORE any
/// target-legality gate: a wipe line that clears an un-targetable
/// (hexproof/protected) population is a real removal line, not a whiff.
pub(crate) fn has_opposing_mass_population(&self) -> bool {
super::removal_lethality::has_opposing_mass_population(self)
}
}

/// Walk a ResolvedAbility's sub_ability chain, collecting all effects.
Expand Down Expand Up @@ -269,7 +281,9 @@ mod tests {
use engine::game::zones::create_object;
use engine::types::ability::{
AbilityDefinition, AbilityKind, EffectKind, PtValue, QuantityExpr, TargetFilter,
TypedFilter,
};
use engine::types::format::FormatConfig;
use engine::types::game_state::{PendingCast, TargetEffectDetail, TargetSelectionSlot};
use engine::types::identifiers::{CardId, ObjectId};
use engine::types::mana::ManaCost;
Expand Down Expand Up @@ -530,6 +544,98 @@ mod tests {
assert!(facts.has_direct_removal_text());
}

#[test]
fn legal_opponent_creature_target_is_team_aware() {
let mut state = GameState::new(FormatConfig::two_headed_giant(), 4, 42);
let source_id = create_object(
&mut state,
CardId(10),
PlayerId(0),
"Test Spell".to_string(),
Zone::Hand,
);
let teammate_id = create_object(
&mut state,
CardId(11),
PlayerId(1),
"Teammate Bear".to_string(),
Zone::Battlefield,
);
state
.objects
.get_mut(&teammate_id)
.unwrap()
.card_types
.core_types
.push(CoreType::Creature);

let config = AiConfig::default();
let decision = AiDecisionContext {
waiting_for: WaitingFor::Priority {
player: PlayerId(0),
},
candidates: Vec::new(),
};
let candidate = CandidateAction {
action: GameAction::CastSpell {
object_id: source_id,
card_id: CardId(10),
targets: Vec::new(),
payment_mode: CastPaymentMode::Auto,
},
metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Spell),
};
let ai_context = crate::context::AiContext::empty(&config.weights);
let creature_filter = TargetFilter::Typed(TypedFilter::creature());

{
let ctx = PolicyContext {
state: &state,
decision: &decision,
candidate: &candidate,
ai_player: PlayerId(0),
config: &config,
context: &ai_context,
cast_facts: None,
search_depth: SearchDepth::Root,
};
assert!(
!ctx.has_legal_opponent_creature_target(&creature_filter, source_id, |_| true),
"P1's legal creature target is P0's teammate in 2HG, not an opponent"
);
}

let opponent_id = create_object(
&mut state,
CardId(12),
PlayerId(2),
"Opponent Bear".to_string(),
Zone::Battlefield,
);
state
.objects
.get_mut(&opponent_id)
.unwrap()
.card_types
.core_types
.push(CoreType::Creature);

let ctx = PolicyContext {
state: &state,
decision: &decision,
candidate: &candidate,
ai_player: PlayerId(0),
config: &config,
context: &ai_context,
cast_facts: None,
search_depth: SearchDepth::Root,
};
assert!(
ctx.has_legal_opponent_creature_target(&creature_filter, source_id, |_| true),
"P2's legal creature target is P0's opponent in 2HG"
);
}

fn deadline_test_ctx<'a>(
state: &'a GameState,
decision: &'a AiDecisionContext,
Expand Down
Loading
Loading