👁️
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

add ub predicate

+135 -19
+3
.claude/SEARCH.md
··· 111 111 **Promo types:** 112 112 - `is:buyabox`, `is:prerelease`, `is:fnm`, `is:gameday`, `is:release`, `is:datestamped`, `is:promopacks` 113 113 114 + **Product lines:** 115 + - `is:ub`, `is:universesbeyond` - Universes Beyond cards (crossover products like LotR, Warhammer, etc.) 116 + 114 117 **Land types:** (derived from oracle text patterns) 115 118 - `is:fetchland` - Fetch lands (search + pay life) 116 119 - `is:shockland` - Shock lands (pay 2 life)
+2
src/lib/__tests__/test-cards.json
··· 2 2 "$comment": "Maps card names to oracle IDs for test fixtures. Add cards here as needed for tests.", 3 3 "A-Lantern Bearer": "ba790609-7b48-4a96-a21f-5a0cfcf316a3", 4 4 "A-Llanowar Greenwidow": "57920afe-61b7-4db1-ad79-dca4f0bc281b", 5 + "Aang, Airbending Master": "f3176779-74f7-4136-a701-438feeead7a0", 5 6 "Aardwolf's Advantage": "4c445455-5b91-4c22-8b3b-8cff4e97f00a", 6 7 "Abdel Adrian, Gorion's Ward": "cab092f9-b7ff-43b9-935f-310869a4daf8", 7 8 "Aboroth": "28f70f86-19a9-4811-bc10-423a05842d39", ··· 135 136 "Tel-Jilad Chosen": "de6854d1-bdb0-4b9a-b442-08d4d90a538d", 136 137 "Temple of Mystery": "7e26f0b7-20e6-46d5-8130-d98c14d6aa29", 137 138 "The Eighth Doctor": "0956bc16-ff04-4e96-8059-ef62afa2405f", 139 + "The Eleventh Doctor": "721e8249-ff1b-4c15-964b-4ba2a96beb75", 138 140 "The Eternity Elevator": "11323af4-b8b8-4ca9-932f-377c7fd77dea", 139 141 "Thran Dynamo": "a699c663-8131-4045-9265-a83e86609374", 140 142 "Thran Portal": "926ce6a2-7bdd-4380-ac65-bc902ba0c284",
+103
src/lib/search/__tests__/is-predicates.test.ts
··· 794 794 }); 795 795 }); 796 796 797 + describe("universes beyond predicates", () => { 798 + it("is:ub matches cards with universesbeyond promo_type", () => { 799 + const ubCard = { promo_types: ["universesbeyond"] } as Card; 800 + const result = search("is:ub"); 801 + expect(result.ok).toBe(true); 802 + if (result.ok) { 803 + expect(result.value.match(ubCard)).toBe(true); 804 + } 805 + }); 806 + 807 + it("is:ub matches cards with triangle security stamp", () => { 808 + const triangleCard = { security_stamp: "triangle" } as Card; 809 + const result = search("is:ub"); 810 + expect(result.ok).toBe(true); 811 + if (result.ok) { 812 + expect(result.value.match(triangleCard)).toBe(true); 813 + } 814 + }); 815 + 816 + it("is:ub matches cards with both indicators", () => { 817 + const bothCard = { 818 + promo_types: ["universesbeyond"], 819 + security_stamp: "triangle", 820 + } as Card; 821 + const result = search("is:ub"); 822 + expect(result.ok).toBe(true); 823 + if (result.ok) { 824 + expect(result.value.match(bothCard)).toBe(true); 825 + } 826 + }); 827 + 828 + it("is:ub does NOT match regular cards", () => { 829 + const normalCard = { 830 + security_stamp: "oval", 831 + promo_types: [] as string[], 832 + } as Card; 833 + const result = search("is:ub"); 834 + expect(result.ok).toBe(true); 835 + if (result.ok) { 836 + expect(result.value.match(normalCard)).toBe(false); 837 + } 838 + }); 839 + 840 + it("is:ub does NOT match acorn stamp cards", () => { 841 + const acornCard = { security_stamp: "acorn" } as Card; 842 + const result = search("is:ub"); 843 + expect(result.ok).toBe(true); 844 + if (result.ok) { 845 + expect(result.value.match(acornCard)).toBe(false); 846 + } 847 + }); 848 + 849 + it("is:universesbeyond is alias for is:ub", () => { 850 + const ubCard = { promo_types: ["universesbeyond"] } as Card; 851 + const shortResult = search("is:ub"); 852 + const longResult = search("is:universesbeyond"); 853 + expect(shortResult.ok && longResult.ok).toBe(true); 854 + if (shortResult.ok && longResult.ok) { 855 + expect(shortResult.value.match(ubCard)).toBe( 856 + longResult.value.match(ubCard), 857 + ); 858 + } 859 + }); 860 + 861 + it("not:ub excludes Universes Beyond cards", () => { 862 + const ubCard = { promo_types: ["universesbeyond"] } as Card; 863 + const normalCard = { security_stamp: "oval" } as Card; 864 + const result = search("not:ub"); 865 + expect(result.ok).toBe(true); 866 + if (result.ok) { 867 + expect(result.value.match(ubCard)).toBe(false); 868 + expect(result.value.match(normalCard)).toBe(true); 869 + } 870 + }); 871 + 872 + it("is:ub matches The Eleventh Doctor (Doctor Who - triangle stamp)", async () => { 873 + const card = await cards.get("The Eleventh Doctor"); 874 + const result = search("is:ub"); 875 + expect(result.ok).toBe(true); 876 + if (result.ok) { 877 + expect(result.value.match(card)).toBe(true); 878 + } 879 + }); 880 + 881 + it("is:ub matches Aang, Airbending Master (Avatar - promo_types, no triangle)", async () => { 882 + const card = await cards.get("Aang, Airbending Master"); 883 + const result = search("is:ub"); 884 + expect(result.ok).toBe(true); 885 + if (result.ok) { 886 + expect(result.value.match(card)).toBe(true); 887 + } 888 + }); 889 + 890 + it("is:ub does NOT match Lightning Bolt (regular Magic card)", async () => { 891 + const card = await cards.get("Lightning Bolt"); 892 + const result = search("is:ub"); 893 + expect(result.ok).toBe(true); 894 + if (result.ok) { 895 + expect(result.value.match(card)).toBe(false); 896 + } 897 + }); 898 + }); 899 + 797 900 describe("paupercommander predicates", () => { 798 901 it("is:paupercommander matches uncommon creatures with paper printing", async () => { 799 902 const card = await cards.get("Crackling Drake");
+4
src/lib/search/describe.ts
··· 173 173 gameday: "game day promos", 174 174 release: "release promos", 175 175 promopacks: "promo pack cards", 176 + 177 + // Universes Beyond 178 + ub: "Universes Beyond cards", 179 + universesbeyond: "Universes Beyond cards", 176 180 }; 177 181 178 182 function sortColors(colors: Set<string>): string[] {
+23 -19
src/lib/search/fields.ts
··· 689 689 partner: hasPartnerMechanic, 690 690 // Can be a Pauper Commander (PDH) - uncommon creature/vehicle/spacecraft in paper/MTGO 691 691 paupercommander: canBePauperCommander, 692 - pdhcommander: canBePauperCommander, 693 692 694 693 // Type-based predicates 695 694 permanent: (card) => { ··· 734 733 showcase: (card) => card.frame_effects?.includes("showcase") ?? false, 735 734 extendedart: (card) => card.frame_effects?.includes("extendedart") ?? false, 736 735 borderless: (card) => card.border_color === "borderless", 737 - fullart: (card) => card.full_art === true, 738 736 inverted: (card) => card.frame_effects?.includes("inverted") ?? false, 739 737 colorshifted: (card) => card.frame_effects?.includes("colorshifted") ?? false, 740 738 retro: (card) => card.frame === "1997" || card.frame === "1993", ··· 838 836 oracle.includes("enters tapped unless you control two or more basic") 839 837 ); 840 838 }, 841 - battleland: (card) => { 842 - const oracle = card.oracle_text?.toLowerCase() ?? ""; 843 - const types = card.type_line?.toLowerCase() ?? ""; 844 - const landTypes = ["plains", "island", "swamp", "mountain", "forest"]; 845 - const matchCount = landTypes.filter((t) => types.includes(t)).length; 846 - return ( 847 - matchCount >= 2 && 848 - oracle.includes("enters tapped unless you control two or more basic") 849 - ); 850 - }, 851 839 scryland: (card) => { 852 840 const oracle = card.oracle_text ?? ""; 853 841 // Pattern: "This land enters tapped.\nWhen this land enters, scry 1. (reminder text)\n{T}: Add {W} or {U}." ··· 873 861 const pattern = 874 862 /^\{T\}, Pay 1 life: Add \{[WUBRG]\} or \{[WUBRG]\}\.\n\{1\}, \{T\}, Sacrifice this land: Draw a card\.$/i; 875 863 return pattern.test(oracle); 876 - }, 877 - creatureland: (card) => { 878 - const oracle = card.oracle_text?.toLowerCase() ?? ""; 879 - const types = card.type_line?.toLowerCase() ?? ""; 880 - return types.includes("land") && oracle.includes("becomes a"); 881 864 }, 882 865 883 866 // Card archetypes ··· 1028 1011 1029 1012 // Hires/quality 1030 1013 hires: (card) => card.highres_image === true, 1014 + 1015 + // Universes Beyond (promo_types or triangle security stamp) 1016 + ub: (card) => 1017 + card.promo_types?.includes("universesbeyond") || 1018 + card.security_stamp === "triangle", 1019 + }; 1020 + 1021 + /** 1022 + * Map of is: predicate aliases to canonical names 1023 + */ 1024 + export const IS_PREDICATE_ALIASES: Record<string, string> = { 1025 + universesbeyond: "ub", 1026 + battleland: "tangoland", 1027 + creatureland: "manland", 1028 + fullart: "full", 1029 + pdhcommander: "paupercommander", 1031 1030 }; 1032 1031 1033 1032 /** 1034 1033 * Set of valid is: predicate names (for autocomplete) 1035 1034 */ 1036 - export const IS_PREDICATE_NAMES = new Set(Object.keys(IS_PREDICATES)); 1035 + export const IS_PREDICATE_NAMES = new Set([ 1036 + ...Object.keys(IS_PREDICATES), 1037 + ...Object.keys(IS_PREDICATE_ALIASES), 1038 + ]); 1037 1039 1038 1040 /** 1039 1041 * Compile is: predicate ··· 1046 1048 return err({ message: "is: requires a text value", span }); 1047 1049 } 1048 1050 1049 - const predicate = IS_PREDICATES[value.value.toLowerCase()]; 1051 + const name = value.value.toLowerCase(); 1052 + const canonical = IS_PREDICATE_ALIASES[name] ?? name; 1053 + const predicate = IS_PREDICATES[canonical]; 1050 1054 if (!predicate) { 1051 1055 return err({ 1052 1056 message: `'${value.value}' is not a valid is: predicate`,