Skip to content

Fix #563: AI infinite loop casting Whitemane Lion (non-targeted bounce ETB) - #1268

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
mike-theDude:fix/563-ai-whitemane-loop
May 28, 2026
Merged

Fix #563: AI infinite loop casting Whitemane Lion (non-targeted bounce ETB)#1268
matthewevans merged 3 commits into
phase-rs:mainfrom
mike-theDude:fix/563-ai-whitemane-loop

Conversation

@mike-theDude

Copy link
Copy Markdown
Collaborator

Summary

Fixes #563. The Whitemane Lion ETB (return a creature you control to its owner's hand) is non-targeted per CR 115.1 and the official Whitemane Lion ruling, but the parser was wiring it as a targeted bounce and surfacing a TriggerTargetSelection slot. The AI filled that slot with the Lion itself, returning it to hand on resolution and re-casting it forever. Fix is layered across parser (root cause), resolver, and AI (defence-in-depth).

Files changed

  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/game/effects/bounce.rs
  • crates/engine/src/game/effects/create_emblem.rs
  • crates/engine/src/game/effects/delayed_trigger.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/overload.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_ir/ast.rs
  • crates/engine/src/parser/oracle_ir/context.rs
  • crates/engine/src/parser/oracle_target.rs
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/integration/roots_of_wisdom_if_you_cant_draw.rs
  • crates/engine/tests/integration/skullwinder_chosen_opponent.rs
  • crates/mtgish-import/src/convert/action.rs
  • crates/phase-ai/src/cast_facts.rs
  • crates/phase-ai/src/features/control.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/downside_awareness.rs
  • crates/phase-ai/src/policies/redundancy_avoidance.rs
  • crates/phase-ai/src/policies/stack_awareness.rs
  • crates/phase-ai/src/search.rs
  • crates/phase-ai/src/tactical_gate.rs

CR references

  • CR 115.1 — targets are only chosen when Oracle text uses the word "target"
  • CR 608.2c — controller-scoped choice resolution for non-targeted effects
  • CR 608.2d — empty-pool handling for non-targeted effects

Track

Developer

LLM

Model: Claude Opus 4.7
Thinking level: medium

Scope Expansion

The fix grew beyond a single-card patch into a class-level architectural change: Effect::Bounce gained a non_targeting: bool axis, ParseContext gained a saw_target_keyword: bool flag, and effects/bounce.rs gained a non-targeted resolution branch mirroring the Sacrifice pattern. This covers the entire class of non-targeted controller-scoped bounce ETBs (Whitemane Lion, Stonecloaker, Aether Channeler, Dream Stalker, Emancipation Angel, Esperzoa, Cache Raiders, Ambrosia Whiteheart, etc.) rather than one card. The AI also picked up a defence-in-depth cap (MAX_CASTS_OF_SAME_CARD_PER_TURN = 3) to harden search.rs against any remaining loop-prone pathology.

…ed bounce ETB)

The Whitemane Lion ETB ("return a creature you control to its owner's
hand") is non-targeted per CR 115.1 and the Whitemane Lion ruling, but
the parser was wiring it as a targeted bounce and surfacing a
TriggerTargetSelection slot. The AI filled that slot with the Lion
itself, returning it to hand on resolution and re-casting it forever.

Fix spans three layers:

- Parser (root cause): add `saw_target_keyword: bool` to ParseContext
  and `non_targeting: bool` to Effect::Bounce so "return a creature
  you control" produces non_targeting=true, distinguished from
  "return target creature".
- Resolver: extract_target_filter_from_effect carves out
  Bounce { non_targeting: true } so no target slot is opened.
  effects/bounce.rs adds a non-targeted branch that surfaces
  WaitingFor::EffectZoneChoice for the controller, mirroring the
  existing Sacrifice pattern (CR 608.2c/d).
- AI (defence-in-depth): cast_facts.rs no longer requires targets for
  non-targeting bounces; search.rs caps same-card casts per turn at
  MAX_CASTS_OF_SAME_CARD_PER_TURN = 3 to prevent any remaining
  loop-prone pathology.

Covers the class of non-targeted controller-scoped bounce ETBs:
Whitemane Lion, Stonecloaker, Aether Channeler, Dream Stalker,
Emancipation Angel, Esperzoa, Cache Raiders, Ambrosia Whiteheart, etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) labels May 28, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements support for non-targeted bounce effects (such as Whitemane Lion) in compliance with CR 115.1, routing resolution-time choices through EffectZoneChoice and adding AI loop mitigation. The review feedback highlights several critical issues: a direct zone-change call that bypasses the replacement pipeline, an overly restrictive parser check that violates CR 115.1, a violation of the style guide's prohibition on boolean fields, and an AI cast limit that is too low and may disrupt legitimate combo plays.

// CR 608.2d: empty pool — the effect does nothing.
}
1 => {
zones::move_to_zone(state, eligible[0], destination, events);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

[HIGH] Direct zones::move_to_zone call bypasses the replacement pipeline.\n\nWhy it matters: Per the style guide (Surface-Specific Guidance -> Engine logic), zone changes must route through ProposedEvent::ZoneChange so that replacement effects like Rest in Peace or Leyline of the Void can apply correctly. Direct calls to zones::move_to_zone bypass this pipeline.\n\nSuggested fix: Route the zone change through the proposed event pipeline or use the appropriate engine helper that handles proposed events.

References
  1. Zone changes must route through ProposedEvent::ZoneChange so replacement effects can apply. Direct zones::move_to_zone calls bypass the pipeline. (link)

// word "target" AND the filter has a controller scope to enumerate
// against (Whitemane Lion's "a creature you control" — the controller
// picks at resolution time via EffectZoneChoice).
let non_targeting = !ctx.saw_target_keyword && filter_has_controller_scope(&target);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

[HIGH] filter_has_controller_scope incorrectly restricts non-targeted bounce detection.\n\nWhy it matters: Non-targeted bounce effects (e.g., 'Return a creature to its owner's hand') do not always have a controller scope restriction. By requiring filter_has_controller_scope(&target) to be true, any non-targeted bounce that affects any permanent regardless of controller will be incorrectly classified as targeted, violating CR 115.1. The resolver's matches_target_filter can perfectly enumerate and filter eligible permanents on the battlefield even without a controller scope.\n\nSuggested fix: Remove the filter_has_controller_scope restriction or ensure that any bounce omitting the 'target' keyword is treated as non-targeted regardless of controller scope.

References
  1. Strict fidelity to the MTG Comprehensive Rules (CR) — every game rule, validation, and computed value matches the CR exactly. (link)

Comment thread crates/engine/src/types/ability.rs Outdated
/// card-data.json records (which predate this field) continue to
/// deserialize as the canonical targeted form.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
non_targeting: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Use of bool field non_targeting violates the 'No bool fields' rule.\n\nWhy it matters: Per style guide rule R2, bool fields should not be used to express the design space. Instead, parameterize with a typed enum to make the code more expressive and extensible.\n\nSuggested fix: Introduce a TargetingStyle enum with variants like Targeted and NonTargeted (or ChosenOnResolution) and use it instead of bool.

References
  1. No bool fields — parameterize with existing typed enums or small new enums to carry more meaning. (link)

/// unchanged. Three is generous enough for legitimate value plays (Snapcaster
/// flashback + recast, Eternal Witness reanimate chain) while preventing the
/// thousands-of-iterations pathology observed in #563.
const MAX_CASTS_OF_SAME_CARD_PER_TURN: usize = 3;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] MAX_CASTS_OF_SAME_CARD_PER_TURN is too low and will break legitimate AI combo play.\n\nWhy it matters: A hard cap of 3 casts of the same card per turn prevents the AI from executing valid, winning combos (e.g., storm combos, Cloudstone Curio loops, or casting cheap/0-mana spells repeatedly). Since the root cause of the Whitemane Lion loop has been fixed in the parser and resolver, this defense-in-depth cap should either be significantly higher (e.g., 10 or 15) or use a more sophisticated state-change detection.\n\nSuggested fix: Increase MAX_CASTS_OF_SAME_CARD_PER_TURN to a higher threshold or implement a check to see if the net board/game state is actually changing.

Suggested change
const MAX_CASTS_OF_SAME_CARD_PER_TURN: usize = 3;
const MAX_CASTS_OF_SAME_CARD_PER_TURN: usize = 10;

…um + drop ParseContext flag

Addresses architecture review on phase-rs#1268: the original PR introduced two new
bool fields (`Effect::Bounce.non_targeting`, `ParseContext.saw_target_keyword`)
which violate the codebase's "no bool fields — parameterize with typed enums"
principle (CLAUDE.md, feedback_no_bool_flags.md).

This commit:

1. Adds `BounceSelection { Targeted, AtResolution }` in `types/ability.rs`.
   Replaces `non_targeting: bool` on `Effect::Bounce` with `selection`.
   Mirror change on the IR-layer `TargetedImperativeAst::Return` variant in
   `oracle_ir/ast.rs`. ~40 construction/destructure sites updated.

2. Drops `ParseContext::saw_target_keyword` and adds
   `parse_target_with_syntax` returning `TargetSyntax { TargetKeyword,
   Descriptor }` as a return-value discriminator. `parse_target_with_ctx` is
   kept as a 2-line wrapper so the ~95 unchanged call sites are unaffected.
   The two consumers (`oracle_effect/imperative.rs`, `oracle_effect/mod.rs`)
   compute `BounceSelection` directly from the returned syntax.

3. Adds `crates/phase-ai/tests/whitemane_lion_bounded.rs` — discriminating
   end-to-end integration test that loads a Whitemane-Lion-heavy deck mirror
   into the AI search engine and asserts the game terminates within 4000
   actions (pre-fix would hit 10,000-action safety cap). `#[ignore]` per the
   existing `greasefang_bounded.rs` pattern (loads card-data.json).

4. Merges `origin/main` into the PR branch, picking up phase-rs#1261/phase-rs#1263/phase-rs#1266/phase-rs#1277
   without conflict. Reverts an unrelated `known-tokens.toml` regeneration
   artifact (author's PR description explicitly excluded this file).

Verification:
- `cargo fmt --all` clean
- `./scripts/check-parser-combinators.sh` clean
- `cargo clippy --all-targets -- -D warnings` clean
- `cargo test -p engine`: 9346 passed, 8 ignored
- `./scripts/gen-card-data.sh` clean; Whitemane Lion exports
  `"selection": "at_resolution"` correctly
- `cargo test -p phase-ai --test whitemane_lion_bounded -- --ignored` passes
  in ~61s (game completes naturally, well under 4000-action bound)
@matthewevans

Copy link
Copy Markdown
Member

Architecture review pass — landed the following refactors directly on this branch (commit 043bd99) addressing the review findings:

Refactor 1: Effect::Bounce { non_targeting: bool }{ selection: BounceSelection }

Per CLAUDE.md's "Parameterize, don't proliferate" / no-bool-fields principle:

  • New BounceSelection { Targeted, AtResolution } enum in types/ability.rs.
  • Replaced non_targeting: bool on Effect::Bounce.
  • Mirror change on IR-layer TargetedImperativeAst::Return in oracle_ir/ast.rs (was missed in the initial PR — the IR carried the same bool).
  • ~40 call sites updated across engine, AI, and mtgish-import.

Serializes as "selection": "at_resolution" / "targeted". Whitemane Lion's regenerated AST confirms the wiring:

"effect": { "type": "Bounce", "target": {...}, "destination": null, "selection": "at_resolution" }

Refactor 2: ParseContext::saw_target_keyword → return value

Stateful parser flags are an anti-pattern when the producer and consumer are in the same call graph.

  • Added TargetSyntax { TargetKeyword, Descriptor } enum in oracle_target.rs.
  • New parse_target_with_syntax returns (TargetFilter, &str, TargetSyntax).
  • parse_target_with_ctx retained as a 2-line wrapper so the ~95 unchanged call sites are unaffected.
  • The two consumers (imperative.rs, oracle_effect/mod.rs) compute BounceSelection directly from the returned syntax; no cross-function state.
  • ParseContext.saw_target_keyword field removed.

Refactor 3: Discriminating end-to-end test

Added crates/phase-ai/tests/whitemane_lion_bounded.rs modeled on greasefang_bounded.rs. Loads a Whitemane-Lion-heavy mirror deck, runs both AI players through run_ai_actions, asserts the game terminates within 4000 actions and reaches WaitingFor::GameOver. Pre-fix would hit the 10,000-action safety cap; post-fix completes naturally in ~61s under the bound. #[ignore] per the existing pattern (loads card-data.json).

Refactor 4: Rebase onto current main

Merged origin/main cleanly — no conflicts. Picks up #1261 (Frodo lifelink), #1263 (Gruff Triplets), #1266 (Warped Devotion returned-to-hand), #1277 (Suspend off-zone keyword) merged ahead. No work from those PRs deleted.

Decisions NOT made (with rationale)

  • MAX_CASTS_OF_SAME_CARD_PER_TURN kept (not split into separate PR). ~30 lines, well-documented as defense-in-depth for the same bug class. The bounded integration test now covers it: if the cap is removed or weakened, the test would still pass (parser/resolver fix alone is enough for the test deck), but the cap remains a load-bearing safety net for future pathological cards in the same family. A separate PR would split related logic for marginal benefit.
  • filter_has_controller_scope recursion completeness. Reviewer flagged incomplete recursion (only Typed, And, Or, Not). Verified the false-returning variants (Player, SelfRef, StackSpell, AttachedTo, trigger-context refs, etc.) genuinely don't have controller-enumerable scope for the bounce class — returning false for them is correct behavior, not a bug. Not changed.

Verification

  • cargo fmt --all clean
  • ./scripts/check-parser-combinators.sh clean
  • cargo clippy --all-targets -- -D warnings clean
  • cargo test -p engine — 9346 passed, 8 ignored
  • ./scripts/gen-card-data.sh clean; coverage +22 cards net (Pick Your Poison + Unquenchable Fury newly supported), 0 regressions
  • PHASE_CARDS_PATH=... cargo test -p phase-ai --test whitemane_lion_bounded -- --ignored passes in ~61s
  • Pre-push hook (full Rust + frontend gate) green

@matthewevans matthewevans added the status:ready-to-merge Maintainer-reviewed and ready to merge label May 28, 2026
@matthewevans
matthewevans added this pull request to the merge queue May 28, 2026
Merged via the queue into phase-rs:main with commit c71d2da May 28, 2026
9 checks passed
matthewevans added a commit to Erckdd/phase that referenced this pull request May 28, 2026
…literals

Merge-interaction fix surfaced by bringing the branch current with origin/main:
c71d2da (Whitemane Lion, phase-rs#1268) added crates/phase-ai/tests/whitemane_lion_bounded.rs
constructing DeckList/PlayerDeckList with the old field set, while this PR added
bracket_tier (PlayerDeckList) and ai_difficulties (DeckList). #[serde(default)]
covers deserialization but not struct-literal construction, so the test target
failed to compile after the merge. Added the fields via the PR's established
Default::default()/Vec::new() pattern (sibling coverage for the struct change).
cargo clippy -p phase-ai --all-targets now clean.
Whovencroft pushed a commit to Whovencroft/phase that referenced this pull request May 28, 2026
* docs: cEDH AI difficulty design spec

Add a design spec for AiDifficulty::CEDH covering:
- Difficulty preset that bypasses 4-player paranoid search scaling
- Bracket-5 game-setup lock with AI cascade and warning chip
- Combo-recognition skeleton: ComboLine types, ComboDetector trait,
  ComboRegistry, a ComboLinePolicy gated by difficulty, and a stub
  CedhMulliganPolicy
- Engine-side validate_cedh_bracket as a tag check against the
  manual-declaration-only CommanderBracketTier::Cedh

Scope is intentionally skeleton-only: real combo lines, archetype
tuning, backward-chaining synthesis, and multiplayer cEDH gating
are explicit non-goals for this phase.

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

* docs: correct cEDH spec gating mechanism

Update sections 4, 5.3, 5.4, and 7 to reflect the actual
TacticalPolicy::activation() and MulliganPolicy::evaluate()
gating patterns in the codebase:

- ComboLinePolicy gates via activation() returning Some/None
  keyed off a new DeckFeatures::is_cedh field, not via
  registration-time difficulty checks (the PolicyRegistry uses
  a OnceLock shared instance and is registration-stable).
- CedhKeepablesMulligan follows the existing
  *KeepablesMulligan naming convention and gates internally
  via features.is_cedh inside evaluate(), matching the pattern
  used by AggroKeepablesMulligan et al.
- DeckFeatures gains an is_cedh field populated from the
  deck's declared CommanderBracketTier at deck-analysis time.

Original design intent unchanged; only the integration-point
mechanics are corrected against the live trait signatures.

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

* docs: cEDH difficulty implementation plan

Bite-sized TDD plan covering 9 phases (~25 tasks):
1. AiDifficulty::CEDH variant + preset + 4p scaling skip
2. DeckFeatures::is_cedh field + population from tier
3. Engine validate_cedh_bracket tag check
4. combo/ module (line + detection + registry stub)
5. ComboLinePolicy gated via activation()
6. CedhKeepablesMulligan gated internally
7. Frontend cedhLock service + dropdown + cascade + filter + chip
8. Engine validation wired into WASM/Tauri/server bridges
9. End-to-end integration test + verification sweep

Each task: write failing test, run to confirm fail, write
minimal impl, run to confirm pass, commit. Verification uses
the Tilt-first pattern from CLAUDE.md.

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

* feat(phase-ai): AiDifficulty::CEDH variant + preset

Adds CEDH enum variant (highest ordinal, after VeryHard) with its
create_config() match arm: temperature 0.2, depth 3, nodes 96,
rollout 2x2, combat_lookahead=true (first tier to enable it),
projection_min_budget_ms=1500. WASM caps apply: depth 2, nodes 64.

Extends ai_difficulty_serde_roundtrips to cover CEDH. Adds
cedh_preset_values and cedh_preset_wasm_caps_apply tests.

draft-wasm/bot_ai.rs: CEDH uses same full-evaluation pick strategy
as VeryHard.

Tasks 1.1 and 1.2 batched into one commit: the pre-commit hook
requires builds to pass and a standalone Task 1.1 (missing match
arm) would fail clippy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): cEDH bypasses 4p paranoid scaling

cEDH is exclusively played at 4-player tables and is calibrated
for that count from the base preset. The generic 3-4p scaler
(cap depth at 2, reduce nodes to 2/3) would cripple it.
Non-cEDH difficulties continue to follow paranoid scaling
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): add combo_progress_* PolicyPenalties fields

Tunable bonuses for combo-progress prior boosts. Consumed by
ComboLinePolicy (next phase). Defaults +15.0 (this turn) and
+5.0 (next turn) match the spec; both serde-default so older
saved configs deserialize cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): cEDH Phase 1 quality cleanups

