Skip to content

feat(phase-ai): pick the least-costly land for a self-bounce, never the loop - #6679

Merged
matthewevans merged 2 commits into
phase-rs:mainfrom
minion1227:minion_4730_self_bounce_target
Jul 26, 2026
Merged

feat(phase-ai): pick the least-costly land for a self-bounce, never the loop#6679
matthewevans merged 2 commits into
phase-rs:mainfrom
minion1227:minion_4730_self_bounce_target

Conversation

@minion1227

@minion1227 minion1227 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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

Closes #4730.

Summary

When a "return a land you control" ETB forces the AI to return one of its own lands, pick the least-costly one — and never the land that just entered.

Azorius Chancery and the rest of the Ravnica/MOM bounce-land ("Karoo") cycle enter and their ETB returns "a land you control" to hand (CR 608.2c, chosen at resolution). No policy scored that choice, so the AI kept returning the bounce-land it had just played — putting it back in hand to be replayed and bounced again, the tempo loop the reporter observed.

land_sequencing fixed the play-sequencing half of this report and its own doc-comment deferred the other half verbatim:

"the AI should return the least-useful land, never the just-played bounce-land — that needs its own target-selection policy."

This is that policy.

How it works

The choice surfaces as a WaitingFor::EffectZoneChoice returning battlefield lands you control to hand; each candidate is a SelectCards selection. Detection is structural (no card names), so it covers every "return a land you control" bouncer:

Candidate land Score Why
the ability's own source (just-played bounce-land) strong penalty CR 608.2c loop guard — chosen only when it is the sole eligible land
an already-tapped land preferred its mana is spent this turn, so replaying it next turn costs the least tempo
an untapped land mild penalty returning it forfeits mana still available this turn, but still beats looping the source

A value self-bounce of a creature (blink, save-from-removal) is deliberately left to the value policies — this policy only governs the land-bounce class.

Files changed

  • crates/phase-ai/src/policies/self_bounce_target.rs (new) · policies/tests/self_bounce_target.rs (new)
  • crates/phase-ai/src/policies/mod.rs, policies/registry.rs (PolicyId + registration), policies/tests/mod.rs

CR references

  • CR 608.2c — a non-targeted "return a land you control" is chosen by its controller as the spell/ability resolves
  • CR 305.1 — lands (the class this policy scopes to)

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

Implementation method (required)

Method: manual — traced the deferred #4b gap from land_sequencing.rs, confirmed the resolution-time bounce surfaces as EffectZoneChoice/SelectCards (mirroring SacrificeValuePolicy), and added a dedicated SelectTarget-population policy.

Track

Developer

LLM

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

Performance

verdict returns neutral immediately unless the candidate is a SelectCards under an EffectZoneChoice that returns AI-controlled battlefield lands to hand — a handful of field checks. No board scan beyond confirming the eligible pool is lands, no affordability sweep, no find_legal_targets.

Verification

  • cargo test -p phase-ai --lib1537 passed; 0 failed (4 new self-bounce tests: the pure return_desirability ordering source < untapped < tapped, and the composed verdict over a real EffectZoneChoice/SelectCards context — prefers a spent land over the bounce source, and stays neutral for a non-land pool and a non-hand destination)
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo fmt --all — clean
  • cargo ai-gate — this policy activates only on a resolution-time land self-bounce, which none of the three gate decks (mono-red burn, affinity, Selesnya enchantress) can produce, so it is inert on the gate suite; CI's paired-seed AI gate confirms on this head.

