Skip to content

feat(phase-ai): add draw-matters deck-feature axis + DrawPayoffPolicy#6688

Open
minion1227 wants to merge 16 commits into
phase-rs:mainfrom
minion1227:minion_draw_matters_axis
Open

feat(phase-ai): add draw-matters deck-feature axis + DrawPayoffPolicy#6688
minion1227 wants to merge 16 commits into
phase-rs:mainfrom
minion1227:minion_draw_matters_axis

Conversation

@minion1227

@minion1227 minion1227 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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

Summary

Adds a draw-matters deck-feature axis (DrawMattersFeature) and its companion DrawPayoffPolicy — CR 121.1 "whenever you draw."

With a "whenever you draw a card" engine on the battlefield — The Locust God (make an Insect), Psychosis Crawler / Niv-Mizzet (ping each opponent), Chulane — every extra draw is a repeatable value trigger. card_advantage values having cards but not triggering the engine, so the AI won't lean into an extra-draw spell or ability when it has a payoff out. This axis lets a policy see that engine.

Structural detection, no card-name matching:

Axis AST surface
sources Effect::Draw { target: Controller } (CR 121.1) — the card-draw enablers that feed the engine
payoffs a TriggerMode::Drawn trigger (CR 121.1) that is controller-scoped (valid_target None/Controller) and not self-referential, so a "when this card is drawn" hand trigger is excluded — only repeatable battlefield engines count

Commitment is a geometric mean over (source, payoff) — both mandatory: card draw with no engine is just card advantage (card_advantage governs it), an engine with no way to draw extra only fires on the natural draw for turn.

Policy rewards a CastSpell/ActivateAbility that draws the controller a card — detected via the action's own CastFacts primary/ETB effects (a cast permanent's activated draw ability doesn't fire on cast) or the activated ability — when the AI controls a live draw engine (structural match over trigger_definitions, the runtime trigger authority). Composes with card_advantage the same way CyclingDiscipline (patience) composes with a payoff policy.

Files changed

  • crates/phase-ai/src/features/draw_matters.rs (new) · features/tests/draw_matters.rs (new)
  • crates/phase-ai/src/policies/draw_payoff.rs (new) · policies/tests/draw_payoff.rs (new)
  • crates/phase-ai/src/features/mod.rs, features/tests/mod.rs, policies/mod.rs, policies/registry.rs, policies/tests/mod.rs, config.rs

CR references

  • CR 121.1 — a card is drawn (the TriggerMode::Drawn event and the Effect::Draw enabler)

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

Implementation method (required)

Method: /add-ai-feature-policy

Track

Developer

LLM

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

Performance

verdict() runs per candidate per search node. The card-local check — does this action draw the controller a card (its own CastFacts primary/ETB effects, or the activated ability) — runs FIRST and rejects every non-draw action; only then does it scan the battlefield for a live engine, and only in a deck whose activation floor is already cleared. No affordability sweep, no find_legal_targets.

Verification