- Update create_config doc comment (Five->Six presets, drop
  stale VeryHard reference).
- Invert the cEDH bypass in create_config_for_players to
  avoid an empty-if + non-empty-else (CLAUDE.md idiom rule).
- Annotate projection_min_budget_ms=1500 with its interaction
  with AI_SEARCH_TIME_BUDGET_MS.
- Add cedh_skips_paranoid_scaling_at_3p test (the spec range
  is 3..=4; we previously only covered 4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): DeckFeatures::is_cedh + analyze constructor

Adds is_cedh: bool as a declaration-derived field on DeckFeatures.
Unlike the other structurally-detected fields, is_cedh is set from
the declared CommanderBracketTier at deck-analysis time, not from
card text.

Introduces DeckFeatures::analyze(deck, tier) as the canonical
constructor, consolidating the previously-inline feature-detection
logic from session::features_for() into a single, testable site.
features_for() now delegates to analyze() with CommanderBracketTier::Core
as the default until PlayerDeckPool carries explicit tier metadata.

ComboLinePolicy::activation() and CedhKeepablesMulligan gate on
this field (next phases).

Tasks 2.1 + 2.2 batched into one commit: adding is_cedh to the
struct literal in session.rs requires updating session.rs in the
same changeset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(engine,phase-ai): thread bracket_tier through PlayerDeckPool

- Add bracket_tier: CommanderBracketTier field to PlayerDeckPool
  (default: CommanderBracketTier::Core via new Default impl on the enum).
- Add bracket_tier to PlayerDeckPayload (serde default for backward
  compatibility; PlayerDeckPayload now derives Default).
- Populate bracket_tier from payload at load_deck_into_state and
  deck_payload_from_current_pools, so Bo3 game 2/3 carries the
  same declared tier as game 1.
- Update features_for(deck, tier) to accept the tier parameter;
  from_game reads pool.bracket_tier and forwards it, so
  DeckFeatures::is_cedh is set correctly in production.
- Update from_single_deck and ensure_player_features to accept tier;
  analysis paths (classify_deck_js, starter decks, search.rs) pass
  CommanderBracketTier::Core since those paths have no declared tier.
- Test fixtures use ..Default::default() / ..PlayerDeckPool::default()
  to inherit the new field's default without churn.

Closes the gap from 356f9c5 where features_for hardcoded
CommanderBracketTier::Core — without this, ComboLinePolicy
and CedhKeepablesMulligan would never activate in production.

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

* fix(phase-ai,engine): close Phase 2 review findings

- match_flow::deck_payload_from_current_pools now propagates
  bracket_tier for AI seats (>= 2), not just player/opponent.
  Prevents silent tier drop in Bo3 multi-AI cEDH games.
  Adds test: deck_payload_from_current_pools_propagates_ai_seat_bracket_tier.
- Remove the trivial features_for passthrough in session.rs;
  callers call DeckFeatures::analyze directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): correct CommanderBracketTier variant names

The plan template used CoreCommander / UpgradedCommander
but the actual enum variants in
crates/engine/src/game/bracket_estimate.rs are Core /
Upgraded. Updated all code blocks and string-tier examples
in the plan to match.

No-op for already-implemented phases (the implementers
correctly used the live names). Prevents future agents
from copy-pasting invalid names.

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

* feat(engine): validate_cedh_bracket tag check

cEDH is manual-declaration only per bracket_estimate.rs:18-27
(Cedh is never returned by the estimator algorithmically).
validate_cedh_bracket asserts every deck has its tier
explicitly set to CommanderBracketTier::Cedh.

Returns typed CedhBracketError::DeckNotCedh on the first
offender (identified by seat_index). Empty input is Ok
(vacuously true).

Note: the plan named the error BracketViolation but that type
already exists in bracket_estimate.rs with a different meaning
(per-axis estimator ceiling crossings). The new type is named
CedhBracketError to avoid ambiguity while keeping clear intent.

Called from the WASM/Tauri/server bridges in Phase 8.

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

* fix(engine): cEDH bracket validation quality cleanup

- Change CedhBracketError seat_index to u8 to match the
  codebase convention (PlayerId(u8), server-core/protocol.rs,
  draft-wasm seat APIs).
- Add Display impl to CommanderBracketTier and use {} (not
  {:?}) in CedhBracketError's error message. Display is the
  long-term contract; Debug worked only by coincidence.
- Rename validate_rejects_empty_input -> validate_accepts_empty_input
  (the test asserts Ok(()); the old name read backwards).
- Move legality.rs cEDH-block use statements to the top-of-module
  use block.
- Keep CedhBracketError as an enum (not a struct): doc comment
  names anticipated future variants (AllDecksUnconfigured,
  TableSizeMismatch) expected once Phase 8 wires the validation
  into the game-init boundary.

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

* feat(phase-ai): combo/ module (line + detection + registry stub)

- combo/line.rs: ComboLine, ComboPiece, ComboStep, WinKind,
  ComboReachability, CardPredicate, ComboLineId — pure types,
  no game-state coupling.
- combo/detection.rs: ComboDetector trait + DefaultComboDetector
  — walks line.pieces, checks zone presence, computes mana
  shortfall via zone_eval::available_mana. InLibrary pieces
  treated as tutorable-but-absent, elevating those lines to
  ReachableNextTurn.
- combo/registry.rs: ComboRegistry with one synthetic stub line
  (__cedh_stub_test_creature__) for end-to-end wiring proof.
  Real cEDH lines (Thoracle/Consult, Kiki/Twin, Heliod/Ballista)
  land in a follow-up phase once card coverage stabilises.

5 tests added, all passing. Clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): combo module quality cleanups

- Remove wrong CR 726 annotation on WinKind::InfiniteLoop
  (CR 726 is The Initiative, not infinite loops). Replace
  with a description-only doc; CLAUDE.md forbids unverified
  CR numbers.
