feat(phase-ai): add cycling deck-feature axis + CyclingPayoffPolicy#6683
feat(phase-ai): add cycling deck-feature axis + CyclingPayoffPolicy#6683minion1227 wants to merge 9 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.
…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>
|
Follow-up at head
Added |
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — current head 6952cb6627f288ad7134d45788cd440e90044139
The prior ManaBrew stale-base concern is resolved and is not a finding on this head. Two live-trigger-eligibility gaps remain.
🟡 Blocker [MED] Target-required cycling payoffs can still receive value when no legal target exists
crates/phase-ai/src/policies/cycling_payoff.rs:82-107 rewards a target-required payoff trigger without consulting the engine’s execute/target-legality path. The engine removes a trigger that requires an unavailable target rather than allowing it to produce an effect (crates/engine/src/game/engine.rs:8775-8807; CR 603.3d). The policy can therefore value a cycling trigger that cannot actually fire.
Please make hypothetical payoff fireability use the engine’s authoritative target/execute legality rather than a policy-local approximation. Add runtime coverage showing: (1) a required target with no legal target is neutral (no payoff bonus), and (2) the same payoff is positive when a legal target is available.
🟡 Blocker [MED] MaxTimesPerTurn is treated as categorically unavailable
crates/phase-ai/src/policies/cycling_payoff.rs:139-146 rejects every MaxTimesPerTurn trigger. That is stricter than the runtime engine, which permits the trigger while its observed count remains below the configured maximum (crates/engine/src/game/triggers.rs:8139-8147). This creates a false negative before the cap is exhausted.
Please route both constraint evaluation and target legality through one shared, full hypothetical-fireability authority exposed by/reused from the engine. Add runtime coverage for a positive payoff below the cap and neutral behavior at exhaustion. This shared authority must cover the complete trigger condition, not just selected constraint variants.
Confidence: high. Evidence is the current-head policy logic and the engine’s runtime eligibility paths cited above. This conclusion would be contradicted only if the policy’s hypothetical query already delegates to that same engine authority and its tests drive these unavailable-target and pre-/post-cap states; the current code does not show either.
…ound 5) Addresses both [MED] blockers on head 6952cb6 by moving live cycling-payoff eligibility into a single engine authority that covers the COMPLETE trigger condition, rather than a policy-local approximation of selected 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 own `check_trigger_constraint_with_ref` (now `event: Option<&_>`; `Some` = real evaluation, `None` = hypothetical, so event-dependent constraints report NOT-satisfied instead of guessing), conservatively rejects an intervening-if `condition` (CR 603.4), and preflights execution target legality (CR 603.3d — a trigger with no legal choice for a required target is removed from the stack rather than producing its effect). - `ability_utils::execute_targets_satisfiable` answers only from CONFIRMED legality. The cheap single-slot check is a guard; every shape it cannot decide falls through to `has_legal_target_assignment_for_ability`, the same full legal-assignment authority the target walk uses. It builds the ability with `build_resolved_from_def` (CR 113.1a) so a sub-ability chain's own target slots are preflighted too, not just the root effect's. Policy (cycling_payoff.rs): - Deleted the policy-local partial `trigger_still_fireable`. The live scan is now `is_cycle_payoff_trigger && hypothetical_trigger_fireable`, so the policy holds no eligibility rules of its own. - This fixes both findings at once: a target-required payoff with no legal target no longer scores, and `MaxTimesPerTurn` is no longer rejected categorically — the engine permits it while below the configured cap. Tests (external, no cfg(test) in source): - Target legality: no-legal-target neutral / legal-target rewarded. - MaxTimesPerTurn: below-cap rewarded / at-cap neutral. - Multi-slot: no-legal-target neutral / legal-targets rewarded, the latter pinning the fixture's two-slot shape so the negative case cannot silently degrade into a single-slot test. - `permanent_with_trigger` now returns its `ObjectId` so the shape assertion addresses the fixture directly, keeping the `no_name_matching` architectural lint clean. CR 603.2-603.4, CR 603.3d, CR 113.1a grep-verified against docs/MagicCompRules.txt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round 5 pushed as The shared authority you asked for
This fixes On target legality — I also closed the gap you flagged on the sibling PRYou noted on #6688 that a preflight returning "unknown ⇒ satisfiable" would credit multi-target shapes it can't decide. This PR's copy does not do that — it answers only from confirmed legality:
Regressions added
The multi-slot positive case also asserts the fixture really carries two mandatory slots, so the negative case can't silently degrade into a single-slot test.
One coordination note
One thing I want to flag rather than bury: while writing the multi-target regression I first assumed two |
matthewevans
left a comment
There was a problem hiding this comment.
**Changes requested — the new shared hypothetical-fireability authority incorrectly treats every class-level trigger as unavailable.
🟡 Blocker
[MED] AtClassLevel payoffs are always rejected in the hypothetical scan. Evidence: crates/engine/src/game/triggers.rs:8178-8185 calls check_trigger_constraint_with_ref with source_context set to None, while the TriggerConstraint::AtClassLevel arm at :8140-8142 obtains the source class level exclusively from that context. The existing trigger_source_context_for_latch authority at :717-737 constructs the required current source snapshot. Why it matters: an otherwise usable cycling payoff with an AtClassLevel constraint can never contribute policy value, even when the source is at the required level.
Suggested fix: build and pass trigger_source_context_for_latch(state, source) into the shared constraint check. Add positive and negative AtClassLevel payoff regressions (at the required level and at a different level), and retain the current conservative None handling for genuinely event-dependent constraints rather than treating unknown events as fireable.
Recommendation: request changes — preserve source-sensitive constraints in the hypothetical authority, then request another review.
|
Formatting correction: the opening verdict of my current-head formal review should read Changes requested — the new shared hypothetical-fireability authority incorrectly treats every class-level trigger as unavailable. The blocker, evidence, requested fix, and recommendation in that review are unchanged. |
Parse changes introduced by this PR✓ No card-parse changes detected. |
|
Maintainer hold for current head |
…igger authority (round 6) Addresses matthewevans' AtClassLevel blocker. The shared `hypothetical_trigger_fireable` authority 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 cycling 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>
|
Fixed in Fix: it now builds and passes Coverage:
|
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the shared fireability check still credits triggers that can only resolve to an unsupported/no-op execution.
🟡 Blocker
[MED] A cycling payoff with no execute is considered fireable and earns policy value despite resolving as TriggerNoExecute. Evidence: crates/engine/src/game/triggers.rs:8195-8198 returns true when TriggerDefinition.execute is None; the live trigger builder instead converts that shape to Effect::Unimplemented { name: "TriggerNoExecute", ... } at crates/engine/src/game/triggers.rs:10294-10310. crates/phase-ai/src/policies/cycling_payoff.rs:89-94 therefore counts the trigger as an engine, while deck detection calls the same structural predicate at crates/phase-ai/src/features/cycling.rs:81-85 and :109-130, so it can also inflate cycling commitment for an unsupported payoff. Why it matters: the AI is rewarded for cycling based on a trigger that cannot produce the claimed payoff; the deck-level feature and live policy then disagree with the engine's supported execution surface.
Suggested fix: make the engine-owned hypothetical-fireability authority reject a missing execute and any root or nested Effect::Unimplemented execute path using one shared recursive support predicate; make the deck classifier use the corresponding structural execute-support check so it does not classify missing/unsupported payoff triggers as engines. Add discriminating negative regressions for (1) a live policy payoff with execute: None, (2) root and nested unsupported execute paths, and (3) deck-feature payoff counting for the same shapes, alongside the existing supported control.
Recommendation: request changes — make executable support part of the shared fireability and deck-payoff contracts, then request another review.
…xecute target constraints (round 7) Addresses matthewevans' executable-support blocker, and mirrors the two shared-authority fixes made on the sibling draw PR (byte-identical engine). 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_cycle_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` now threads `execute.target_constraints` into both the cheap and full solvers (CR 115.1 / CR 601.2c), so a constrained multi-target execute is judged against the same target space the live trigger receives. Corrected the preflight annotation from CR 113.1a to CR 603.3d. Coverage: no-execute and unsupported-execute engines → `cycling_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>
|
Fixed in Executable-support contract. Correct — Coverage: I also threaded the execute's
|
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — modal payoff legality is still not preflighted on current head 04f98f5177d8a4b3e43f4505efeacc3763e40d52
[MED] A required modal cycling payoff is counted as live even when every selectable mode has an unavailable mandatory target. Evidence: execute_targets_satisfiable builds only the execute root and returns true as soon as target_slot_specs(state, &resolved) is empty (crates/engine/src/game/ability_utils.rs:1335-1337). build_resolved_from_def merely copies mode_abilities onto that root (:167-172), while collect_target_slot_specs descends through sub_ability but never through mode_abilities (:4068-4400). The actual trigger path instead filters each required modal mode with filter_modes_by_target_legality and returns DroppedNoLegalMode when none can be chosen (crates/engine/src/game/triggers.rs:6519-6570). Therefore, on an empty board, a choose one cycling payoff whose only modes each say “target creature …” receives the policy/deck payoff value even though the real trigger is removed before it produces an effect.
Why it matters: this is the same live-versus-hypothetical contract this PR introduces; rewarding an activation for a payoff that the runtime necessarily drops makes the tactical score wrong for a real class of modal triggers. CR 603.3c says an illegal mode cannot be chosen and no-mode triggers are removed; CR 603.3d separately removes a triggered ability if its required stack-time choice has no legal option. I verified both against Wizards’ current Comprehensive Rules (effective June 19, 2026).
Suggested fix: make the hypothetical preflight use/factor the same modal choice + per-mode target-legality authority as dispatch_pending_trigger_context, rather than treating an unresolved modal root as a no-target effect. Add a discriminating regression that creates a required choose one payoff with all target-required modes on an empty board and proves both policy neutrality and the live DroppedNoLegalMode result; retain a legal-target control that earns the bonus.
The existing targeted, chained, constraint, and unsupported-execute tests do not construct a modal execute or run the live trigger-dispatch branch, so they would remain green if this modal discrepancy were reverted.
Quality Gate: FAIL — the current head has no discriminating production-pipeline test for this modal legality contract.
…d payoff preflight (round 9) CR 603.3c: a required modal trigger whose every mode needs an unavailable target has no legal mode and is removed from the stack. The payoff preflight never saw those modes -- `execute_targets_satisfiable` walked only the execute root, and a modal execute keeps its real effects (and therefore its target slots) in `mode_abilities`. A "choose one -- deal 2 damage to target creature; or ..." cycling payoff therefore scored on an empty board even though the runtime necessarily drops the trigger. Factor the whole mode choice into `ability_utils::resolve_legal_modal_choice`: the dynamic "choose up to X" cap (CR 700.2 + CR 107.3m), the modes unavailable for non-target reasons, the per-mode target-legality filter (CR 115.1), the cross-mode assignment cap, and the CR 603.3c no-legal-mode verdict. `dispatch_pending_trigger_context` now asks that authority -- inside the trigger event window, so every step still sees the triggering event -- and the hypothetical preflight asks the same function. A hypothetical answer can no longer drift from the live one. CR 700.2: `ability_definition_supported` no longer treats a modal ability's `modal_placeholder` root as a parser gap, since its real effects live in the modes -- unless there are no modes for the placeholder to stand in for, in which case the placeholder itself is what would resolve. Tests: live dispatch returns `DroppedNoLegalMode` for an all-target-required modal on an empty board and does not drop it once a legal target exists; `CyclingPayoffPolicy` is neutral / positive across that same pair; a modal with no modes is neutral; the deck classifier counts a modal payoff but not one whose modes are themselves unsupported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
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).