Round-11 head 1a411e1c2:

  • cargo test -p engine --lib17817 passed; 0 failed (covers the inline cfg(test) suites in replacement.rs and draw.rs, the two files this round changes)
  • cargo test -p phase-ai --lib1636 passed; 0 failed (feature-detection axes + policy verdict branches, incl. registry-routed CastSpell and ActivateAbility regressions, and six paired replacement cases: mandatory execute substitution, mandatory runtime_execute substitution and zero-count rescale all withhold; optional substitution, opponent-scoped substitution and positive count rescale all still pay)
  • cargo test -p engine --test integration draw_preflight_matches_live_pipeline8 passed; 0 failed — the preflight-vs-pipeline equivalence suite: each shape asks can_draw_at_least_one for a prediction, then drives the REAL draw and asserts predicted == observed, with expected delivery pinned independently so a regression breaking both sides alike still fails. Covers all five suppression legs (draw restriction, empty library, Prevent, both substitute slots, zero-count) plus unreplaced and count-modified surviving controls.
  • Fail-on-revert evidence: reverting the preflight to the prior prevention-only logic fails exactly the three suppression cases and leaves the three positive controls green — the controls are not vacuous.
  • cargo clippy -p engine --all-targets --features proptest -- -D warnings — clean
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo fmt --all — clean
  • scripts/draw_replacement_census.py --producers --check — PASS (the file's two Draw-definition producers are consolidated into one parameterized helper; single renamed row, re-frozen with --write)
  • cargo ai-gate — the policy activates only on a draw-engine deck, which none of the three gate matchups (mono-red burn, affinity, Selesnya enchantress) is, so DrawPayoffPolicy::activation returns None and it never fires; CI's paired-seed AI gate is authoritative on this head.

Gate A

./scripts/check-parser-combinators.sh against the committed head:

Gate G PASS (router/grant architecture: strict router vs permissive grant boundary intact)
Gate A PASS head=1a411e1c2e73fed826867ed68c7bff0bdc0dfa15 base=8f2abfe08268785533cc2694b0bd775605c54796

Notes

  • draw_payoff_bonus is registered UNTUNED pending a paired-seed calibration.
  • Payoff detection is structural over live trigger_definitions at decision time (not a name lookup), the pattern adopted for the cycling axis.
  • Boundary with card_advantage / spellslinger_prowess: card_advantage scores the card itself; this axis adds the extra value a draw carries when it fires an engine. spellslinger_prowess counts spell-cast triggers; a draw event is a disjoint trigger. A card can read on both axes — the overlap is intentional and the axes stay independent.

Summary by CodeRabbit

  • New Features
    • Added a new “whenever you draw” deck feature that detects draw sources and draw-payoff engines to produce a commitment score.
    • Introduced a draw-focused AI policy (with new draw_payoff_bonus tuning) that prioritizes drawing when qualifying draw-payoff engines are active.
  • Bug Fixes
    • Improved trigger preflight so engines aren’t rewarded when their required trigger/execute targets can’t be satisfied.
  • Tests
    • Added/expanded unit tests for draw-matters detection, draw-payoff activation gating, modal classification, timing/trigger constraints, and policy routing.

CR 121.1: with a "whenever you draw a card" engine on the battlefield
(The Locust God, Psychosis Crawler, Niv-Mizzet), every extra draw is a
repeatable value trigger. card_advantage values having cards but not
triggering the engine, so the AI won't lean into extra draws when it has
a payoff out.

New DrawMattersFeature axis (structural, no card names):
- source_count: card-draw enablers (Effect::Draw scoped to Controller).
- payoff_count: permanents with a controller-scoped, non-self
  TriggerMode::Drawn engine trigger.
- commitment: geometric mean over (source, payoff) — both mandatory
  (draw with no engine is just card advantage; an engine with no extra
  draw only fires on the natural draw for turn).

New DrawPayoffPolicy: on a CastSpell/ActivateAbility that draws the
controller a card (its own CastFacts primary/ETB effects, or the
activated ability), if the AI controls a live draw engine (structural
match over trigger_definitions), score a positive per-engine bonus.
Composes with card_advantage. draw_payoff_bonus registered UNTUNED.

Tests: 11 feature + 6 policy (incl a registry-routed regression and a
name-only-impostor neutral case) + full 1582-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 27, 2026 00:14
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 27, 2026
@superagent-security

Copy link
Copy Markdown

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

@coderabbitai

coderabbitai Bot commented Jul 27, 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
📝 Walkthrough

Walkthrough

Adds structural deck detection for controller-drawing sources and “whenever you draw” engines, aggregates the feature, and introduces a registered policy that rewards qualifying draw actions. Configuration defaults, trigger preflight support, and feature and policy tests are included.

Changes

Draw Matters AI Policy

Layer / File(s) Summary
Draw-matters feature detection
crates/phase-ai/src/features/draw_matters.rs, crates/phase-ai/src/features/mod.rs, crates/phase-ai/src/features/tests/*
Classifies controller draw sources and draw-payoff triggers structurally, computes commitment, exposes it through DeckFeatures, and tests classification and calibration behavior.
Hypothetical trigger fireability
crates/engine/src/game/ability_utils.rs, crates/engine/src/game/effects/draw.rs, crates/engine/src/game/triggers.rs
Adds conservative trigger-constraint, draw-permission, and execute-target preflight used to identify currently fireable draw-payoff engines.
Draw-payoff policy integration
crates/phase-ai/src/config.rs, crates/phase-ai/src/policies/draw_payoff.rs, crates/phase-ai/src/policies/mod.rs, crates/phase-ai/src/policies/registry.rs
Adds the configurable payoff bonus, implements candidate and battlefield trigger checks, and registers DrawPayoffPolicy.
Policy validation and routing
crates/phase-ai/src/policies/tests/*
Tests activation thresholds, draw-action verdicts, fireability limits, target legality, draw restrictions, impostor engines, and registry routing.

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

Possibly related PRs

  • phase-rs/phase#6683: Extends PolicyPenalties and tuning registration with a related payoff-bonus field.

Suggested labels: quality

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 92.31% 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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding the draw-matters feature axis and DrawPayoffPolicy.
✨ 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

🧹 Nitpick comments (1)
crates/phase-ai/src/policies/draw_payoff.rs (1)

108-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer an exhaustive match over the _ => false fallback.

Coding guidelines call for exhaustive match over wildcard defaults so new GameAction variants surface at compile time rather than silently falling through to false here.

As per coding guidelines, "Use idiomatic Rust: prefer enums over stringly typed data, exhaustive match over wildcard defaults" and path instructions for crates/**/*.rs: "wildcard _ match arms where the enum is known and an exhaustive match would let the compiler catch missing variants."

🤖 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/draw_payoff.rs` around lines 108 - 121, The
candidate_draws_controller match currently hides newly added GameAction variants
behind a wildcard false arm. Replace `_ => false` with explicit arms for every
remaining GameAction variant, preserving false behavior for non-CastSpell and
non-ActivateAbility actions while making the match exhaustive.

Sources: Coding guidelines, Path instructions

🤖 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/draw_payoff.rs`:
- Around line 162-286: Add verdict tests for DrawPayoffPolicy’s ActivateAbility
path using an ability activation candidate, including a modal ability whose
selected mode draws and another mode does not. Assert the drawing mode receives
the engine-active reward while the non-drawing mode remains neutral, exercising
candidate_draws_controller’s ActivateAbility branch and selected-mode scoping.

---

Nitpick comments:
In `@crates/phase-ai/src/policies/draw_payoff.rs`:
- Around line 108-121: The candidate_draws_controller match currently hides
newly added GameAction variants behind a wildcard false arm. Replace `_ =>
false` with explicit arms for every remaining GameAction variant, preserving
false behavior for non-CastSpell and non-ActivateAbility actions while making
the match exhaustive.
🪄 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: 9d9b5814-7a9a-4738-862f-3addf971a63a

📥 Commits

Reviewing files that changed from the base of the PR and between 6e0701b and 7952325.

📒 Files selected for processing (10)
  • crates/phase-ai/src/config.rs
  • crates/phase-ai/src/features/draw_matters.rs
  • crates/phase-ai/src/features/mod.rs
  • crates/phase-ai/src/features/tests/draw_matters.rs
  • crates/phase-ai/src/features/tests/mod.rs
  • crates/phase-ai/src/policies/draw_payoff.rs
  • crates/phase-ai/src/policies/mod.rs
  • crates/phase-ai/src/policies/registry.rs
  • crates/phase-ai/src/policies/tests/draw_payoff.rs
  • crates/phase-ai/src/policies/tests/mod.rs

Comment thread crates/phase-ai/src/policies/tests/draw_payoff.rs
Parity with the phase-rs#6683 review fix: the draw-payoff scan matched an engine
only by its structural trigger shape, so a rate-limited "whenever you
draw ... only once each turn" engine would still earn a bonus on a second
draw even though its trigger can't fire again.

The battlefield scan now pairs the structural classifier with live firing
eligibility per trigger entry — a OncePerTurn / OncePerGame engine that
has already fired (per the engine's authoritative triggers_fired_this_turn
/ triggers_fired_this_game ledgers, keyed via
GameObject::trigger_definition_ref) no longer counts. trigger_is_draw_payoff
is exposed as is_draw_payoff_trigger so the per-entry check can pair shape
with eligibility.

Regressions: a once-per-turn engine already fired this turn scores
neutral; the same engine unfired still rewards.

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

Copy link
Copy Markdown
Contributor Author

Re: the CodeRabbit nitpick on candidate_draws_controller's _ => false arm — keeping it as-is, intentionally.

  • Precedent: this mirrors the merged graveyard_types::candidate_fills_own_graveyard (and hand_disruption, board_development, anti_self_harm, …), which all match the two action-bearing GameAction variants and fall the rest through to a default. GameAction is a ~30-variant enum; an exhaustive false-for-everything-else match would be ~28 lines of noise and diverge from that established pattern.
  • Unreachable in production: DrawPayoffPolicy::decision_kinds() is [CastSpell, ActivateAbility], so PolicyRegistry only ever routes those two GameAction kinds here. The _ arm is a defensive default for an unreachable case, not a place where a future variant would silently need draw-detection — a new GameAction that draws cards would arrive as a new DecisionKind and wouldn't reach this function at all.

Happy to expand it if you'd prefer the fully-exhaustive form despite the precedent — just say the word.

@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/draw_payoff.rs`:
- Around line 260-316: Add symmetric OncePerGame coverage near
once_per_turn_engine: create an once_per_game_engine helper using
TriggerConstraint::OncePerGame, then add a test that inserts its trigger key
into triggers_fired_this_game and verifies DrawPayoffPolicy returns
draw_payoff_no_engine with zero delta. Also add an unfired OncePerGame control
test confirming the engine remains rewarding.
🪄 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: 8d5d0e3f-0323-4000-8a65-d69020ad3275

📥 Commits

Reviewing files that changed from the base of the PR and between 7952325 and 09edbb1.

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

Comment thread crates/phase-ai/src/policies/tests/draw_payoff.rs
@matthewevans matthewevans self-assigned this Jul 27, 2026
@matthewevans matthewevans added the enhancement New feature or request label Jul 27, 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 — candidate timing and trigger eligibility are both overstated in the live draw-payoff score.

🟡 Blockers

[MED] A modal/else draw is credited before its draw mode is selected. crates/phase-ai/src/features/draw_matters.rs:98-109 scans with AbilityScope::Potential, and draw_payoff.rs:110-121 uses that classifier directly for the live candidate. Potential is appropriate for deck classification, but not an action before SelectModes: a non-draw mode can receive a draw payoff. Split runtime scanning to unconditional effects plus the definitions selected by SelectModes, and cover paired draw/non-draw modal choices.

[MED] trigger_still_fireable treats every constraint except OncePerTurn/Game as live. draw_payoff.rs:126-140 therefore rewards engines that cannot trigger under constraints such as OnlyDuringYourTurn, main-phase, nth-draw, or max-times. The engine's trigger path evaluates the full constraint authority (crates/engine/src/game/triggers.rs:8005+); use the authoritative occurrence/fireability evaluation rather than a partial policy mirror, with regressions for those unavailable cases.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…omplete trigger eligibility

Round-1 review (phase-rs#6688): two ways the live draw-payoff score was overstated.

[MED] A modal/else draw was credited before its mode was selected.
`is_draw_source_parts` scanned with `AbilityScope::Potential`, so a modal
"choose one — deal damage; OR draw a card" got a draw payoff even when the
non-draw mode would be chosen. The predicate now takes the scope: deck-time
detection keeps `Potential` (a modal draw still marks the card), but the live
candidate scan uses `Unconditional` (CR 700.2) so only a draw that always
happens is credited pre-mode-selection.

[MED] `trigger_still_fireable` treated every constraint except OncePerTurn/Game
as live, rewarding engines that cannot fire. It is now exhaustive over
`TriggerConstraint` (no wildcard): OncePerTurn/Game via the fired-trigger
ledgers; OnlyDuringYourTurn / OnlyDuringOpponentsTurn / OnlyDuringYourMainPhase
via `active_player` + `phase`; and the event/count-dependent constraints
(MaxTimesPerTurn, NthSpell/NthDraw, OncePerOpponentPerTurn, AtClassLevel,
EventSourceControlledBy) conservatively NOT confirmed, so the payoff is never
over-credited.

Tests: modal-draw-not-credited (+ deck-time still counts it), OncePerGame
fired/unfired, OnlyDuringYourTurn off-turn/on-turn; full 1590-test phase-ai lib
suite pass; clippy -p phase-ai --lib --tests -D warnings clean.

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

Copy link
Copy Markdown
Contributor Author

Both blockers fixed at head 8af6c3165.

[MED] Modal/else draw credited before mode selection

is_draw_source_parts now takes an AbilityScope. Deck-time detection keeps Potential (a modal draw mode still marks the card as a draw enabler for the archetype), but the live candidate scan uses Unconditional (CR 700.2), so a modal "choose one — deal 3 damage; OR draw a card" is not credited a draw until the draw mode is actually chosen.

Tests: modal_draw_not_credited_before_mode_selected (runtime → draw_payoff_na) and modal_draw_mode_still_counts_as_a_deck_source (deck-time still counts it).

[MED] trigger_still_fireable incomplete

It's now exhaustive over TriggerConstraint (no wildcard), evaluated against authoritative state:

Constraint Check
OncePerTurn / OncePerGame triggers_fired_this_turn / _game ledger, keyed via trigger_definition_ref
OnlyDuringYourTurn / OnlyDuringOpponentsTurn state.active_player vs the engine's controller
OnlyDuringYourMainPhase your turn and state.phase ∈ {PreCombatMain, PostCombatMain}
MaxTimesPerTurn, NthSpellThisTurn, NthDrawThisTurn, OncePerOpponentPerTurn, AtClassLevel, EventSourceControlledBy conservatively not confirmed at decision time (depends on the triggering event or a per-turn count) → no bonus, so the payoff is never over-credited

Tests: once_per_game_engine_already_fired_is_neutral / _unfired_rewards, only_during_your_turn_engine_is_neutral_off_turn / _rewards_on_your_turn.

The exhaustive match also resolves the earlier CodeRabbit nitpick about wildcard arms here.

Full 1590-test phase-ai suite pass; clippy -p phase-ai --lib --tests -D warnings clean. (The same eligibility-completeness fix is going onto #6683's cycling policy for parity.)

minion1227 added a commit to minion1227/phase that referenced this pull request Jul 27, 2026
…e constraints)

Parity with the phase-rs#6688 review: trigger_still_fireable is now exhaustive over
TriggerConstraint (no wildcard). OncePerTurn/Game via the fired-trigger ledgers;
OnlyDuringYourTurn / OnlyDuringOpponentsTurn / OnlyDuringYourMainPhase via
active_player + phase; and the event/count-dependent constraints
(MaxTimesPerTurn, NthSpell/NthDraw, OncePerOpponentPerTurn, AtClassLevel,
EventSourceControlledBy) conservatively NOT confirmed so the payoff is never
over-credited.

Regressions: once-per-game engine already fired -> neutral; OnlyDuringYourTurn
engine on the opponent's turn -> neutral.

cargo test -p phase-ai --lib cycling_payoff — 13 pass; clippy
-p phase-ai --lib --tests -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/draw_payoff.rs`:
- Around line 415-448: Extend the draw payoff timing-constraint tests around
trigger_still_fireable to cover TriggerConstraint::OnlyDuringOpponentsTurn and
TriggerConstraint::OnlyDuringYourMainPhase: assert rewards on each constraint’s
valid turn/phase, zero payoff on invalid turns, and zero payoff for a non-main
phase. Reuse the existing state, engine_with_constraint, draw_spell, and verdict
setup while preserving the current OnlyDuringYourTurn cases.
🪄 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: 647fb847-c745-4c3d-9ed9-a38482fc59fd

📥 Commits

Reviewing files that changed from the base of the PR and between 09edbb1 and 8af6c31.

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

Comment thread crates/phase-ai/src/policies/tests/draw_payoff.rs
@minion1227
minion1227 requested a review from matthewevans July 27, 2026 01:19

@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.

Reviewed at head 8af6c31658cf42b10e06cd03ebefdcd894e1afc4.

[MED] Live draw-payoff detection treats a structurally matching trigger as a usable payoff without evaluating its trigger condition or resolution targets. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:83-86,146-172; the production trigger pipeline evaluates TriggerDefinition::condition with the event and source context and rejects unresolvable target sets (crates/engine/src/game/triggers.rs:435-445,1447-1473,4511-4539). Why it matters: the policy awards a draw bonus for engines that will produce no value, such as Wizard Class at level 3 with no creature you control to target. Suggested fix: use an engine-owned, event/source-aware eligibility preflight for the prospective draw event, including execution target legality, or conservatively reject every engine whose eligibility cannot be established; add Wizard Class zero-target and legal-target runtime cases.

[MED] The cast candidate's immediate-ETB draw path ignores the ETB trigger's condition. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:115-123 classifies every qualifying ETB execute body, while crates/phase-ai/src/cast_facts.rs:330-336 admits it based only on its zone-change shape and execute. Why it matters: Latchkey Faerie is credited as a draw spell even when its prowl cost was not paid, because its condition is never preflighted. Suggested fix: route immediate-ETB draw detection through the same event/source-aware preflight (or reject conditional ETBs until that exists), with runtime no-prowl and prowl-paid Latchkey Faerie cases.

[MED] Deck draw-source detection omits ETB cantrips that the runtime policy intentionally rewards. Evidence: crates/phase-ai/src/features/draw_matters.rs:82-85 scans only face.abilities, whereas crates/phase-ai/src/policies/draw_payoff.rs:115-124 also recognizes immediate ETB trigger bodies. Why it matters: a deck relying on enter-the-battlefield draw sources is undercounted and can miss the activation floor even though those same casts receive the live bonus. Suggested fix: include structurally qualifying ETB cantrip triggers in the deck feature's potential-source scan and add a detector regression that changes source_count/commitment.

[MED] The decision branches introduced by this policy still lack discriminating coverage. Evidence: crates/phase-ai/src/policies/tests/draw_payoff.rs:118-128,186-448 constructs only GameAction::CastSpell candidates and tests only OnlyDuringYourTurn; DrawPayoffPolicy::decision_kinds() also registers ActivateAbility, and trigger_still_fireable separately handles OnlyDuringOpponentsTurn and OnlyDuringYourMainPhase (crates/phase-ai/src/policies/draw_payoff.rs:49-51,125-130,158-164). Why it matters: regressions in activated-draw selection and the remaining timing paths can ship while every current assertion stays green. Suggested fix: add verdict/registry tests that reach ActivateAbility (including draw/non-draw selected branches) and positive/negative runtime cases for opponent-turn and both main-phase timing constraints.

…d 2)