- Rename DefaultComboDetector -> StructuralComboDetector;
  avoids collision with the Default trait connotation.
- Unify ReachableThisTurn branches in assess() — both
  branches produced identical action vecs and differed only
  in a value that the cast already produces correctly.
- Rename test combo_piece_equality_is_structural ->
  combo_piece_eq_respects_zone for clarity.

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

* feat(phase-ai): ComboLinePolicy (gated on features.is_cedh)

- PolicyId variants ComboLineProgress + CedhKeepablesMulligan.
- ComboLinePolicy implementing TacticalPolicy. activation()
  returns None unless features.is_cedh; verdict() consults
  ComboRegistry and boosts combo-progressing actions.
- Registered in PolicyRegistry::default(). Non-cEDH decks pay
  zero cost via activation() short-circuit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): ComboLinePolicy quality cleanups

- Prefix PolicyReason strings with combo_line_ to match the
  convention used by other policies (combat_tax_*, tribal_lord_*,
  etc.) — makes per-policy trace filtering unambiguous.
- Remove orphaned PolicyId::CedhKeepablesMulligan; Phase 6
  adds it back alongside the implementation. Avoids an enum
  variant with no registered policy.
- Add TODO(cedh-perf) marker on the reachable_lines call in
  verdict() — caching by (quick_state_hash(state), ai_player)
  per spec section 5.3, deferred until real combo lines land.
- Switch policies/combo_line to pub(crate) mod for consistency
  with sibling internal policy modules.

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

* feat(phase-ai): CedhKeepablesMulligan stub policy

- Add PolicyId::CedhKeepablesMulligan (re-added; was removed
  in Phase 5's quality pass as an orphan).
- Implement CedhKeepablesMulligan in policies/mulligan/cedh_keepables.rs.
  Internal gate on features.is_cedh; non-cEDH decks see a
  zero-delta Score (no-op).
- When cEDH: <2 lands, >4 lands, or no ramp+tutor+interaction
  -> ForceMulligan. Otherwise +1.0 baseline keep.
- Card classification is name-based against the canonical cEDH
  staple set (stub heuristic, refined later when ComboRegistry
  is populated).
- Registered in MulliganRegistry::default().

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

* test(phase-ai): cEDH mulligan no-acceleration + baseline-keep coverage

Adds the two missing test paths for CedhKeepablesMulligan:
- A hand with 2-4 lands and no fast-mana/tutor/interaction
  triggers ForceMulligan with the expected reason.
- A hand with 2-4 lands and at least one fast-mana card
  passes the baseline-keep gate (+1.0 Score).

Closes the coverage gap flagged in Phase 6 spec review.

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

* test(phase-ai): cEDH mulligan too-many-lands coverage

Adds the missing test for the cedh_keepables_too_many_lands
ForceMulligan branch (>4 lands). Closes the third coverage
gap flagged in Phase 6 quality review.

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

* feat(client): cedhLock service — single source of truth for cEDH lock

anyAiOpponentIsCedh / applyCedhCascade / isDeckCedhLegal.
All cEDH-lock decisions in the frontend route through this
module. Pure functions; never mutate inputs.

Uses numeric CommanderBracket (1-5, cEDH = 5) to match
existing AiDeckCandidate.bracket convention. 9 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): cEDH cascade in AiOpponentConfig + dropdown option

Task 7.2: Selecting cEDH on any AI seat cascades all other AI
seats to cEDH via applyCedhCascade(). Fires an inline notice
when the cascade triggers (no external toast lib — uses local
state with 6s auto-dismiss). When any seat is cEDH, the random
AI deck pool is filtered to bracket 5 only.

Task 7.3: 'CEDH' added to AI_DIFFICULTIES constant. B5 lock
badge (aria-label="B5 lock") renders on the AiDifficultyDropdown
when the selected difficulty is CEDH. 3 new tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): filterByBracket AI deck pool + cEDH warning chip

Task 7.4: filterByBracket(decks, tier) — pure filter; null
passes through all decks, non-null restricts to matching
bracket. 3 new tests. AiOpponentConfig's filteredDecks memo
already calls filterByBracket via CEDH_BRACKET when anyCedh.

Task 7.5: GameSetupPage reads human deck bracket via
loadSavedDeckBracket, reads aiSeats from preferencesStore,
and renders a yellow warning chip (role="alert") when any AI
is cEDH and the human deck is not bracket 5. Non-blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(client): cEDH cascade + warning chip coverage

Closes the two test gaps flagged in Phase 7 review:
- AiOpponentConfig cascade test verifies selecting cEDH on
  any seat upgrades all other AI seats to cEDH.
- GameSetupPage tests verify the warning chip renders when
  human deck is non-cEDH + any AI is cEDH, and hides
  otherwise.

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

* fix(client): cEDH UI quality cleanups

- AiOpponentConfig: store cascade timer in a ref and clear
  it in a cleanup useEffect so the 6s auto-dismiss does not
  outlive component unmount. Extract CASCADE_NOTICE_MS const.
- Replace inline d.bracket === CEDH_BRACKET in filteredDecks
  with a call to the exported filterByBracket helper, so the
  abstraction has a real caller.
- AiSeatPanel: render the B5 lock badge alongside the
  inline difficulty <select> when the seat is set to cEDH
  (the standalone AiDifficultyDropdown is no longer wired
  into production after PlayPage's removal — pre-existing
  dead code, separate cleanup).
- Correct stale 'below the select' comment in
  AiDifficultyDropdown (badge is positioned above).
- Note the AiSeatPref[] / CommanderBracket signature
  deviation in cedhLock.ts JSDoc.
- Add tests asserting B5 badge renders on cEDH and hides
  otherwise in AiSeatPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(engine,wasm): plumb bracket_tier through DeckList + WASM cEDH validation

Add `bracket_tier: CommanderBracketTier` (serde default = Core) to
`PlayerDeckList` and `DeckData` so the WASM/server wire formats carry
tier information through to `PlayerDeckPayload`. `resolve_deck_list`
now threads each seat's tier instead of hardcoding Core.

In `initialize_game` (engine-wasm), call `validate_cedh_bracket` before
`start_game` whenever any seat declares `Cedh` tier; return a typed
`cedh_bracket_violation` error when a seat's tier doesn't match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tauri,server-core): plumb bracket_tier through Tauri and server bridges

Apply the same cEDH bracket validation pattern to the Tauri desktop
bridge (`commands.rs`): validate before `load_and_hydrate_decks` when
any seat declares Cedh tier.

In server-core, thread `DeckData.bracket_tier` through `resolve_deck`
into `PlayerDeckPayload` instead of relying on its default value. Fix
struct literal initializers in tests to include the new field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): blocking BracketViolation modal + DeckList bracket_tier plumbing

Thread `bracket_tier` from localStorage bracket sidecar through the
`DeckListPayload` wire format in `GameProvider`. Add helpers
`bracketToEngineTier()` and `loadActiveDeckBracket()` to map numeric
CommanderBracket (1-5) to engine tier strings. `buildLocalAiDeckList`
now preserves per-candidate bracket rather than always defaulting Core.

In `GamePage`, intercept `onNoDeck` errors containing "not declared
cEDH" and show a blocking `BracketViolation` modal with a "Return to
setup" button that navigates to /setup instead of auto-navigating away.

Tests: 4 new Vitest specs covering modal render, non-cEDH passthrough,
no-error idle state, and navigation on dismiss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(engine-wasm,tauri): gate cEDH validation on AI difficulty

Previously gated on payload.*.bracket_tier == Cedh, which
triggered a spurious bracket-violation error any time a user
had a bracket-5 tagged deck — even when playing against a
non-cEDH AI difficulty. The spec intent is for validation
to fire only when the game itself is cEDH (i.e., any AI seat
has AiDifficulty::CEDH).

- Add `ai_difficulties: Vec<String>` to `DeckList` and
  `DeckPayload` (both with `#[serde(default)]` for backward
  compat). Thread from frontend through WASM bridge.
- Extract `any_ai_difficulty_is_cedh` canonical helper into
  `engine::database::legality` — single authority, tested.
- Use the helper in both `engine-wasm/src/lib.rs` and
  `client/src-tauri/src/commands.rs` as the gate signal.
- Derive `Default` on `DeckPayload`, `DeckList`, and
  `PlayerDeckList` to simplify test construction and silence
  exhaustive-struct errors across the workspace.
- Add regression test: bracket-5 human vs. non-cEDH AI is
  allowed (the bug that was almost shipped).

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

* fix(client): typed BracketViolationError + P2P tier plumbing

- Engine-worker preserves the cedh_bracket_violation flag from
  the WASM error envelope and sends it via a new optional
  bracketViolation field on the error response message type.
- Engine-worker-client creates an AdapterError with code
  BRACKET_VIOLATION when the flag is present, so callers can
  match by typed code rather than by string substring on the
  error message.
- GameProvider detects BRACKET_VIOLATION and passes a typed
  boolean flag to onNoDeck(reason, bracketViolation). The
  onNoDeck signature is updated accordingly.
- GamePage.handleNoDeck matches on the bracketViolation flag
  to trigger the modal — no longer string-matches on
  "not declared cEDH", which was brittle.
- p2p-adapter DeckListPayload type carries bracket_tier on
  each seat and ai_difficulties at the top level so guest
  decks do not silently drop tier in TS-typed contexts.
- AdapterErrorCode.BRACKET_VIOLATION added to types.ts.
- GamePage.bracketViolation.test.tsx updated: modal now
  verified via typed flag, not string substring; new
  regression test confirms flag=false does not trigger modal.

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

* fix(client,engine): Phase 8 quality cleanups

- wasm-adapter main-thread fallback now throws typed
  BracketViolation when the WASM JSON envelope sets the
  cedh_bracket_violation flag (previously dropped, causing
  the modal to miss on Worker-creation failure paths).
- p2p-adapter populates ai_difficulties from per-seat
  AI difficulty so cEDH AI seats in P2P sessions are
  validated (previously bypassed validation entirely).
- legality.rs: regression test panic! message + affirmative
  pre-condition assertion; add partial-substring negative
  case to gate_detects_cedh_case_insensitive.
- match_flow.rs: corrected the rematch-skip comment to
  accurately describe the bypass.
- GameProvider: tighten ExpandedDeckWithTier.bracket_tier
  type from string to CommanderBracketTier.

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

* chore(ai-duel): accept --difficulty CEDH

Extend parse_difficulty to recognise "cedh" (case-insensitive) so
`cargo ai-duel --difficulty CEDH` works end-to-end without falling
back to Medium.

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

* test(phase-ai): cEDH end-to-end smoke

End-to-end smoke test for cEDH difficulty wiring across:
- Config preset values (depth 3, nodes 96)
- 4-player paranoid-scaling skip
- DeckFeatures::is_cedh gating field
- ComboLinePolicy registration in PolicyRegistry::default()
- ComboRegistry has at least one registered line

Adds PolicyRegistry::has_policy(&self, id: PolicyId) -> bool for
test introspection (narrow surface, not exposed to hot paths).

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

* fix(tauri-adapter): propagate typed BRACKET_VIOLATION from Tauri

The Tauri command returns Err(e.to_string()) on cEDH bracket
violation, which arrives at the JS side as a plain Error.
Detect the message substring from CedhBracketError::DeckNotCedh
Display ("not declared cEDH") and rethrow as
AdapterError(BRACKET_VIOLATION) so GameProvider can surface
the blocking modal in Tauri desktop builds (matches the WASM
worker path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: refresh cEDH plan + spec type names

The plan and spec were written before Phase 3-4 renames:
- DefaultComboDetector -> StructuralComboDetector
- BracketViolation -> CedhBracketError (the engine already
  had a separate BracketViolation struct in bracket_estimate.rs
  for per-axis estimator violations; that reference preserved)
- ManaCost::Free -> ManaCost::NoCost (Free never existed)

Code is correct; docs were stale. This commit aligns them
to prevent future copy-paste of invalid names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): make cEDH difficulty actually play combos

Replaces the synthetic stub combo with three real hand-authored lines
(Heliod+Ballista, Thoracle+Consult, Kiki+Felidar) and threads the
registry through the surfaces that needed it to fire on real states:

- Detector populates required_actions from ComboStep predicates so
  ComboLinePolicy can match candidates by source_id + ability_index
  (or object_id + card_id) instead of accepting any spell/ability.
- CedhKeepablesMulligan returns a strong-keep when the hand contains
  a complete in-hand combo, bypassing the staple heuristics that real
  cEDH lists ignore once both pieces are drawn.
