Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
95 changes: 95 additions & 0 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 @@ -397,6 +398,24 @@ fn score_pre_cast(ctx: &PolicyContext<'_>) -> f64 {
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;
}

// Harmful bounce with no opposing legal targets will force a self-bounce line.
if has_harmful_bounce && !has_opponent_bounce_target(ctx, &effects) {
penalty += ctx.penalties().wasted_cast_penalty;
Expand Down Expand Up @@ -5474,4 +5493,80 @@ 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}"
);
}
}
29 changes: 28 additions & 1 deletion crates/phase-ai/src/policies/effect_classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,11 @@ pub(crate) fn effect_polarity(effect: &Effect) -> EffectPolarity {
}

/// Extract the target filter from an effect, if present.
///
/// Inherently-mass effects whose `target` IS the population filter
/// (`DestroyAll`) are surfaced, while scope-keyed mass arms (`SetTapState`
/// All / `Suspect` All) stay hidden because a `Single` sibling exists and only
/// that single scope exposes a selectable target.
pub(crate) fn extract_target_filter(effect: &Effect) -> Option<&TargetFilter> {
match effect {
// Beneficial effects
Expand All @@ -404,10 +409,16 @@ pub(crate) fn extract_target_filter(effect: &Effect) -> Option<&TargetFilter> {
| Effect::Regenerate { target, .. }
| Effect::RemoveAllDamage { target, .. }
| Effect::PreventDamage { target, .. }
// Harmful effects
// Harmful effects. `DestroyAll` is included deliberately β€” unlike the
// `SetTapState`/`Suspect` scope-keyed mass arms below there is NO
// `Single` sibling, so a wipe is inherently mass (CR 701.8) and its
// `target` IS the population filter it destroys. Surfacing it lets
// removal classification see the wipe line of a mixed spell instead of
// treating the whole spell as only its other halves.
| Effect::Destroy { target, .. }
| Effect::DealDamage { target, .. }
| Effect::RemoveCounter { target, .. }
| Effect::DestroyAll { target, .. }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
// Removal / disruption
| Effect::Bounce { target, .. }
| Effect::Counter { target, .. }
Expand Down Expand Up @@ -1089,6 +1100,7 @@ mod lethality_tests {
#[cfg(test)]
mod suspect_scope_tests {
use super::*;
use engine::types::ability::TypedFilter;

// CR 701.60a: mass un-designation ("all suspected creatures are no longer
// suspected", Absolving Lammasu) is a non-targeting population effect. The
Expand Down Expand Up @@ -1135,6 +1147,21 @@ mod suspect_scope_tests {
"mass Unsuspect{{All}} (Absolving Lammasu) is a population effect, not target-filtered"
);
}

#[test]
fn extract_target_filter_handles_destroy_all() {
// `DestroyAll` is inherently mass β€” no `Single` sibling exists β€” so its
// `target` IS the population filter and must be surfaced. Contrast the
// `Suspect`/`SetTapState` All scopes above (CR 701.60a / CR 701.26a/b),
// which stay None because a `Single` sibling exists and only the
// single scope exposes a selectable target (CR 701.8).
let wipe_target = TargetFilter::Typed(TypedFilter::creature());
let wipe = Effect::DestroyAll {
target: wipe_target.clone(),
cant_regenerate: false,
};
assert_eq!(extract_target_filter(&wipe), Some(&wipe_target));
}
}

#[cfg(test)]
Expand Down
Loading
Loading