feat(phase-ai): add cycling deck-feature axis + CyclingPayoffPolicy - #6683
feat(phase-ai): add cycling deck-feature axis + CyclingPayoffPolicy#6683minion1227 wants to merge 12 commits into
Conversation
|
🚨 Contributor flagged. Click here for more info: Superagent Dashboard |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds structural cycling deck detection, integrates it into deck features, and introduces a registered tactical policy that rewards cycling activations when active cycling payoff engines exist. ChangesCycling payoff behavior
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant PolicyRegistry
participant CyclingPayoffPolicy
participant DeckFeatures
participant GameState
PolicyRegistry->>CyclingPayoffPolicy: route ActivateAbility candidate
CyclingPayoffPolicy->>DeckFeatures: read cycling commitment
CyclingPayoffPolicy->>GameState: scan AI-controlled payoff engines
GameState-->>CyclingPayoffPolicy: matching engine count
CyclingPayoffPolicy-->>PolicyRegistry: return capped cycling payoff verdict
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
CR 702.29a cycling is card-neutral, so the AI's priors undervalue it and CyclingDisciplinePolicy only adds patience (don't cycle a needed land); self_cost_value explicitly defers cycling value. Nothing modelled the upside: with a "whenever you cycle a card" engine (Astral Drift, Drannith Stinger — CR 702.29c/d) on the battlefield, every cycle is a repeatable value trigger, so the AI should cycle eagerly. New CyclingFeature axis (structural, no card names): - source_count: cyclers (Keyword::Cycling / Typecycling, CR 702.29a/e). - payoff_count: permanents with a controller-scoped, non-self TriggerMode::Cycled / CycledOrDiscarded engine trigger. - commitment: geometric mean over (source, payoff) — both mandatory (cyclers with no engine is just smoothing; an engine with no cyclers never fires). payoff_names carries one entry per unique engine face for the policy's battlefield identity lookup. New CyclingPayoffPolicy: on a Cycling activation, if the AI controls a known engine (identity lookup, since GameObject has no triggers field), score a positive per-engine bonus. Composes with CyclingDiscipline's patience so a payoff deck cycles into its engine while a smoothing deck stays patient. cycling_payoff_bonus registered UNTUNED. Tests: 12 feature + 6 policy (incl a registry-routed regression) + full 1551-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>
There was a problem hiding this comment.
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/cycling_payoff.rs`:
- Around line 84-109: Update the engine-counting and bonus flow in
cycling_payoff.rs to retain typed payoff requirements and validate runtime
target legality/value before rewarding each matched engine, returning neutral
when no usable payoff exists; implement this as a composable rule rather than an
Astral Drift special case. In
crates/phase-ai/src/policies/tests/cycling_payoff.rs lines 155-173, add a legal
creature target to the positive case and add a regression asserting neutral when
no valid target exists.
🪄 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: 56444d39-ec9c-41be-bf02-f5cf5a1717b3
📒 Files selected for processing (10)
crates/phase-ai/src/config.rscrates/phase-ai/src/features/cycling.rscrates/phase-ai/src/features/mod.rscrates/phase-ai/src/features/tests/cycling.rscrates/phase-ai/src/features/tests/mod.rscrates/phase-ai/src/policies/cycling_payoff.rscrates/phase-ai/src/policies/mod.rscrates/phase-ai/src/policies/registry.rscrates/phase-ai/src/policies/tests/cycling_payoff.rscrates/phase-ai/src/policies/tests/mod.rs
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the cycling-payoff bonus currently trusts a permanent name instead of the live trigger that makes cycling valuable.
🟡 Non-blocking
[MED] Name-only engine detection can reward cycling when the claimed payoff has no usable live trigger. Evidence: crates/phase-ai/src/policies/cycling_payoff.rs:84-109 counts a battlefield object solely by its name, while crates/engine/src/game/game_object.rs:480-487 exposes trigger_definitions as the live trigger authority. The tests construct a bare name-only Astral Drift at crates/phase-ai/src/policies/tests/cycling_payoff.rs:54-71, then assert a reward at :155-173 and through the registry at :218-245; that fixture has no trigger/effect or legal target. Why it matters: the AI can prefer cycling for a purported payoff that cannot actually produce value. Suggested fix: inspect the structural live trigger/effect and target usability before awarding the bonus; preserve no-target payoffs such as Drannith Stinger, and add positive real-trigger/legal-target plus no-target-neutral regressions.
Recommendation: request changes — make payoff detection structural and cover both usable and unusable live-trigger cases, then request another review.
4a310fc to
8d5fdc8
Compare
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the rebased head still awards cycling payoff from a permanent name without proving a usable live payoff exists.
🟡 Non-blocking
[MED] Cycling payoff detection is still name-only rather than structural. Evidence: crates/phase-ai/src/policies/cycling_payoff.rs:84-109 counts engines solely by permanent name and does not inspect a live trigger, effect, or target legality. crates/phase-ai/src/policies/tests/cycling_payoff.rs:54-71 still creates a bare name-only Astral Drift, then :155-173 and :227-245 reward it directly and through the registry. Why it matters: the AI can prefer cycling when the claimed payoff has no usable live trigger. Suggested fix: check the structural live trigger/effect and target usability before awarding the bonus; preserve no-target payoffs such as Drannith Stinger, and add real-trigger/legal-target and no-target-neutral regressions.
Recommendation: request changes — make payoff detection structural and cover usable and unusable live-trigger cases, then request another review.
…name Round-1 review (phase-rs#6683): CyclingPayoffPolicy counted a battlefield engine solely by its name (feature.payoff_names), so it could reward cycling for a permanent that merely shares the engine's name but carries no usable "whenever you cycle" trigger. The policy now re-classifies each permanent the AI controls STRUCTURALLY against its live `trigger_definitions` (the runtime trigger authority, GameObject.trigger_definitions) via the same `is_cycle_payoff_parts` predicate detection uses — a name match is no longer sufficient. The `payoff_names` field is removed from CyclingFeature (its only consumer was the policy's identity lookup). Target legality is deliberately not checked: that would be a per-candidate `find_legal_targets` sweep and would wrongly drop no-target payoffs like Drannith Stinger. Regressions: a name-only permanent with no live trigger scores neutral; a no-target payoff still rewards. Tests: full 1581-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>
|
Fixed at head [MED] Payoff detection is now structural, not name-basedYou're right — the policy trusted obj.controller == ctx.ai_player
&& is_cycle_payoff_parts(
obj.trigger_definitions.iter_unchecked().map(|entry| &entry.definition),
)A name match is no longer sufficient — the object must actually carry a controller-scoped, non-self On target usability: I deliberately do not check target legality. Per the Regressions added:
Full 1581-test phase-ai suite passes; |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/phase-ai/src/features/cycling.rs (1)
131-146: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winHandle decks with zero nonland cards before normalizing.
A non-empty all-land deck reaches
compute_commitmentwithtotal_nonland == 0. The density division can produceNaN; becauseCyclingPayoffPolicy::activationonly checkscommitment < CYCLING_PAYOFF_FLOOR,NaNbypasses the floor and can propagate into policy scoring. Define an explicit zero-denominator result and add an all-land regression test.As per path instructions, boundary and empty-input cases must be covered.
🤖 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/features/cycling.rs` around lines 131 - 146, Update compute_commitment to return an explicit zero commitment when total_nonland is zero before calculating either density, preventing NaN from reaching CyclingPayoffPolicy::activation. Add a regression test covering a non-empty all-land deck and verify the commitment remains below the activation floor without propagating invalid values.Source: 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.
Outside diff comments:
In `@crates/phase-ai/src/features/cycling.rs`:
- Around line 131-146: Update compute_commitment to return an explicit zero
commitment when total_nonland is zero before calculating either density,
preventing NaN from reaching CyclingPayoffPolicy::activation. Add a regression
test covering a non-empty all-land deck and verify the commitment remains below
the activation floor without propagating invalid values.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 15d34f3f-d9ac-46e5-b6a3-5786698bb41a
📒 Files selected for processing (4)
crates/phase-ai/src/features/cycling.rscrates/phase-ai/src/features/tests/cycling.rscrates/phase-ai/src/policies/cycling_payoff.rscrates/phase-ai/src/policies/tests/cycling_payoff.rs
CodeRabbit flagged a potential NaN when a non-empty all-land deck reaches compute_commitment with total_nonland == 0 (NaN would bypass the `commitment < FLOOR` activation gate). commitment::density_per_60 already guards total_nonland == 0 → 0.0, and geometric_mean returns 0.0 for any non-positive pillar, so commitment is a clean 0.0. Adds an all-land regression (including a cycling land, so source_count > 0 while total_nonland == 0) asserting not-NaN and == 0.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed at head All-land-deck / NaN boundary (CodeRabbit)Verified: the NaN path is already closed. Added the requested regression — Full phase-ai suite green; clippy clean. (Note: the standing |
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the live cycling-payoff scan ignores trigger eligibility.
🟡 Blocker
[MED] A rate-limited cycling engine still earns a bonus after its trigger has already fired. crates/phase-ai/src/features/cycling.rs:115-129 classifies a payoff only by structural fields, and crates/phase-ai/src/policies/cycling_payoff.rs:78-92 rewards every live match. The engine's trigger authority checks TriggerDefinition.constraint; specifically OncePerTurn consults the fired-trigger ledger in crates/engine/src/game/triggers.rs:8005-8035. Thus Valiant Rescuer's second cycle receives a payoff bonus even though the payoff cannot trigger.
Use the authoritative fireable-trigger/occurrence eligibility for the policy scan, and add a regression where the second cycle in the same turn is neutral.
Round-2 review (phase-rs#6683): the payoff scan matched an engine only by its structural trigger shape, so a rate-limited engine (Valiant Rescuer's "whenever you cycle or discard a card ... only once each turn") still earned a bonus on the second cycle even though its trigger cannot 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. Constraints whose eligibility is a value nuance rather than a hard on/off are treated as live. `trigger_is_cycle_payoff` is exposed as `is_cycle_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. Tests: 35 cycling tests + clippy -p phase-ai --all-targets -D warnings clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Fixed at head [MED] Payoff scan now respects live trigger eligibilityYou're right — the scan matched an engine only by its structural trigger shape, so a rate-limited engine (Valiant Rescuer's "only once each turn") still earned a bonus on the second cycle even though the trigger can't fire again. The battlefield scan now pairs the structural classifier with live firing eligibility per trigger entry, consulting the engine's authoritative ledgers (not re-deriving eligibility): obj.trigger_definitions.iter_unchecked().any(|entry| {
is_cycle_payoff_trigger(&entry.definition)
&& trigger_still_fireable(ctx.state, obj, entry)
})
Regressions added:
35 cycling tests pass; |
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>
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — this branch needs a current-base rebase, and the payoff score still counts unavailable trigger value.
🔴 Blocker
[HIGH] The branch is based before merged #6681 and drops the ManaBrew v2 adapter on reconciliation. The PR base is one commit ahead of this branch's merge base; #6681's merged adapter update changes crates/manabrew-compat/src/lib.rs by roughly 3.4k lines. Rebase onto current main and preserve that adapter before further review.
🟡 Blockers
[MED] A target-required payoff such as Astral Drift is counted with no legal target. cycling_payoff.rs:81-94 counts structural, fireable triggers but does not consult the engine's target/execute requirements. A cycle receives an engine bonus when the required trigger target cannot be chosen. Reuse the root targeting/execution authority and add a required-target-absent neutral regression.
[MED] The policy still only models OncePerTurn/Game. trigger_still_fireable at cycling_payoff.rs:110-128 treats time- and max-limited constraints as live, whereas the engine's trigger authority evaluates the complete constraint set (crates/engine/src/game/triggers.rs:8005+). Use full hypothetical fireability and add max/time constraint regressions.
Round 9 — modal payoff legality is now preflighted through the live authorityHead The defect, confirmed. The fix is a shared authority, not a second implementation. New
One behavioural note on the refactor: step 4 now runs inside the event window rather than just after CR 700.2 placeholder. Discriminating regressions
Reverting the modal branch fails the policy pair; reverting the placeholder exemption fails the deck-feature pair; reverting the dispatch refactor fails the Scope boundary, deliberately. The two other call sites of this sequence are left alone: CR 603.3c, CR 603.3d, CR 700.2, CR 107.3m, CR 115.1 all grep-verified against |
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the modal-choice authority is still bypassed after a live modal trigger pauses.
[MED] dispatch_pending_trigger_context invokes resolve_legal_modal_choice while the triggering event is live (crates/engine/src/game/triggers.rs:6542-6549), but the mandatory continuation through begin_pending_trigger_target_selection recomputes the same modal choice independently (crates/engine/src/game/engine.rs:8632-8655) after the event context is restored. This contradicts the new single-authority contract and makes event-context-dependent caps (for example EventContextSourceModesChosen, documented at engine.rs:8620-8625) resolve differently between preflight and the mode prompt. Route the continuation through the shared authority while retaining the event context, or persist the initial resolved choice, and add a paused production-pipeline regression proving the prompt’s cap/unavailable modes equal the live preflight.
Round 9 collapsed the mode prompt's six inline steps into one `resolve_legal_modal_choice` call. That deduplicated the code but not the derivation: dispatch asked the authority when it put the ability on the stack, and the prompt asked it again in a later re-entry. Two answers, two moments. CR 603.3c and CR 700.2b both bind mode legality to the earlier moment — "the controller ... chooses the mode(s) as part of putting that ability on the stack. If one of the modes would be illegal (due to an inability to choose legal targets, for example), that mode can't be chosen." The board can move between the two halves, and the target path in the same function already documents that window: "an effect earlier in the SAME simultaneous cascade removed the only legal target". This is not just a duplicated derivation. When a mode's only legal target leaves the battlefield inside that window, the re-derivation finds no legal mode, drops the trigger, and returns `Ok(None)` — the controller is never prompted at all, losing a mode choice CR 700.2b already granted them. The new regression fails with `got None` when the announcement is bypassed, so it is red on revert rather than merely restating the contract. The announcement now travels with the trigger: - `PendingTrigger::announced_modal_choice` carries the `AnnouncedModalChoice` produced at the push site, set only after `resolve_legal_modal_choice` confirms a legal choice exists. - `begin_pending_trigger_target_selection` surfaces that announcement and falls back to the same single authority only for a trigger parked without one — it never re-implements the mode-choice sequence. - `resolve_legal_modal_choice` returns the named `AnnouncedModalChoice` instead of a loose `(ModalChoice, Vec<usize>)` pair, so the value that crosses the pause has one type and one producer. The field is boxed: the `types::game_state_size` stack guard fired, and that module's own instruction is to box a large, rarely-populated field rather than widen a ceiling. It embeds a whole `ModalChoice` and is populated only on the modal dispatch path, which is exactly that shape. The remaining 36 construction sites are mechanical `announced_modal_choice: None`. CR 603.3c, CR 603.3d, CR 700.2b verified against docs/MagicCompRules.txt. Verification: cargo fmt; clippy -D warnings across engine + phase-ai + server-core --all-targets --features proptest; engine 21,876 tests green; phase-ai + server-core 1,968 tests green. cargo ai-gate output is byte-identical to the round-9 parent commit run with the same card database (red 50%/13.8, affinity 30%/11.3, enchantress 10%/14.3; same 2 WARNs, same flip counts and p-values), so this change is gate-neutral and those warnings are pre-existing.
Sibling PR phase-rs#6688 (draw-matters axis) merged as 7d63341, which collided with this branch in five files. Resolutions: - phase-ai config.rs / features/mod.rs / policies/registry.rs: keep both axes. Ordering follows the files' append-chronological convention, so draw-matters (already on main) precedes cycling. - engine ability_utils.rs: keep this branch's `resolve_legal_modal_choice` delegation over main's open-coded filter_modes_by_target_legality + modal_choice_with_target_assignment_limit. The authority runs those exact steps internally, so the delegation subsumes main's behaviour and satisfies the single-authority contract this PR was asked for. Same for the Unimplemented placeholder check: a modal root with NO modes is still a real gap, because then the placeholder itself is what would resolve. - engine triggers.rs: keep the gated `Effect::unimplemented(..)` constructor (CLAUDE.md forbids hand-built `Effect::Unimplemented` literals), plus this branch's `announced_modal_choice` field and its round-10 regression.
Maintainer holdAt current head Maintainer conflict resolution has been prepared in an isolated worktree (local merge commit Next step: a maintainer with push access to |
…phase-rs#6688) * feat(phase-ai): add draw-matters deck-feature axis + DrawPayoffPolicy 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> * fix(phase-ai): gate draw payoff on live per-turn trigger eligibility 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> * fix(phase-ai): scope live draw detection to unconditional effects + complete 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> * fix(phase-ai): engine-owned trigger fireability for draw-payoff (round 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> * fix(phase-ai): gate draw-payoff on draw-delivery + full multi-target 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> * fix(engine): preserve source-sensitive constraints in hypothetical trigger authority (round 4) Addresses matthewevans' AtClassLevel blocker (raised on the sibling cycling PR, same shared authority). `hypothetical_trigger_fireable` passed `source_context: None`, so the `AtClassLevel` constraint arm — which reads the class level exclusively from the source context (CR 716) — rejected every class-level payoff as unavailable, even at the required level. Fix: build and pass `trigger_source_context_for_latch(state, source)` (a current snapshot of the source) into the shared constraint check, so source-sensitive constraints read real source state. Only the triggering EVENT stays withheld (`None`), so genuinely event-dependent constraints remain conservatively not-fireable. New coverage: `at_class_level_engine_at_required_level_rewards` (level 2, needs level 2 → live) and `at_class_level_engine_at_wrong_level_is_neutral` (level 1, needs level 2 → not live). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(engine): align execute_targets_satisfiable with trigger-pipeline ability build (round 5) Build the preflight ability via `build_resolved_from_def` (the same construction the live trigger pipeline uses) instead of wrapping only the root effect, so a sub-ability chain's own target slots are preflighted too (CR 113.1a). The cheap single-slot guard still decides the common case; unknown shapes fall through to the full `has_legal_target_assignment_for_ability` authority, and a slot-building error leaves legality unproven (not credited). Keeps this shared engine authority byte-identical with the sibling cycling PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(phase-ai): gate draw-payoff on library delivery + correct draw CR annotations (round 6) Addresses matthewevans' two [MED] blockers on the 07:52 review. 1. Empty-library deliverability (CR 121.1 / CR 704.5b). `can_draw_at_least_one` previously checked only `allowed_draw_count` (draw restrictions), so an empty-library candidate was credited a draw even though it delivers no card and emits no `CardDrawn` event (CR 704.5b: an empty-library draw only records an attempted draw). It now also routes through `select_cards_to_draw` — the same delivery authority the resolver uses — so it is true only when a card would actually reach hand (CR 121.1). New coverage: `empty_library_draw_is_a_no_op` (draw_payoff_na) and `nonempty_library_draw_rewards`; the test `state()` seeds a non-empty library so existing positive cases stay deliverable. 2. CR annotation correction. The draw-delivery code and tests cited `CR 120.3`, which is the damage-results rule. Replaced with the verified draw rules: `CR 121.1` (drawing = top card of library to hand) and `CR 704.5b` (attempted empty-library draw) in draw.rs, draw_payoff.rs, and the tests. `cargo fmt`, `clippy -p engine`/`-p phase-ai` (lib+tests) clean; draw suite green (71 assertions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(engine+phase-ai): reject unsupported/no-execute payoffs + honor execute target constraints (round 7) Two shared-authority fixes (also applied identically to the sibling cycling PR). 1. Executable-support contract. `hypothetical_trigger_fireable` credited a trigger with `execute: None` (a `TriggerNoExecute` no-op) and any `Effect::Unimplemented` execute, and the deck classifier `is_draw_payoff_trigger` had the same gap — inflating both the live bonus and deck commitment for a payoff that produces nothing. New shared engine predicate `ability_utils::ability_definition_supported` recursively rejects an `Effect::Unimplemented` at the root or any nested sub-/else-/mode-ability; both the fireability preflight and the deck classifier now consult it. 2. Execute target constraints. `execute_targets_satisfiable` preflighted the root effect's per-slot filters but sent no cross-target constraints, so a constrained multi-target execute (e.g. "two permanents controlled by different players") was judged against a broader space than the live trigger receives (`PendingTrigger::target_constraints`). It now threads `execute.target_constraints` into both the cheap and full solvers (CR 115.1 / CR 601.2c). Corrected the preflight annotation from CR 113.1a (defines abilities) to CR 603.3d (removal for no legal choice). Coverage: no-execute and unsupported-execute engines → `draw_payoff_no_engine`; constrained two-target engine neutral (same controller) / rewarded (different controllers); deck-feature payoff counting rejects no-execute + unsupported. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(phase-ai): replacement-aware draw preflight + registry-routed activated-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> * fix(PR-6688): freeze draw replacement test producer * fix(engine+phase-ai): route draw preflight through the live replacement 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> * fix(engine+phase-ai): share the draw-substitution classifier with the 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. * fix(engine): factor the test replacement-shape tuple into a type alias 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> * fix(phase-ai): card-local gate first + exhaustive action match + bounded-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> * fix(phase-ai): classify GameAction::EndContinuousEffect in the draw-payoff match main added a 128th `GameAction` variant while this branch carried a 127-arm exhaustive match, so the merge CI builds were non-exhaustive and every Rust job failed to compile. Classify it neutral: ending a continuous effect draws no card. This is the maintenance cost of an exhaustive action match living inside a single policy — see the PR discussion for centralizing it in the routing authority instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(phase-ai): require a positive resolved draw quantity on live candidates (round 12) `is_draw_source_parts` matched every controller-targeted `Effect::Draw` without inspecting its count, while the resolver resolves that quantity (`resolve_quantity_with_targets(..).max(0)`) and emits `CardDrawn` only per delivered card. A candidate whose own instruction draws ZERO therefore received the full draw-payoff bonus despite being unable to fire any engine. The player-level delivery gate added last round does not catch this: it asks whether the PLAYER can draw, not whether this CANDIDATE draws. Parameterizes the one classifier with a typed `DrawQuantity` rather than forking a second copy: `Any` for deck-time classification (a "draw X" card is still an enabler for archetype detection, its count unknowable at deck-build time) and `ResolvesPositive` for a live candidate. The positive check delegates to the engine's `resolve_quantity` authority instead of re-deriving quantity semantics, so it agrees with the resolver by construction. X is genuinely unbound at the candidate seam — `resolve_quantity` supplies no `chosen_x` (only `resolve_quantity_with_targets` does, from a `ResolvedAbility` a spell still being announced does not have), so every `Variable{X}` draw resolves to zero and stays conservatively neutral until known positive. Tests: registry-routed fixed-zero (withheld) and fixed-positive (rewarded) pair; an X case asserting neutrality for both unset and set `cost_x_paid`, documenting that a stale value from an earlier activation must not be mistaken for this candidate's X; and a live-resolver assertion in the equivalence suite that a zero-count draw emits no `CardDrawn`, paired with a positive control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com> Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Round 10 — the announcement is now carried to its prompt, not re-derivedHead The finding was right, and it was worse than a duplicated derivationRound 9 collapsed the continuation's six inline steps into a single CR 700.2b: "The controller of a modal triggered ability chooses the mode(s) as part of putting that ability on the stack. If one of the modes would be illegal (due to an inability to choose legal targets, for example), that mode can't be chosen." CR 603.3c says the same for the announcement. Both bind legality to the earlier moment — and the board can move in between. The target path a few lines below already documents exactly that window: "an effect earlier in the SAME simultaneous cascade removed the only legal target." So the prompt was answering a later game state than the announcement it was meant to present. It is a player-visible defect, proven by reverting the fixI bypassed the carried announcement and re-ran the new regression. It fails with The fix — persist the announcement (the second option you offered)
The field is boxed: the Regressions
One correction worth stating plainly: my first version of that second test was vacuous. It injected the drift by setting Verification
Merge resolution (#6688 landed underneath this branch)
CR 603.3c, CR 603.3d, CR 700.2b verified against |
|
Maintainer hold on current head The Paired-seed AI gate and Decision-cost performance gate are still running. Please leave this head unchanged while they finish; once both succeed, I will live-recheck the checks and mergeability, then approve and enqueue if the remaining maintainer gates are clear. |
|
Additional current-head hold for After the paired-seed AI and Decision-cost performance gates settle, the maintainer will reconcile/update the branch as needed and recheck the resulting head, checks, and mergeability before any approval or enqueue. |
Resolves the conflict introduced by phase-rs#6718 (derive targeting intent from the announcing effect), which landed after this branch's previous merge. The only collision is the `types::ability` import list in `crates/engine/src/game/ability_utils.rs`: this branch adds `AnnouncedModalChoice`, phase-rs#6718 adds `EffectKind` and `PtValue`. Resolved as the union of both — no logic conflict. The two changes compose at different stages of the announcement pipeline, so neither bypasses the other: - This branch's `resolve_legal_modal_choice` decides which modes are legal when the ability is put on the stack (CR 700.2b / CR 603.3c). - phase-rs#6718 stamps `EffectKind` onto target slots in `collect_target_slots`, which runs after a mode has been chosen. Verified on the merged tree: `cargo check -p engine -p phase-ai --all-targets` is clean, and both round-10 regressions still pass (`prompt_cap_equals_modal_choice_authority`, `prompt_unavailable_modes_equal_modal_choice_authority`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflict resolved on new head
|
| matchup | last green run | timed-out run |
|---|---|---|
| red-mirror | 25.7 min | 22.4 min |
| affinity-mirror | 38.0 min | 27.0 min |
| enchantress-mirror | 50.0 min (job total ~59m) | not finished at 50 min |
Matchups 1 and 2 were faster on the timed-out head. enchantress-mirror is the long pole in both runs, and the last green run cleared the 60-minute cap with roughly one minute of headroom. That is the cliff this run went over.
Two independent signals that this is not decision-cost regression from this branch:
- The Decision-cost perf gate passed on the same head (
49m42s) — that is the gate that exists to catch this class of regression. - The gate passed on this branch four times on 7/27 (
06:59,07:49,08:59,13:26).
The seed 10592737 aborted: AI panic during suite game line in affinity-mirror is the known pre-existing panic that the suite catches; affinity-mirror still reported PASS 3/10, matching the green run's 3/10 exactly.
Checks are re-running on 443ce0d63. If the paired-seed gate lands near the cap again, that is a pre-existing wall-clock fragility in enchantress-mirror rather than something this branch introduces — happy to open a separate issue for it if you'd like.
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — current head 443ce0d63c32a53c64cd5e2ea44f06659e7330b2
🟡 Blocker [MED] The modal-continuation regression is not discriminating through the production pipeline.
The only test that changes legality between announcement and prompt hand-builds GameState, calls internal dispatch_two_mode_creature_target_modal, mutates via move_to_zone, then directly invokes begin_pending_trigger_target_selection (crates/engine/src/game/triggers.rs:16740-16783). The integration tests reach a prompt through GameScenario/GameAction::CastSpell, but only compare it with a fresh resolve_legal_modal_choice result and never change state after announcement (crates/engine/tests/integration/trigger_modal_choice_single_authority.rs:156-197, 204-262). They would therefore stay green if the continuation dropped announced_modal_choice and recomputed legality.
Please add a production-path GameScenario/GameAction continuation test where a prior simultaneous trigger/effect removes the only target after the modal trigger is announced but before AbilityModeChoice, and assert the prompt retains its announced availability/cap. That test must fail for the old recomputation/bypass behavior.
The implementation itself appears to persist/consume the field at crates/engine/src/game/triggers.rs:6551-6585 and crates/engine/src/game/engine.rs:8645-8697; this request is solely for discriminating runtime coverage. CI has Rust lint/tests and paired-seed/perf still in progress, and the parse artifact reports no parse changes.
|
Closing this PR. The merge conflict with For anyone picking this up later: I confirmed the round-11 finding is correct by experiment. With the carried announcement bypassed (forcing the old recomputation path), both integration tests in What I could not find was a reachable production window for that drift: in every call site I traced, Thanks for the review depth on this one. |
Tier: Frontier
Model: claude-opus-4-8
Summary
Adds a cycling deck-feature axis (
CyclingFeature) and its companionCyclingPayoffPolicy— CR 702.29 "cycling matters."CR 702.29a: cycling is card-neutral selection, so the AI's generic priors undervalue it.
CyclingDisciplinePolicyonly adds patience (don't cycle away a needed land), andself_cost_valueexplicitly defers cycling value (self_cost_cycling_deferred). Nothing modelled the upside: with a "whenever you cycle a card" engine on the battlefield (Astral Drift, Drannith Stinger, New Perspectives — CR 702.29c/d), every cycle is a repeatable value trigger, so the AI should cycle eagerly into it.Structural detection, no card-name matching:
Keyword::Cycling(_)/Keyword::Typecycling { .. }(CR 702.29a/e) — the cyclable cardsTriggerMode::Cycled/CycledOrDiscardedtrigger (CR 702.29c/d) that is controller-scoped (valid_targetNone/Controller) and not self-referential (valid_card != SelfRef), so a one-shot "when you cycle THIS card" bonus is excluded — only the repeatable battlefield engines countCommitment is a geometric mean over (source, payoff) — both mandatory: cyclers with no engine is just smoothing (
CyclingDisciplinePolicygoverns it), an engine with no cyclers never fires. Calibrated so a dedicated cycling shell clears the floor; a control deck splashing two cycling lands stays below it.Policy rewards a
Cyclingactivation when the AI controls a LIVE engine. Detection is structural over the permanent's owntrigger_definitions(no name matching), and liveness is delegated toengine::game::triggers::hypothetical_trigger_fireable— the engine-owned authority for “could this trigger still fire AND resolve to an effect?”, which reuses the live pipeline's own constraint check plus a CR 603.3d target preflight. The policy holds no eligibility rules of its own. The positive score composes withCyclingDiscipline's patience penalty so a payoff deck cycles into its engine while a smoothing-only deck stays patient.Files changed
crates/engine/src/game/triggers.rs—hypothetical_trigger_fireable(new, the shared authority);check_trigger_constraint_with_refnow takesevent: Option<&GameEvent>(Some= real evaluation,None= hypothetical)crates/engine/src/game/ability_utils.rs—execute_targets_satisfiable(new, the CR 603.3d preflight)crates/phase-ai/src/features/cycling.rs(new) ·features/tests/cycling.rs(new)crates/phase-ai/src/policies/cycling_payoff.rs(new) ·policies/tests/cycling_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.rsCR references
CR 702.29a— cycling is an activated ability that discards the card to drawCR 702.29c— "when you cycle this card" triggerCR 702.29d— "whenever you cycle or discard a card" triggerCR 702.29e— TypecyclingCR 603.2–603.4— trigger constraints; intervening-“if” (conservatively treated as not-live in the preflight)CR 603.3d— a triggered ability with no legal choice for a required target is removed from the stack rather than producing its effectCR 113.1a— building a resolved ability from its definition, preserving sub-ability chainsEvery 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 — the candidate is aCycling-tagged activation — runs FIRST and rejects every other activation; only then does it scan the battlefield, and only in a deck whoseactivationfloor is already cleared. Each structurally matching engine is then confirmed live by the engine authority, which orders its work cheapest-first: the trigger's constraint is evaluated before any targeting work, and target legality resolves through a cheap single-slot check whose “undecidable” shapes alone reach the full legal-assignment search. A no-target payoff (Drannith Stinger) short-circuits before any of it — it has no target slot to satisfy.Verification
cargo test -p phase-ai --lib— 1592 passed; 0 failed; 8 ignored (19cycling_payoffpolicy tests, incl. this round's six trigger-eligibility regressions: no-legal-target neutral / legal-target rewarded,MaxTimesPerTurnbelow-cap rewarded / at-cap neutral, and multi-slot no-legal-target neutral / legal-targets rewarded)cargo clippy -p engine -p phase-ai --all-targets --features proptest -- -D warnings— cleancargo test -p phase-ai --lib— 1551 passed; 0 failed (12 feature tests: each detection axis, the self-cycle-bonus and opponent-scope exclusions,payoff_namesdedup, both-pillars-mandatory collapse, and calibration; 6 policy tests: activation gate, each verdict branch, and a registry-routed regression assertingPolicyId::CyclingPayoffis registered and theActivateAbilityrouting delivers the engine reward)cargo clippy -p phase-ai --all-targets -- -D warnings— cleancargo fmt --all— cleancargo ai-gate— the policy activates only on a cycling-payoff deck, which none of the three gate matchups (mono-red burn, affinity, Selesnya enchantress) is, soCyclingPayoffPolicy::activationreturnsNoneand it never fires; CI's paired-seed AI gate confirms on this head.Notes
cycling_payoff_bonusis registeredUNTUNEDpending a paired-seed calibration.features/tests/andpolicies/tests/per the house convention.spellslinger_prowess: spellslinger counts spell-cast triggers; cycling triggers on the cycling keyword action (CR 702.29c), a disjoint event. A cycling instant/sorcery can read on both axes — the overlap is intentional and the axes stay independent.CyclingDisciplinePolicy(patience, a penalty) — this adds the payoff upside; the two compose.Summary by CodeRabbit
cycling_payoff_bonus) to control cycling payoff reward strength.0.0cycling commitment (no NaN).