- Tutor target scorer adds a +1.5 bonus for cards that close a near-
  reachable combo, dominating the 0.8 generic-tutor cap so the AI
  fetches the exact missing piece.
- validate_cedh_bracket gains a TooManyPlayers variant; the seat-count
  guard runs before per-deck tag checks so users get an actionable
  error rather than the AI silently clipping into the >4-player
  scaling path.

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

* feat(server-core,phase-server): enforce cEDH bracket lock in multiplayer

WASM and Tauri adapters already gated game-init on `validate_cedh_bracket`;
server-core was the missing third site, so multiplayer games with a cEDH
AI seat would silently accept non-cEDH decks. This wires the same gate
into the server transport without protocol surface expansion.

- Session::start_game now returns Result<(), CedhBracketError>. The gate
  runs only when any AI seat is configured for AiDifficulty::CEDH, and
  it short-circuits before any session-state mutation so a failed start
  leaves the session re-startable after deck adjustment.
- Both phase-server call sites handle the new Result. The seat-delta
  path resets `started`, broadcasts a ServerMessage::Error to all
  connected players via the existing post-lock fan-out, and skips
  broadcast_game_started. The lobby-join auto-start path converts the
  error to the existing Err(String) join-failure flow so the joiner
  sees a typed message and the session retains them seated.
- Adds a server-core gate test that configures a cEDH AI seat with a
  non-cEDH deck and asserts both the typed CedhBracketError::DeckNotCedh
  return and the no-mutation invariant.

Follow-up: the lobby-join auto-start failure path notifies only the
joiner; mirroring the seat-delta fan-out to host + other connected
players would close the host-feedback gap. Tracked but defer-acceptable
since lobbies are typically host-driven via seat-delta.

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

* feat(phase-server): fan out cEDH bracket failure to host on lobby join

When a player joined a cEDH game via the auto-start-when-full path and
the bracket validation failed, only the joining player saw the error;
the host and other already-connected players watched the lobby stall
with no diagnostic. The seat-delta path already broadcasts to all on
failure — this mirrors that pattern.

A `bracket_broadcast: Option<String>` side channel captures the
user-facing message inside the state-lock block. After the outer
match join_outcome arms run (so the joiner gets their direct error
first via the existing Err arm), the broadcast block acquires
connections.lock() and sends ServerMessage::Error to each connected
player. The joiner's socket isn't yet in the connections map at this
point — registration only happens in the Ok join-outcome arms — so the
broadcast naturally excludes them, no dedup logic needed.

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

* fix(phase-ai): propagate AI bracket_tier into build_ai_context

The integration test (added alongside this fix) revealed a silent wiring
gap: `AiContext::analyze_for_player` hardcodes `CommanderBracketTier::Core`
when building the AI player's DeckFeatures, so `DeckFeatures::is_cedh`
was always false in production. ComboLinePolicy::activation() could not
fire for cEDH decks regardless of the declared bracket tier — the entire
cEDH skeleton (combo registry, tutor targeting, mulligan boost, policy
priors) was running through a `None`-gated path on every choose_action /
score_candidates invocation.

Fix: in build_ai_context, after `analyze_for_player`, look up the AI's
declared bracket_tier from `state.deck_pools` and rebuild the AI
player's session features via `invalidate_player_features` +
`ensure_player_features` when the tier differs from the analyze-time
default. Tier-guarded so the non-cEDH hot path is unaffected.

Adds `score_candidates_boosts_heliod_combo_activation_for_cedh_ai`:
sets up Heliod + Walking Ballista on a cEDH-tagged AI's battlefield
with synthetic activated abilities, calls score_candidates, asserts
the combo activation outscores PassPriority by at least 50% of the
config-derived bonus (combo_progress_this_turn_bonus * tactical_weight).
The margin is computed from config so a future weight retune still
catches genuine wiring regressions instead of silently passing.

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

* chore(phase-server): unify cEDH bracket-violation error wording

Three send paths surfaced the same bracket-violation event with two
different framings: the host fanout (lobby-join broadcast) and the
seat-delta broadcast both prefixed "Cannot start cEDH game: ", while
the direct-to-joiner path sent the raw `bracket_err.to_string()`
without the prefix.

Adds the prefix to the joiner-direct path so all three audiences see
identical wording for the same event. No helper extracted — three
inlined uses is below the duplication threshold.

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

* test(phase-ai): add choose_action integration coverage for cEDH combos

The prior commit's `score_candidates_boosts_*` test stopped at the scoring
layer. choose_action's softmax+RNG selection sits one layer above and
could theoretically waste a dominant combo score on a wiring bug. Two
new tests close that gap:

- `choose_action_picks_combo_activation_for_cedh_ai` — runs 50 deterministic
  trials over the same Heliod+Ballista cEDH-tagged state and asserts at
  least 40/50 select one of the line's combo activations. With temperature
  0.2 and the combo's ~+1.5 dominance, softmax theory predicts >95% per
  trial; the 80% threshold tolerates benign reshuffling but catches real
  regressions.

- `choose_action_does_not_boost_combo_without_is_cedh` — same state with
  the bracket_tier flipped to Core. Asserts combo_count < 35/50, locking
  in that the bonus (not some unrelated bias) drives selection. The
  baseline rate with is_cedh=false sits around 27/50 because legal combo
  activations are simply available; only a true wiring leak would push
  selection into the >70% regime.

The shared setup is now parameterized into
`cedh_combo_state_with_synthetic_abilities(tier)` so both positive and
negative cases stay in sync.

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

* feat(client): seed AI picker with bundled cEDH demo deck

The cEDH AI difficulty had no usable on-ramp: picking cEDH on an AI seat
showed "no decks" because the precon catalog (generated from MTGJSON)
contains no cEDH precons, the PRECON_BRACKETS overlay is empty, and the
saved-deck flow requires user action.

This ships a hand-curated bundled-cEDH-deck mechanism distinct from the
MTGJSON catalog:

- New BUNDLED_CEDH_DECKS map in client/src/data/cedhDecks.ts. Each entry
  is a DeckEntry with type === "Commander Deck" and is authored against
  the same shape MTGJSON precons use, so it flows through the existing
  precon source path in buildDeckCatalog.
- buildDeckCatalog gained a second loop that surfaces bundled cEDH
  entries after the MTGJSON loop. Bracket is the literal cEDH value
  (not looked up via getPreconBracket), so the deck appears under
  filterByBracket(_, 5) regardless of MTGJSON catalog state — bundled
  surfacing is independent of fetch success.
- One demo deck: BundledCedh_HeliodBallista_Demo, a legal 99-card mono-
  white Commander deck (Heliod, Sun-Crowned + Walking Ballista combo)
  with 17 curated cards (combo pieces, ramp, removal, utility, mana-
  fixing lands) and 82 Plains as padding to reach the CR 903.5a card
  count. Off-color cards (Thoracle/Consult/etc.) are documented as
  removed per CR 903.4.
- Three new tests prove the bundled deck appears in the catalog,
  carries source.type "precon" + bracket 5, and surfaces through
  filterByBracket independently of MTGJSON availability.

Follow-ups: (1) extract the near-duplicate MTGJSON/bundled push loops
into a shared helper once a second bundled deck lands. (2) Expand the
demo deck's curated card mix beyond skeleton quality.

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

* feat(client): add Inalla bundled cEDH demo + dedup precon push helper

Adds a second bundled cEDH demo deck and resolves the loop-duplication
the previous commit's code review flagged as an Important follow-up.

Deck #2: BundledCedh_InallaThoracle_Demo (Grixis = U/B/R color identity)
features the Thassa's Oracle + Demonic Consultation registered combo
(both off-color in mono-white, hence why Heliod's deck couldn't host
them). Legal 99-card mainboard: 30 curated singletons (combo + tutors
+ counterspells + cantrips + fast mana + removal + 5 colorless utility
lands) + 35 Islands + 34 Swamps. No Plains/Forests/Mountains and no
White/Green spells per CR 903.4.

Helper extraction: pushPreconCandidate consolidates the shared push
shape both precon flows (MTGJSON catalog and bundled cEDH) need. The
helper takes the bracket as a parameter so MTGJSON entries continue
to look up via getPreconBracket while bundled entries pass the literal
CEDH_BRACKET. The bundled-specific id-collision guard
(`if (decks && decks[deckId]) continue;`) stays at the call site —
it's semantically bundled-specific, not helper-shared.

Adding a third bundled deck now requires one BUNDLED_CEDH_DECKS entry
and zero deckCatalog.ts changes — class-not-card extensibility per
CLAUDE.md.

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

* feat(client): add Winota Kiki-Felidar bundled cEDH demo deck

Third bundled cEDH demo deck, validating that the Task F helper
extraction achieved its goal — this required zero deckCatalog.ts changes.

BundledCedh_WinotaKikiFelidar_Demo (Boros = Red/White) features the
Kiki-Jiki, Mirror Breaker + Felidar Guardian registered combo: Kiki
copies Felidar with haste, the token Felidar's ETB blinks Kiki, and
the loop produces unbounded hasty attackers for lethal combat damage.
Both combo pieces are mono-color (Kiki Red, Felidar White) so Boros
is the minimal legal color identity.

Legal 99-card mainboard: 23 curated singletons (combo, colorless fast
mana, white removal, red/white interaction, colorless utility lands)
+ 40 Plains + 36 Mountain. No Islands/Swamps/Forests and no Blue/Black/
Green spells per CR 903.4.

The multi-deck enumeration test now asserts all three bundled decks
surface with bracket 5 and source type "precon".

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

* fix(cedh): address PR phase-rs#816 review comments

- CRITICAL: combo detector computed mana shortfall with i32 saturating_sub,
  which floors at i32::MIN — `2 - 3 = -1` cast to u8 became 255, making the
  policy treat an affordable combo as unreachable. Use unsigned subtraction
  so it saturates at 0.
- HIGH: Tauri adapter only mapped the DeckNotCedh bracket error to the typed
  BRACKET_VIOLATION; TooManyPlayers ("limited to 4 seats") bypassed it. Match
  both Display messages so table-size violations show the blocking modal too.
- MEDIUM: guard combo piece lookups with players.get() instead of direct
  index — an eliminated player would otherwise panic.
- MEDIUM (R2): replace DeckFeatures::is_cedh bool with bracket_tier:
  CommanderBracketTier. Consumers gate on `== Cedh`; the full tier keeps the
  design space open for future bracket-aware behavior.
- MEDIUM: drop the redundant bracket_tier parameter on
  resolve_player_deck_list — PlayerDeckList already carries it, removing the
  sync hazard.
- MEDIUM: correct the stale "1 line" perf comment in ComboLinePolicy (the
  registry now holds 3 lines).
- MEDIUM: document why validate_cedh_bracket carries no CR annotation — the
  Commander Bracket system is WotC format guidance (2024+), not the
  Comprehensive Rules, so a CR number would be fabricated.

The "overly broad combo heuristic" comment was already resolved earlier:
verdict() matches candidates against the line's resolved required_actions and
gates on missing_mana == 0. Caching reachable_lines() remains a documented
follow-up (per-call cost is still small at 3 lines).

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

* test(engine): drop Thorin from anaphoric-scope freeze guard after merge

The merge from main brought in the phase-rs#511/phase-rs#512 parser fix that corrects
Thorin, Mountain-King's category-2/3 anaphoric "its" misparse, so the card
no longer retains ObjectScope::Anaphoric in the exported card data. The
freeze guard is a tripwire designed to fail exactly here; per its own
instructions, remove the card from ANAPHORIC_SCOPE_CARDS and drop both count
assertions 265 -> 264.

Surfaced after merge because the pre-push hook runs parser::oracle::tests but
not the engine integration suite.

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

* docs(ai): spec cEDH AI 4-card mulligan floor

Design for a ForceKeep mulligan verdict that stops the cEDH AI from
mulliganing below 4 cards, with CR 103.5 free-first card-count math.

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

* feat(engine): add kept_hand_size_after mulligan helper (CR 103.5)

Expose a public `kept_hand_size_after(mulligan_count, free_first)` helper in
`crates/engine/src/game/mulligan` so cEDH AI logic can query how many cards
a player would keep before committing to another mulligan, without needing
to reach into the private `bottom_count_for` function.

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

* fix(engine): correct CR annotations and add mulligan boundary tests

