Skip to content

fix(deckbuilder): gate copy-limit affordances on engine canonical counts - #6712

Closed
wondercreatemaster wants to merge 1 commit into
phase-rs:mainfrom
wondercreatemaster:fix/6659-deck-builder-copy-limit-affordance
Closed

fix(deckbuilder): gate copy-limit affordances on engine canonical counts#6712
wondercreatemaster wants to merge 1 commit into
phase-rs:mainfrom
wondercreatemaster:fix/6659-deck-builder-copy-limit-affordance

Conversation

@wondercreatemaster

@wondercreatemaster wondercreatemaster commented Jul 27, 2026

Copy link
Copy Markdown

Summary

Fixes #6659: two copy-limit affordance gaps. (1) Client combinedCopyCounts keyed on raw entry names, so alias spellings (Nazgul/Nazgûl, case, DFC forms) each got their own ceiling. Expose the engine’s existing canonical_deck_count_key via WASM and aggregate on that key. (2) Search CardGrid had no canAddCard gate (unlike DeckStack); wire canIncrement so at-ceiling search results disable visibly.

Neither gap can produce an illegal playable deck — copy_limit_violations already canonicalizes — these are stale-affordance fixes.

Files changed

  • crates/engine/src/game/deck_validation.rspub fn canonical_deck_count_key + unit test
  • crates/engine/src/game/mod.rs — re-export
  • crates/engine-wasm/src/lib.rscanonicalDeckCountKey WASM binding
  • client/src/services/engineRuntime.ts / client/src/wasm/engine_wasm.d.ts — TS surface
  • client/src/components/deck-builder/useDeckBuilder.ts — canonical aggregation; include search names in limit fetch
  • client/src/components/deck-builder/CardGrid.tsx + DeckBuilder.tsxcanAddCard affordance
  • client/src/components/deck-builder/__tests__/CardGrid.test.tsx — disabled/enabled coverage
  • client/src/i18n/locales/*/deck-builder.json — copy-limit strings

Track

Developer

LLM

Model: Composer
Tier: Frontier
Thinking: high

Implementation method (required)

Method: not-applicable — exposes the existing deck_validation::canonical_deck_count_key authority for deck-builder affordance gating; no rules/resolver/parser behavior change. Legality still enforced solely by copy_limit_violations.

CR references

  • CR 100.2a / CR 100.4a — copy limit across construction zones (existing annotations on max_deck_copies / combined_copy_counts)
  • CR 201.3 — name identity / canonical face name (existing annotation on canonical_deck_count_key)

Verification

  • Gate A output below is for the current committed head.
  • Both anchors cite existing analogous code at the same seam.
  • Local cargo test / frontend vitest deferred — no Rust toolchain here; CI will run engine + check-frontend / test-frontend.
  • scripts/check-parser-combinators.sh origin/mainGate A PASS (no parser files touched).

Gate A

Gate A PASS head=26695aa6ed852ac492b6e38dc684919096499112 base=e9a268a4d5ef4db00dd903ed2e1c1f6ceebcd95a

Anchored on

  • crates/engine/src/game/deck_validation.rscombined_copy_counts / max_deck_copies already use canonical_deck_count_key; WASM now exposes that same key
  • client/src/components/deck-builder/DeckStack.tsx — existing canAddCard={canIncrement} pattern mirrored onto CardGrid

Final review-impl

Final review-impl PASS head=26695aa6ed852ac492b6e38dc684919096499112 — no name folding in the client; ceilings and keys come from the engine; search grid uses the same canIncrement gate as deck rows.

Claimed parse impact

None.

Scope Expansion

None.

Validation Failures

None.

CI Failures

None.

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Deck building now accurately enforces card copy limits across alternate names, spellings, and card faces.
    • Cards at their copy limit display clearer disabled states, tooltips, and visual indicators.
    • Copy-limit messaging is now available across supported languages.
  • Bug Fixes

    • Prevented cards from being added when their canonical copy limit has been reached.
  • Tests

    • Added coverage for enabled and disabled card-add behavior at copy limits.

Expose canonical_deck_count_key via WASM so the deck builder aggregates
alias spellings into one bucket, and disable CardGrid search-add when
canIncrement says the ceiling is reached.

Fixes phase-rs#6659
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The deck builder now canonicalizes card identities for copy-limit counting and passes increment eligibility into search-result card tiles. Tiles disable additions and show copy-limit messaging when limits are reached, with localized strings and component tests added.

Changes

Deck builder copy-limit gating

Layer / File(s) Summary
Engine canonicalization API
crates/engine/src/game/deck_validation.rs, crates/engine/src/game/mod.rs, crates/engine-wasm/src/lib.rs, client/src/services/engineRuntime.ts
The engine exposes canonical deck-count keys through WASM and the runtime API, with alias-normalization coverage.
Canonical copy-count tracking
client/src/components/deck-builder/useDeckBuilder.ts
Copy counts are aggregated by canonical keys, and increment checks resolve candidate names before comparing limits.
Search-result add affordance
client/src/components/deck-builder/CardGrid.tsx, client/src/components/deck-builder/DeckBuilder.tsx, client/src/components/deck-builder/__tests__/CardGrid.test.tsx, client/src/i18n/locales/*/deck-builder.json
Card tiles combine legality with canIncrement, disable blocked additions, display copy-limit states, and test the enabled and disabled behaviors with localized messages.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested labels: bug

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: gating deck-builder copy-limit affordances on canonical counts.
Linked Issues check ✅ Passed The PR implements canonical count aggregation and disables search-result adds at the copy limit, matching #6659.
Out of Scope Changes check ✅ Passed The added engine bindings, tests, and locale strings all support the stated copy-limit fix and do not appear unrelated.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/components/deck-builder/useDeckBuilder.ts`:
- Around line 342-360: Remove the React-side copy-limit derivation in
combinedCopyCounts and canIncrement, and expose an engine/WASM eligibility
result for the current deck and candidate card using the authoritative
copy_limit_violations logic. Update the frontend to render that engine-provided
result and dispatch the existing add action without aggregating deck zones or
applying its own limits.
- Around line 388-414: Update the copy-limit loading flow in the Promise.all
request and its surrounding state logic to fail closed: clear or invalidate
copyLimits before each format/name request, and make canIncrement return false
when an entry is unresolved or absent. Preserve cancellation protection for
stale responses, and retry the request after transient WASM-load failures
instead of leaving additions enabled indefinitely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 079ec60d-d1da-49d4-b2db-ba9492017f22

📥 Commits

Reviewing files that changed from the base of the PR and between e9a268a and 26695aa.

⛔ Files ignored due to path filters (1)
  • client/src/wasm/engine_wasm.d.ts is excluded by !client/src/wasm/**, !**/*.d.ts
📒 Files selected for processing (15)
  • client/src/components/deck-builder/CardGrid.tsx
  • client/src/components/deck-builder/DeckBuilder.tsx
  • client/src/components/deck-builder/__tests__/CardGrid.test.tsx
  • client/src/components/deck-builder/useDeckBuilder.ts
  • client/src/i18n/locales/de/deck-builder.json
  • client/src/i18n/locales/en/deck-builder.json
  • client/src/i18n/locales/es/deck-builder.json
  • client/src/i18n/locales/fr/deck-builder.json
  • client/src/i18n/locales/it/deck-builder.json
  • client/src/i18n/locales/pl/deck-builder.json
  • client/src/i18n/locales/pt/deck-builder.json
  • client/src/services/engineRuntime.ts
  • crates/engine-wasm/src/lib.rs
  • crates/engine/src/game/deck_validation.rs
  • crates/engine/src/game/mod.rs

Comment on lines +342 to +360
// Counts are keyed by the engine's canonical name so alias spellings share a
// bucket (#6659) — never fold accents/case/DFC forms in the display layer.
const [canonicalKeys, setCanonicalKeys] = useState<Map<string, string>>(
() => new Map(),
);

const combinedCopyCounts = useMemo(() => {
const counts = new Map<string, number>();
const add = (name: string, n: number) =>
counts.set(name, (counts.get(name) ?? 0) + n);
const add = (name: string, n: number) => {
const key = canonicalKeys.get(name) ?? name;
counts.set(key, (counts.get(key) ?? 0) + n);
};
for (const entry of deck.main) add(entry.name, entry.count);
for (const entry of deck.sideboard) add(entry.name, entry.count);
for (const name of commanders) add(name, 1);
for (const name of deck.signature_spell ?? []) add(name, 1);
if (deck.companion) add(deck.companion, 1);
return counts;
}, [deck, commanders]);
}, [deck, commanders, canonicalKeys]);

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Move copy-limit derivation back into the engine.

combinedCopyCounts and canIncrement reconstruct game-rule state in React by aggregating deck zones and comparing the result with an engine limit. Even with canonicalDeckCountKey, this can drift from the engine’s authoritative copy_limit_violations behavior for special formats, zones, or future rule changes. Expose an engine/WASM eligibility result for the current deck and candidate card; the frontend should only render that result and dispatch the add action.

As per coding guidelines and path instructions, the frontend must only render engine-provided game state and the engine owns copy-limit logic.

Also applies to: 421-428

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/deck-builder/useDeckBuilder.ts` around lines 342 - 360,
Remove the React-side copy-limit derivation in combinedCopyCounts and
canIncrement, and expose an engine/WASM eligibility result for the current deck
and candidate card using the authoritative copy_limit_violations logic. Update
the frontend to render that engine-provided result and dispatch the existing add
action without aggregating deck zones or applying its own limits.

