From f82c5bf18b6a19ac0ffabec2c2d4396ee7efe7f0 Mon Sep 17 00:00:00 2001 From: jeffrey701 Date: Mon, 6 Jul 2026 01:28:17 -0400 Subject: [PATCH] fix(client): enforce colorless color identity for colorless commanders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isInColorIdentity short-circuited 'if (identity.length === 0) return true', so a genuinely colorless commander (e.g. Kozilek, color identity []) allowed any card — a red card passed as legal, violating CR 903.5c (a colorless commander permits only colorless cards). The short-circuit doubled as a 'commander data not loaded' guard, so simply dropping it would flag every colored card during the load window. Move that guard to getColorIdentityViolations (skip until all commanders are cached) and let [].every() enforce the colorless case correctly. --- .../__tests__/commanderUtils.test.ts | 58 +++++++++++++++++++ .../components/deck-builder/commanderUtils.ts | 9 ++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 client/src/components/deck-builder/__tests__/commanderUtils.test.ts diff --git a/client/src/components/deck-builder/__tests__/commanderUtils.test.ts b/client/src/components/deck-builder/__tests__/commanderUtils.test.ts new file mode 100644 index 0000000000..e88bf4eaa6 --- /dev/null +++ b/client/src/components/deck-builder/__tests__/commanderUtils.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import type { ScryfallCard } from "../../../services/scryfall"; +import { getColorIdentityViolations } from "../commanderUtils"; + +function card(colorIdentity: string[]): ScryfallCard { + return { color_identity: colorIdentity } as ScryfallCard; +} + +describe("getColorIdentityViolations", () => { + it("flags off-color cards for a genuinely colorless commander (CR 903.5c)", () => { + const cache = new Map([ + ["Kozilek, Butcher of Truth", card([])], + ["Lightning Bolt", card(["R"])], + ["Wastes", card([])], + ]); + const deck = [ + { name: "Lightning Bolt", count: 1 }, + { name: "Wastes", count: 1 }, + ]; + + const violations = getColorIdentityViolations( + deck, + ["Kozilek, Butcher of Truth"], + cache, + ); + + expect(violations).toEqual(["Lightning Bolt"]); + }); + + it("does not flag on-identity cards for a colored commander", () => { + const cache = new Map([ + ["Krenko, Mob Boss", card(["R"])], + ["Lightning Bolt", card(["R"])], + ["Counterspell", card(["U"])], + ]); + const deck = [ + { name: "Lightning Bolt", count: 1 }, + { name: "Counterspell", count: 1 }, + ]; + + expect( + getColorIdentityViolations(deck, ["Krenko, Mob Boss"], cache), + ).toEqual(["Counterspell"]); + }); + + it("returns no violations while commander data is still loading", () => { + // Commander not in cache — an empty identity must NOT flag every colored card. + const cache = new Map([ + ["Lightning Bolt", card(["R"])], + ]); + const deck = [{ name: "Lightning Bolt", count: 1 }]; + + expect( + getColorIdentityViolations(deck, ["Kozilek, Butcher of Truth"], cache), + ).toEqual([]); + }); +}); diff --git a/client/src/components/deck-builder/commanderUtils.ts b/client/src/components/deck-builder/commanderUtils.ts index 411d3ece44..c4513e07f0 100644 --- a/client/src/components/deck-builder/commanderUtils.ts +++ b/client/src/components/deck-builder/commanderUtils.ts @@ -25,7 +25,11 @@ export function getCombinedColorIdentity( } function isInColorIdentity(card: ScryfallCard, identity: string[]): boolean { - if (identity.length === 0) return true; + // A card is legal iff every color in its identity is within the commander's. + // For a colorless commander (`identity` empty), only colorless cards pass — + // `[].every(...)` is `true`, a colored card's `.every(...)` is `false` — which + // is exactly CR 903.5c. (Callers must only invoke this once commander data is + // loaded; an empty identity from a cache miss would otherwise flag everything.) const identitySet = new Set(identity); return card.color_identity.every((c) => identitySet.has(c)); } @@ -36,6 +40,9 @@ export function getColorIdentityViolations( cardDataCache: Map, ): string[] { if (commanders.length === 0) return []; + // Don't compute violations until every commander's data has loaded — a cache + // miss yields an empty identity that would wrongly flag every colored card. + if (!commanders.every((name) => cardDataCache.has(name))) return []; const identity = getCombinedColorIdentity(commanders, cardDataCache); const violations: string[] = []; for (const entry of deck) {