Starting hand size and the kept-hand free-first discount are governed by
CR 103.5 / 103.5c, not CR 103.4 (starting life total). Fix the
STARTING_HAND_SIZE constant comment and the kept_hand_size_after doc
annotation, and add boundary tests exercising the saturating_sub floor
where the kept hand reaches zero cards.

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

* feat(ai): cEDH mulligan floor — never mulligan below 4 cards

Add `MulliganScore::ForceKeep` variant with three-way registry
precedence (ForceKeep > ForceMulligan > score sum), and implement a
hard card-count floor in `CedhKeepablesMulligan` that emits `ForceKeep`
when one more mulligan would leave fewer than 4 cards, covering both
free-first and normal London mulligan rules via
`engine::game::mulligan::kept_hand_size_after`.

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

* test(ai): cover cEDH mulligan floor end-to-end + tidy const placement

Add a registry-level integration test wiring the real
`CedhKeepablesMulligan` alongside a `ForceMulligan` policy with cEDH
features at a floor-engaged mulligan count, proving the real cEDH floor
`ForceKeep` overrides a real `ForceMulligan` through the registry. Move
`CEDH_MULLIGAN_FLOOR` below the use groups so the imports stay contiguous.

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

* fix(engine): tag ChooseFromZoneConstraint so the choice modal can validate

ChooseFromZoneConstraint serialized externally-tagged
(`{ "DistinctCardTypes": {...} }`), but the frontend CardChoiceModal reads it
internally-tagged on `constraint.type`. The mismatch left `type` undefined, so
the confirm button never enabled for any selection under a DistinctCardTypes
constraint (Atraxa, Grand Unifier). Add `#[serde(tag = "type")]` to match the
sibling SearchSelectionConstraint convention and the frontend contract.

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

* feat(client): make cEDH a table-wide toggle instead of a per-seat difficulty

Selecting cEDH on any AI seat used to cascade-lock every opponent to cEDH, so
multi-opponent games couldn't mix difficulties without dropping to one opponent.
Replace the per-seat "CEDH" difficulty with a table-wide `cedhMode` toggle at the
top of the AI opponents panel: when on, all AI play cEDH (deck pools restricted
to bracket 5) and per-seat difficulty dropdowns are disabled with a cEDH badge
while preserving each seat's remembered difficulty; when off, every opponent's
difficulty is independent.

`effectiveAiDifficulty` maps `cedhMode` to the engine's per-seat "CEDH" contract
at both init sites, so the engine is unchanged. A persisted-store migration
(v11->v12) converts existing per-seat cEDH selections to `cedhMode`.

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

* refactor(tauri): structured cEDH bracket error instead of Display matching

The Tauri initialize_game command returned Err(e.to_string()), forcing the
tauri-adapter to substring-match the Rust Display text ("not declared cEDH" /
"limited to 4 seats") to detect a cEDH bracket violation — fragile and against
the project's typed-over-stringly-typed style. Return a serializable
CommandError enum (BracketViolation / Generic) and discriminate on `kind` in
the adapter, mirroring the WASM worker path's `cedh_bracket_violation` flag so
both transports surface the violation as a typed signal.

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

* chore(PR-816): strip accidental external-tool artifacts

The docs/superpowers/ plan + spec files are Claude Code plugin output and
do not belong in the repo (per pr-contribution-handler skill). Removing them
from the PR per maintainer confirmation.

* fix(PR-816): route cEDH modal + warning chip through i18n; drop redundant anyCedh alias

Architecture-review findings:
- GamePage cEDH bracket-violation modal (title/body/return button/aria-label)
  and GameSetupPage cEDH warning chip rendered hardcoded English instead of
  t(). Added gameSetup.bracketViolation.* + gameSetup.cedhWarning{,Untagged}
  keys to all 7 locale game.json catalogs (real translations) and routed the
  frontend-authored chrome through t(). Engine-authored error text
  (bracketViolationError) intentionally stays un-t()'d per the i18n boundary.
- Removed the redundant 1:1 anyCedh alias of cedhMode in AiOpponentConfig
  (leftover from the removed multi-seat cascade design).

Verified: pnpm type-check + eslint clean; GamePage.bracketViolation and
GameSetupPage suites pass against the real en catalog (test-setup loads real
i18n, so existing copy assertions validate the new keys).

* fix(PR-816): make cEDH combo detector color-aware; drop dead reachability/decision-kind

Architecture-review finding (correctness): StructuralComboDetector collapsed a
ManaCost's colored pips + generic into a single count compared against the
colorless zone_eval::available_mana, so a colored combo line (Heliod+Ballista
{1}{W}, Thoracle+Consultation {1}{U}{U}{B}) was reported ReachableThisTurn
{missing_mana:0} whenever total mana sufficed REGARDLESS of color — driving a
+15 combo_progress_this_turn_bonus onto a combo the AI cannot actually cast.

Fix: consume the engine's existing color-accurate affordability primitive
engine::game::casting::can_pay_cost_after_auto_tap (auto-taps all mana sources
into a simulated pool, runs the real can_pay_for_spell — colored pips, generic,
hybrid, Phyrexian, {C}, restrictions, summoning sickness CR 302.6). The
cost-bearing source is derived generically from the line's first ComboStep
(Activate -> battlefield obj, Cast -> hand obj); NoCost/SelfManaCost short-circuit
(Kiki). An all-pieces-present-but-unaffordable line collapses to NotReachable, so
reachable_lines stops surfacing uncastable lines to the policy. CR 601.2g/601.2h
annotated (grep-verified).

Also (cleanup): removed dead ComboReachability::ReachableSoon (never constructed,
YAGNI) and dropped DecisionKind::SelectTarget from ComboLinePolicy::decision_kinds
(verdict can never score a target candidate -> wasted per-candidate reachable_lines
scan).

Tests: added discriminating negative regression
thoracle_line_not_reachable_with_wrong_color_mana (4 wrong-color lands -> NOT
reachable; fails under old count-based code, passes under fix); updated the 3
existing fixtures to give test lands real basic-land subtypes so the engine
auto-tap path produces the correct colors.

Reviewed-clean via engine-implementer pipeline (2 plan-review rounds, 1 impl
review). Verified with direct cargo (Tilt watches main, not this worktree):
cargo clippy -p engine -p phase-ai --all-targets clean; cargo test -p phase-ai
--lib 676 passed.

* fix(PR-816): add bracket_tier/ai_difficulties to whitemane test deck literals

Merge-interaction fix surfaced by bringing the branch current with origin/main:
c71d2da (Whitemane Lion, phase-rs#1268) added crates/phase-ai/tests/whitemane_lion_bounded.rs
constructing DeckList/PlayerDeckList with the old field set, while this PR added
bracket_tier (PlayerDeckList) and ai_difficulties (DeckList). #[serde(default)]
covers deserialization but not struct-literal construction, so the test target
failed to compile after the merge. Added the fields via the PR's established
Default::default()/Vec::new() pattern (sibling coverage for the struct change).
cargo clippy -p phase-ai --all-targets now clean.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
matthewevans added a commit to Whovencroft/phase that referenced this pull request May 29, 2026
…t i18n, collapsible groups (phase-rs#1382)

* docs: cEDH AI difficulty design spec

Add a design spec for AiDifficulty::CEDH covering:
- Difficulty preset that bypasses 4-player paranoid search scaling
- Bracket-5 game-setup lock with AI cascade and warning chip
- Combo-recognition skeleton: ComboLine types, ComboDetector trait,
  ComboRegistry, a ComboLinePolicy gated by difficulty, and a stub
  CedhMulliganPolicy
- Engine-side validate_cedh_bracket as a tag check against the
  manual-declaration-only CommanderBracketTier::Cedh

Scope is intentionally skeleton-only: real combo lines, archetype
tuning, backward-chaining synthesis, and multiplayer cEDH gating
are explicit non-goals for this phase.

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

* docs: correct cEDH spec gating mechanism

Update sections 4, 5.3, 5.4, and 7 to reflect the actual
TacticalPolicy::activation() and MulliganPolicy::evaluate()
gating patterns in the codebase:

- ComboLinePolicy gates via activation() returning Some/None
  keyed off a new DeckFeatures::is_cedh field, not via
  registration-time difficulty checks (the PolicyRegistry uses
  a OnceLock shared instance and is registration-stable).
- CedhKeepablesMulligan follows the existing
  *KeepablesMulligan naming convention and gates internally
  via features.is_cedh inside evaluate(), matching the pattern
  used by AggroKeepablesMulligan et al.
- DeckFeatures gains an is_cedh field populated from the
  deck's declared CommanderBracketTier at deck-analysis time.

Original design intent unchanged; only the integration-point
mechanics are corrected against the live trait signatures.

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

* docs: cEDH difficulty implementation plan

Bite-sized TDD plan covering 9 phases (~25 tasks):
1. AiDifficulty::CEDH variant + preset + 4p scaling skip
2. DeckFeatures::is_cedh field + population from tier
3. Engine validate_cedh_bracket tag check
4. combo/ module (line + detection + registry stub)
5. ComboLinePolicy gated via activation()
6. CedhKeepablesMulligan gated internally
7. Frontend cedhLock service + dropdown + cascade + filter + chip
8. Engine validation wired into WASM/Tauri/server bridges
9. End-to-end integration test + verification sweep

Each task: write failing test, run to confirm fail, write
minimal impl, run to confirm pass, commit. Verification uses
the Tilt-first pattern from CLAUDE.md.

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

* feat(phase-ai): AiDifficulty::CEDH variant + preset

Adds CEDH enum variant (highest ordinal, after VeryHard) with its
create_config() match arm: temperature 0.2, depth 3, nodes 96,
rollout 2x2, combat_lookahead=true (first tier to enable it),
projection_min_budget_ms=1500. WASM caps apply: depth 2, nodes 64.

Extends ai_difficulty_serde_roundtrips to cover CEDH. Adds
cedh_preset_values and cedh_preset_wasm_caps_apply tests.

draft-wasm/bot_ai.rs: CEDH uses same full-evaluation pick strategy
as VeryHard.

Tasks 1.1 and 1.2 batched into one commit: the pre-commit hook
requires builds to pass and a standalone Task 1.1 (missing match
arm) would fail clippy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): cEDH bypasses 4p paranoid scaling

cEDH is exclusively played at 4-player tables and is calibrated
for that count from the base preset. The generic 3-4p scaler
(cap depth at 2, reduce nodes to 2/3) would cripple it.
Non-cEDH difficulties continue to follow paranoid scaling
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): add combo_progress_* PolicyPenalties fields

Tunable bonuses for combo-progress prior boosts. Consumed by
ComboLinePolicy (next phase). Defaults +15.0 (this turn) and
+5.0 (next turn) match the spec; both serde-default so older
saved configs deserialize cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): cEDH Phase 1 quality cleanups

- Update create_config doc comment (Five->Six presets, drop
  stale VeryHard reference).
- Invert the cEDH bypass in create_config_for_players to
  avoid an empty-if + non-empty-else (CLAUDE.md idiom rule).
- Annotate projection_min_budget_ms=1500 with its interaction
  with AI_SEARCH_TIME_BUDGET_MS.
- Add cedh_skips_paranoid_scaling_at_3p test (the spec range
  is 3..=4; we previously only covered 4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): DeckFeatures::is_cedh + analyze constructor

Adds is_cedh: bool as a declaration-derived field on DeckFeatures.
Unlike the other structurally-detected fields, is_cedh is set from
the declared CommanderBracketTier at deck-analysis time, not from
card text.

Introduces DeckFeatures::analyze(deck, tier) as the canonical
constructor, consolidating the previously-inline feature-detection
logic from session::features_for() into a single, testable site.
features_for() now delegates to analyze() with CommanderBracketTier::Core
as the default until PlayerDeckPool carries explicit tier metadata.

ComboLinePolicy::activation() and CedhKeepablesMulligan gate on
this field (next phases).

Tasks 2.1 + 2.2 batched into one commit: adding is_cedh to the
struct literal in session.rs requires updating session.rs in the
same changeset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(engine,phase-ai): thread bracket_tier through PlayerDeckPool

- Add bracket_tier: CommanderBracketTier field to PlayerDeckPool
  (default: CommanderBracketTier::Core via new Default impl on the enum).
- Add bracket_tier to PlayerDeckPayload (serde default for backward
  compatibility; PlayerDeckPayload now derives Default).