Sources: Coding guidelines, Path instructions

Comment on lines +388 to +414
setCanonicalKeys(new Map());
return;
}
let cancelled = false;
Promise.all(
names.map(async (name) => [name, await maxDeckCopies(name, format)] as const),
names.map(async (name) => {
const [limit, canonical] = await Promise.all([
maxDeckCopies(name, format),
canonicalDeckCountKey(name),
]);
return [name, limit, canonical] as const;
}),
)
.then((results) => {
if (!cancelled) setCopyLimits(new Map(results));
if (cancelled) return;
setCopyLimits(new Map(results.map(([name, limit]) => [name, limit])));
setCanonicalKeys(
new Map(results.map(([name, , canonical]) => [name, canonical])),
);
})
.catch(() => {
// WASM may not be loaded yet; an empty map leaves increments open and
// the engine's compatibility warnings still flag any real violation.
if (!cancelled) setCopyLimits(new Map());
if (!cancelled) {
setCopyLimits(new Map());
setCanonicalKeys(new Map());
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail closed while copy-limit data is stale or unavailable.

A new request does not clear the previous copyLimits, so changing formats can temporarily use the old format’s ceiling. For new names or rejected WASM requests, copyLimits.get(name) is undefined and canIncrement returns true, re-enabling additions while eligibility is unknown. Clear or version the state before each request, return false for unresolved entries, and retry after transient engine-load failures.

Suggested direction
 useEffect(() => {
+  setCopyLimits(new Map());
+  setCanonicalKeys(new Map());
   ...
 }, [copyLimitKey, format]);

 const limit = copyLimits.get(name);
- if (!limit || limit.type === "Unlimited") return true;
+ if (!limit) return false;
+ if (limit.type === "Unlimited") return true;

Also applies to: 421-428

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/deck-builder/useDeckBuilder.ts` around lines 388 - 414,
Update the copy-limit loading flow in the Promise.all request and its
surrounding state logic to fail closed: clear or invalidate copyLimits before
each format/name request, and make canIncrement return false when an entry is
unresolved or absent. Preserve cancellation protection for stale responses, and
retry the request after transient WASM-load failures instead of leaving
additions enabled indefinitely.

@matthewevans matthewevans self-assigned this Jul 27, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 27, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Changes requested

Reviewed current head 26695aa6ed852ac492b6e38dc684919096499112.

  1. The required frontend job is red. Its lint step passed, but TypeScript fails in the new CardGrid.test.tsx: legalityFormat="modern" is not a BrowserLegalityFilter (the type requires "Modern"), and the partial fixture is not structurally compatible with ScryfallCard. Vitest was consequently skipped. Please correct the typed test setup and provide a green required frontend run.

  2. Copy-limit eligibility needs to remain engine-authoritative. useDeckBuilder now rebuilds the relevant deck-zone counts in React (combinedCopyCounts) and compares them to separate WASM results (maxDeckCopies and canonicalDeckCountKey). That duplicates the decision already represented by engine copy_limit_violations, and can drift when the validation's zones or format-specific rules evolve. Expose an engine/WASM eligibility result for the deck plus candidate card and have the grid render that result rather than re-deriving eligibility in React.

  3. Do not leave the new affordance fail-open. While requests are pending, when a new result has no map entry, or after the catch path empties the maps, canIncrement returns true for !limit; a format change can also retain the prior format's map until the new request settles. The search-grid path calls handleAddCard, which does not independently call canIncrement. Make unresolved or stale eligibility unavailable (fail closed) until the authoritative response for the current deck/format/candidate is ready.

Please add a discriminating runtime test through the engine → WASM → deck-builder search-grid path. It should demonstrate that alias spellings sharing a canonical key reach the ceiling, and cover the unresolved/stale response behavior. The present CardGrid unit tests only verify a supplied boolean and would not catch a broken canonicalization/binding/eligibility path.

Evidence: failed required frontend job; the two unresolved CodeRabbit threads at the current head independently identify the engine-boundary and fail-open defects.

@matthewevans matthewevans removed their assignment Jul 27, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@wondercreatemaster

Copy link
Copy Markdown
Author

Superseded by a fresh one-shot PR that includes the CardGrid.test.tsx type-check fix (CI failure on this head).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants