Skip to content

Center mana pips, wire AI bracket filter to engine estimate, lean test i18n, collapsible groups - #1382

Merged
matthewevans merged 67 commits into
mainfrom
pr/816
May 29, 2026
Merged

Center mana pips, wire AI bracket filter to engine estimate, lean test i18n, collapsible groups#1382
matthewevans merged 67 commits into
mainfrom
pr/816

Conversation

@matthewevans

@matthewevans matthewevans commented May 29, 2026

Copy link
Copy Markdown
Member

Net change over main is the 13 files below (the prior cEDH commits on this branch already landed on main via squash).

  • Mana pips (ManaCostPips.tsx): flex-center the glyph in its circle — the inline-block baseline pushed it ~5px low (spilling past the bottom). Normalize lg padding now that the baseline-compensation hack is moot.
  • AI bracket filter (aiDeckCatalog.ts + test): resolve a Commander candidate's bracket from the engine's computed BracketEstimate when no explicit tag exists, so the filter stops collapsing every selection to "Random (0)". Explicit tags win; non-Commander formats skip estimation.
  • Test i18n (test-setup.ts): 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.
  • Collapsible permanent groups (BattlefieldRow.tsx, GroupedPermanent.tsx, 7× locale game.json): a count badge toggles an expanded group back to stacked; collapseGroup added across all 7 locales to satisfy the key-parity gate.

Erckdd and others added 30 commits May 22, 2026 14:38
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
- 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>
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>
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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
- 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>
…lidation

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>
…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>
… 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>
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>
- 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>
Erckdd and others added 19 commits May 27, 2026 20:57
# Conflicts:
#	client/src/components/menu/AiDifficultyDropdown.tsx
#	client/src/components/menu/AiOpponentConfig.tsx
#	client/src/constants/ai.ts
#	client/src/pages/GameSetupPage.tsx
#	crates/engine-wasm/src/lib.rs
#	crates/phase-server/src/main.rs
#	crates/seat-reducer/src/tests.rs
#	crates/server-core/src/session.rs
- 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>
The merge from main brought in the #511/#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>
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>
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>
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>
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>
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>
…idate

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>
…ficulty

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>
…ching

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>
# Conflicts:
#	crates/engine/tests/integration/anaphoric_scope_allowlist_guard.rs
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.
…dant 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).
…lity/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.
…literals