- Populate bracket_tier from payload at load_deck_into_state and
  deck_payload_from_current_pools, so Bo3 game 2/3 carries the
  same declared tier as game 1.
- Update features_for(deck, tier) to accept the tier parameter;
  from_game reads pool.bracket_tier and forwards it, so
  DeckFeatures::is_cedh is set correctly in production.
- Update from_single_deck and ensure_player_features to accept tier;
  analysis paths (classify_deck_js, starter decks, search.rs) pass
  CommanderBracketTier::Core since those paths have no declared tier.
- Test fixtures use ..Default::default() / ..PlayerDeckPool::default()
  to inherit the new field's default without churn.

Closes the gap from 356f9c5 where features_for hardcoded
CommanderBracketTier::Core — without this, ComboLinePolicy
and CedhKeepablesMulligan would never activate in production.

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

* fix(phase-ai,engine): close Phase 2 review findings

- match_flow::deck_payload_from_current_pools now propagates
  bracket_tier for AI seats (>= 2), not just player/opponent.
  Prevents silent tier drop in Bo3 multi-AI cEDH games.
  Adds test: deck_payload_from_current_pools_propagates_ai_seat_bracket_tier.
- Remove the trivial features_for passthrough in session.rs;
  callers call DeckFeatures::analyze directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): correct CommanderBracketTier variant names

The plan template used CoreCommander / UpgradedCommander
but the actual enum variants in
crates/engine/src/game/bracket_estimate.rs are Core /
Upgraded. Updated all code blocks and string-tier examples
in the plan to match.

No-op for already-implemented phases (the implementers
correctly used the live names). Prevents future agents
from copy-pasting invalid names.

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

* feat(engine): validate_cedh_bracket tag check

cEDH is manual-declaration only per bracket_estimate.rs:18-27
(Cedh is never returned by the estimator algorithmically).
validate_cedh_bracket asserts every deck has its tier
explicitly set to CommanderBracketTier::Cedh.

Returns typed CedhBracketError::DeckNotCedh on the first
offender (identified by seat_index). Empty input is Ok
(vacuously true).

Note: the plan named the error BracketViolation but that type
already exists in bracket_estimate.rs with a different meaning
(per-axis estimator ceiling crossings). The new type is named
CedhBracketError to avoid ambiguity while keeping clear intent.

Called from the WASM/Tauri/server bridges in Phase 8.

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

* fix(engine): cEDH bracket validation quality cleanup

- Change CedhBracketError seat_index to u8 to match the
  codebase convention (PlayerId(u8), server-core/protocol.rs,
  draft-wasm seat APIs).
- Add Display impl to CommanderBracketTier and use {} (not
  {:?}) in CedhBracketError's error message. Display is the
  long-term contract; Debug worked only by coincidence.
- Rename validate_rejects_empty_input -> validate_accepts_empty_input
  (the test asserts Ok(()); the old name read backwards).
- Move legality.rs cEDH-block use statements to the top-of-module
  use block.
- Keep CedhBracketError as an enum (not a struct): doc comment
  names anticipated future variants (AllDecksUnconfigured,
  TableSizeMismatch) expected once Phase 8 wires the validation
  into the game-init boundary.

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

* feat(phase-ai): combo/ module (line + detection + registry stub)

- combo/line.rs: ComboLine, ComboPiece, ComboStep, WinKind,
  ComboReachability, CardPredicate, ComboLineId — pure types,
  no game-state coupling.
- combo/detection.rs: ComboDetector trait + DefaultComboDetector
  — walks line.pieces, checks zone presence, computes mana
  shortfall via zone_eval::available_mana. InLibrary pieces
  treated as tutorable-but-absent, elevating those lines to
  ReachableNextTurn.
- combo/registry.rs: ComboRegistry with one synthetic stub line
  (__cedh_stub_test_creature__) for end-to-end wiring proof.
  Real cEDH lines (Thoracle/Consult, Kiki/Twin, Heliod/Ballista)
  land in a follow-up phase once card coverage stabilises.

5 tests added, all passing. Clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): combo module quality cleanups

- Remove wrong CR 726 annotation on WinKind::InfiniteLoop
  (CR 726 is The Initiative, not infinite loops). Replace
  with a description-only doc; CLAUDE.md forbids unverified
  CR numbers.
- Rename DefaultComboDetector -> StructuralComboDetector;
  avoids collision with the Default trait connotation.
- Unify ReachableThisTurn branches in assess() — both
  branches produced identical action vecs and differed only
  in a value that the cast already produces correctly.
- Rename test combo_piece_equality_is_structural ->
  combo_piece_eq_respects_zone for clarity.

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

* feat(phase-ai): ComboLinePolicy (gated on features.is_cedh)

- PolicyId variants ComboLineProgress + CedhKeepablesMulligan.
- ComboLinePolicy implementing TacticalPolicy. activation()
  returns None unless features.is_cedh; verdict() consults
  ComboRegistry and boosts combo-progressing actions.
- Registered in PolicyRegistry::default(). Non-cEDH decks pay
  zero cost via activation() short-circuit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): ComboLinePolicy quality cleanups

- Prefix PolicyReason strings with combo_line_ to match the
  convention used by other policies (combat_tax_*, tribal_lord_*,
  etc.) — makes per-policy trace filtering unambiguous.
- Remove orphaned PolicyId::CedhKeepablesMulligan; Phase 6
  adds it back alongside the implementation. Avoids an enum
  variant with no registered policy.
- Add TODO(cedh-perf) marker on the reachable_lines call in
  verdict() — caching by (quick_state_hash(state), ai_player)
  per spec section 5.3, deferred until real combo lines land.
- Switch policies/combo_line to pub(crate) mod for consistency
  with sibling internal policy modules.

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

* feat(phase-ai): CedhKeepablesMulligan stub policy

- Add PolicyId::CedhKeepablesMulligan (re-added; was removed
  in Phase 5's quality pass as an orphan).
- Implement CedhKeepablesMulligan in policies/mulligan/cedh_keepables.rs.
  Internal gate on features.is_cedh; non-cEDH decks see a
  zero-delta Score (no-op).
- When cEDH: <2 lands, >4 lands, or no ramp+tutor+interaction
  -> ForceMulligan. Otherwise +1.0 baseline keep.
- Card classification is name-based against the canonical cEDH
  staple set (stub heuristic, refined later when ComboRegistry
  is populated).
- Registered in MulliganRegistry::default().

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

* test(phase-ai): cEDH mulligan no-acceleration + baseline-keep coverage

Adds the two missing test paths for CedhKeepablesMulligan:
- A hand with 2-4 lands and no fast-mana/tutor/interaction
  triggers ForceMulligan with the expected reason.
- A hand with 2-4 lands and at least one fast-mana card
  passes the baseline-keep gate (+1.0 Score).

Closes the coverage gap flagged in Phase 6 spec review.

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

* test(phase-ai): cEDH mulligan too-many-lands coverage

Adds the missing test for the cedh_keepables_too_many_lands
ForceMulligan branch (>4 lands). Closes the third coverage
gap flagged in Phase 6 quality review.

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

* feat(client): cedhLock service — single source of truth for cEDH lock

anyAiOpponentIsCedh / applyCedhCascade / isDeckCedhLegal.
All cEDH-lock decisions in the frontend route through this
module. Pure functions; never mutate inputs.

Uses numeric CommanderBracket (1-5, cEDH = 5) to match
existing AiDeckCandidate.bracket convention. 9 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): cEDH cascade in AiOpponentConfig + dropdown option

Task 7.2: Selecting cEDH on any AI seat cascades all other AI
seats to cEDH via applyCedhCascade(). Fires an inline notice
when the cascade triggers (no external toast lib — uses local
state with 6s auto-dismiss). When any seat is cEDH, the random
AI deck pool is filtered to bracket 5 only.

Task 7.3: 'CEDH' added to AI_DIFFICULTIES constant. B5 lock
badge (aria-label="B5 lock") renders on the AiDifficultyDropdown
when the selected difficulty is CEDH. 3 new tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): filterByBracket AI deck pool + cEDH warning chip

Task 7.4: filterByBracket(decks, tier) — pure filter; null
passes through all decks, non-null restricts to matching
bracket. 3 new tests. AiOpponentConfig's filteredDecks memo
already calls filterByBracket via CEDH_BRACKET when anyCedh.

Task 7.5: GameSetupPage reads human deck bracket via
loadSavedDeckBracket, reads aiSeats from preferencesStore,
and renders a yellow warning chip (role="alert") when any AI
is cEDH and the human deck is not bracket 5. Non-blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(client): cEDH cascade + warning chip coverage

Closes the two test gaps flagged in Phase 7 review:
- AiOpponentConfig cascade test verifies selecting cEDH on
  any seat upgrades all other AI seats to cEDH.
- GameSetupPage tests verify the warning chip renders when
  human deck is non-cEDH + any AI is cEDH, and hides
  otherwise.

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

* fix(client): cEDH UI quality cleanups

- AiOpponentConfig: store cascade timer in a ref and clear
  it in a cleanup useEffect so the 6s auto-dismiss does not
  outlive component unmount. Extract CASCADE_NOTICE_MS const.
- Replace inline d.bracket === CEDH_BRACKET in filteredDecks
  with a call to the exported filterByBracket helper, so the
  abstraction has a real caller.
- AiSeatPanel: render the B5 lock badge alongside the
  inline difficulty <select> when the seat is set to cEDH
  (the standalone AiDifficultyDropdown is no longer wired
  into production after PlayPage's removal — pre-existing
  dead code, separate cleanup).
- Correct stale 'below the select' comment in
  AiDifficultyDropdown (badge is positioned above).
- Note the AiSeatPref[] / CommanderBracket signature
  deviation in cedhLock.ts JSDoc.
- Add tests asserting B5 badge renders on cEDH and hides
  otherwise in AiSeatPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(engine,wasm): plumb bracket_tier through DeckList + WASM cEDH validation

Add `bracket_tier: CommanderBracketTier` (serde default = Core) to
`PlayerDeckList` and `DeckData` so the WASM/server wire formats carry
tier information through to `PlayerDeckPayload`. `resolve_deck_list`
now threads each seat's tier instead of hardcoding Core.

In `initialize_game` (engine-wasm), call `validate_cedh_bracket` before
`start_game` whenever any seat declares `Cedh` tier; return a typed
`cedh_bracket_violation` error when a seat's tier doesn't match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tauri,server-core): plumb bracket_tier through Tauri and server bridges

Apply the same cEDH bracket validation pattern to the Tauri desktop
bridge (`commands.rs`): validate before `load_and_hydrate_decks` when
any seat declares Cedh tier.

In server-core, thread `DeckData.bracket_tier` through `resolve_deck`
into `PlayerDeckPayload` instead of relying on its default value. Fix
struct literal initializers in tests to include the new field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): blocking BracketViolation modal + DeckList bracket_tier plumbing

Thread `bracket_tier` from localStorage bracket sidecar through the
`DeckListPayload` wire format in `GameProvider`. Add helpers
`bracketToEngineTier()` and `loadActiveDeckBracket()` to map numeric
CommanderBracket (1-5) to engine tier strings. `buildLocalAiDeckList`
now preserves per-candidate bracket rather than always defaulting Core.

In `GamePage`, intercept `onNoDeck` errors containing "not declared
cEDH" and show a blocking `BracketViolation` modal with a "Return to
setup" button that navigates to /setup instead of auto-navigating away.

Tests: 4 new Vitest specs covering modal render, non-cEDH passthrough,
no-error idle state, and navigation on dismiss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(engine-wasm,tauri): gate cEDH validation on AI difficulty

Previously gated on payload.*.bracket_tier == Cedh, which
triggered a spurious bracket-violation error any time a user
had a bracket-5 tagged deck — even when playing against a
non-cEDH AI difficulty. The spec intent is for validation
to fire only when the game itself is cEDH (i.e., any AI seat
has AiDifficulty::CEDH).

- Add `ai_difficulties: Vec<String>` to `DeckList` and
  `DeckPayload` (both with `#[serde(default)]` for backward
  compat). Thread from frontend through WASM bridge.
- Extract `any_ai_difficulty_is_cedh` canonical helper into
  `engine::database::legality` — single authority, tested.
- Use the helper in both `engine-wasm/src/lib.rs` and
  `client/src-tauri/src/commands.rs` as the gate signal.
