diff --git a/client/src/components/deck-builder/CardGrid.tsx b/client/src/components/deck-builder/CardGrid.tsx index 62cf836351..b3b1bbe712 100644 --- a/client/src/components/deck-builder/CardGrid.tsx +++ b/client/src/components/deck-builder/CardGrid.tsx @@ -12,6 +12,8 @@ interface CardGridProps { onCardHover?: (cardName: string | null) => void; cardCounts?: Map; legalityFormat?: BrowserLegalityFilter; + /** When false, the add affordance is disabled (copy ceiling reached). */ + canAddCard?: (name: string) => boolean; } function getArtCropUrl(card: ScryfallCard): string { @@ -35,6 +37,7 @@ export function CardGrid({ onCardHover, cardCounts, legalityFormat = "all", + canAddCard, }: CardGridProps) { return (
@@ -44,6 +47,7 @@ export function CardGrid({ key={card.id ?? card.name} card={card} legal={isFormatLegal(card, legalityFormat)} + canAdd={canAddCard?.(card.name) ?? true} count={cardCounts?.get(card.name)} legalityFormat={legalityFormat} onAddCard={onAddCard} @@ -58,6 +62,7 @@ export function CardGrid({ interface CardGridTileProps { card: ScryfallCard; legal: boolean; + canAdd: boolean; count: number | undefined; legalityFormat: BrowserLegalityFilter; onAddCard: (card: ScryfallCard) => void; @@ -67,6 +72,7 @@ interface CardGridTileProps { function CardGridTile({ card, legal, + canAdd, count, legalityFormat, onAddCard, @@ -77,6 +83,7 @@ function CardGridTile({ const formatLabel = legalityFormat === "all" ? t("grid.allFormats") : legalityFormat.charAt(0).toUpperCase() + legalityFormat.slice(1); + const addEnabled = legal && canAdd; // Touch model (mirrors MobileHandDrawer's DrawerCard): tap adds the card, // long-press opens the preview. firedRef suppresses the click that follows a @@ -89,9 +96,15 @@ function CardGridTile({ firedRef.current = false; return; } - if (legal) onAddCard(card); + if (addEnabled) onAddCard(card); }; + const title = !legal + ? t("grid.notLegal", { name: card.name, format: formatLabel }) + : !canAdd + ? t("grid.copyLimitReached", { name: card.name }) + : t("grid.addCard", { name: card.name }); + return ( )} + {legal && !canAdd && ( +
+ + {t("grid.atCopyLimit")} + +
+ )} + {/* Legality badge */} {legalityFormat !== "all" && (
diff --git a/client/src/components/deck-builder/DeckBuilder.tsx b/client/src/components/deck-builder/DeckBuilder.tsx index 2509edc350..277b02ecd8 100644 --- a/client/src/components/deck-builder/DeckBuilder.tsx +++ b/client/src/components/deck-builder/DeckBuilder.tsx @@ -398,6 +398,7 @@ export function DeckBuilder({ onCardHover={onCardHover} cardCounts={cardCounts} legalityFormat={searchFilters.browseFormat} + canAddCard={canIncrement} />
) : ( diff --git a/client/src/components/deck-builder/__tests__/CardGrid.test.tsx b/client/src/components/deck-builder/__tests__/CardGrid.test.tsx new file mode 100644 index 0000000000..587862b0e1 --- /dev/null +++ b/client/src/components/deck-builder/__tests__/CardGrid.test.tsx @@ -0,0 +1,73 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CardGrid } from "../CardGrid"; +import type { ScryfallCard } from "../../../services/scryfall"; + +afterEach(cleanup); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (key === "grid.copyLimitReached") return `${opts?.name} - at copy limit`; + if (key === "grid.atCopyLimit") return "At limit"; + if (key === "grid.addCard") return `Add ${opts?.name}`; + if (key === "grid.notLegal") return `${opts?.name} - not legal`; + if (key === "grid.notFormat") return `Not ${opts?.format}`; + if (key === "grid.allFormats") return "all formats"; + return key; + }, + }), +})); + +vi.mock("../../../hooks/useLongPress", () => ({ + useLongPress: () => ({ handlers: {}, firedRef: { current: false } }), +})); + +function makeCard(name: string): ScryfallCard { + return { + id: name.toLowerCase(), + name, + mana_cost: "", + cmc: 0, + type_line: "Artifact", + color_identity: [], + legalities: { modern: "legal" }, + }; +} + +describe("CardGrid copy-limit affordance", () => { + it("disables add when canAddCard returns false", () => { + const onAddCard = vi.fn(); + render( + false} + legalityFormat="Modern" + />, + ); + + const button = screen.getByRole("button", { name: /Sol Ring/i }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("title", "Sol Ring - at copy limit"); + fireEvent.click(button); + expect(onAddCard).not.toHaveBeenCalled(); + }); + + it("adds when canAddCard allows it", () => { + const onAddCard = vi.fn(); + render( + true} + legalityFormat="Modern" + />, + ); + + const button = screen.getByRole("button", { name: /Sol Ring/i }); + expect(button).not.toBeDisabled(); + fireEvent.click(button); + expect(onAddCard).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/deck-builder/useDeckBuilder.ts b/client/src/components/deck-builder/useDeckBuilder.ts index 9aeeaa3ba4..03db56d968 100644 --- a/client/src/components/deck-builder/useDeckBuilder.ts +++ b/client/src/components/deck-builder/useDeckBuilder.ts @@ -31,6 +31,7 @@ import { getSharedAdapter } from "../../adapter/wasm-adapter"; import { useBracketEstimate } from "../../hooks/useBracketEstimate"; import { projectSignatureSpellForFormat } from "../../services/savedDeckProjection"; import { + canonicalDeckCountKey, commanderPartnerCandidates, companionCandidates, isCardCommanderEligibleForFormat, @@ -338,30 +339,41 @@ export function useDeckBuilder({ // CR 100.4a: the copy limit applies to the main deck, sideboard, and command // zone combined, so the increment gate counts every slot a card can occupy. + // 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>( + () => new Map(), + ); + const combinedCopyCounts = useMemo(() => { const counts = new Map(); - 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]); // Distinct names currently in the partition, as a stable key — the ceiling // for a (name, format) pair never changes, so this only refetches when the // set of names or the format actually changes, not on every count edit. - const copyLimitKey = useMemo( - () => - [ - ...new Set([...deck.main, ...deck.sideboard].map((entry) => entry.name)), - ] - .sort() - .join("|"), - [deck.main, deck.sideboard], - ); + const copyLimitKey = useMemo(() => { + const names = new Set(); + for (const entry of deck.main) names.add(entry.name); + for (const entry of deck.sideboard) names.add(entry.name); + for (const name of commanders) names.add(name); + for (const name of deck.signature_spell ?? []) names.add(name); + if (deck.companion) names.add(deck.companion); + // Search results need ceilings too so CardGrid can disable adds at the + // limit — including when the result's spelling differs from a deck entry. + for (const card of searchResults) names.add(card.name); + return [...names].sort().join("|"); + }, [deck, commanders, searchResults]); // CR 100.2a / CR 903.5b: the ceiling is engine-resolved per card and format // (basic-land exemption, printed overrides like Relentless Rats or Seven @@ -373,19 +385,33 @@ export function useDeckBuilder({ const names = copyLimitKey ? copyLimitKey.split("|") : []; if (names.length === 0) { setCopyLimits(new Map()); + 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()); + } }); return () => { cancelled = true; @@ -396,9 +422,10 @@ export function useDeckBuilder({ (name: string) => { const limit = copyLimits.get(name); if (!limit || limit.type === "Unlimited") return true; - return (combinedCopyCounts.get(name) ?? 0) < limit.data; + const key = canonicalKeys.get(name) ?? name; + return (combinedCopyCounts.get(key) ?? 0) < limit.data; }, - [copyLimits, combinedCopyCounts], + [copyLimits, combinedCopyCounts, canonicalKeys], ); const handleIncrementCard = useCallback( diff --git a/client/src/i18n/locales/de/deck-builder.json b/client/src/i18n/locales/de/deck-builder.json index 239f0cef42..cbaec33ae6 100644 --- a/client/src/i18n/locales/de/deck-builder.json +++ b/client/src/i18n/locales/de/deck-builder.json @@ -87,7 +87,9 @@ "allFormats": "Alle", "addCard": "{{name}} hinzufügen", "notLegal": "{{name}} – nicht legal in {{format}}", - "notFormat": "Nicht {{format}}" + "notFormat": "Nicht {{format}}", + "copyLimitReached": "{{name}} – Kopielimit erreicht", + "atCopyLimit": "Limit erreicht" }, "deckList": { "currentList": "Aktuelle Liste", diff --git a/client/src/i18n/locales/en/deck-builder.json b/client/src/i18n/locales/en/deck-builder.json index 33a13d8661..eca7f448b8 100644 --- a/client/src/i18n/locales/en/deck-builder.json +++ b/client/src/i18n/locales/en/deck-builder.json @@ -87,7 +87,9 @@ "allFormats": "All", "addCard": "Add {{name}}", "notLegal": "{{name}} - Not {{format}} legal", - "notFormat": "Not {{format}}" + "notFormat": "Not {{format}}", + "copyLimitReached": "{{name}} - at copy limit", + "atCopyLimit": "At limit" }, "deckList": { "currentList": "Current List", diff --git a/client/src/i18n/locales/es/deck-builder.json b/client/src/i18n/locales/es/deck-builder.json index 60de2bc187..9faab27f86 100644 --- a/client/src/i18n/locales/es/deck-builder.json +++ b/client/src/i18n/locales/es/deck-builder.json @@ -87,7 +87,9 @@ "allFormats": "Todos", "addCard": "Añadir {{name}}", "notLegal": "{{name}} - No es legal en {{format}}", - "notFormat": "No es {{format}}" + "notFormat": "No es {{format}}", + "copyLimitReached": "{{name}} - límite de copias alcanzado", + "atCopyLimit": "En el límite" }, "deckList": { "currentList": "Lista actual", diff --git a/client/src/i18n/locales/fr/deck-builder.json b/client/src/i18n/locales/fr/deck-builder.json index 2d2926917e..1469709482 100644 --- a/client/src/i18n/locales/fr/deck-builder.json +++ b/client/src/i18n/locales/fr/deck-builder.json @@ -87,7 +87,9 @@ "allFormats": "Tous", "addCard": "Ajouter {{name}}", "notLegal": "{{name}} - Non légal en {{format}}", - "notFormat": "Non {{format}}" + "notFormat": "Non {{format}}", + "copyLimitReached": "{{name}} - limite de copies atteinte", + "atCopyLimit": "Limite atteinte" }, "deckList": { "currentList": "Liste actuelle", diff --git a/client/src/i18n/locales/it/deck-builder.json b/client/src/i18n/locales/it/deck-builder.json index 4be4ad0479..3d78804df5 100644 --- a/client/src/i18n/locales/it/deck-builder.json +++ b/client/src/i18n/locales/it/deck-builder.json @@ -87,7 +87,9 @@ "allFormats": "Tutti", "addCard": "Aggiungi {{name}}", "notLegal": "{{name}} - Non legale in {{format}}", - "notFormat": "Non {{format}}" + "notFormat": "Non {{format}}", + "copyLimitReached": "{{name}} - limite copie raggiunto", + "atCopyLimit": "Al limite" }, "deckList": { "currentList": "Lista corrente", diff --git a/client/src/i18n/locales/pl/deck-builder.json b/client/src/i18n/locales/pl/deck-builder.json index 25f8725aff..525747ce7c 100644 --- a/client/src/i18n/locales/pl/deck-builder.json +++ b/client/src/i18n/locales/pl/deck-builder.json @@ -87,7 +87,9 @@ "allFormats": "Wszystkie", "addCard": "Dodaj {{name}}", "notLegal": "{{name}} - Niedozwolona w formacie {{format}}", - "notFormat": "Nie {{format}}" + "notFormat": "Nie {{format}}", + "copyLimitReached": "{{name}} - osiągnięto limit kopii", + "atCopyLimit": "Limit" }, "deckList": { "currentList": "Bieżąca lista", diff --git a/client/src/i18n/locales/pt/deck-builder.json b/client/src/i18n/locales/pt/deck-builder.json index 23d7be03c9..ef05db1804 100644 --- a/client/src/i18n/locales/pt/deck-builder.json +++ b/client/src/i18n/locales/pt/deck-builder.json @@ -87,7 +87,9 @@ "allFormats": "Todos", "addCard": "Adicionar {{name}}", "notLegal": "{{name}} - Não válido em {{format}}", - "notFormat": "Não {{format}}" + "notFormat": "Não {{format}}", + "copyLimitReached": "{{name}} - limite de cópias atingido", + "atCopyLimit": "No limite" }, "deckList": { "currentList": "Lista Atual", diff --git a/client/src/services/engineRuntime.ts b/client/src/services/engineRuntime.ts index 6045f6f769..562c91d27a 100644 --- a/client/src/services/engineRuntime.ts +++ b/client/src/services/engineRuntime.ts @@ -296,6 +296,17 @@ export async function maxDeckCopies( return engine.maxDeckCopies(name, format) as DeckCopyLimit; } +/** + * CR 201.3 + CR 100.2a: Engine canonical key for aggregating deck copy counts. + * Alias spellings, case variants, and DFC combined/front-face names share one + * bucket — the deck builder must key affordance counts on this value. + */ +export async function canonicalDeckCountKey(name: string): Promise { + await ensureCardDatabase(); + const engine = await loadEngineModule(); + return engine.canonicalDeckCountKey(name) as string; +} + /** * CR 100.4a: Per-format sideboard policy as a discriminated union. * diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index 9c956aa489..be2c7af2e8 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -293,6 +293,13 @@ export function load_replay_for_playback(json_str: string): number; */ export function maxDeckCopies(name: string, format: any): any; +/** + * CR 201.3 + CR 100.2a: Canonical key for aggregating deck copy counts so + * alias spellings share one bucket. Returns the lowercased input when the + * card database isn't loaded. + */ +export function canonicalDeckCountKey(name: string): string; + /** * Verify WASM integration works. */ @@ -493,6 +500,7 @@ export interface InitOutput { readonly load_card_database: (a: number, b: number) => [number, number, number]; readonly load_replay_for_playback: (a: number, b: number) => [number, number, number]; readonly maxDeckCopies: (a: number, b: number, c: any) => any; + readonly canonicalDeckCountKey: (a: number, b: number) => [number, number]; readonly ping: () => [number, number]; readonly preview_action_js: (a: number, b: any) => any; readonly preview_mana_payment_js: (a: number, b: any) => any; diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 9497964387..5ddacf7cd3 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -19,8 +19,8 @@ use engine::game::engine::{ use engine::game::interaction::bind_interaction_authority; use engine::game::preview::{compute_preview_diff, preview_auto_payment_sources}; use engine::game::{ - can_pair_commanders, companion_candidates, deck_copy_limit_for, estimate_bracket, - evaluate_deck_compatibility, filter_state_for_viewer, finalize_public_state, + can_pair_commanders, canonical_deck_count_key, companion_candidates, deck_copy_limit_for, + estimate_bracket, evaluate_deck_compatibility, filter_state_for_viewer, finalize_public_state, is_brawl_commander_eligible, is_commander_eligible, is_tiny_leader_eligible, load_and_hydrate_decks, max_deck_copies, rehydrate_game_from_card_db, resolve_deck_list, signature_spell_selection_policy, start_game, start_game_with_starting_player, @@ -451,6 +451,24 @@ pub fn max_deck_copies_for_format(name: &str, format: JsValue) -> JsValue { }) } +/// CR 201.3 + CR 100.2a: Canonical key for aggregating deck copy counts so +/// alias spellings (`Nazgul`/`Nazgûl`), case variants, and DFC combined vs +/// front-face names share one bucket. The deck builder must key affordance +/// counts on this — never fold names in JS. +/// +/// Returns the input lowercased when the card database isn't loaded, matching +/// `maxDeckCopies`'s fail-open posture for a not-yet-hydrated frontend. +#[wasm_bindgen(js_name = canonicalDeckCountKey)] +pub fn canonical_deck_count_key_for_name(name: &str) -> String { + CARD_DB.with(|cell| { + let db = cell.borrow(); + let Some(db) = db.as_ref() else { + return name.to_lowercase(); + }; + canonical_deck_count_key(db, name) + }) +} + /// Whether the named card can serve as this format's command-zone leader. /// Reads the engine's MTGJSON-derived `CardFace` leadership fields and /// format-specific deck-validation predicates. diff --git a/crates/engine/src/game/deck_validation.rs b/crates/engine/src/game/deck_validation.rs index 8e49b9f9b9..da5a5b14c2 100644 --- a/crates/engine/src/game/deck_validation.rs +++ b/crates/engine/src/game/deck_validation.rs @@ -2619,7 +2619,10 @@ fn card_is_known(db: &CardDatabase, name: &str) -> bool { /// CR 201.3 + CR 100.2a: Canonical key for aggregating deck copy counts. /// Uses the indexed face name when the card resolves so alias spellings /// ("Nazgul" vs "Nazgûl") merge into one bucket for copy-limit checks. -fn canonical_deck_count_key(db: &CardDatabase, name: &str) -> String { +/// +/// Public so the deck-builder WASM surface can share the same authority the +/// legality checker uses (affordance gating must not re-derive folding in JS). +pub fn canonical_deck_count_key(db: &CardDatabase, name: &str) -> String { let resolved = resolve_card_name(db, name); db.get_face_by_name(resolved) .map(|face| face.name.to_lowercase()) @@ -3994,6 +3997,21 @@ mod tests { ); } + #[test] + fn canonical_deck_count_key_merges_alias_spellings() { + // Public surface for the deck-builder affordance (#6659): alias + // spellings must share one key so client-side counts match validation. + let db = CardDatabase::from_json_str(&test_db_json()).unwrap(); + assert_eq!( + canonical_deck_count_key(&db, "Nazgul"), + canonical_deck_count_key(&db, "Nazgûl") + ); + assert_eq!( + canonical_deck_count_key(&db, "Nazgul"), + canonical_deck_count_key(&db, "nazgûl") + ); + } + /// CR 100.2b: the restricted list is a copy ceiling, so the query the deck /// builder gates its increment control on has to honour it — otherwise the /// `+` stays live through four Black Lotuses and the deck only fails later, diff --git a/crates/engine/src/game/mod.rs b/crates/engine/src/game/mod.rs index 4111158df5..f6befe7ad1 100644 --- a/crates/engine/src/game/mod.rs +++ b/crates/engine/src/game/mod.rs @@ -201,11 +201,12 @@ pub use deck_loading::{ resolve_deck_list, resolve_player_deck_list, DeckEntry, DeckList, DeckPayload, PlayerDeckList, }; pub use deck_validation::{ - can_pair_commanders, companion_candidates, deck_copy_limit_for, evaluate_deck_compatibility, - is_brawl_commander_eligible, is_commander_eligible, is_tiny_leader_eligible, max_deck_copies, - signature_spell_selection_policy, validate_deck_for_format, validate_name_deck_for_format, - validate_name_deck_for_format_full, CompatibilityCheck, DeckCompatibilityRequest, - DeckCompatibilityResult, DeckCoverage, SignatureSpellSelectionPolicy, UnsupportedCard, + can_pair_commanders, canonical_deck_count_key, companion_candidates, deck_copy_limit_for, + evaluate_deck_compatibility, is_brawl_commander_eligible, is_commander_eligible, + is_tiny_leader_eligible, max_deck_copies, signature_spell_selection_policy, + validate_deck_for_format, validate_name_deck_for_format, validate_name_deck_for_format_full, + CompatibilityCheck, DeckCompatibilityRequest, DeckCompatibilityResult, DeckCoverage, + SignatureSpellSelectionPolicy, UnsupportedCard, }; pub use engine::{ apply, apply_as_current, new_game, start_game, start_game_skip_mulligan,