Addresses matthewevans' four [MED] blockers on phase-rs#6688 by moving live
"whenever you draw" payoff eligibility into a single engine authority that
covers the COMPLETE trigger condition, not just selected constraint variants.

Engine (single authority):
- `triggers::hypothetical_trigger_fireable(state, source, entry)` — the one
  place a policy asks "is this on-battlefield payoff live?". It reuses the
  live pipeline's `check_trigger_constraint_with_ref` (now `event: Option<&_>`;
  Some = real eval, None = hypothetical → event-dependent constraints report
  NOT-satisfied rather than guess), rejects an intervening-if `condition`
  (CR 603.4, conservative), and preflights execution target legality via
  `ability_utils::execute_targets_satisfiable` (CR 603.3d — a mandatory-target
  trigger with no legal target produces no effect).
- Deleted the policy-local partial `trigger_still_fireable` reimplementation.

Policy (draw_payoff.rs):
- Live engine scan now `is_draw_payoff_trigger && hypothetical_trigger_fireable`.
- Immediate-ETB draw path skips conditional ETBs (`condition.is_none()`), so a
  Latchkey Faerie prowl cantrip is not credited a draw until it will fire.

Feature (draw_matters.rs):
- `detect()` now also counts self-ETB draw cantrips (Elvish Visionary) as deck
  draw sources via `is_etb_draw_source`, matching what the live policy rewards.

Tests (external, no cfg(test) in source):
- Wizard-Class zero-target neutral / legal-target reward (target legality).
- Latchkey conditional-ETB not credited / unconditional-ETB credited.
- ActivateAbility draw reward / non-draw neutral.
- OnlyDuringOpponentsTurn positive+negative; OnlyDuringYourMainPhase both main
  phases positive + off-phase (upkeep) negative; MaxTimesPerTurn below/at cap.
- Feature: ETB-cantrip source-count regression + opponent-draw control.

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

Copy link
Copy Markdown
Contributor Author

Thanks — all four [MED] blockers addressed in db3fe363d. The common root cause was that the policy carried its own partial trigger-eligibility logic; I replaced it with a single engine-owned authority that reuses the live pipeline's constraint checker, so the policy no longer re-implements any part of trigger evaluation.