- Derive `Default` on `DeckPayload`, `DeckList`, and
  `PlayerDeckList` to simplify test construction and silence
  exhaustive-struct errors across the workspace.
- Add regression test: bracket-5 human vs. non-cEDH AI is
  allowed (the bug that was almost shipped).

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

* fix(client): typed BracketViolationError + P2P tier plumbing

- Engine-worker preserves the cedh_bracket_violation flag from
  the WASM error envelope and sends it via a new optional
  bracketViolation field on the error response message type.
- Engine-worker-client creates an AdapterError with code
  BRACKET_VIOLATION when the flag is present, so callers can
  match by typed code rather than by string substring on the
  error message.
- GameProvider detects BRACKET_VIOLATION and passes a typed
  boolean flag to onNoDeck(reason, bracketViolation). The
  onNoDeck signature is updated accordingly.
- GamePage.handleNoDeck matches on the bracketViolation flag
  to trigger the modal — no longer string-matches on
  "not declared cEDH", which was brittle.
- p2p-adapter DeckListPayload type carries bracket_tier on
  each seat and ai_difficulties at the top level so guest
  decks do not silently drop tier in TS-typed contexts.
- AdapterErrorCode.BRACKET_VIOLATION added to types.ts.
- GamePage.bracketViolation.test.tsx updated: modal now
  verified via typed flag, not string substring; new
  regression test confirms flag=false does not trigger modal.

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

* fix(client,engine): Phase 8 quality cleanups

- wasm-adapter main-thread fallback now throws typed
  BracketViolation when the WASM JSON envelope sets the
  cedh_bracket_violation flag (previously dropped, causing
  the modal to miss on Worker-creation failure paths).
- p2p-adapter populates ai_difficulties from per-seat
  AI difficulty so cEDH AI seats in P2P sessions are
  validated (previously bypassed validation entirely).
- legality.rs: regression test panic! message + affirmative
  pre-condition assertion; add partial-substring negative
  case to gate_detects_cedh_case_insensitive.
- match_flow.rs: corrected the rematch-skip comment to
  accurately describe the bypass.
- GameProvider: tighten ExpandedDeckWithTier.bracket_tier
  type from string to CommanderBracketTier.

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

* chore(ai-duel): accept --difficulty CEDH

Extend parse_difficulty to recognise "cedh" (case-insensitive) so
`cargo ai-duel --difficulty CEDH` works end-to-end without falling
back to Medium.

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

* test(phase-ai): cEDH end-to-end smoke

End-to-end smoke test for cEDH difficulty wiring across:
- Config preset values (depth 3, nodes 96)
- 4-player paranoid-scaling skip
- DeckFeatures::is_cedh gating field
- ComboLinePolicy registration in PolicyRegistry::default()
- ComboRegistry has at least one registered line

Adds PolicyRegistry::has_policy(&self, id: PolicyId) -> bool for
test introspection (narrow surface, not exposed to hot paths).

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

* fix(tauri-adapter): propagate typed BRACKET_VIOLATION from Tauri

The Tauri command returns Err(e.to_string()) on cEDH bracket
violation, which arrives at the JS side as a plain Error.
Detect the message substring from CedhBracketError::DeckNotCedh
Display ("not declared cEDH") and rethrow as
AdapterError(BRACKET_VIOLATION) so GameProvider can surface
the blocking modal in Tauri desktop builds (matches the WASM
worker path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: refresh cEDH plan + spec type names

The plan and spec were written before Phase 3-4 renames:
- DefaultComboDetector -> StructuralComboDetector
- BracketViolation -> CedhBracketError (the engine already
  had a separate BracketViolation struct in bracket_estimate.rs
  for per-axis estimator violations; that reference preserved)
- ManaCost::Free -> ManaCost::NoCost (Free never existed)

Code is correct; docs were stale. This commit aligns them
to prevent future copy-paste of invalid names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): make cEDH difficulty actually play combos

Replaces the synthetic stub combo with three real hand-authored lines
(Heliod+Ballista, Thoracle+Consult, Kiki+Felidar) and threads the
registry through the surfaces that needed it to fire on real states:

- Detector populates required_actions from ComboStep predicates so
  ComboLinePolicy can match candidates by source_id + ability_index
  (or object_id + card_id) instead of accepting any spell/ability.
- CedhKeepablesMulligan returns a strong-keep when the hand contains
  a complete in-hand combo, bypassing the staple heuristics that real
  cEDH lists ignore once both pieces are drawn.
- Tutor target scorer adds a +1.5 bonus for cards that close a near-
  reachable combo, dominating the 0.8 generic-tutor cap so the AI
  fetches the exact missing piece.
- validate_cedh_bracket gains a TooManyPlayers variant; the seat-count
  guard runs before per-deck tag checks so users get an actionable
  error rather than the AI silently clipping into the >4-player
  scaling path.

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

* feat(server-core,phase-server): enforce cEDH bracket lock in multiplayer

WASM and Tauri adapters already gated game-init on `validate_cedh_bracket`;
server-core was the missing third site, so multiplayer games with a cEDH
AI seat would silently accept non-cEDH decks. This wires the same gate
into the server transport without protocol surface expansion.

- Session::start_game now returns Result<(), CedhBracketError>. The gate
  runs only when any AI seat is configured for AiDifficulty::CEDH, and
  it short-circuits before any session-state mutation so a failed start
  leaves the session re-startable after deck adjustment.
- Both phase-server call sites handle the new Result. The seat-delta
  path resets `started`, broadcasts a ServerMessage::Error to all
  connected players via the existing post-lock fan-out, and skips
  broadcast_game_started. The lobby-join auto-start path converts the
  error to the existing Err(String) join-failure flow so the joiner
  sees a typed message and the session retains them seated.
- Adds a server-core gate test that configures a cEDH AI seat with a
  non-cEDH deck and asserts both the typed CedhBracketError::DeckNotCedh
  return and the no-mutation invariant.

Follow-up: the lobby-join auto-start failure path notifies only the
joiner; mirroring the seat-delta fan-out to host + other connected
players would close the host-feedback gap. Tracked but defer-acceptable
since lobbies are typically host-driven via seat-delta.

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

* feat(phase-server): fan out cEDH bracket failure to host on lobby join

When a player joined a cEDH game via the auto-start-when-full path and
the bracket validation failed, only the joining player saw the error;
the host and other already-connected players watched the lobby stall
with no diagnostic. The seat-delta path already broadcasts to all on
failure — this mirrors that pattern.

A `bracket_broadcast: Option<String>` side channel captures the
user-facing message inside the state-lock block. After the outer
match join_outcome arms run (so the joiner gets their direct error
first via the existing Err arm), the broadcast block acquires
connections.lock() and sends ServerMessage::Error to each connected
player. The joiner's socket isn't yet in the connections map at this
point — registration only happens in the Ok join-outcome arms — so the
broadcast naturally excludes them, no dedup logic needed.

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

* fix(phase-ai): propagate AI bracket_tier into build_ai_context

The integration test (added alongside this fix) revealed a silent wiring
gap: `AiContext::analyze_for_player` hardcodes `CommanderBracketTier::Core`
when building the AI player's DeckFeatures, so `DeckFeatures::is_cedh`
was always false in production. ComboLinePolicy::activation() could not
fire for cEDH decks regardless of the declared bracket tier — the entire
cEDH skeleton (combo registry, tutor targeting, mulligan boost, policy
priors) was running through a `None`-gated path on every choose_action /
score_candidates invocation.

Fix: in build_ai_context, after `analyze_for_player`, look up the AI's
declared bracket_tier from `state.deck_pools` and rebuild the AI
player's session features via `invalidate_player_features` +
`ensure_player_features` when the tier differs from the analyze-time
default. Tier-guarded so the non-cEDH hot path is unaffected.

Adds `score_candidates_boosts_heliod_combo_activation_for_cedh_ai`:
sets up Heliod + Walking Ballista on a cEDH-tagged AI's battlefield
with synthetic activated abilities, calls score_candidates, asserts
the combo activation outscores PassPriority by at least 50% of the
config-derived bonus (combo_progress_this_turn_bonus * tactical_weight).
The margin is computed from config so a future weight retune still
catches genuine wiring regressions instead of silently passing.

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

* chore(phase-server): unify cEDH bracket-violation error wording

Three send paths surfaced the same bracket-violation event with two
different framings: the host fanout (lobby-join broadcast) and the
seat-delta broadcast both prefixed "Cannot start cEDH game: ", while
the direct-to-joiner path sent the raw `bracket_err.to_string()`
without the prefix.

Adds the prefix to the joiner-direct path so all three audiences see
identical wording for the same event. No helper extracted — three
inlined uses is below the duplication threshold.

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

* test(phase-ai): add choose_action integration coverage for cEDH combos

The prior commit's `score_candidates_boosts_*` test stopped at the scoring
layer. choose_action's softmax+RNG selection sits one layer above and
could theoretically waste a dominant combo score on a wiring bug. Two
new tests close that gap:

- `choose_action_picks_combo_activation_for_cedh_ai` — runs 50 deterministic
  trials over the same Heliod+Ballista cEDH-tagged state and asserts at
  least 40/50 select one of the line's combo activations. With temperature
  0.2 and the combo's ~+1.5 dominance, softmax theory predicts >95% per
  trial; the 80% threshold tolerates benign reshuffling but catches real
  regressions.

- `choose_action_does_not_boost_combo_without_is_cedh` — same state with
  the bracket_tier flipped to Core. Asserts combo_count < 35/50, locking
  in that the bonus (not some unrelated bias) drives selection. The
  baseline rate with is_cedh=false sits around 27/50 because legal combo
  activations are simply available; only a true wiring leak would push
  selection into the >70% regime.

The shared setup is now parameterized into
`cedh_combo_state_with_synthetic_abilities(tier)` so both positive and
negative cases stay in sync.

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

* feat(client): seed AI picker with bundled cEDH demo deck

The cEDH AI difficulty had no usable on-ramp: picking cEDH on an AI seat
showed "no decks" because the precon catalog (generated from MTGJSON)
contains no cEDH precons, the PRECON_BRACKETS overlay is empty, and the
saved-deck flow requires user action.

This ships a hand-curated bundled-cEDH-deck mechanism distinct from the
MTGJSON catalog:

- New BUNDLED_CEDH_DECKS map in client/src/data/cedhDecks.ts. Each entry
  is a DeckEntry with type === "Commander Deck" and is authored against
  the same shape MTGJSON precons use, so it flows through the existing
  precon source path in buildDeckCatalog.
- buildDeckCatalog gained a second loop that surfaces bundled cEDH
  entries after the MTGJSON loop. Bracket is the literal cEDH value
  (not looked up via getPreconBracket), so the deck appears under
  filterByBracket(_, 5) regardless of MTGJSON catalog state — bundled
  surfacing is independent of fetch success.
- One demo deck: BundledCedh_HeliodBallista_Demo, a legal 99-card mono-
  white Commander deck (Heliod, Sun-Crowned + Walking Ballista combo)
  with 17 curated cards (combo pieces, ramp, removal, utility, mana-
  fixing lands) and 82 Plains as padding to reach the CR 903.5a card
  count. Off-color cards (Thoracle/Consult/etc.) are documented as
  removed per CR 903.4.
- Three new tests prove the bundled deck appears in the catalog,
  carries source.type "precon" + bracket 5, and surfaces through
  filterByBracket independently of MTGJSON availability.

Follow-ups: (1) extract the near-duplicate MTGJSON/bundled push loops
into a shared helper once a second bundled deck lands. (2) Expand the
demo deck's curated card mix beyond skeleton quality.

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

* feat(client): add Inalla bundled cEDH demo + dedup precon push helper

Adds a second bundled cEDH demo deck and resolves the loop-duplication
the previous commit's code review flagged as an Important follow-up.

Deck #2: BundledCedh_InallaThoracle_Demo (Grixis = U/B/R color identity)
features the Thassa's Oracle + Demonic Consultation registered combo
(both off-color in mono-white, hence why Heliod's deck couldn't host
them). Legal 99-card mainboard: 30 curated singletons (combo + tutors
+ counterspells + cantrips + fast mana + removal + 5 colorless utility
lands) + 35 Islands + 34 Swamps. No Plains/Forests/Mountains and no
White/Green spells per CR 903.4.

