๐Ÿ‘๏ธ
6
fork

Configure Feed

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

at main 391 lines 11 kB view raw
1import type { Card } from "@/lib/scryfall-types"; 2import { getPrimaryFace } from "./card-faces"; 3import type { DeckCard, GroupBy, SortBy } from "./deck-types"; 4 5/** 6 * Card lookup function type (returns Card data for a given Scryfall ID) 7 */ 8export type CardLookup = (card: DeckCard) => Card | undefined; 9 10/** 11 * Extract the primary type from a card's type line 12 * Example: "Legendary Creature โ€” Human Wizard" โ†’ "Creature" 13 */ 14export function extractPrimaryType(typeLine: string | undefined): string { 15 if (!typeLine) return "Other"; 16 17 // Split on "โ€”" or "-" to remove subtypes 18 const mainPart = typeLine.split(/โ€”|-/)[0].trim(); 19 20 // Common type order: "Legendary Enchantment Creature" 21 // We want the rightmost non-supertype word 22 const types = [ 23 "Creature", 24 "Instant", 25 "Sorcery", 26 "Enchantment", 27 "Artifact", 28 "Planeswalker", 29 "Land", 30 "Battle", 31 "Kindred", 32 "Tribal", 33 ]; 34 35 for (const type of types) { 36 if (mainPart.includes(type)) { 37 return type; 38 } 39 } 40 41 return "Other"; 42} 43 44/** 45 * Extract subtypes from a card's type line 46 * Example: "Legendary Creature โ€” Human Wizard" โ†’ ["Human", "Wizard"] 47 */ 48export function extractSubtypes(typeLine: string | undefined): string[] { 49 if (!typeLine) return []; 50 51 // Split on "โ€”" or "-" to get subtypes 52 const parts = typeLine.split(/โ€”|-/); 53 if (parts.length < 2) return []; 54 55 const subtypesPart = parts[1].trim(); 56 return subtypesPart.split(/\s+/).filter((s) => s.length > 0); 57} 58 59// Color combination names (guild/shard/wedge names) 60// Keys are in WUBRG order (how we sort them internally) 61// But we display the canonical MTG order in the UI 62const COLOR_NAMES_BY_SORTED: Record< 63 string, 64 { name: string; canonical: string } 65> = { 66 // Mono 67 W: { name: "White", canonical: "W" }, 68 U: { name: "Blue", canonical: "U" }, 69 B: { name: "Black", canonical: "B" }, 70 R: { name: "Red", canonical: "R" }, 71 G: { name: "Green", canonical: "G" }, 72 // Guilds (2-color) - already in WUBRG order 73 WU: { name: "Azorius", canonical: "WU" }, 74 WB: { name: "Orzhov", canonical: "WB" }, 75 WR: { name: "Boros", canonical: "WR" }, 76 WG: { name: "Selesnya", canonical: "WG" }, 77 UB: { name: "Dimir", canonical: "UB" }, 78 UR: { name: "Izzet", canonical: "UR" }, 79 UG: { name: "Simic", canonical: "UG" }, 80 BR: { name: "Rakdos", canonical: "BR" }, 81 BG: { name: "Golgari", canonical: "BG" }, 82 RG: { name: "Gruul", canonical: "RG" }, 83 // Shards (3-color, color + 2 allies) 84 WUG: { name: "Bant", canonical: "GWU" }, // Sorted WUG, shown as GWU 85 WUB: { name: "Esper", canonical: "WUB" }, 86 UBR: { name: "Grixis", canonical: "UBR" }, 87 BRG: { name: "Jund", canonical: "BRG" }, 88 WRG: { name: "Naya", canonical: "RGW" }, // Sorted WRG, shown as RGW 89 // Wedges (3-color, color + 2 enemies) 90 WBG: { name: "Abzan", canonical: "WBG" }, 91 WUR: { name: "Jeskai", canonical: "URW" }, // Sorted WUR, shown as URW 92 UBG: { name: "Sultai", canonical: "BGU" }, // Sorted UBG, shown as BGU 93 WBR: { name: "Mardu", canonical: "RWB" }, // Sorted WBR, shown as RWB 94 URG: { name: "Temur", canonical: "GUR" }, // Sorted URG, shown as GUR 95 // 4-color 96 WUBR: { name: "Non-Green", canonical: "WUBR" }, 97 WUBG: { name: "Non-Red", canonical: "WUBG" }, 98 WURG: { name: "Non-Black", canonical: "WURG" }, 99 WBRG: { name: "Non-Blue", canonical: "WBRG" }, 100 UBRG: { name: "Non-White", canonical: "UBRG" }, 101 // 5-color 102 WUBRG: { name: "Five-Color", canonical: "WUBRG" }, 103}; 104 105/** 106 * Get a label for a color identity 107 * Example: ["W", "U"] โ†’ "Azorius (WU)" 108 * Example: ["U"] โ†’ "Blue" 109 * Example: [] โ†’ "Colorless" 110 */ 111export function getColorIdentityLabel( 112 colorIdentity: string[] | undefined, 113): string { 114 if (!colorIdentity || colorIdentity.length === 0) return "Colorless"; 115 116 // Sort in WUBRG order for lookup 117 const order = ["W", "U", "B", "R", "G"]; 118 const sorted = [...colorIdentity].sort( 119 (a, b) => order.indexOf(a) - order.indexOf(b), 120 ); 121 122 const sortedKey = sorted.join(""); 123 const colorInfo = COLOR_NAMES_BY_SORTED[sortedKey]; 124 125 // For mono-color, just return the name 126 if (sorted.length === 1) return colorInfo?.name ?? sortedKey; 127 128 // For multi-color, return "Name (Canonical)" 129 return colorInfo ? `${colorInfo.name} (${colorInfo.canonical})` : sortedKey; 130} 131 132/** 133 * Get mana value bucket for grouping 134 * Example: 0 โ†’ "0", 0.5 โ†’ "1", 3 โ†’ "3", 8 โ†’ "8" 135 */ 136export function getManaValueBucket(cmc: number | undefined): string { 137 if (cmc === undefined || cmc === 0) return "0"; 138 return Math.ceil(cmc).toString(); 139} 140 141/** 142 * Sort cards by the specified method 143 */ 144export function sortCards( 145 cards: DeckCard[], 146 cardLookup: CardLookup, 147 sortBy: SortBy, 148): DeckCard[] { 149 const sorted = [...cards]; 150 151 switch (sortBy) { 152 case "name": { 153 sorted.sort((a, b) => { 154 const cardA = cardLookup(a); 155 const cardB = cardLookup(b); 156 const nameA = cardA?.name ?? ""; 157 const nameB = cardB?.name ?? ""; 158 return nameA.localeCompare(nameB); 159 }); 160 break; 161 } 162 163 case "manaValue": { 164 sorted.sort((a, b) => { 165 const cardA = cardLookup(a); 166 const cardB = cardLookup(b); 167 const cmcA = cardA?.cmc ?? 0; 168 const cmcB = cardB?.cmc ?? 0; 169 if (cmcA !== cmcB) return cmcA - cmcB; 170 // Tiebreak by name 171 return (cardA?.name ?? "").localeCompare(cardB?.name ?? ""); 172 }); 173 break; 174 } 175 176 case "rarity": { 177 const rarityOrder: Record<string, number> = { 178 common: 0, 179 uncommon: 1, 180 rare: 2, 181 mythic: 3, 182 special: 4, 183 bonus: 5, 184 }; 185 186 sorted.sort((a, b) => { 187 const cardA = cardLookup(a); 188 const cardB = cardLookup(b); 189 const rarityA = rarityOrder[cardA?.rarity ?? ""] ?? 999; 190 const rarityB = rarityOrder[cardB?.rarity ?? ""] ?? 999; 191 if (rarityA !== rarityB) return rarityA - rarityB; 192 // Tiebreak by name 193 return (cardA?.name ?? "").localeCompare(cardB?.name ?? ""); 194 }); 195 break; 196 } 197 } 198 199 return sorted; 200} 201 202/** 203 * Group cards by the specified method 204 * Returns a Map of group name โ†’ cards in that group 205 * also includes a bool to indicate if the group is based on a user tag 206 * 207 * Note: Cards with multiple tags will appear in multiple groups 208 */ 209export function groupCards( 210 cards: DeckCard[], 211 cardLookup: CardLookup, 212 groupBy: GroupBy, 213): Map< 214 string, 215 { 216 cards: DeckCard[]; 217 forTag: boolean; 218 } 219> { 220 const groups = new Map< 221 string, 222 { 223 cards: DeckCard[]; 224 forTag: boolean; 225 } 226 >(); 227 228 switch (groupBy) { 229 case "none": { 230 groups.set("all", { cards, forTag: false }); 231 break; 232 } 233 234 case "type": { 235 for (const card of cards) { 236 const cardData = cardLookup(card); 237 const face = cardData ? getPrimaryFace(cardData) : undefined; 238 const type = extractPrimaryType(face?.type_line); 239 const group = groups.get(type) ?? { cards: [], forTag: false }; 240 group.cards.push(card); 241 groups.set(type, group); 242 } 243 break; 244 } 245 246 case "typeAndTags": { 247 for (const card of cards) { 248 if (!card.tags || card.tags.length === 0) { 249 // No tags โ†’ group by type 250 const cardData = cardLookup(card); 251 const face = cardData ? getPrimaryFace(cardData) : undefined; 252 const type = extractPrimaryType(face?.type_line); 253 const group = groups.get(type) ?? { cards: [], forTag: false }; 254 group.cards.push(card); 255 groups.set(type, group); 256 } else { 257 // Has tags โ†’ add to each unique tag group (dedupe to handle malformed data) 258 for (const tag of new Set(card.tags)) { 259 const group = groups.get(tag) ?? { cards: [], forTag: true }; 260 group.forTag = true; 261 group.cards.push(card); 262 groups.set(tag, group); 263 } 264 } 265 } 266 break; 267 } 268 269 case "subtype": { 270 for (const card of cards) { 271 const cardData = cardLookup(card); 272 const face = cardData ? getPrimaryFace(cardData) : undefined; 273 const subtypes = extractSubtypes(face?.type_line); 274 275 if (subtypes.length === 0) { 276 const group = groups.get("(No Subtype)") ?? { 277 cards: [], 278 forTag: false, 279 }; 280 group.cards.push(card); 281 groups.set("(No Subtype)", group); 282 } else { 283 // Add card to each subtype group it belongs to 284 for (const subtype of subtypes) { 285 const group = groups.get(subtype) ?? { cards: [], forTag: false }; 286 group.cards.push(card); 287 groups.set(subtype, group); 288 } 289 } 290 } 291 break; 292 } 293 294 case "manaValue": { 295 for (const card of cards) { 296 const cardData = cardLookup(card); 297 const bucket = getManaValueBucket(cardData?.cmc); 298 const group = groups.get(bucket) ?? { cards: [], forTag: false }; 299 group.cards.push(card); 300 groups.set(bucket, group); 301 } 302 break; 303 } 304 305 case "colorIdentity": { 306 for (const card of cards) { 307 const cardData = cardLookup(card); 308 const label = getColorIdentityLabel(cardData?.color_identity); 309 const group = groups.get(label) ?? { cards: [], forTag: false }; 310 group.cards.push(card); 311 groups.set(label, group); 312 } 313 break; 314 } 315 } 316 317 return groups; 318} 319 320/** 321 * Sort group names for consistent display order 322 */ 323export function sortGroupNames( 324 groups: Map<string, { cards: DeckCard[]; forTag: boolean }>, 325 groupBy: GroupBy, 326): string[] { 327 const groupNames = Array.from(groups.keys()); 328 329 switch (groupBy) { 330 case "manaValue": { 331 // Sort numerically: 0, 1, 2, ..., 7+ 332 return groupNames.sort((a, b) => { 333 if (a === "7+") return 1; 334 if (b === "7+") return -1; 335 return Number.parseInt(a, 10) - Number.parseInt(b, 10); 336 }); 337 } 338 339 case "colorIdentity": { 340 // Sort by WUBRG order, then by length (mono โ†’ multi) 341 const order = ["W", "U", "B", "R", "G"]; 342 return groupNames.sort((a, b) => { 343 if (a === "Colorless") return -1; 344 if (b === "Colorless") return 1; 345 346 // Compare by length first (mono < dual < tri, etc) 347 if (a.length !== b.length) return a.length - b.length; 348 349 // Same length, compare by first color 350 const firstA = order.indexOf(a[0]); 351 const firstB = order.indexOf(b[0]); 352 return firstA - firstB; 353 }); 354 } 355 356 case "typeAndTags": { 357 // Sort tags before types, then alphabetically within each category 358 return groupNames.sort((a, b) => { 359 const aIsSpecial = a.startsWith("("); 360 const bIsSpecial = b.startsWith("("); 361 const aIsTag = groups.get(a)?.forTag ?? false; 362 const bIsTag = groups.get(b)?.forTag ?? false; 363 364 // Put special groups at the end 365 if (aIsSpecial && !bIsSpecial) return 1; 366 if (!aIsSpecial && bIsSpecial) return -1; 367 368 // Tags before types 369 if (aIsTag && !bIsTag) return -1; 370 if (!aIsTag && bIsTag) return 1; 371 372 // Both tags, both types, or both special: sort alphabetically 373 return a.localeCompare(b); 374 }); 375 } 376 377 default: 378 // Alphabetical for type, subtype, etc 379 return groupNames.sort((a, b) => { 380 const aIsSpecial = a.startsWith("("); 381 const bIsSpecial = b.startsWith("("); 382 383 // Put special groups at the end 384 if (aIsSpecial && !bIsSpecial) return 1; 385 if (!aIsSpecial && bIsSpecial) return -1; 386 387 // Both special or both normal: sort alphabetically 388 return a.localeCompare(b); 389 }); 390 } 391}