👁️
at dev 148 lines 4.9 kB view raw
1import type { DeckCard } from "./deck-types"; 2import type { Card } from "./scryfall-types"; 3 4/** 5 * Irregular plurals relevant to MTG typal deck names. 6 * These can't be derived algorithmically. 7 */ 8const IRREGULAR_PLURALS: Record<string, string[]> = { 9 geese: ["goose"], 10 teeth: ["tooth"], 11 feet: ["foot"], 12 men: ["man"], 13 mice: ["mouse"], 14 children: ["child"], 15 oxen: ["ox"], 16 people: ["person"], 17}; 18 19/** 20 * Get possible singular forms of a word for matching deck names to card names. 21 * Handles common English plural patterns used in MTG typal deck names. 22 */ 23export function getSingularForms(word: string): string[] { 24 const forms = [word]; 25 26 // Check irregular plurals first 27 const irregular = IRREGULAR_PLURALS[word]; 28 if (irregular) { 29 forms.push(...irregular); 30 return forms; 31 } 32 33 if (word.endsWith("ies") && word.length > 3) { 34 forms.push(`${word.slice(0, -3)}y`); // pixies -> pixy 35 forms.push(word.slice(0, -1)); // faeries -> faerie 36 } else if (word.endsWith("ves") && word.length > 3) { 37 forms.push(`${word.slice(0, -3)}f`); // elves -> elf 38 forms.push(`${word.slice(0, -3)}fe`); // knives -> knife 39 } else if ( 40 word.length > 2 && 41 (word.endsWith("xes") || 42 word.endsWith("sses") || 43 word.endsWith("ches") || 44 word.endsWith("shes") || 45 word.endsWith("zzes")) 46 ) { 47 forms.push(word.slice(0, -2)); // boxes -> box, passes -> pass 48 } else if (word.endsWith("s") && !word.endsWith("ss") && word.length > 1) { 49 // Skip words ending in -ss (prowess, boss, moss) - not plurals 50 forms.push(word.slice(0, -1)); // bogles -> bogle, goblins -> goblin 51 } 52 return forms; 53} 54 55/** 56 * Extract meaningful words from a deck name for matching against card names/text. 57 * Returns lowercase words (3+ chars) plus their possible singular forms. 58 */ 59export function getDeckNameWords(name: string): string[] { 60 return name 61 .toLowerCase() 62 .split(/\s+/) 63 .filter((w) => w.length >= 3) 64 .flatMap(getSingularForms); 65} 66 67/** 68 * Check if text contains any of the deck title words. 69 */ 70export function textMatchesDeckTitle( 71 text: string | undefined, 72 deckWords: string[], 73): boolean { 74 if (!text || deckWords.length === 0) return false; 75 const lower = text.toLowerCase(); 76 return deckWords.some((word) => lower.includes(word)); 77} 78 79/** 80 * Check if a type line represents a non-creature land. 81 */ 82export function isNonCreatureLand(typeLine: string | undefined): boolean { 83 if (!typeLine) return false; 84 const lower = typeLine.toLowerCase(); 85 return lower.includes("land") && !lower.includes("creature"); 86} 87 88type CardPreviewData = Partial< 89 Pick<Card, "name" | "type_line" | "oracle_text"> 90>; 91 92/** 93 * Select the best cards to show in a deck preview. 94 * 95 * For commander decks: returns commanders (up to 3). 96 * For other decks: returns mainboard cards sorted by quantity, with tiebreakers 97 * preferring cards whose name/type/text match the deck title. Filters out lands. 98 * 99 * @param deckName - The deck's name (used for title matching) 100 * @param deckCards - Array of deck cards with scryfallId, quantity, section 101 * @param getCard - Function to look up card data by ID (returns undefined if not found) 102 * @param count - Number of cards to return (default 3) 103 */ 104export function getPreviewCardIds( 105 deckName: string, 106 deckCards: DeckCard[], 107 getCard: (id: string) => CardPreviewData | undefined, 108 count = 3, 109): string[] { 110 const commanders = deckCards.filter((c) => c.section === "commander"); 111 if (commanders.length > 0) { 112 return commanders.slice(0, count).map((c) => c.scryfallId); 113 } 114 115 const mainboardCards = deckCards.filter((c) => c.section === "mainboard"); 116 const deckWords = getDeckNameWords(deckName); 117 118 const withData = mainboardCards 119 .map((deckCard) => ({ 120 deckCard, 121 card: getCard(deckCard.scryfallId), 122 })) 123 .filter(({ card }) => card && !isNonCreatureLand(card.type_line)); 124 125 return withData 126 .sort((a, b) => { 127 const qtyDiff = b.deckCard.quantity - a.deckCard.quantity; 128 if (qtyDiff !== 0) return qtyDiff; 129 // Tiebreak 1: prefer cards whose name matches deck title 130 const aNameMatch = textMatchesDeckTitle(a.card?.name, deckWords); 131 const bNameMatch = textMatchesDeckTitle(b.card?.name, deckWords); 132 if (aNameMatch && !bNameMatch) return -1; 133 if (bNameMatch && !aNameMatch) return 1; 134 // Tiebreak 2: prefer cards whose type line matches deck title 135 const aTypeMatch = textMatchesDeckTitle(a.card?.type_line, deckWords); 136 const bTypeMatch = textMatchesDeckTitle(b.card?.type_line, deckWords); 137 if (aTypeMatch && !bTypeMatch) return -1; 138 if (bTypeMatch && !aTypeMatch) return 1; 139 // Tiebreak 3: prefer cards whose oracle text matches deck title 140 const aTextMatch = textMatchesDeckTitle(a.card?.oracle_text, deckWords); 141 const bTextMatch = textMatchesDeckTitle(b.card?.oracle_text, deckWords); 142 if (aTextMatch && !bTextMatch) return -1; 143 if (bTextMatch && !aTextMatch) return 1; 144 return 0; 145 }) 146 .slice(0, count) 147 .map(({ deckCard }) => deckCard.scryfallId); 148}