Helper extraction: pushPreconCandidate consolidates the shared push
shape both precon flows (MTGJSON catalog and bundled cEDH) need. The
helper takes the bracket as a parameter so MTGJSON entries continue
to look up via getPreconBracket while bundled entries pass the literal
CEDH_BRACKET. The bundled-specific id-collision guard
(`if (decks && decks[deckId]) continue;`) stays at the call site —
it's semantically bundled-specific, not helper-shared.

Adding a third bundled deck now requires one BUNDLED_CEDH_DECKS entry
and zero deckCatalog.ts changes — class-not-card extensibility per
CLAUDE.md.

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

* feat(client): add Winota Kiki-Felidar bundled cEDH demo deck

Third bundled cEDH demo deck, validating that the Task F helper
extraction achieved its goal — this required zero deckCatalog.ts changes.

BundledCedh_WinotaKikiFelidar_Demo (Boros = Red/White) features the
Kiki-Jiki, Mirror Breaker + Felidar Guardian registered combo: Kiki
copies Felidar with haste, the token Felidar's ETB blinks Kiki, and
the loop produces unbounded hasty attackers for lethal combat damage.
Both combo pieces are mono-color (Kiki Red, Felidar White) so Boros
is the minimal legal color identity.

Legal 99-card mainboard: 23 curated singletons (combo, colorless fast
mana, white removal, red/white interaction, colorless utility lands)
+ 40 Plains + 36 Mountain. No Islands/Swamps/Forests and no Blue/Black/
Green spells per CR 903.4.

The multi-deck enumeration test now asserts all three bundled decks
surface with bracket 5 and source type "precon".

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

* fix(cedh): address PR phase-rs#816 review comments

- CRITICAL: combo detector computed mana shortfall with i32 saturating_sub,
  which floors at i32::MIN — `2 - 3 = -1` cast to u8 became 255, making the
  policy treat an affordable combo as unreachable. Use unsigned subtraction
  so it saturates at 0.
- HIGH: Tauri adapter only mapped the DeckNotCedh bracket error to the typed
  BRACKET_VIOLATION; TooManyPlayers ("limited to 4 seats") bypassed it. Match
  both Display messages so table-size violations show the blocking modal too.
- MEDIUM: guard combo piece lookups with players.get() instead of direct
  index — an eliminated player would otherwise panic.
- MEDIUM (R2): replace DeckFeatures::is_cedh bool with bracket_tier:
  CommanderBracketTier. Consumers gate on `== Cedh`; the full tier keeps the
  design space open for future bracket-aware behavior.
- MEDIUM: drop the redundant bracket_tier parameter on
  resolve_player_deck_list — PlayerDeckList already carries it, removing the
  sync hazard.
- MEDIUM: correct the stale "1 line" perf comment in ComboLinePolicy (the
  registry now holds 3 lines).
- MEDIUM: document why validate_cedh_bracket carries no CR annotation — the
  Commander Bracket system is WotC format guidance (2024+), not the
  Comprehensive Rules, so a CR number would be fabricated.

The "overly broad combo heuristic" comment was already resolved earlier:
verdict() matches candidates against the line's resolved required_actions and
gates on missing_mana == 0. Caching reachable_lines() remains a documented
follow-up (per-call cost is still small at 3 lines).

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

* test(engine): drop Thorin from anaphoric-scope freeze guard after merge

The merge from main brought in the phase-rs#511/phase-rs#512 parser fix that corrects
Thorin, Mountain-King's category-2/3 anaphoric "its" misparse, so the card
no longer retains ObjectScope::Anaphoric in the exported card data. The
freeze guard is a tripwire designed to fail exactly here; per its own
instructions, remove the card from ANAPHORIC_SCOPE_CARDS and drop both count
assertions 265 -> 264.

Surfaced after merge because the pre-push hook runs parser::oracle::tests but
not the engine integration suite.

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

* docs(ai): spec cEDH AI 4-card mulligan floor

Design for a ForceKeep mulligan verdict that stops the cEDH AI from
mulliganing below 4 cards, with CR 103.5 free-first card-count math.

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

* feat(engine): add kept_hand_size_after mulligan helper (CR 103.5)

Expose a public `kept_hand_size_after(mulligan_count, free_first)` helper in
`crates/engine/src/game/mulligan` so cEDH AI logic can query how many cards
a player would keep before committing to another mulligan, without needing
to reach into the private `bottom_count_for` function.

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

* fix(engine): correct CR annotations and add mulligan boundary tests

Starting hand size and the kept-hand free-first discount are governed by
CR 103.5 / 103.5c, not CR 103.4 (starting life total). Fix the
STARTING_HAND_SIZE constant comment and the kept_hand_size_after doc
annotation, and add boundary tests exercising the saturating_sub floor
where the kept hand reaches zero cards.

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

* feat(ai): cEDH mulligan floor — never mulligan below 4 cards

Add `MulliganScore::ForceKeep` variant with three-way registry
precedence (ForceKeep > ForceMulligan > score sum), and implement a
hard card-count floor in `CedhKeepablesMulligan` that emits `ForceKeep`
when one more mulligan would leave fewer than 4 cards, covering both
free-first and normal London mulligan rules via
`engine::game::mulligan::kept_hand_size_after`.

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

* test(ai): cover cEDH mulligan floor end-to-end + tidy const placement

Add a registry-level integration test wiring the real
`CedhKeepablesMulligan` alongside a `ForceMulligan` policy with cEDH
features at a floor-engaged mulligan count, proving the real cEDH floor
`ForceKeep` overrides a real `ForceMulligan` through the registry. Move
`CEDH_MULLIGAN_FLOOR` below the use groups so the imports stay contiguous.

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

* fix(engine): tag ChooseFromZoneConstraint so the choice modal can validate

ChooseFromZoneConstraint serialized externally-tagged
(`{ "DistinctCardTypes": {...} }`), but the frontend CardChoiceModal reads it
internally-tagged on `constraint.type`. The mismatch left `type` undefined, so
the confirm button never enabled for any selection under a DistinctCardTypes
constraint (Atraxa, Grand Unifier). Add `#[serde(tag = "type")]` to match the
sibling SearchSelectionConstraint convention and the frontend contract.

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

* feat(client): make cEDH a table-wide toggle instead of a per-seat difficulty

Selecting cEDH on any AI seat used to cascade-lock every opponent to cEDH, so
multi-opponent games couldn't mix difficulties without dropping to one opponent.
Replace the per-seat "CEDH" difficulty with a table-wide `cedhMode` toggle at the
top of the AI opponents panel: when on, all AI play cEDH (deck pools restricted
to bracket 5) and per-seat difficulty dropdowns are disabled with a cEDH badge
while preserving each seat's remembered difficulty; when off, every opponent's
difficulty is independent.

`effectiveAiDifficulty` maps `cedhMode` to the engine's per-seat "CEDH" contract
at both init sites, so the engine is unchanged. A persisted-store migration
(v11->v12) converts existing per-seat cEDH selections to `cedhMode`.

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

* refactor(tauri): structured cEDH bracket error instead of Display matching

The Tauri initialize_game command returned Err(e.to_string()), forcing the
tauri-adapter to substring-match the Rust Display text ("not declared cEDH" /
"limited to 4 seats") to detect a cEDH bracket violation — fragile and against
the project's typed-over-stringly-typed style. Return a serializable
CommandError enum (BracketViolation / Generic) and discriminate on `kind` in
the adapter, mirroring the WASM worker path's `cedh_bracket_violation` flag so
both transports surface the violation as a typed signal.

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

* chore(PR-816): strip accidental external-tool artifacts

The docs/superpowers/ plan + spec files are Claude Code plugin output and
do not belong in the repo (per pr-contribution-handler skill). Removing them
from the PR per maintainer confirmation.

* fix(PR-816): route cEDH modal + warning chip through i18n; drop redundant anyCedh alias

Architecture-review findings:
- GamePage cEDH bracket-violation modal (title/body/return button/aria-label)
  and GameSetupPage cEDH warning chip rendered hardcoded English instead of
  t(). Added gameSetup.bracketViolation.* + gameSetup.cedhWarning{,Untagged}
  keys to all 7 locale game.json catalogs (real translations) and routed the
  frontend-authored chrome through t(). Engine-authored error text
  (bracketViolationError) intentionally stays un-t()'d per the i18n boundary.
- Removed the redundant 1:1 anyCedh alias of cedhMode in AiOpponentConfig
  (leftover from the removed multi-seat cascade design).

Verified: pnpm type-check + eslint clean; GamePage.bracketViolation and
GameSetupPage suites pass against the real en catalog (test-setup loads real
i18n, so existing copy assertions validate the new keys).

* fix(PR-816): make cEDH combo detector color-aware; drop dead reachability/decision-kind

Architecture-review finding (correctness): StructuralComboDetector collapsed a
ManaCost's colored pips + generic into a single count compared against the
colorless zone_eval::available_mana, so a colored combo line (Heliod+Ballista
{1}{W}, Thoracle+Consultation {1}{U}{U}{B}) was reported ReachableThisTurn
{missing_mana:0} whenever total mana sufficed REGARDLESS of color — driving a
+15 combo_progress_this_turn_bonus onto a combo the AI cannot actually cast.

Fix: consume the engine's existing color-accurate affordability primitive
engine::game::casting::can_pay_cost_after_auto_tap (auto-taps all mana sources
into a simulated pool, runs the real can_pay_for_spell — colored pips, generic,
hybrid, Phyrexian, {C}, restrictions, summoning sickness CR 302.6). The
cost-bearing source is derived generically from the line's first ComboStep
(Activate -> battlefield obj, Cast -> hand obj); NoCost/SelfManaCost short-circuit
(Kiki). An all-pieces-present-but-unaffordable line collapses to NotReachable, so
reachable_lines stops surfacing uncastable lines to the policy. CR 601.2g/601.2h
annotated (grep-verified).

Also (cleanup): removed dead ComboReachability::ReachableSoon (never constructed,
YAGNI) and dropped DecisionKind::SelectTarget from ComboLinePolicy::decision_kinds
(verdict can never score a target candidate -> wasted per-candidate reachable_lines
scan).

Tests: added discriminating negative regression
thoracle_line_not_reachable_with_wrong_color_mana (4 wrong-color lands -> NOT
reachable; fails under old count-based code, passes under fix); updated the 3
existing fixtures to give test lands real basic-land subtypes so the engine
auto-tap path produces the correct colors.

Reviewed-clean via engine-implementer pipeline (2 plan-review rounds, 1 impl
review). Verified with direct cargo (Tilt watches main, not this worktree):
cargo clippy -p engine -p phase-ai --all-targets clean; cargo test -p phase-ai
--lib 676 passed.

* fix(PR-816): add bracket_tier/ai_difficulties to whitemane test deck literals

Merge-interaction fix surfaced by bringing the branch current with origin/main:
c71d2da (Whitemane Lion, phase-rs#1268) added crates/phase-ai/tests/whitemane_lion_bounded.rs
constructing DeckList/PlayerDeckList with the old field set, while this PR added
bracket_tier (PlayerDeckList) and ai_difficulties (DeckList). #[serde(default)]
covers deserialization but not struct-literal construction, so the test target
failed to compile after the merge. Added the fields via the PR's established
Default::default()/Vec::new() pattern (sibling coverage for the struct change).
cargo clippy -p phase-ai --all-targets now clean.

* fix(PR-816): center mana pips, wire bracket filter to engine estimate, collapsible groups

- ManaCostPips: flex-center the glyph in its pip circle (inline-block baseline
  pushed it ~5px low); normalize lg padding now that the baseline hack is moot.
- aiDeckCatalog: resolve a Commander candidate's bracket from the engine's
  computed BracketEstimate when no explicit tag exists, so the AI bracket
  filter stops collapsing every selection to 'Random (0)'. Explicit tags win;
  non-Commander formats skip estimation. Adds fallback + tag-precedence tests.
- test-setup: register a lean English-only i18next instead of importing the app
  i18n module, which eager-loaded 49 catalogs + a store subscription per test
  file under worker isolation (~54% less per-file setup time).
- board: make expanded permanent groups collapsible via a count badge that
  toggles the group (BattlefieldRow toggle + GroupedPermanent button); add the
  collapseGroup key across all 7 locales to satisfy the key-parity gate.

---------

Co-authored-by: AgilErck <Erickd1190@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: AgilErck <25510186+AgilErck@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) status:ready-to-merge Maintainer-reviewed and ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Commander Rin and Seri — I was playing against this deck and the Ai got into a loop of only trying to play the card "Wh…

3 participants