Notes

  • Scores use the band-helper contract (PolicyVerdict::score), not a raw Score literal.
  • The penalties are local calibrated constants documented in-module (the same style as land_sequencing's BOUNCE_DEPRIORITIZE); a color/mana-redundancy refinement (prefer returning a land whose color you have duplicates of) is a natural follow-up left out to keep this change focused.

Summary by CodeRabbit

  • New Features
    • Added a new AI policy to evaluate which of your controlled lands to return to hand.
    • Improved decision quality by ranking candidate lands based on desirability and game context.
  • Bug Fixes
    • Avoids infinite bounce-back by strongly penalizing returning the original bounce land.
  • Tests
    • Expanded policy and registry routing coverage, including ranking expectations and neutral handling for non-land or non-hand selections.

…he loop

Closes phase-rs#4730.

A "return a land you control" ETB (Azorius Chancery and the rest of the
Ravnica/MOM bounce-land cycle) forces the controller to return one of
their own lands at resolution (CR 608.2c). No policy scored that choice,
so the AI kept returning the bounce-land it had just played — replaying
and re-bouncing it every turn, the tempo loop reported in phase-rs#4730.
`land_sequencing` fixed the play-sequencing half and explicitly deferred
this half to "its own target-selection policy"; this is that policy.

New `policies/self_bounce_target.rs` (SelfBounceTargetPolicy):

- Fires only on the resolution-time land bounce — a
  WaitingFor::EffectZoneChoice returning battlefield lands you control to
  hand — leaving value self-bounce of creatures (blink, save-from-
  removal) to the value policies. Structural, no card names.
- Scores each candidate land by how little returning it costs:
  the ability's own source (the just-played bounce-land) takes a strong
  penalty so it loses to any other land (CR 608.2c loop guard); an
  already-tapped land is preferred (its mana is spent this turn); an
  untapped land is mildly penalized but still beats looping the source.

Covers the whole bounce-land class, not one card.

Tests: 4 new self-bounce tests (pure return_desirability ordering +
composed verdict over a real EffectZoneChoice/SelectCards context) +
full 1537-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 18:37
@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

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: 01b6b8ee-ce0a-478d-8368-89bfc4b40173

📥 Commits

Reviewing files that changed from the base of the PR and between 5919551 and 56424ae.

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

📝 Walkthrough

Walkthrough

Adds SelfBounceTargetPolicy, registers it in the AI policy registry, and scores battlefield-to-hand land returns by penalizing the bounce source, preferring tapped lands, and ignoring ineligible choices. Tests cover scoring, neutrality, and registry routing.

Changes

Self-bounce target selection

Layer / File(s) Summary
Land return scoring heuristic
crates/phase-ai/src/policies/self_bounce_target.rs
Defines desirability scoring for the source land, tapped lands, and untapped lands.
Policy integration and registry wiring
crates/phase-ai/src/policies/mod.rs, crates/phase-ai/src/policies/registry.rs, crates/phase-ai/src/policies/self_bounce_target.rs
Handles eligible EffectZoneChoice decisions and registers the policy for routing.
Scoring and routing tests
crates/phase-ai/src/policies/tests/*
Tests ranking, neutral verdicts, registry registration, and end-to-end policy routing.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AiDecisionContext
  participant PolicyRegistry
  participant SelfBounceTargetPolicy
  AiDecisionContext->>PolicyRegistry: route EffectZoneChoice decision
  PolicyRegistry->>SelfBounceTargetPolicy: evaluate SelectCards candidates
  SelfBounceTargetPolicy->>AiDecisionContext: return desirability score
Loading

Possibly related PRs

  • phase-rs/phase#6602: Adds and registers a tactical policy through the same registry integration points.

Suggested labels: enhancement

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the new self-bounce land-selection policy and the loop-prevention goal.
Linked Issues check ✅ Passed The changes implement the requested fix by preferring another controlled land over the bounce source and adding registry coverage.
Out of Scope Changes check ✅ Passed The PR stays focused on the land-bounce policy, registry wiring, and regression tests with no obvious unrelated changes.
✨ 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: 2

🤖 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/self_bounce_target.rs`:
- Around line 44-54: Replace the raw score values in RETURN_SOURCE_PENALTY,
RETURN_TAPPED_BONUS, and RETURN_UNTAPPED_PENALTY with the corresponding
PolicyVerdict penalty and bonus band helpers. Preserve the existing outcome
mapping: source return uses the penalty band, tapped-land return uses the bonus
band, and untapped non-source return uses the penalty band.
- Around line 108-116: Update the WaitingFor::EffectZoneChoice pattern in the
self-bounce resolution policy to bind and require is_cost_payment: false,
excluding cost-payment land returns while preserving resolution-time bounce
handling. Add a neutral regression test covering a controlled land returned to
hand as an activated-ability cost.
🪄 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: 80173a2e-b937-4ac8-b515-39a38a7577ea

📥 Commits

Reviewing files that changed from the base of the PR and between 607050a and 5919551.

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

Comment on lines +44 to +54
/// Penalty for returning the ability's own source — the just-played bounce-land.
/// Returning it re-buys the land drop for nothing and loops, so it must lose to
/// any other land the pool offers.
pub(crate) const RETURN_SOURCE_PENALTY: f64 = -3.0;
/// Bonus for returning an already-tapped land: its mana is spent this turn, so
/// replaying it next turn costs the least tempo.
pub(crate) const RETURN_TAPPED_BONUS: f64 = 1.0;
/// Penalty for returning an untapped land: doing so forfeits mana still
/// available this turn. Milder than the source penalty — an untapped non-source
/// land is still a better return than looping the bounce-land.
pub(crate) const RETURN_UNTAPPED_PENALTY: f64 = -0.5;

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the registry’s PolicyVerdict band helpers rather than raw score constants.

The literal -3.0, 1.0, and -0.5 bypass the required verdict-band contract, making this policy’s influence unstable when composed with other tactical policies. Map the three outcomes to the established penalty/bonus helpers.

As per path instructions, “New tactical policies must use the PolicyVerdict band helpers, not raw sentinel scores.”

Also applies to: 140-144

🤖 Prompt for 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.

In `@crates/phase-ai/src/policies/self_bounce_target.rs` around lines 44 - 54,
Replace the raw score values in RETURN_SOURCE_PENALTY, RETURN_TAPPED_BONUS, and
RETURN_UNTAPPED_PENALTY with the corresponding PolicyVerdict penalty and bonus
band helpers. Preserve the existing outcome mapping: source return uses the
penalty band, tapped-land return uses the bonus band, and untapped non-source
return uses the penalty band.

Source: Path instructions

Comment on lines +108 to +116
let WaitingFor::EffectZoneChoice {
player,
source_id,
effect_kind: EffectKind::ChangeZone,
zone: Zone::Battlefield,
destination: Some(Zone::Hand),
cards: pool,
..
} = &ctx.decision.waiting_for

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exclude land-return cost payments from this resolution policy.

is_cost_payment is ignored, so any activated-ability cost that returns a controlled land to hand receives this source/tapped heuristic. Gate on is_cost_payment: false and add a neutral regression test; this policy is intended for resolution-time bounce effects.

Proposed guard
             destination: Some(Zone::Hand),
             cards: pool,
+            is_cost_payment: false,
             ..

As per path instructions, policies must be composable building blocks for the intended class rather than unrelated selections.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let WaitingFor::EffectZoneChoice {
player,
source_id,
effect_kind: EffectKind::ChangeZone,
zone: Zone::Battlefield,
destination: Some(Zone::Hand),
cards: pool,
..
} = &ctx.decision.waiting_for
let WaitingFor::EffectZoneChoice {
player,
source_id,
effect_kind: EffectKind::ChangeZone,
zone: Zone::Battlefield,
destination: Some(Zone::Hand),
cards: pool,
is_cost_payment: false,
..
} = &ctx.decision.waiting_for
🤖 Prompt for 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.

In `@crates/phase-ai/src/policies/self_bounce_target.rs` around lines 108 - 116,
Update the WaitingFor::EffectZoneChoice pattern in the self-bounce resolution
policy to bind and require is_cost_payment: false, excluding cost-payment land
returns while preserving resolution-time bounce handling. Add a neutral
regression test covering a controlled land returned to hand as an
activated-ability cost.

Source: Path instructions

@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 policy's ranking is tested only through a direct call, leaving the production registry/routing seam unproven.

🔴 Blocker

[MED] Missing registry-routed regression for SelfBounceTargetPolicy. Evidence: crates/phase-ai/src/policies/tests/self_bounce_target.rs:165-212 constructs EffectZoneChoice but calls SelfBounceTargetPolicy.verdict directly; production depends on registry registration at crates/phase-ai/src/policies/registry.rs:401 and EffectZoneChoice routing through crates/phase-ai/src/decision_kind.rs:100-201. Why it matters: the direct probe stays green if the policy is omitted from PolicyRegistry or no longer receives the routed decision kind, so it does not protect the shipped behavior. Suggested fix: add a registry-routed EffectZoneChoice / SelectCards regression that asserts PolicyId::SelfBounceTarget is present and preserves the ordering: tapped other land > untapped other land > source. Follow the production-seam pattern in crates/phase-ai/src/policies/tests/poison.rs:788-831.

Recommendation: request changes — add the registry-routed regression, then request another review.

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

Round-1 review (phase-rs#6679): the verdict tests called SelfBounceTargetPolicy
directly, so they would stay green if the policy were dropped from
PolicyRegistry or the EffectZoneChoice decision-kind routing changed —
they did not protect the shipped seam.

Adds, following the poison policy's production-seam pattern:
- registry_registers_the_policy: PolicyRegistry::default() contains
  PolicyId::SelfBounceTarget.
- registry_routes_the_land_bounce_ordering: routes each SelectCards
  selection through PolicyRegistry::verdicts (classify → filter by
  DecisionKind → activation → verdict) and asserts the production
  ordering tapped-other > untapped-other > source.

The shared `eval` helper gained a `route` flag so the direct and routed
paths build the identical EffectZoneChoice context.

cargo test -p phase-ai --lib self_bounce — 6 pass; 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 at head 56424aeac.

[MED] Registry-routed regression added

You're right — the verdict tests probed SelfBounceTargetPolicy.verdict directly, so they'd stay green even if the policy were dropped from PolicyRegistry or EffectZoneChoice stopped routing to its declared DecisionKind. Added two tests following the poison.rs production-seam pattern:

  • registry_registers_the_policyPolicyRegistry::default().has_policy(PolicyId::SelfBounceTarget).
  • registry_routes_the_land_bounce_ordering — routes each SelectCards selection through PolicyRegistry::verdicts (classify → filter by DecisionKind::ActivateAbilityactivationverdict) and asserts the shipped ordering tapped-other > untapped-other > source. Each routed_delta is Some, so it also proves the policy actually runs at the routed kind.

The shared eval helper gained a route flag so the direct and routed paths build the identical EffectZoneChoice context.

cargo test -p phase-ai --lib self_bounce — 6 pass; 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 56424aeacd6ce373d435edaa52242182d3e28bf1: the registry-routed EffectZoneChoice regression proves SelfBounceTargetPolicy is registered and preserves source < untapped other < tapped other ranking.

@matthewevans matthewevans added the enhancement New feature or request label Jul 26, 2026
@matthewevans
matthewevans enabled auto-merge July 26, 2026 19:04
@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 c8fdd42 Jul 26, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:flagged Contributor flagged for review by trust analysis. enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

land targeting issue — AI gets stuck in a loop choosing the same land it played while having other options on board Azo…

2 participants