Skip to content

fix(phase-ai): rank damage-removal targets by whether the damage is lethal - #6665

Merged
matthewevans merged 5 commits into
phase-rs:mainfrom
minion1227:minion_6582_removal_lethality
Jul 26, 2026
Merged

fix(phase-ai): rank damage-removal targets by whether the damage is lethal#6665
matthewevans merged 5 commits into
phase-rs:mainfrom
minion1227:minion_6582_removal_lethality

Conversation

@minion1227

@minion1227 minion1227 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Tier: Frontier
Model: claude-opus-4-8

Closes #6582.

Summary

Makes a direct-damage removal spell prefer a target its damage can actually kill over the biggest body on the board.

EvasionRemovalPriorityPolicy ranked removal targets purely by threat value (removal_target_quality_score grows with the creature's value, capped at 2.0). So a fixed-damage burn spell was pointed at the largest creature — the reporter's 7/7 — even when the spell deals 3 and can't destroy it, wasting the card. Nothing in the AI compared the pending damage to the target's toughness, so "kills it" and "tickles it" scored the same and the biggest threat always won.

New building block policies/removal_lethality.rs — pure functions over the pending spell's own DealDamage effects and the target's runtime toughness / marked damage / indestructibility:

Function Behavior
pending_damage_to_object total damage the pending cast will deal to a candidate, resolved against live state (so X and dynamic amounts are concrete). None when it deals no damage — the signal that keeps the term inert for non-damage removal (-X/-X, destroy, exile)
damage_is_lethal CR 120.6 / CR 704.5g marked-damage-vs-toughness; CR 702.12b indestructible is never lethal; a 0-toughness body is dying to its own SBA, not to the spell
lethality_bonus +LETHAL_BONUS for a clean kill; a waste penalty scaled by the surviving body (indestructible counts its full toughness), capped — so a killable small target outranks a survivable large one

EvasionRemovalPriorityPolicy folds lethality_bonus into its existing additive target score. Because it keys on the effect AST (Effect::DealDamage) and runtime P/T, it covers every direct-damage removal spell, not one card.

Files changed

  • crates/phase-ai/src/policies/removal_lethality.rs (new) · policies/tests/removal_lethality.rs (new)
  • crates/phase-ai/src/policies/evasion_removal_priority.rs (wires the term into the target score)
  • crates/phase-ai/src/policies/mod.rs, policies/tests/mod.rs

CR references

  • CR 120.6 — damage marked accumulates; total marked ≥ toughness is lethal and destroys as an SBA
  • CR 704.5g — the lethal-damage state-based action (toughness > 0, marked ≥ toughness → destroyed)
  • CR 702.12b — indestructible ignores the lethal-damage SBA
  • CR 120.3e — damage from a source without wither/infect is marked on the creature (multiple DealDamage effects stack additively)

Every number grep-verified against docs/MagicCompRules.txt.

Implementation method (required)

Method: manual — traced EvasionRemovalPriorityPolicy end-to-end, confirmed player_impact scores DealDamage as a flat −1.0 (no amount/toughness term), and added the lethality assessment as a reusable helper at the seam where the removal-target verdict originates.

Track

Developer

LLM

Model: claude-opus-4-8
Thinking: high

Performance

lethality_bonus runs only inside EvasionRemovalPriorityPolicy::score, which has already gated on WaitingFor::TargetSelection + a harmful targeted_object_impact. It walks the pending cast's own effect list (a handful of effects) and does one resolve_quantity per DealDamage; no board scan, no affordability sweep, no find_legal_targets. Returns 0.0 immediately when the pending spell deals the target no damage.

Verification

  • cargo test -p phase-ai --lib1514 passed; 0 failed (9 new removal-lethality tests: the pure CR arithmetic — exact/short/prior-damage/indestructible/zero-toughness — plus the composed lethality_bonus over a real pending-cast PolicyContext: lethal target rewarded, non-lethal 7/7 penalized below the +2.0 threat lure, indestructible penalized, non-damage removal inert)
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo fmt --all — clean
  • cargo ai-gate — 0 FAIL. The term only moves removal target choice among creatures a damage spell is already being aimed at; CI's paired-seed AI gate re-confirms on this head.

Notes

  • Distinct from the existing lethality_awareness policy, which is about the AI's own aggression toward the opponent player (opponent_lethal_damage); this is removal-target lethality. Orthogonal scopes.
  • The wiring lives in evasion_removal_priority.rs, whose inline #[cfg(test)] mod tests predates this change; the new logic and its tests are in the dedicated module so they stay independently reviewable.
  • LETHAL_BONUS / WASTE_PENALTY_MULT / WASTE_PENALTY_MAX are local calibrated constants documented in-module, matching the existing VELOCITY_BONUS_* style in the same policy; happy to promote them to PolicyPenalties with a paired-seed calibration if preferred.

Summary by CodeRabbit

  • AI Improvements
    • Improved removal-target prioritization by adding lethality-aware scoring.
    • The AI now strongly prefers “clean kills” and applies bounded penalties for non-lethal outcomes, with accurate handling of deathtouch, wither/infect, indestructible interactions, and zero toughness.
    • Removal actions without damage components no longer receive lethality bonuses.
  • Tests
    • Added focused unit tests for lethal determination and scoring, including unresolved/non-modelable damage cases.
    • Added an end-to-end regression test ensuring the AI selects the killable body over a larger unkillable threat.

…ethal

Closes phase-rs#6582.

EvasionRemovalPriorityPolicy ranked removal targets purely by threat
value, so a fixed-damage burn spell was pointed at the biggest creature
on the board — a 7/7 — even when the spell deals 3 and cannot destroy
it, wasting the card. "Kills it" and "tickles it" scored identically.

New building block `policies/removal_lethality.rs` (pure functions over
the pending spell's own DealDamage effects and the target's runtime
toughness / marked damage / indestructibility):

- pending_damage_to_object: total damage the pending cast will deal to a
  candidate, resolved against live state (X/dynamic amounts concrete);
  None when it deals no damage, so the term stays inert for non-damage
  removal (-X/-X, destroy, exile).
- damage_is_lethal: CR 120.6 / CR 704.5g marked-damage-vs-toughness,
  with CR 702.12b indestructible never lethal and a 0-toughness body not
  "killed" by the spell (its own SBA is).
- lethality_bonus: +clean-kill for a lethal target; a waste penalty
  scaled by the surviving body (indestructible counts full toughness),
  capped — so a killable small target outranks a survivable large one.

EvasionRemovalPriorityPolicy folds the term into its target score. This
covers every direct-damage removal spell, not one card.

Tests: 9 new removal-lethality tests (pure CR arithmetic + composed over
a real pending-cast PolicyContext) + full 1514-test phase-ai lib suite
pass; clippy -p phase-ai --all-targets -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227
minion1227 requested a review from matthewevans as a code owner July 26, 2026 12:14
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 26, 2026
@superagent-security

Copy link
Copy Markdown

🚨 Contributor flagged. Click here for more info: Superagent Dashboard

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: adb942a1-a8ed-47c0-a14a-ec1d2d499e51

📥 Commits

Reviewing files that changed from the base of the PR and between 058baba and d370d52.

📒 Files selected for processing (3)
  • crates/phase-ai/src/policies/evasion_removal_priority.rs
  • crates/phase-ai/src/policies/mod.rs
  • crates/phase-ai/src/policies/tests/mod.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/phase-ai/src/policies/tests/mod.rs
  • crates/phase-ai/src/policies/mod.rs
  • crates/phase-ai/src/policies/evasion_removal_priority.rs

📝 Walkthrough

Walkthrough

Adds lethality-aware removal-target scoring. Pending damage is modeled per target, lethal outcomes receive a fixed bonus, non-lethal damage receives a capped penalty, and unresolved or non-damage removal remains neutral. Tests cover damage, counters, keywords, indestructibility, source resolution, and end-to-end target selection.

Changes

Removal lethality scoring

Layer / File(s) Summary
Lethality calculation helpers
crates/phase-ai/src/policies/removal_lethality.rs
Models pending damage, resolves source-dependent effects, evaluates lethal outcomes, and computes lethal bonuses or non-lethal waste penalties.
Removal priority integration
crates/phase-ai/src/policies/mod.rs, crates/phase-ai/src/policies/evasion_removal_priority.rs
Registers the new module and adds lethality scoring to removal target prioritization, including a Lightning Bolt regression test.
Lethality policy tests
crates/phase-ai/src/policies/tests/mod.rs, crates/phase-ai/src/policies/tests/removal_lethality.rs
Tests arithmetic, pending damage aggregation, source-dependent keywords, non-lethal penalties, batch effects, and unresolved damage neutrality.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested labels: bug

Suggested reviewers: matthewevans

Sequence Diagram(s)

sequenceDiagram
  participant EvasionRemovalPriorityPolicy
  participant lethality_bonus
  participant PolicyContext
  participant Target
  EvasionRemovalPriorityPolicy->>lethality_bonus: score removal target
  lethality_bonus->>PolicyContext: inspect pending DealDamage effects
  PolicyContext-->>lethality_bonus: resolve damage outcome
  lethality_bonus->>Target: evaluate lethality
  Target-->>lethality_bonus: lethal or non-lethal result
  lethality_bonus-->>EvasionRemovalPriorityPolicy: return scoring bonus
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: prioritizing damage-removal targets based on whether the damage is lethal.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/phase-ai/src/policies/removal_lethality.rs`:
- Around line 61-65: Update the damage aggregation in the removal-lethality
logic around the DealDamage handling and effect_targets_object to preserve each
effect’s damage_source semantics. Return a typed per-effect damage outcome,
evaluate deathtouch and wither/infect before aggregating outcomes, and ensure
those sources correctly model lethal damage against large or indestructible
creatures under the Comprehensive Rules. Use composable source-based logic and
typed enums rather than special cases, while preserving ordinary marked-damage
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 190e03a0-880f-411c-9c9c-4dc7b9ea2717

📥 Commits

Reviewing files that changed from the base of the PR and between 778cfab and 62e465b.

📒 Files selected for processing (5)
  • crates/phase-ai/src/policies/evasion_removal_priority.rs
  • crates/phase-ai/src/policies/mod.rs
  • crates/phase-ai/src/policies/removal_lethality.rs
  • crates/phase-ai/src/policies/tests/mod.rs
  • crates/phase-ai/src/policies/tests/removal_lethality.rs

Comment thread crates/phase-ai/src/policies/removal_lethality.rs Outdated
@matthewevans matthewevans self-assigned this Jul 26, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 High — removal lethality drops source-dependent damage semantics

Reviewed commit 62e465b9f49b7fb97c5ae7e4b9224586b3ab3a7e.

crates/phase-ai/src/policies/removal_lethality.rs:60-69 ignores Effect::DealDamage.damage_source, and :78-87 models lethality only as marked damage against toughness with an indestructible exception. That is not the engine's damage model:

  • crates/engine/src/game/effects/deal_damage.rs:1150-1215 resolves DamageSource::Target from the first object target and excludes that source from the recipient slice; EachTarget has its own multi-source path.
  • :164-188 obtains deathtouch, wither, and infect from the resolved source; :519-540 records deathtouch and applies wither/infect as -1/-1 counters rather than ordinary marked damage.
  • crates/engine/src/game/sba.rs:757-780 treats deathtouch damage as lethal independently of damage >= toughness, while 0 toughness is a separate SBA that indestructible does not prevent.

Consequently the policy can score the source as a recipient for DamageSource::Target, miss a source's deathtouch clean kill, and call infect/wither damage to an indestructible creature a waste even when the resulting -1/-1 counters kill it. The existing tests construct only damage_source: None (removal_lethality.rs:151-157), so none would fail for those regressions.

Please preserve source-correct recipient and damage semantics in a typed per-effect outcome before aggregation, or conservatively leave source-dependent cases neutral until that model exists. Add discriminating policy/runtime coverage for:

  1. default-source deathtouch against a creature too large for ordinary marked damage;
  2. infect and wither against an indestructible creature driven to 0 toughness;
  3. DamageSource::Target and DamageSource::EachTarget source/recipient mapping, including the first Target object not being scored as a recipient.

This is a behaviorally incorrect AI-targeting result, so it cannot be enqueued as-is.

@matthewevans matthewevans added the bug Bug fix label Jul 26, 2026
@matthewevans matthewevans removed their assignment Jul 26, 2026
…lity

Review round 1 found the lethality term dropped `Effect::DealDamage`'s
`damage_source` and modelled a kill as marked-damage-vs-toughness with an
indestructible exception. That is not the engine's damage model, so the
policy could mis-rank three whole classes of removal.

Damage results depend on the SOURCE, not just the amount (CR 120.3), so
the per-effect amount is now reduced to a typed `DamageOutcome` (marked
damage + -1/-1 counters + deathtouch) resolved against that effect's own
source, and only then judged by `outcome_is_lethal`, which follows the
same precedence as `engine::game::sba`:

- CR 120.3d + CR 702.80a/702.90c: a wither/infect source marks no damage;
  it puts -1/-1 counters on the creature, lowering toughness (CR 122.1a).
  Reaching 0 toughness is CR 704.5f, which is not a destruction, so
  CR 702.12b indestructible does NOT prevent it — previously scored as a
  waste even when it cleanly killed.
- CR 702.2b + CR 704.5h: a deathtouch source makes any marked damage
  lethal, however large the body — previously missed entirely.
- CR 704.5g: the marked-damage threshold now drops with any -1/-1
  counters the same spell adds.

Where the damage source is not knowable while a target is still being
chosen, the term reports the new `PendingDamage::Unresolved` and the
policy stays neutral instead of scoring a guess:

- `DamageSource::Target` — the first object target IS the source and is
  excluded from the recipient slice, so the object being scored may not
  be a recipient at all (the review's source-scored-as-recipient bug).
- `DamageSource::EachTarget` — every leading target is an independent
  source with its own keywords and its own re-resolved amount.
- `DamageSource::TriggeringSource` — bound to the triggering event's
  object; the engine's `extract_source_from_event` authority is
  crate-private and re-deriving it here would duplicate engine logic.
- `EachDealsDamageEqualToPower` / `EachSourceDealsDamage` / `DamageAll` /
  `ApplyPostReplacementDamage` — per-source batches this layer does not
  model, so they bail rather than silently under-count to zero.

Tests: 22 removal-lethality tests (up from 9). New discriminating cases
for default-source deathtouch on an oversized body, wither and infect
driving an indestructible creature to 0 toughness, counter-reduced
marked-damage thresholds, waste scaled by surviving toughness, and
`Target` / `EachTarget` / `TriggeringSource` / multi-source neutrality —
each paired with a same-amount control that IS scored, so the assertion
discriminates the source semantics rather than the target filter. Full
1527-test phase-ai lib suite passes; clippy -p phase-ai --all-targets
-D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Fixed in 62b1943d8. You were right that the term was reading a damage integer where the engine reads a damage source; the model is now source-first.

Typed per-effect outcome before aggregation. pending_damage_to_object resolves each DealDamage effect's source, then routes that effect's amount into a DamageOutcome { marked, minus_counters, deathtouch } according to that source's keywords (via the same object_has_effective_keyword_kind authority DamageContext::from_source uses), so a spell mixing sources aggregates correctly instead of collapsing to one number. outcome_is_lethal then follows sba.rs's own precedence:

  1. CR 704.5f — -1/-1 counters (CR 120.3d) lower toughness (CR 122.1a); at 0 the creature is put into the graveyard, which is not a destruction, so CR 702.12b indestructible does not prevent it.
  2. CR 702.12b — otherwise indestructible ignores both lethal-damage SBAs.
  3. CR 704.5h + CR 702.2b — any marked damage from a deathtouch source is lethal. Gated on marked damage because a wither/infect deathtouch source marks none, matching damage_marked > 0 in lethal_damage_candidates.
  4. CR 704.5g — marked damage against the counter-reduced toughness.

The waste penalty is likewise scaled by surviving toughness, so wither that doesn't finish the job is penalised as the 7/5 it left behind, not the printed 7/7.

Source-dependent cases now leave the ranking alone via a new PendingDamage::Unresolved: DamageSource::Target (the first object target is the source and is excluded from the recipient slice, so this object may not be a recipient at all), DamageSource::EachTarget (per-source keywords and per-source re-resolved amounts), and DamageSource::TriggeringSource. I kept TriggeringSource neutral rather than re-deriving the event→source mapping in the AI layer, since targeting::extract_source_from_event is crate-private and duplicating it here would put engine logic in the wrong crate — happy to expose it and model that case instead if you'd prefer. EachDealsDamageEqualToPower / EachSourceDealsDamage / DamageAll / ApplyPostReplacementDamage bail the same way, so a multi-source spell can't be silently under-counted to zero and mis-reported as a whiff.

Coverage is 22 tests, up from 9, including your three cases:

  1. default_source_deathtouch_reads_as_a_clean_kill_on_an_oversized_body — 1 deathtouch damage on a 7/7.
  2. wither_kills_an_indestructible_creature_it_drives_to_zero_toughness and infect_kills_an_indestructible_creature_it_drives_to_zero_toughness.
  3. target_sourced_damage_stays_neutral, each_target_sourced_damage_stays_neutral, triggering_source_damage_stays_neutral, multi_source_damage_effects_stay_neutral — asserting PendingDamage::Unresolved, not just a 0.0 score, so the source is never modelled as a recipient hit.

Each source-dependent test is paired with a same-amount control that is scored (and each keyword test with a no-keyword control that whiffs), so the assertions discriminate the source semantics rather than the target filter — none of them pass against the previous commit. Full 1527-test phase-ai lib suite green; clippy -p phase-ai --all-targets -D warnings clean.

`outcome_is_lethal` and the waste-penalty scale both needed toughness
after this spell's -1/-1 counters (CR 120.3d + CR 122.1a) and each did
the saturating subtraction itself. Extract `reduced_toughness` as the one
authority: it is the CR 704.5g lethal-damage threshold, and clamped at 0
it is the surviving body the penalty scales by. No behaviour change —
full 1527-test phase-ai lib suite passes, clippy -p phase-ai
--all-targets -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/phase-ai/src/policies/tests/removal_lethality.rs`:
- Around line 477-490: Extend the removal-lethality tests alongside
multi_source_damage_effects_stay_neutral with companion cases for
Effect::EachDealsDamageEqualToPower, Effect::DamageAll, and
Effect::ApplyPostReplacementDamage. Construct valid instances for each effect
and assert pending_for returns PendingDamage::Unresolved, preserving coverage of
the unresolved batch-damage branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9532a76d-536e-4d76-9bf5-c4f61bbc550c

📥 Commits

Reviewing files that changed from the base of the PR and between 62e465b and 62b1943.

📒 Files selected for processing (2)
  • crates/phase-ai/src/policies/removal_lethality.rs
  • crates/phase-ai/src/policies/tests/removal_lethality.rs

Comment thread crates/phase-ai/src/policies/tests/removal_lethality.rs
@matthewevans matthewevans self-assigned this Jul 26, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — the current head has two remaining test-evidence gaps before this AI-targeting change can merge.

🟡 Non-blocking

  • [MED] The lethal-removal scoring change lacks a production-policy ranking regression. Evidence: crates/phase-ai/src/policies/evasion_removal_priority.rs:64 is the production integration point, while crates/phase-ai/src/policies/tests/removal_lethality.rs:268-288 invokes lethality_bonus / pending_damage_to_object directly. Why it matters: those unit-level probes do not prove TargetSelection, EvasionRemovalPriorityPolicy, and score_candidates carry the bonus through to the actual target ranking. Suggested fix: add an end-to-end fixture with a 3-damage spell, a killable lower-threat creature, and a higher-threat 7/7; assert the production scorer ranks the killable target higher.

  • [LOW] The batch-damage neutral-result claim is wider than its regression coverage. Evidence: crates/phase-ai/src/policies/removal_lethality.rs:209-212 groups EachDealsDamageEqualToPower, EachSourceDealsDamage, DamageAll, and ApplyPostReplacementDamage, but crates/phase-ai/src/policies/tests/removal_lethality.rs:477-489 exercises only EachSourceDealsDamage. Why it matters: a later change can accidentally make an untested grouped variant look lethal. Suggested fix: add one minimal valid fixture for each remaining variant and assert PendingDamage::Unresolved (and therefore a neutral bonus).

Recommendation: request changes — add the production ranking regression and the three missing grouped-variant neutral fixtures, then request another review.

@matthewevans matthewevans removed their assignment Jul 26, 2026
…erage

Review round 2 flagged two test-evidence gaps. Both closed.

**[MED] production-path ranking regression.** The unit probes called
`lethality_bonus` / `pending_damage_to_object` directly and never proved
the bonus survives registry wiring. `burn_prefers_the_killable_body_over_
the_bigger_unkillable_threat` now drives the real path: a real Lightning
Bolt ("Lightning Bolt deals 3 damage to any target.", Oracle text
verified against Scryfall) is cast to `TargetSelection` over an opponent
2/2 and 7/7, with reach guards that the spell parses as 3 default-sourced
damage and drops no clause. It then asserts at all three production
layers — the registered `PolicyRegistry::verdicts` delta, the full
`search::score_candidates` ranking, and `choose_action` not burning the
7/7. The killable body is deliberately the LOWER threat, so threat value
alone picks the wrong target.

Fail-on-revert verified: with the `evasion_removal_priority.rs:64` wiring
neutralized, the registered policy scores the unkillable 7/7 at 2.0 and
the killable 2/2 at 1.095 and the test fails — the exact phase-rs#6582 misplay.

**[LOW] batch-damage coverage matched to the claim.** The grouped match
arm covers four variants but only `EachSourceDealsDamage` was exercised.
`batch_damage_effects_stay_neutral` is now table-driven over all four —
`EachSourceDealsDamage`, `EachDealsDamageEqualToPower`, `DamageAll`, and
`ApplyPostReplacementDamage` — asserting both `PendingDamage::Unresolved`
and a neutral bonus, so a later change can't make an untested grouped
variant look lethal. The `ApplyPostReplacementDamage` snapshot sets every
source characteristic off, so if it were ever modelled instead of bailing
it would read as plain marked damage and the assertion would fail loudly.

Tests: 23 removal-lethality tests + the production regression; full
1528-test phase-ai lib suite passes; clippy -p phase-ai --all-targets
-D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Both gaps closed in 058baba67.

[MED] production ranking regression. Fair hit — the unit probes proved the arithmetic, not the wiring. burn_prefers_the_killable_body_over_the_bigger_unkillable_threat now sits alongside the Beast Within regression and drives the real path: a real Lightning Bolt (Oracle text verified against Scryfall) cast to TargetSelection over an opponent 2/2 and 7/7, with reach guards that it parses as 3 default-sourced damage and drops no clause. It asserts at all three production layers you named:

  • the registered PolicyRegistry::verdicts delta (registry_delta),
  • the full search::score_candidates ranking,
  • choose_action not spending the Bolt on the 7/7 — assert_ne rather than assert_eq, since "any target" legitimately keeps the opponent's face on the table and pinning an exact choice there would be asserting something the fix doesn't claim.

The killable body is deliberately the lower threat, so threat value alone picks the wrong target. Fail-on-revert verified: with the evasion_removal_priority.rs:64 wiring neutralized, the registered policy scores the unkillable 7/7 at 2.0 against the killable 2/2 at 1.095 and the test fails — the #6582 misplay reproduced exactly, then flipped by the fix.

[LOW] batch-damage coverage. Also correct, and the same critique CodeRabbit raised. batch_damage_effects_stay_neutral is now table-driven over all four grouped variants — EachSourceDealsDamage, EachDealsDamageEqualToPower, DamageAll, ApplyPostReplacementDamage — asserting both PendingDamage::Unresolved and a neutral bonus. Table form so the assertion set can't drift behind the match arm it guards. The ApplyPostReplacementDamage snapshot deliberately sets every source characteristic off: if that variant were ever modelled instead of bailing, it would read as plain marked damage and the assertion would fail loudly rather than silently pass.

Full 1528-test phase-ai lib suite green; clippy -p phase-ai --all-targets -D warnings clean.

@matthewevans matthewevans self-assigned this Jul 26, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved on current head d370d52d9a18c66875b058d0daa109564cec767f: the source-dependent damage handling, production ranking regression, and grouped neutral-result coverage now clear review.

@matthewevans
matthewevans enabled auto-merge July 26, 2026 18:07
@matthewevans matthewevans removed their assignment Jul 26, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 26, 2026
Merged via the queue into phase-rs:main with commit 607050a Jul 26, 2026
17 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix contributor:flagged Contributor flagged for review by trust analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI targets creatures with damage spells without considering their toughness?

2 participants