Merge-interaction fix surfaced by bringing the branch current with origin/main:
c71d2da (Whitemane Lion, #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.
…, 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.
@matthewevans
matthewevans enabled auto-merge May 29, 2026 01:01
# Conflicts:
#	client/src/services/__tests__/aiDeckCatalog.test.ts

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces table-wide cEDH mode support, featuring a dedicated cEDH toggle, hand-curated bundled cEDH decks, and a typed bracket-violation error when non-cEDH decks are brought to a cEDH table. It also adds a cEDH combo-recognition layer with a ComboLinePolicy to boost actions progressing reachable combos, alongside a CedhKeepablesMulligan policy for aggressive mulligans. Additionally, a collapse button is added to grouped permanents. Feedback highlights a critical compilation failure due to a missing invalidate_player_features method on AiSession. It also recommends gating the cEDH mode and toggle to the Commander format to prevent initialization failures, and expanding the touch target of the new collapse button to at least 44px for mobile friendliness.

I am having trouble creating individual review comments. Click here to see my feedback.

crates/phase-ai/src/session.rs (107)

critical

[CRITICAL] Missing invalidate_player_features method on AiSession causes a compilation failure because it is called in crates/phase-ai/src/search.rs to refresh features when the AI's bracket tier is not Core.

    pub fn invalidate_player_features(&mut self, player: PlayerId) {
        self.features.remove(&player);
        self.plan.remove(&player);
    }

    pub fn ensure_player_features(

client/src/pages/GameSetupPage.tsx (167)

high

[HIGH] Gating cedhMode on selectedFormat === "Commander" is necessary to prevent game-breaking initialization failures when playing non-Commander formats if cedhMode was left enabled in the persisted preferences.

    const cedhMode = prefs.cedhMode && selectedFormat === "Commander";

client/src/providers/GameProvider.tsx (312-314)

high

[HIGH] Gating cedhMode on formatConfig?.format === "Commander" in buildLocalAiDeckList is necessary to prevent game-breaking initialization failures when playing non-Commander formats if cedhMode was left enabled in the persisted preferences.

  const effectiveCedhMode = cedhMode && formatConfig?.format === "Commander";
  const aiDifficulties = picks.map((_, i) =>
    effectiveAiDifficulty(aiSeats[i]?.difficulty ?? "Medium", effectiveCedhMode),
  );

client/src/components/board/GroupedPermanent.tsx (180)

medium

[MEDIUM] The collapse button has a size of h-5 w-5 (20px), which violates the style guide rule requiring touch targets to be at least 44pt/px for mobile/touch friendliness. We can expand the touch target using a pseudo-element without changing the visual size.

          className="absolute left-1 top-1 z-30 flex h-5 w-5 items-center justify-center rounded-full bg-black/80 text-[10px] font-bold text-white ring-1 ring-gray-500 transition-colors hover:bg-black before:absolute before:-inset-3"
References
  1. Mobile / touch: touch targets ≥ 44pt; :hover-only state breaks on mobile and needs a touch-equivalent. (link)

client/src/components/menu/AiOpponentConfig.tsx (140-161)

medium

[MEDIUM] The table-wide cEDH toggle should only be rendered when selectedFormat === "Commander" to avoid confusing users when playing non-Commander formats.

      {selectedFormat === "Commander" && (
        <div className="flex items-center justify-between gap-3 rounded-lg border border-rose-500/25 bg-rose-500/5 px-3 py-2">
          <div className="flex min-w-0 flex-col">
            <span className="text-xs font-semibold text-rose-200">{t("aiOpponent.cedhToggle.label")}</span>
            <span className="text-[10px] text-slate-400">{t("aiOpponent.cedhToggle.hint")}</span>
          </div>
          <button
            type="button"
            role="switch"
            aria-checked={cedhMode}
            aria-label={t("aiOpponent.cedhToggle.label")}
            onClick={() => setCedhMode(!cedhMode)}
            className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60 ${
              cedhMode ? "bg-rose-500" : "bg-white/15"
            }`}
          >
            <span
              className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
                cedhMode ? "translate-x-5" : "translate-x-0.5"
              }`}
            />
          </button>
        </div>
      )}

@matthewevans matthewevans changed the title pr/816 Center mana pips, wire AI bracket filter to engine estimate, lean test i18n, collapsible groups May 29, 2026
@matthewevans
matthewevans added this pull request to the merge queue May 29, 2026
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request May 29, 2026
@matthewevans

Copy link
Copy Markdown
Member Author

Triage of the Gemini review — note this PR's net diff over main is only the 13 frontend files; the cEDH commits Gemini summarized already landed on main via an earlier squash, so most findings are out of scope here:

  • [CRITICAL] session.rs missing invalidate_player_features — false alarm. The method exists (crates/phase-ai/src/session.rs:130) and is called (search.rs:1127); the tree compiles. Gemini analyzed a pre-squash state. Not in this PR's diff.
  • [HIGH] GameSetupPage.tsx / GameProvider.tsx cedhMode gating and [MEDIUM] AiOpponentConfig.tsx toggle gating — these files are not in this PR's net diff (cEDH code already on main). If the non-Commander gating gap is real, it belongs in a follow-up against main, not here.
  • [MEDIUM] GroupedPermanent.tsx collapse button ≥44px touch target — in scope, but declined as a one-off. Every board badge is 20px (h-5 w-5): the sibling expand badge at GroupedPermanent.tsx:293 (same left-1 top-1 anchor, collapsed mode) and PermanentCard.tsx:729. This collapse button deliberately mirrors the expand badge. Adding an invisible 44px halo to only one of a matched pair is inconsistent; the ≥44px rule should be a single consistent pass across all board badges as a follow-up.

@matthewevans
matthewevans added this pull request to the merge queue May 29, 2026
Merged via the queue into main with commit 3ae2875 May 29, 2026
10 checks passed
@matthewevans
matthewevans deleted the pr/816 branch May 29, 2026 01:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants