Fix #563: AI infinite loop casting Whitemane Lion (non-targeted bounce ETB) - #1268
Conversation
…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>
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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
- 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); |
There was a problem hiding this comment.
[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
- Strict fidelity to the MTG Comprehensive Rules (CR) — every game rule, validation, and computed value matches the CR exactly. (link)
| /// 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, |
There was a problem hiding this comment.
[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
- 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; |
There was a problem hiding this comment.
[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.
| 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)
|
Architecture review pass — landed the following refactors directly on this branch (commit 043bd99) addressing the review findings: Refactor 1:
|
…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.
* 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>
…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>
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 aTriggerTargetSelectionslot. 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
CR references
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::Bouncegained anon_targeting: boolaxis,ParseContextgained asaw_target_keyword: boolflag, andeffects/bounce.rsgained a non-targeted resolution branch mirroring theSacrificepattern. 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 hardensearch.rsagainst any remaining loop-prone pathology.