👁️
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}