diff --git a/.claude/SEARCH.md b/.claude/SEARCH.md index 4a1ced0..f0eea09 100644 --- a/.claude/SEARCH.md +++ b/.claude/SEARCH.md @@ -153,14 +153,26 @@ Colors can be specified as: ### Color Operators -Color comparisons use set theory: -- `:` or `>=` - Card has at least these colors (superset) +Color comparisons use set theory. **Important:** The default `:` operator behaves differently for color vs identity: + +**For `c:` (color):** +- `:` or `>=` - Card has at least these colors (superset) - `c:rg` matches Gruul and 3+ color cards - `=` - Card has exactly these colors -- `<=` - Card has at most these colors (subset) - useful for commander +- `<=` - Card has at most these colors (subset) - `<` - Strict subset - `>` - Strict superset - `!=` - Not exactly these colors +**For `id:`/`ci:` (color identity):** +- `:` or `<=` - Card fits in a deck with this identity (subset) - `id:rg` matches mono-R, mono-G, and Gruul +- `>=` - Card has at least this identity (superset) - `id>=rg` matches Gruul and 3+ color cards +- `=` - Card has exactly this identity +- `<` - Strict subset +- `>` - Strict superset +- `!=` - Not exactly this identity + +This matches Scryfall's behavior: `id:rg` finds cards playable in a Gruul commander deck. + ### Identity Count Queries The `identity` field also supports numeric comparisons on the *number* of colors: diff --git a/src/components/SearchPrimer.tsx b/src/components/SearchPrimer.tsx index d306f12..2526979 100644 --- a/src/components/SearchPrimer.tsx +++ b/src/components/SearchPrimer.tsx @@ -180,18 +180,18 @@ export function SearchPrimer() { {/* Color identity for commander */}

- For commander decks + Color identity (for commander)

- goes in + fits in decks

- 2+ color commanders + has at least +

- PEDH in - + 2+ color commanders

diff --git a/src/lib/search/__tests__/describe.test.ts b/src/lib/search/__tests__/describe.test.ts index 775a316..d7b1056 100644 --- a/src/lib/search/__tests__/describe.test.ts +++ b/src/lib/search/__tests__/describe.test.ts @@ -88,7 +88,7 @@ describe("describeQuery", () => { it.each([ ["c:r", "color includes {R}"], - ["id:bg", "color identity includes {B}{G}"], + ["id:bg", "color identity is within {B}{G}"], ["id<=bg", "color identity is within {B}{G}"], ["id>=bg", "color identity includes at least {B}{G}"], ["id=wubrg", "color identity is exactly {W}{U}{B}{R}{G}"], diff --git a/src/lib/search/__tests__/integration.test.ts b/src/lib/search/__tests__/integration.test.ts index c600e2b..3089609 100644 --- a/src/lib/search/__tests__/integration.test.ts +++ b/src/lib/search/__tests__/integration.test.ts @@ -147,6 +147,27 @@ describe("Scryfall search integration", () => { } }); + it("c: uses superset semantics (at least these colors)", async () => { + const bolt = await cards.get("Lightning Bolt"); // R + const bte = await cards.get("Burning-Tree Emissary"); // RG + + // c:r means "at least red" - matches mono-R and multicolor with R + const atLeastRed = search("c:r"); + expect(atLeastRed.ok).toBe(true); + if (atLeastRed.ok) { + expect(atLeastRed.value.match(bolt)).toBe(true); // R contains R + expect(atLeastRed.value.match(bte)).toBe(true); // RG contains R + } + + // c:rg means "at least RG" - only matches cards with both + const atLeastGruul = search("c:rg"); + expect(atLeastGruul.ok).toBe(true); + if (atLeastGruul.ok) { + expect(atLeastGruul.value.match(bolt)).toBe(false); // R doesn't contain G + expect(atLeastGruul.value.match(bte)).toBe(true); // RG contains RG + } + }); + it("c: differs from id: (color vs color identity)", async () => { const forest = await cards.get("Forest"); @@ -174,6 +195,38 @@ describe("Scryfall search integration", () => { }); describe("color identity matching", () => { + it("id: uses subset semantics (commander deckbuilding)", async () => { + const bolt = await cards.get("Lightning Bolt"); // R + const elves = await cards.get("Llanowar Elves"); // G + const bte = await cards.get("Burning-Tree Emissary"); // RG + + // id:rg means "identity fits in Gruul" (subset) + const gruul = search("id:rg"); + expect(gruul.ok).toBe(true); + if (gruul.ok) { + expect(gruul.value.match(bolt)).toBe(true); // R fits in RG + expect(gruul.value.match(elves)).toBe(true); // G fits in RG + expect(gruul.value.match(bte)).toBe(true); // RG fits in RG + } + + // id:r should NOT match BTE (RG doesn't fit in mono-R) + const monoRed = search("id:r"); + expect(monoRed.ok).toBe(true); + if (monoRed.ok) { + expect(monoRed.value.match(bolt)).toBe(true); // R fits in R + expect(monoRed.value.match(bte)).toBe(false); // RG doesn't fit in R + } + + // id>=rg means "identity contains at least RG" (superset) + const atLeastGruul = search("id>=rg"); + expect(atLeastGruul.ok).toBe(true); + if (atLeastGruul.ok) { + expect(atLeastGruul.value.match(bolt)).toBe(false); // R doesn't contain G + expect(atLeastGruul.value.match(elves)).toBe(false); // G doesn't contain R + expect(atLeastGruul.value.match(bte)).toBe(true); // RG contains RG + } + }); + it("id<= matches subset (commander deckbuilding)", async () => { const bolt = await cards.get("Lightning Bolt"); const elves = await cards.get("Llanowar Elves"); diff --git a/src/lib/search/__tests__/parser.test.ts b/src/lib/search/__tests__/parser.test.ts index 524ff57..bf9b063 100644 --- a/src/lib/search/__tests__/parser.test.ts +++ b/src/lib/search/__tests__/parser.test.ts @@ -97,6 +97,14 @@ describe("parse", () => { } }); + it("parses ci: as identity alias", () => { + const node = expectParse("ci:wubrg"); + expect(node.type).toBe("FIELD"); + if (node.type === "FIELD") { + expect(node.field).toBe("identity"); + } + }); + it("parses numeric fields", () => { const node = expectParse("cmc>=3"); expect(node.type).toBe("FIELD"); diff --git a/src/lib/search/colors.ts b/src/lib/search/colors.ts index 25e36d9..43cd0f0 100644 --- a/src/lib/search/colors.ts +++ b/src/lib/search/colors.ts @@ -2,9 +2,11 @@ * Color set comparison utilities for Scryfall search * * Scryfall uses set theory for color comparisons: - * - : or >= means "superset of" (card has at least these colors) + * - For color (c:), : means "superset of" (card has at least these colors) + * - For identity (id:/ci:), : means "subset of" (card fits in this commander's deck) + * - >= means "superset of" (card has at least these colors) * - = means "exactly these colors" - * - <= means "subset of" (card has at most these colors) - key for commander deckbuilding + * - <= means "subset of" (card has at most these colors) * - < means "strict subset" * - > means "strict superset" */ diff --git a/src/lib/search/describe.ts b/src/lib/search/describe.ts index a236299..5a64a26 100644 --- a/src/lib/search/describe.ts +++ b/src/lib/search/describe.ts @@ -66,6 +66,12 @@ const COLOR_OPERATOR_LABELS: Record = { ">=": "includes at least", }; +// For identity, : means "is within" (subset) for commander deckbuilding +const IDENTITY_OPERATOR_LABELS: Record = { + ...COLOR_OPERATOR_LABELS, + ":": "is within", +}; + const WUBRG_ORDER = ["W", "U", "B", "R", "G"]; /** @@ -201,7 +207,6 @@ function describeValue(value: FieldValue, quoted = true): string { function describeField(node: FieldNode): string { const fieldLabel = FIELD_LABELS[node.field]; - const isColorField = node.field === "color" || node.field === "identity"; // Special handling for identity count queries (id>1, id=2, etc.) if (node.field === "identity" && node.value.kind === "number") { @@ -243,7 +248,9 @@ function describeField(node: FieldNode): string { // But regex always uses "includes" since it's a pattern match, not exact // "in" field is special - the label already implies the relationship let opLabel: string; - if (isColorField) { + if (node.field === "identity") { + opLabel = IDENTITY_OPERATOR_LABELS[node.operator]; + } else if (node.field === "color") { opLabel = COLOR_OPERATOR_LABELS[node.operator]; } else if (node.field === "in" && node.operator === ":") { opLabel = ""; diff --git a/src/lib/search/fields.ts b/src/lib/search/fields.ts index 59fa7d2..cf51abe 100644 --- a/src/lib/search/fields.ts +++ b/src/lib/search/fields.ts @@ -90,7 +90,7 @@ export function compileField( case "color": return ok(compileColorField((c) => c.colors, operator, value)); - case "identity": + case "identity": { // Numeric comparison: id>1 means "more than 1 color in identity" if (value.kind === "number") { return ok( @@ -101,7 +101,12 @@ export function compileField( ), ); } - return ok(compileColorField((c) => c.color_identity, operator, value)); + // For identity, default : means "subset" (<=) not "superset" (>=) + // This matches Scryfall's commander deckbuilding semantics: + // id:rg finds cards playable in a Gruul deck (identity within RG) + const identityOp = operator === ":" ? "<=" : operator; + return ok(compileColorField((c) => c.color_identity, identityOp, value)); + } // Mana fields case "mana": diff --git a/src/lib/search/types.ts b/src/lib/search/types.ts index b429f0f..2f826e6 100644 --- a/src/lib/search/types.ts +++ b/src/lib/search/types.ts @@ -136,6 +136,7 @@ export const FIELD_ALIASES: Record = { // Colors c: "color", color: "color", + ci: "identity", id: "identity", identity: "identity", // Mana