1. Live payoff detection ignored condition + resolution targets.
New triggers::hypothetical_trigger_fireable(state, source, entry) is the one authority the policy calls. It:

  • reuses the same check_trigger_constraint_with_ref the live pipeline runs — I changed its event param to Option<&GameEvent> (Some = real evaluation, None = hypothetical preflight). In None mode the event-dependent constraints (OncePerOpponentPerTurn, EventSourceControlledBy, NthSpellThisTurn, NthDrawThisTurn) report not satisfied rather than guess — so it covers the complete constraint set, not a hand-picked subset;
  • rejects an intervening-if condition (CR 603.4) conservatively;
  • preflights execution target legality via ability_utils::execute_targets_satisfiable (CR 603.3d — a mandatory-target trigger with no legal target is removed and produces no effect).

Tests: targeted_engine_with_no_legal_target_is_neutral (Wizard-Class enchantment, empty board → no reward) and targeted_engine_with_a_legal_target_rewards (a creature in play → rewarded).

2. Immediate-ETB draw path ignored the ETB condition (Latchkey).
Took the sanctioned conservative path: the ETB draw path now filters condition.is_none(), so a conditional ETB (Latchkey's prowl clause) is not credited a draw. This keeps the semantics uniform with (1) — both the live-engine and ETB paths reject conditional triggers. Because it's a conservative reject, a prowl-paid Latchkey is also (safely) not credited — an under-credit, never an over-credit.
Tests: conditional_etb_draw_is_not_credited (Latchkey CastVariantPaid::Prowl condition → draw_payoff_na) and unconditional_etb_draw_is_credited (Elvish Visionary → rewarded).

3. Deck source detection omitted ETB cantrips.
detect() now counts self-ETB draw cantrips as deck sources via is_etb_draw_source (ChangesZone→Battlefield, SelfRef, Effect::Draw{Controller}), matching what the live policy rewards.
Tests: etb_cantrip_counts_as_a_draw_source (source_count regression) + etb_opponent_draw_is_not_a_source control.

4. Discriminating coverage.
Added: activated_draw_ability_rewards / activated_non_draw_ability_is_neutral (the ActivateAbility seam, draw and non-draw selected branches); only_during_opponents_turn_rewards_off_turn / ..._is_neutral_on_your_turn; only_during_your_main_phase_rewards_in_both_main_phases (PreCombatMain + PostCombatMain) + only_during_your_main_phase_off_phase_is_neutral (upkeep); max_times_per_turn_below_cap_rewards / ..._at_cap_is_neutral.

cargo fmt, clippy -p engine/-p phase-ai (lib+tests) clean; full phase-ai lib suite green (62 draw-specific assertions). The engine change is behind the existing live-trigger tests (it only adds a None mode + a new pub fn; the Some path is byte-for-byte the prior logic).

@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head db3fe363d886e033f762dd20494a6125c891c7aa.

[MED] The policy still grants a draw-payoff bonus when the candidate cannot actually draw. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:65-68,110-132 classifies any structural Effect::Draw { target: Controller } as a draw, but the authoritative delivery gate returns zero for CantDraw and exhausted PerTurnDrawLimit at crates/engine/src/game/effects/draw.rs:14-48. Why it matters: no CardDrawn event occurs, so a "whenever you draw" engine never triggers, yet this policy adds a positive score to the no-op draw action. Suggested fix: route live candidate qualification through an engine-owned draw preflight that resolves the requested count and applies the same allowed-draw gate; add discriminating CantDraw and exhausted-limit cases that return draw_payoff_na.

[MED] The new generic trigger preflight reports multi/complex mandatory target executions as live even when no legal target assignment exists. Evidence: crates/engine/src/game/ability_utils.rs:1290-1312 returns None unless there is exactly one simple target slot, and execute_targets_satisfiable converts that None to true at :1320-1338; hypothetical_trigger_fireable relies on it at crates/engine/src/game/triggers.rs:8188-8192. Why it matters: a draw-payoff trigger with two mandatory targets (or another unsupported target shape) is credited despite being removed under CR 603.3d when its required choices are unavailable. Suggested fix: make the preflight distinguish confirmed-legal from unknown and have this scoring policy award the bonus only for confirmed legality (or use the existing full legal-assignment authority behind an appropriate cheap guard); cover a multi-target zero-legal-target engine.

@matthewevans matthewevans removed their assignment Jul 27, 2026

@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.

🧹 Nitpick comments (2)
crates/phase-ai/src/policies/tests/draw_payoff.rs (1)

204-277: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

No test exercises the MAX_REWARDED_ENGINES cap.

Every rewarded-verdict test here (single-engine, once-per-turn, targeted, activated-ability, etc.) has exactly one live engine on the battlefield. DrawPayoffPolicy::verdict caps the reward at engines.min(MAX_REWARDED_ENGINES) (crates/phase-ai/src/policies/draw_payoff.rs line 96), but nothing here places more engines than the cap to confirm the score saturates rather than scaling linearly, or that the reported engines fact still reflects the true (uncapped) count.

♻️ Suggested addition
#[test]
fn engine_count_above_cap_saturates_reward() {
    let config = AiConfig::default();
    let mut st = state();
    // Place more engines than MAX_REWARDED_ENGINES.
    for _ in 0..(MAX_REWARDED_ENGINES + 1) {
        engine_on_battlefield(&mut st);
    }
    let (oid, cid) = draw_spell(&mut st);
    let context = context(&config, session(0.9));
    let candidate = cast(oid, cid);
    let decision = priority_decision(&candidate);
    let (delta, reason) =
        score_of(DrawPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config)));
    assert_eq!(reason.kind, "draw_payoff_engine_active");
    let capped = config.policy_penalties.draw_payoff_bonus * MAX_REWARDED_ENGINES as f64;
    assert!((delta - capped).abs() < f64::EPSILON, "reward should saturate at the cap");
}
🤖 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/tests/draw_payoff.rs` around lines 204 - 277,
Add a test near the existing DrawPayoffPolicy tests that places
MAX_REWARDED_ENGINES + 1 live engines using engine_on_battlefield, then casts a
draw spell and evaluates the verdict. Assert the engine-active reason, verify
the reward equals the configured draw-payoff bonus multiplied by
MAX_REWARDED_ENGINES rather than the true engine count, and confirm the reported
engines fact remains uncapped if exposed by the verdict reason.
crates/engine/src/game/triggers.rs (1)

8155-8195: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

hypothetical_trigger_fireable fails closed on AtClassLevel due to missing source_context, not genuine uncertainty.

check_trigger_constraint_with_ref is called with source_context: None (line 8182), but the TriggerConstraint::AtClassLevel arm (line ~8140) reads source_context.and_then(|source| source.source_read(state).class_level()). Since source_context is None here, any Class-level-gated trigger will always evaluate as not-fireable — even though source: &GameObject is directly available to build a real context via trigger_source_context_for_latch. Unlike the event-dependent constraints (OncePerOpponentPerTurn, EventSourceControlledBy, NthSpellThisTurn, NthDrawThisTurn), AtClassLevel has nothing to do with the (unknown) triggering event — the source's level is fully knowable at hypothetical-evaluation time, so failing it closed here is inconsistent with the function's own documented invariant ("event-dependent constraints... conservatively report NOT satisfied").

🐛 Proposed fix
     let definition_ref = source.trigger_definition_ref(entry);
+    let source_context = trigger_source_context_for_latch(state, source);
     // CR 603.2-603.4: the trigger's own constraint, in hypothetical (no-event)
     // mode — the shared authority the live pipeline also uses.
     if !check_trigger_constraint_with_ref(
         state,
         def,
         Some(&definition_ref),
-        None,
+        Some(&source_context),
         source.controller,
         None,
     ) {
         return false;
     }
🤖 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/engine/src/game/triggers.rs` around lines 8155 - 8195, Update
hypothetical_trigger_fireable to pass a real source context, created from the
available source via trigger_source_context_for_latch, into
check_trigger_constraint_with_ref instead of None. Preserve conservative failure
for genuinely event-dependent constraints while allowing AtClassLevel to
evaluate the source’s known class level.
🤖 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.

Nitpick comments:
In `@crates/engine/src/game/triggers.rs`:
- Around line 8155-8195: Update hypothetical_trigger_fireable to pass a real
source context, created from the available source via
trigger_source_context_for_latch, into check_trigger_constraint_with_ref instead
of None. Preserve conservative failure for genuinely event-dependent constraints
while allowing AtClassLevel to evaluate the source’s known class level.

In `@crates/phase-ai/src/policies/tests/draw_payoff.rs`:
- Around line 204-277: Add a test near the existing DrawPayoffPolicy tests that
places MAX_REWARDED_ENGINES + 1 live engines using engine_on_battlefield, then
casts a draw spell and evaluates the verdict. Assert the engine-active reason,
verify the reward equals the configured draw-payoff bonus multiplied by
MAX_REWARDED_ENGINES rather than the true engine count, and confirm the reported
engines fact remains uncapped if exposed by the verdict reason.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f04e16bb-8d59-47a8-aeb6-b7e05e8d9468

📥 Commits

Reviewing files that changed from the base of the PR and between 8af6c31 and db3fe36.

📒 Files selected for processing (6)
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/triggers.rs
  • crates/phase-ai/src/features/draw_matters.rs
  • crates/phase-ai/src/features/tests/draw_matters.rs
  • crates/phase-ai/src/policies/draw_payoff.rs
  • crates/phase-ai/src/policies/tests/draw_payoff.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/phase-ai/src/features/tests/draw_matters.rs
  • crates/phase-ai/src/features/draw_matters.rs

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

…legality (round 3)

Addresses matthewevans' two round-3 [MED] blockers on phase-rs#6688.

1. Draw-delivery gate (CR 120.3 / CR 101.2). A structural `Effect::Draw
   { target: Controller }` produces no `CardDrawn` event — and fires no
   "whenever you draw" engine — when the controller cannot actually draw:
   under a `CantDraw` static, or with a `PerTurnDrawLimit` already exhausted.
   `candidate_draws_controller` now gates on a new engine authority,
   `effects::draw::can_draw_at_least_one`, which reuses the same
   `allowed_draw_count` gate the delivery path runs — so the bonus is never
   added to a no-op draw. New coverage: `CantDraw` → `draw_payoff_na`;
   exhausted per-turn limit → `draw_payoff_na`; limit with headroom → rewarded.

2. Multi-target legality (CR 603.3d). `execute_targets_satisfiable`
   previously treated the cheap single-slot check's `None` (multi-target or
   other complex shape) as satisfiable, crediting a mandatory multi-target
   engine with no legal target assignment. It now falls through to
   `build_target_slots` + `has_legal_target_assignment_for_ability` (the same
   full solver production target selection uses), so a multi-target execute
   with no legal assignment is correctly reported not-live. New coverage: a
   two-target "exchange control of two permanents" engine on an empty board →
   `draw_payoff_no_engine`; with two exchangeable permanents → rewarded.

`cargo fmt`, `clippy -p engine`/`-p phase-ai` (lib+tests) clean; full phase-ai
lib suite green (1609 passed). Engine changes ride behind existing live
targeting/draw tests.

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

Copy link
Copy Markdown
Contributor Author

Both round-3 [MED] blockers fixed in 36a63732c.

1. Draw-delivery gate (CR 120.3 / CR 101.2). You're right that a structural Effect::Draw { target: Controller } is a no-op — no CardDrawn event, no engine trigger — when the controller can't actually draw. I added an engine-owned preflight effects::draw::can_draw_at_least_one(state, player) that reuses the same allowed_draw_count gate the delivery path runs (it returns 0 under a CantDraw static and caps at the PerTurnDrawLimit remaining). candidate_draws_controller now short-circuits to draw_payoff_na when that gate is closed, so both the CantDraw and exhausted-limit cases withhold the bonus. Covered by cant_draw_static_makes_the_draw_a_no_op, exhausted_per_turn_draw_limit_is_neutral, and a per_turn_draw_limit_with_headroom_rewards control.

2. Multi-target legality (CR 603.3d). Correct — execute_targets_satisfiable was converting the cheap single-slot check's None (multi-target / complex shape) to true. It now falls through to the full authority the same way production target selection does: build_target_slots → if empty, no target needed → otherwise has_legal_target_assignment_for_ability(state, &resolved, &slots, constraints). So a mandatory multi-target execute with no legal assignment is reported not-live. Covered by multi_target_engine_with_no_legal_assignment_is_neutral (a two-target "exchange control of two permanents" engine on an empty board → draw_payoff_no_engine) and multi_target_engine_with_a_legal_assignment_rewards (two exchangeable permanents → rewarded). The cheap single-slot guard still decides the common case first, so the full solver only runs for genuinely multi/complex shapes.

cargo fmt, clippy -p engine/-p phase-ai (lib+tests) clean; full phase-ai lib suite green (1609 passed).

@minion1227
minion1227 requested a review from matthewevans July 27, 2026 07:33
@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head a129a921620d117c1c0b12e9a7cbb252b914650a.

[MED] Activated-draw coverage bypasses PolicyRegistry routing. Evidence: DrawPayoffPolicy opts into both CastSpell and ActivateAbility at crates/phase-ai/src/policies/draw_payoff.rs:47-49, and priority-time GameAction::ActivateAbility is classified as DecisionKind::ActivateAbility at crates/phase-ai/src/decision_kind.rs:57-67 before registry dispatch at crates/phase-ai/src/policies/registry.rs:433-437. Yet the activated-draw assertion calls DrawPayoffPolicy.verdict directly at crates/phase-ai/src/policies/tests/draw_payoff.rs:800-817; the only registry-routed regression covers a cast at :1263-1278. Why it matters: a regression in the action classifier, decision-kind registration, or by_kind construction can leave activated draw actions unscored while every activated-draw test stays green. Suggested fix: add a priority-context PolicyRegistry::verdicts regression for a rewarded ActivateAbility draw (and, preferably, its non-draw control).

[MED] can_draw_at_least_one bypasses mandatory individual draw replacements. Evidence: crates/engine/src/game/effects/draw.rs:23-25 only checks the static allowance and whether a library card is selectable; it never proposes the draw to the replacement pipeline. Live delivery instead calls draw_through_replacement_with_applied at draw.rs:285-294, which invokes replacement::replace_event at :381-402; a mandatory QuantityModification::Prevent draw replacement returns ApplyResult::Prevented at crates/engine/src/game/replacement.rs:2772-2785. GameEvent::CardDrawn is emitted only after the unreplaced settled delivery loop at draw.rs:508-535. Why it matters: the policy awards a “whenever you draw” payoff to a candidate whose individual draw is obligatorily replaced and therefore cannot emit the triggering event. CR 614.1/614.6 define replacement effects and say a replaced event never happens; CR 614.11 says draw replacements apply even when no card can be drawn. Suggested fix: make the policy’s deliverability preflight replacement-aware (conservatively reject a mandatory individual replacement unless its resulting draw event can be established), and add a discriminating mandatory-prevented-draw policy case.

Current-head scope: prior empty-library, execute-target-constraint, unsupported-execute, and CR-citation findings are resolved. This review requests changes only for the two remaining behavior/test gaps above.

@matthewevans matthewevans added area:ai AI opponent area:engine Core rules engine labels Jul 27, 2026
@matthewevans matthewevans removed their assignment Jul 27, 2026
…ivated-draw coverage (round 8)

Addresses matthewevans' two round-8 [MED] blockers on phase-rs#6688.

1. Replacement-aware deliverability (CR 614.1 / CR 614.6 / CR 614.11).
   `can_draw_at_least_one` checked draw restrictions and library delivery but not
   the replacement pipeline, so a candidate whose individual draw is obligatorily
   replaced (a mandatory `QuantityModification::Prevent` draw replacement — Living
   Conundrum's "skip that draw instead") was still credited even though the
   replaced event never happens and no `CardDrawn` fires. It now also scans
   `active_replacements` and conservatively blocks delivery when a mandatory
   prevent-draw replacement (`ReplacementEvent::Draw`/`DrawCards` +
   `QuantityModification::Prevent`) is active. New case:
   `mandatory_prevent_draw_replacement_is_a_no_op` → `draw_payoff_na`.

2. Registry-routed activated-draw coverage. The activated-draw assertions called
   `verdict` directly; only a cast was registry-routed. Added
   `registry_routes_activated_draw_to_the_policy` (a `GameAction::ActivateAbility`
   draw routed through `PolicyRegistry::verdicts` → rewarded) and
   `registry_activated_non_draw_is_not_rewarded` (control), so a regression in the
   action classifier / decision-kind registration / `by_kind` dispatch can't leave
   activated draws silently unscored.

`cargo fmt`, `clippy -p engine`/`-p phase-ai` (lib+tests) clean; draw suite green
(80 assertions).

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

Copy link
Copy Markdown
Contributor Author

Both round-8 blockers fixed in a5e2dfeab.

1. Replacement-aware deliverability (CR 614.6). Correct — can_draw_at_least_one didn't consult the replacement pipeline, so a draw obligatorily replaced by a mandatory Prevent (Living Conundrum's "skip that draw instead") was still credited. It now scans active_replacements and conservatively blocks delivery when a mandatory prevent-draw replacement (ReplacementEvent::Draw/DrawCards + QuantityModification::Prevent) is active — the replaced event never happens, so no CardDrawn, so no engine fires. Kept it conservative per your suggestion (a per-candidate preflight can't run the state-mutating pipeline to establish the post-replacement event). New case: mandatory_prevent_draw_replacement_is_a_no_opdraw_payoff_na.

2. Registry-routed activated-draw coverage. Added registry_routes_activated_draw_to_the_policy — a GameAction::ActivateAbility draw routed through PolicyRegistry::verdicts (exercising classify_decisionDecisionKind::ActivateAbilityby_kind dispatch) and rewarded — plus registry_activated_non_draw_is_not_rewarded as the control. So a regression in the classifier or decision-kind registration can no longer leave activated draws silently unscored.

cargo fmt, clippy -p engine/-p phase-ai (lib+tests) clean; draw suite green (80 assertions).

@matthewevans matthewevans self-assigned this Jul 27, 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.

[MED] Draw-preflight treats every functioning Prevent definition as a mandatory replacement of the candidate's draw. Evidence: crates/engine/src/game/effects/draw.rs:40-50 scans active_replacements only by event and QuantityModification::Prevent; the live candidate authority first runs the handler and condition gates (crates/engine/src/game/replacement.rs:6305-6355), then scopes ProposedEvent::Draw { player_id } against the replacement source (crates/engine/src/game/replacement.rs:6432-6453) and handles optional replacements (crates/engine/src/game/replacement.rs:6549-6560). DrawCards is also registered as a stub rather than a runtime draw handler (crates/engine/src/game/replacement.rs:4953). Why it matters: an unrelated/opponent-source, conditional, optional, or DrawCards-stub Prevent makes the AI classify an otherwise deliverable draw as a no-op and refuse the draw-payoff bonus. The added test does not discriminate: it puts the replacement on P1 (crates/phase-ai/src/policies/tests/draw_payoff.rs:1050-1064) while the candidate draw is P0, so the live authority would reject it by the default source-player scope at replacement.rs:6449; the scan still returns false. Suggested fix: model the candidate ProposedEvent::Draw and reuse the live applicability/choice authority (or expose a pure preflight from it), then add positive controls for an opponent-scoped replacement, a false conditional / optional replacement, and a DrawCards stub alongside the matching mandatory P0 prevent case.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…nt authority + modal-aware execute preflight (round 9)

Draw preflight (review blocker). `can_draw_at_least_one` scanned
`active_replacements` by event + `QuantityModification::Prevent` alone, so any
functioning prevent-draw definition — opponent-scoped, conditional, optional, or
a `DrawCards` stub — made the AI classify a deliverable draw as a no-op and
withhold the payoff bonus. It now models the individual draw as the
`ProposedEvent::Draw` the live path proposes (CR 121.2) and consults the live
applicability authority, so source/player scope, the condition gate, the handler
matcher, and optionality are all honored.

- `replacement.rs`: parameterize `counter_placement_prevention_applies` into
  `mandatory_prevention_applies(state, candidates, events)` and expose a pure,
  read-only preflight `event_is_mandatorily_prevented(state, event)` that routes
  through `find_applicable_replacements` (CR 614.1 / CR 614.6). Only a MANDATORY
  Prevent suppresses the event — an optional one is an accept/decline choice.

Modal execute preflight. A modal ability's root is an `Effect::Unimplemented`
placeholder with its real effects in `mode_abilities`, so round 7's support
check reported every modal payoff unsupported, and the target preflight never
descended into the modes.

- `ability_utils.rs`: `ability_definition_supported` treats an `Unimplemented`
  root as a gap only on a non-modal ability (CR 700.2); modes are still checked
  recursively. `execute_targets_satisfiable` mirrors the live trigger dispatch
  for modal executes by delegating to `filter_modes_by_target_legality` +
  `modal_choice_with_target_assignment_limit` (CR 603.3c / CR 603.3d).

Tests. Five discriminating draw-replacement policy cases, all fail-on-revert
against the old scan: the mandatory prevent now scoped to the DRAWING player,
plus opponent-scoped / false-conditional / optional / `DrawCards`-stub positive
controls. Registered engine dispatch tests assert the live
`DroppedNoLegalMode` contract the modal preflight mirrors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

The round-8 blocker is fixed in 3c8686346.

Draw-preflight over-blocking (CR 614.1a / CR 614.6). Correct, and the test was non-discriminating exactly as you describe — it put the replacement on P1 while the candidate draw was P0, so the live authority would have rejected it by the default source-player scope at replacement.rs:6449 and my event-only scan still returned false. It passed for the wrong reason.

Taking the suggested fix: model the candidate ProposedEvent::Draw and reuse the live authority rather than re-deriving applicability.

  • replacement.rs — parameterized the existing counter_placement_prevention_applies into mandatory_prevention_applies(state, candidates, events) and exposed a pure, read-only preflight event_is_mandatorily_prevented(state, event) that routes through find_applicable_replacements (CR 614.1 / CR 614.6). It runs no applier, so it is safe from a non-mutating preflight. Only a MANDATORY Prevent suppresses the event — an optional one is offered as an accept/decline choice, so it cannot be assumed to apply.
  • draw.rscan_draw_at_least_one now builds the ProposedEvent::Draw { player_id, count: 1 } the live path proposes for one card (CR 121.2) and asks that authority. Source/player scope, the condition gate, the handler matcher, and optionality all come for free; DrawCards is excluded both by stub_matcher and by the event-key scope.

Coverage — the four positive controls you asked for, plus the corrected mandatory case. All five are fail-on-revert against the old scan:

test old scan
mandatory_prevent_draw_replacement_is_a_no_op (now scoped to the DRAWING player) passed vacuously
opponent_scoped_prevent_draw_replacement_still_rewards failed — ignored source scope
false_conditional_prevent_draw_replacement_still_rewards failed — ignored the condition
optional_prevent_draw_replacement_still_rewards failed — ignored mode
draw_cards_stub_prevent_replacement_still_rewards failed — matched DrawCards explicitly

I confirmed the optional case is non-vacuous: optional_decline_is_noop returns early for a Draw event (no battlefield-entry shape), so the candidate does reach the predicate and it is the mandatory-only guard that admits the draw.

Your 8b4c62025 frozen census row is preserved — the helper keeps its name and its single ReplacementDefinition::new(ReplacementEvent::Draw), and draw_replacement_census.py --producers --check passes (8 rows, baseline frozen). I rebased on top of your commit rather than force-pushing over it.

Also in this commit — a self-found defect in my own round-7 code, not a review item. A modal ability carries an Effect::Unimplemented (modal_placeholder) root with its real effects in mode_abilities, so ability_definition_supported was reporting every modal payoff unsupported, and execute_targets_satisfiable never descended into the modes. Fixed both: the placeholder is a gap only on a non-modal ability (CR 700.2, modes still checked recursively), and the modal preflight delegates to filter_modes_by_target_legality + modal_choice_with_target_assignment_limit — the same two authorities the live dispatch uses at triggers.rs:6530-6546 — rather than reimplementing the check. Registered engine dispatch tests assert the live DroppedNoLegalMode contract in both directions.

One annotation correction: I had drafted CR 614.7 for "an optional replacement can be declined". Grepping docs/MagicCompRules.txt, 614.7 is about a replacement whose event never happens, not about optionality — so I stated it as the engine's accept/decline contract instead of attaching a rule number that does not say it.

cargo fmt, clippy -p engine -p phase-ai --all-targets --features proptest -D warnings clean; scripts/check-engine-authorities.sh all gates pass; draw-payoff suite green at 49 tests. The engine lib suite (modal_trigger_ / replacement:: / draw) was still linking locally when I pushed, so I am leaning on CI for that one rather than claiming a result I do not have.

@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head 3c868634621972bdea98cea56d9ee1bb6daaedee.

[MED] Draw-delivery preflight still credits mandatory non-draw substitutions. Evidence: crates/engine/src/game/effects/draw.rs:33-45 decides delivery solely via event_is_mandatorily_prevented; that helper only treats QuantityModification::Prevent definitions as blockers at crates/engine/src/game/replacement.rs:8700-8724,8740-8744. The live replacement path separately recognizes a mandatory non-Draw execute or runtime_execute (Words of Worship/Wilding, Jace/Abundance shapes), zeros the proposed draw count, and thus produces no CardDrawn at replacement.rs:7908-7939; draw.rs:441-555 emits CardDrawn only for delivered cards. Why it matters: with such a mandatory substitution already armed, DrawPayoffPolicy still awards the “whenever you draw” bonus even though the candidate’s draw event is replaced away. Suggested fix: expose/use a pure replacement outcome preflight that distinguishes a surviving positive Draw from a mandatory non-Draw substitute (while retaining the correct optional-decline and draw-count behavior), and add paired policy cases for an armed mandatory non-Draw substitution versus a normal/count-modified draw.

Recommendation: request changes — make the engine-owned draw-delivery preflight model the current live replacement outcome, then cover the mandatory non-draw substitution and surviving-draw controls through DrawPayoffPolicy.

@matthewevans matthewevans removed their assignment Jul 27, 2026
… live pipeline (round 10)

The draw-payoff preflight was a partial mirror of the live draw pipeline, and
each review round surfaced one more leg the mirror failed to model. Fix the
mirror rather than the leg: extract the substitution classification out of
`apply_single_replacement` into `draw_is_substituted_away`, and have both the
live pipeline and the read-only preflight call it — so the two cannot disagree
about whether a draw survives, because there is only one of them.

Replace `event_is_mandatorily_prevented` (prevention only, single caller) with
`proposed_draw_survives_replacement`, routing every replacement suppression leg
through the authority that owns it:

- mandatory `QuantityModification::Prevent` (CR 614.6, Living Conundrum), via
  `mandatory_prevention_applies`
- mandatory non-Draw substitute carried in `execute` (Chains of Mephistopheles,
  Jace Wielder of Mysteries) or `runtime_execute` (Words of Worship/Wilding),
  CR 614.11, via the shared `draw_is_substituted_away`
- mandatory count modification resolving to zero (CR 614.11a), via
  `draw_replacement_count` — a leg no prior round had modeled

Optional-decline and count behavior are preserved: a "you may" replacement is
never assumed to apply, and an Alhammarret's Archive-style positive rescale is
a surviving draw, not a substitution.

Adds `draw_preflight_matches_live_pipeline`: for each shape it asks the
preflight for a prediction, then drives the REAL draw through the pipeline and
asserts prediction == observed delivery, with expected delivery pinned
independently so a regression breaking both sides alike still fails. Covers the
restriction, empty-library, prevent, both substitute slots and zero-count legs,
plus unreplaced and count-modified surviving controls — preflight/pipeline
drift now fails CI in either direction.

Six paired `DrawPayoffPolicy` cases cover the same shapes at the policy seam.
Consolidates the file's two Draw-definition producers into one parameterized
helper and re-freezes the producer census accordingly.
@minion1227

Copy link
Copy Markdown
Contributor Author

Thanks — this one is on me, and the useful thing to say first is why it took ten rounds rather than just patching the tenth finding.

The finding behind the findings

Every round of this review has been the same defect wearing a different hat. can_draw_at_least_one was a hand-rolled partial mirror of the live draw pipeline, and each round surfaced one more leg the mirror didn't model:

Round Leg the mirror missed
3 CantDraw / exhausted PerTurnDrawLimit
4–6 empty library
8 mandatory Prevent replacement
9 applicability gates on that Prevent (source scope, condition, optional, stub handler)
10 mandatory non-Draw substitution (execute / runtime_execute zeroes the count)

You named the root cause in round 1 — "use the authoritative evaluation rather than a partial policy mirror" — and I kept treating each finding as its own bug instead. Patching leg 10 in isolation would only have queued up leg 11, and leg 11 already existed: a mandatory count modification resolving to zero (draw_applierModified { count: 0 }, and apply_draw_after_replacement emits CardDrawn only inside its per-delivered-card loop) also produces no draw event, and nothing modeled it.

So this round fixes the mirror, not the leg.

What changed

The classification is now shared with the pipeline instead of mirrored from it. The is_non_draw_substitute computation that lived inline in apply_single_replacement is extracted verbatim into draw_is_substituted_away. The live pipeline calls it to decide whether to pre-zero the proposed count; the preflight calls the same function to decide whether a draw can still deliver. They can no longer disagree, because there is only one of them.

event_is_mandatorily_prevented is replaced by proposed_draw_survives_replacement, which routes each replacement suppression leg through the authority that owns it:

  • mandatory QuantityModification::Preventmandatory_prevention_applies (CR 614.6)
  • mandatory non-Draw substitute in execute or runtime_executedraw_is_substituted_away (CR 614.11)
  • mandatory count modification resolving to zero → draw_replacement_count (CR 614.11a)

Optional-decline and count behavior are preserved as you asked: a "you may" replacement is never assumed to apply, and a positive rescale ("…draw two cards instead") is a surviving draw. The old seam had exactly one caller, so it is removed rather than left orphaned.

Evidence

Six paired policy cases in policies/tests/draw_payoff.rs — mandatory execute substitution, mandatory runtime_execute substitution, and zero-count rescale all withhold; optional substitution, opponent-scoped substitution, and positive count rescale all still pay. Reverting the preflight to the round-9 prevent-only logic fails exactly the three suppression cases and leaves the three controls green, so the controls aren't vacuous.

Beyond that, crates/engine/tests/integration/draw_preflight_matches_live_pipeline.rs is the piece I think should actually retire this class of finding. For each shape it asks the preflight for a prediction, then drives the real draw through the pipeline and asserts prediction == observed delivery — with the expected delivery pinned independently, so a regression breaking both sides in the same direction still fails rather than quietly agreeing at the wrong answer. It covers all five suppression legs (draw restriction, empty library, prevent, both substitute slots, zero-count) plus unreplaced and count-modified surviving controls. If the preflight ever stops modeling a leg, CI says so, in either direction of drift.

Local: engine clippy and phase-ai clippy clean under -D warnings; 17,817 engine lib tests, 1,634 phase-ai lib tests, 8/8 equivalence tests, 55/55 draw-payoff policy tests, all passing.

Note on the census

I consolidated the two Draw-definition producers in the AI test file into one parameterized add_draw_replacement, so scripts/draw-replacement-producers.txt has a single renamed row (add_prevent_draw_replacementadd_draw_replacement), all 56 rows otherwise intact, re-frozen with --write. add_prevent_draw_replacement survives as a thin wrapper, so the .draw_scope(IndividualDraw) declaration your fixup added is still made in exactly one place.

Card text

Oracle text for every card named in the new comments and tests was verified against Scryfall, and two attributions in my own earlier draft were wrong until I checked: Words of Worship and Words of Wilding are activated one-shots, so they belong to the runtime_execute slot, not execute. The printed statics are Chains of Mephistopheles and Jace, Wielder of Mysteries; Abundance is the optional case; Alhammarret's Archive and Teferi's Ageless Insight both carry the "except the first one you draw in each of your draw steps" gate, noted in the test where the definition is modeled ungated. The zero-count case is labeled as the synthetic boundary it is rather than attributed to a card.

@matthewevans matthewevans self-assigned this Jul 27, 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.

[MED] The hot-path policy performs the whole draw-delivery/replacement preflight before proving that the candidate draws. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:110-145 calls can_draw_at_least_one at line 118 before matching the candidate; that preflight scans active draw statics and invokes replacement::proposed_draw_survives_replacement, which calls find_applicable_replacements (crates/engine/src/game/effects/draw.rs:34-46, crates/engine/src/game/replacement.rs:8790-8811). Why it matters: verdict() runs for every CastSpell and ActivateAbility candidate at each search node, so every non-draw candidate now pays the battlefield/replacement scan despite the PR's stated card-local early-out. Suggested fix: first classify the candidate structurally from its cast facts/effective activated ability, return neutral for non-draw actions, and only then run can_draw_at_least_one for that reduced set.

[LOW] candidate_draws_controller still hides future GameAction variants behind a wildcard fallback. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:124-145 ends the known enum match with _ => false; this is the still-open CodeRabbit finding. Why it matters: a future action added to the existing routing surface can silently bypass this policy rather than forcing an intentional classification. Suggested fix: make the GameAction match exhaustive with explicit neutral arms (or route through an existing exhaustive decision/action classifier).

[LOW] The per-engine reward cap has no discriminating regression. Evidence: MAX_REWARDED_ENGINES caps the score at crates/phase-ai/src/policies/draw_payoff.rs:34,96, while crates/phase-ai/src/policies/tests/draw_payoff.rs has no scenario with more than one live engine. Why it matters: the policy's bounded-score contract can regress to unbounded scaling without a test failing. Suggested fix: add a registry- or policy-routed case with MAX_REWARDED_ENGINES + 1 live engines and assert the capped delta (and uncapped engines fact, if that observability contract is intentional).

@matthewevans matthewevans removed their assignment Jul 27, 2026
Clippy `type_complexity` on the new equivalence test's `scenario` parameter.
The tuple is `(card name, shaper)`; the shaper takes the permanent's ObjectId
because a `runtime_execute` substitute binds its own source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head fd61fd76aec345295e14fe313216218f065dea71.

[MED] The policy still performs draw-delivery/replacement preflight before it knows that the candidate draws. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:110-145 calls can_draw_at_least_one before the GameAction match; that helper scans active draw statics and calls proposed_draw_survives_replacement, which runs find_applicable_replacements (crates/engine/src/game/effects/draw.rs:34-46, crates/engine/src/game/replacement.rs:8790-8811). Why it matters: verdict() runs for every cast and activation candidate at every search node, so non-draw candidates still pay a battlefield/replacement scan despite the policy's stated card-local early-out. Suggested fix: first classify the cast facts/effective activated ability structurally, return neutral for non-draw candidates, and invoke can_draw_at_least_one only after that classifier confirms an unconditional controller draw.

[LOW] The bounded-score contract has no discriminating cap regression. Evidence: MAX_REWARDED_ENGINES limits the reward at crates/phase-ai/src/policies/draw_payoff.rs:38-40,96, while the policy suite has no case with more than one live engine (crates/phase-ai/src/policies/tests/draw_payoff.rs). Why it matters: changing or removing the cap can produce an unbounded hot-path score while every current reward test stays green. Suggested fix: exercise MAX_REWARDED_ENGINES + 1 live engines through the policy or registry, assert the capped delta, and preserve/assert the uncapped engine-count fact if that is intended observability.

[LOW] candidate_draws_controller still uses a wildcard GameAction arm. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:124-145 ends the known enum match with _ => false; this is the unresolved CodeRabbit review finding. Why it matters: a future action variant can silently bypass intentional classification instead of forcing it at compile time. Suggested fix: make this match exhaustive with explicit neutral arms, or delegate to an existing exhaustive action classifier.

The prior draw-delivery/replacement correctness findings are resolved at this head: the shared substitution classifier is now used by the preflight and live replacement pipeline, and the integration test drives both paths. The parse-diff sticky comment is current and reports no card-parse changes.

@matthewevans matthewevans removed their assignment Jul 27, 2026
…ded-score regression (round 11)

Addresses the three round-11 findings.

[MED] hot-path ordering: `candidate_draws_controller` ran the delivery/
replacement preflight BEFORE proving the candidate draws, so every non-draw
CastSpell/ActivateAbility candidate paid a battlefield-static scan and
`find_applicable_replacements` at every search node — the opposite of the
module doc's stated card-local early-out. Split into `candidate_draws_
structurally` (card-local, reads only the candidate AST) and
`draw_is_deliverable` (the expensive, candidate-independent engine authority),
evaluated in that order. Behavior is unchanged; only non-draw candidates get
cheaper, and the module doc is now true.

[LOW] exhaustive `GameAction` match: the wildcard `_ => false` let a future
action silently bypass the policy. Now enumerates all 127 variants, so a new
one fails to compile here and forces an intentional classification. Cast-shaped
siblings (madness, miracle, foretell, ninjutsu, free/copy casts) are grouped
into their own arm documenting WHY they are neutral — `cast_facts` is populated
only for the plain `CastSpell` seam — so that gap is visible rather than silent.

[LOW] bounded-score regression: `MAX_REWARDED_ENGINES` had no test. Adds a
capped case (MAX+1 engines must not out-score MAX) plus a below-cap scaling
control so the pair pins a CAP rather than a constant, and asserts the
`engines` fact still reports the true uncapped count. The constant is
`pub(crate)` so the assertion tracks it instead of a copied literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

All three addressed at 1a411e1c2.

[MED] Preflight ran before the card-local test

Correct, and worse than a missed optimization: the module doc claimed the card-local check "runs FIRST and rejects every non-draw action" while the code did the opposite. The stated contract and the implementation disagreed.

Split into two gates evaluated cheapest-discriminator-first:

  • candidate_draws_structurally — reads only the candidate's own AST (cast facts / effective activated ability), no board access
  • draw_is_deliverable — the expensive, candidate-independent can_draw_at_least_one (battlefield static scan + find_applicable_replacements)

candidate_draws_controller is now structural && deliverable. Behavior is identical (&& is commutative in result, not in cost); only non-draw candidates get cheaper, and the module doc is now true rather than aspirational.

[LOW] Wildcard _ => false

Now exhaustive over all 127 GameAction variants, so a newly added action fails to compile here instead of silently bypassing the policy.

Enumerating that many arms by hand invites typos, so I derived them from the enum definition programmatically and let the compiler confirm exhaustiveness and non-redundancy. Clippy also caught that { .. } is wrong for the 8 unit variants and the 1 tuple variant — those now use the correct pattern shape.

One thing worth flagging that fell out of doing this: the cast-shaped siblings (CastSpellAsMadness, CastSpellForFree, CastSpellAsMiracle, CastSpellAsSneak, CastSpellAsWebSlinging, Foretell, ActivateNinjutsu, PlayFaceDown, CastPreparedCopy, CastParadigmCopy) all route to DecisionKind::ActivateAbility via the priority-time catch-all, so they do reach this policy and were being dropped by the old wildcard. A madness-cast cantrip currently gets no draw payoff. I've given them their own arm documenting why they're neutral — PolicyContext::cast_facts is populated only for the plain CastSpell announcement seam, so there's no AST here to classify and guessing would be worse. That's now a visible, intentional gap with a named place to fix it, rather than silence. Happy to widen cast_facts in a follow-up if you'd like it in scope.

[LOW] Uncovered MAX_REWARDED_ENGINES

Added a paired regression: MAX_REWARDED_ENGINES + 1 live engines must not out-score MAX_REWARDED_ENGINES, plus a below-cap scaling control (2 engines → 2× bonus) so the pair pins a cap rather than a constant score. The engines fact is asserted to still report the true uncapped count — that observability contract is intentional and is now stated as such. MAX_REWARDED_ENGINES is pub(crate) so the assertion tracks the constant instead of a copied literal.

Verification at 1a411e1c2

  • cargo test -p phase-ai --lib1636 passed; 0 failed
  • cargo clippy -p phase-ai --all-targets -- -D warnings — clean
  • cargo fmt --all — clean
  • Gate A — Gate A PASS head=1a411e1c2e73fed826867ed68c7bff0bdc0dfa15

Engine crate untouched this round; the round-10 engine verification (17817 lib tests, 8/8 preflight-vs-pipeline equivalence, --all-targets clippy with --features proptest) still stands, and both Rust CI shards plus Rust lint went green on fd61fd76a.

@matthewevans matthewevans self-assigned this Jul 27, 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.

Reviewed current head 1a411e1c2e73fed826867ed68c7bff0bdc0dfa15.

[HIGH] The new exhaustive GameAction match is not exhaustive against current main. Evidence: crates/phase-ai/src/policies/draw_payoff.rs:146 has no GameAction::EndContinuousEffect { .. } arm, while current main defines it at crates/engine/src/types/actions.rs:922; the current-head Rust lint job fails with E0004 at draw_payoff.rs:146. Why it matters: phase-ai does not compile, so the required Rust lint/tests, paired-seed AI gate, decision-cost perf gate, and WASM check cannot run. Suggested fix: merge/rebase current main, classify EndContinuousEffect { .. } explicitly as neutral in candidate_draws_structurally, and push a head for which the required checks are green.

@matthewevans matthewevans removed their assignment Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ai AI opponent area:engine Core rules engine 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.

2 participants