๐Ÿ‘๏ธ
5
fork

Configure Feed

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

suggust appropriate format

+363 -33
+2 -1
.claude/settings.local.json
··· 69 69 "Bash(goat lex:*)", 70 70 "Bash(npm run test:a11y:*)", 71 71 "Bash(git grep:*)", 72 - "WebFetch(domain:mtg.fandom.com)" 72 + "WebFetch(domain:mtg.fandom.com)", 73 + "Bash(npm test:*)" 73 74 ], 74 75 "deny": [], 75 76 "ask": []
+18
scripts/download-scryfall.ts
··· 1315 1315 const lower = code.toLowerCase(); 1316 1316 return ALCHEMY_YEAR_SETS[lower]; 1317 1317 } 1318 + 1319 + // Build set of all individual alchemy set codes for fast lookup 1320 + const ALL_ALCHEMY_SETS = new Set( 1321 + Object.values(ALCHEMY_YEAR_SETS).flat(), 1322 + ); 1323 + 1324 + /** 1325 + * Check if a set code is an Alchemy set (Y-code or individual alchemy set) 1326 + * 1327 + * Used to detect alchemy decks for format suggestions. 1328 + */ 1329 + export function isAlchemySetCode(code: string): boolean { 1330 + const lower = code.toLowerCase(); 1331 + // Check for Y-codes (y22, y23, etc.) 1332 + if (ALCHEMY_YEAR_SETS[lower]) return true; 1333 + // Check for individual alchemy sets (ymid, yneo, hbg, etc.) 1334 + return ALL_ALCHEMY_SETS.has(lower); 1335 + } 1318 1336 `; 1319 1337 1320 1338 const tsPath = join(__dirname, "../src/lib/set-symbols.ts");
+116
src/lib/__tests__/format-utils.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + formatSuggestionList, 4 + getFormatInfo, 5 + suggestFormats, 6 + } from "../format-utils"; 7 + 8 + describe("getFormatInfo", () => { 9 + it("returns supportsAlchemy true for alchemy formats", () => { 10 + expect(getFormatInfo("alchemy").supportsAlchemy).toBe(true); 11 + expect(getFormatInfo("historic").supportsAlchemy).toBe(true); 12 + expect(getFormatInfo("brawl").supportsAlchemy).toBe(true); 13 + expect(getFormatInfo("standardbrawl").supportsAlchemy).toBe(true); 14 + expect(getFormatInfo("timeless").supportsAlchemy).toBe(true); 15 + expect(getFormatInfo("gladiator").supportsAlchemy).toBe(true); 16 + }); 17 + 18 + it("returns supportsAlchemy false for non-alchemy formats", () => { 19 + expect(getFormatInfo("commander").supportsAlchemy).toBe(false); 20 + expect(getFormatInfo("standard").supportsAlchemy).toBe(false); 21 + expect(getFormatInfo("modern").supportsAlchemy).toBe(false); 22 + expect(getFormatInfo("legacy").supportsAlchemy).toBe(false); 23 + }); 24 + }); 25 + 26 + describe("suggestFormats", () => { 27 + it("suggests alchemy-supporting formats when hasAlchemyCards", () => { 28 + const suggestions = suggestFormats( 29 + { deckSize: 60, hasCommander: false, hasAlchemyCards: true }, 30 + "standard", 31 + ); 32 + 33 + expect(suggestions.length).toBeGreaterThan(0); 34 + for (const fmt of suggestions) { 35 + expect(getFormatInfo(fmt).supportsAlchemy).toBe(true); 36 + } 37 + }); 38 + 39 + it("suggests commander + alchemy formats when both conditions", () => { 40 + const suggestions = suggestFormats( 41 + { deckSize: 100, hasCommander: true, hasAlchemyCards: true }, 42 + "commander", 43 + ); 44 + 45 + expect(suggestions.length).toBeGreaterThan(0); 46 + for (const fmt of suggestions) { 47 + const info = getFormatInfo(fmt); 48 + expect(info.supportsAlchemy).toBe(true); 49 + expect(info.commanderType).not.toBeNull(); 50 + } 51 + }); 52 + 53 + it("suggests commander formats when hasCommander", () => { 54 + const suggestions = suggestFormats( 55 + { deckSize: 100, hasCommander: true, hasAlchemyCards: false }, 56 + "standard", 57 + ); 58 + 59 + expect(suggestions.length).toBeGreaterThan(0); 60 + for (const fmt of suggestions) { 61 + expect(getFormatInfo(fmt).commanderType).not.toBeNull(); 62 + } 63 + }); 64 + 65 + it("excludes current format from suggestions", () => { 66 + const suggestions = suggestFormats( 67 + { deckSize: 60, hasCommander: false, hasAlchemyCards: true }, 68 + "alchemy", 69 + ); 70 + 71 + expect(suggestions).not.toContain("alchemy"); 72 + }); 73 + 74 + it("excludes cube when other suggestions exist", () => { 75 + const suggestions = suggestFormats( 76 + { deckSize: 100, hasCommander: true, hasAlchemyCards: false }, 77 + "standard", 78 + ); 79 + 80 + expect(suggestions).not.toContain("cube"); 81 + // kitchentable also excluded when better options exist 82 + expect(suggestions).not.toContain("kitchentable"); 83 + }); 84 + 85 + it("falls back to kitchentable when nothing else matches", () => { 86 + // Tiny deck with no commander or alchemy - no format matches 87 + const suggestions = suggestFormats( 88 + { deckSize: 5, hasCommander: false, hasAlchemyCards: false }, 89 + "standard", 90 + ); 91 + 92 + expect(suggestions).toEqual(["kitchentable"]); 93 + }); 94 + }); 95 + 96 + describe("formatSuggestionList", () => { 97 + it("handles single format", () => { 98 + expect(formatSuggestionList(["brawl"])).toBe("Brawl"); 99 + }); 100 + 101 + it("handles two formats with 'or'", () => { 102 + expect(formatSuggestionList(["brawl", "standardbrawl"])).toBe( 103 + "Brawl or Standard Brawl", 104 + ); 105 + }); 106 + 107 + it("handles three formats with comma and 'or'", () => { 108 + expect(formatSuggestionList(["alchemy", "historic", "timeless"])).toBe( 109 + "Alchemy, Historic, or Timeless", 110 + ); 111 + }); 112 + 113 + it("handles empty list", () => { 114 + expect(formatSuggestionList([])).toBe(""); 115 + }); 116 + });
+29 -6
src/lib/deck-validation/presets.ts
··· 100 100 "commanderPartner", 101 101 "commanderLegendary", 102 102 ] as const, 103 - config: { legalityField: "brawl", deckSize: 100 }, 103 + config: { legalityField: "brawl", deckSize: 100, supportsAlchemy: true }, 104 104 }, 105 105 standardbrawl: { 106 106 rules: [ ··· 112 112 "commanderRequired", 113 113 "commanderLegendary", 114 114 ] as const, 115 - config: { legalityField: "standardbrawl", deckSize: 60 }, 115 + config: { 116 + legalityField: "standardbrawl", 117 + deckSize: 60, 118 + supportsAlchemy: true, 119 + }, 116 120 }, 117 121 118 122 // Arena formats 119 123 historic: { 120 124 rules: SIXTY_CARD_RULES, 121 - config: { legalityField: "historic", minDeckSize: 60, sideboardSize: 15 }, 125 + config: { 126 + legalityField: "historic", 127 + minDeckSize: 60, 128 + sideboardSize: 15, 129 + supportsAlchemy: true, 130 + }, 122 131 }, 123 132 timeless: { 124 133 rules: SIXTY_CARD_RULES, 125 - config: { legalityField: "timeless", minDeckSize: 60, sideboardSize: 15 }, 134 + config: { 135 + legalityField: "timeless", 136 + minDeckSize: 60, 137 + sideboardSize: 15, 138 + supportsAlchemy: true, 139 + }, 126 140 }, 127 141 alchemy: { 128 142 rules: SIXTY_CARD_RULES, 129 - config: { legalityField: "alchemy", minDeckSize: 60, sideboardSize: 15 }, 143 + config: { 144 + legalityField: "alchemy", 145 + minDeckSize: 60, 146 + sideboardSize: 15, 147 + supportsAlchemy: true, 148 + }, 130 149 }, 131 150 gladiator: { 132 151 rules: ["cardLegality", "banned", "singleton", "deckSizeMin"] as const, 133 - config: { legalityField: "gladiator", minDeckSize: 100 }, 152 + config: { 153 + legalityField: "gladiator", 154 + minDeckSize: 100, 155 + supportsAlchemy: true, 156 + }, 134 157 }, 135 158 136 159 // Retro formats
+2
src/lib/deck-validation/types.ts
··· 72 72 minDeckSize?: number; 73 73 deckSize?: number; 74 74 sideboardSize?: number; 75 + /** Format supports alchemy (rebalanced A-) cards - Arena formats only */ 76 + supportsAlchemy?: boolean; 75 77 } 76 78 77 79 /**
+124
src/lib/format-utils.ts
··· 20 20 hasSideboard: boolean; 21 21 tagline: string; 22 22 isCube: boolean; 23 + supportsAlchemy: boolean; 23 24 } 24 25 25 26 /** ··· 82 83 hasSideboard: false, 83 84 tagline: FORMAT_TAGLINES.cube, 84 85 isCube: true, 86 + supportsAlchemy: false, 85 87 }; 86 88 } 87 89 ··· 95 97 hasSideboard: true, 96 98 tagline: "", 97 99 isCube: false, 100 + supportsAlchemy: false, 98 101 }; 99 102 } 100 103 ··· 128 131 hasSideboard, 129 132 tagline: FORMAT_TAGLINES[format] ?? "", 130 133 isCube: false, 134 + supportsAlchemy: config.supportsAlchemy ?? false, 131 135 }; 132 136 } 133 137 ··· 200 204 if (!format) return ""; 201 205 return FORMAT_DISPLAY_NAMES[format] ?? format; 202 206 } 207 + 208 + /** 209 + * Deck characteristics for format suggestion 210 + */ 211 + export interface DeckCharacteristics { 212 + deckSize: number; 213 + hasCommander: boolean; 214 + /** Deck contains alchemy cards (A- prefix or alchemy set codes) that failed to resolve */ 215 + hasAlchemyCards: boolean; 216 + } 217 + 218 + // Pre-computed format info for all formats (avoids repeated getFormatInfo calls) 219 + const ALL_FORMATS: Array<{ id: string; info: FormatInfo }> = 220 + FORMAT_GROUPS.flatMap((group) => 221 + group.formats.map((fmt) => ({ 222 + id: fmt.value, 223 + info: getFormatInfo(fmt.value), 224 + })), 225 + ); 226 + 227 + /** 228 + * Suggest formats that match the deck's characteristics better than the current format. 229 + * Returns format IDs sorted by relevance (max 3). 230 + * 231 + * Scoring: 232 + * - +100 for alchemy support (when deck has alchemy cards) 233 + * - +50 for commander support (when deck has commander) 234 + * - +30 for matching deck size (within 20%) 235 + * - +10 for close deck size (within 50%) 236 + * 237 + * Exclusions: 238 + * - Formats that don't support alchemy when deck has alchemy cards (hard filter) 239 + * - Formats with commander mismatch (deck has commander but format doesn't, or vice versa) 240 + * - Cube (too specific, user knows if they're building a cube) 241 + * 242 + * Falls back to Kitchen Table if no other formats match. 243 + */ 244 + export function suggestFormats( 245 + characteristics: DeckCharacteristics, 246 + currentFormat: string, 247 + ): string[] { 248 + const { deckSize, hasCommander, hasAlchemyCards } = characteristics; 249 + 250 + const candidates: Array<{ format: string; score: number }> = []; 251 + 252 + for (const { id, info } of ALL_FORMATS) { 253 + if (id === currentFormat) continue; 254 + 255 + // Skip cube (too specific) and kitchentable (handled as fallback) 256 + if (info.isCube || id === "kitchentable") continue; 257 + 258 + let score = 0; 259 + 260 + // Alchemy support is a hard requirement if deck has alchemy cards 261 + if (hasAlchemyCards) { 262 + if (info.supportsAlchemy) { 263 + score += 100; 264 + } else { 265 + continue; 266 + } 267 + } 268 + 269 + // Commander mismatch is a hard exclusion - the format fundamentally 270 + // doesn't fit the deck structure. We exclude rather than penalize because 271 + // a 60-card commander deck in "Modern" shouldn't see "Standard" suggested 272 + // just because it has a slightly less negative score. 273 + const commanderMismatch = 274 + (hasCommander && info.commanderType === null) || 275 + (!hasCommander && info.commanderType !== null); 276 + 277 + if (commanderMismatch) continue; 278 + 279 + if (hasCommander && info.commanderType !== null) { 280 + score += 50; 281 + } 282 + 283 + // Deck size matching (within ~20% tolerance) 284 + const expectedSize = info.deckSize === "variable" ? null : info.deckSize; 285 + if (expectedSize) { 286 + const sizeDiff = Math.abs(deckSize - expectedSize); 287 + const tolerance = expectedSize * 0.2; 288 + if (sizeDiff <= tolerance) { 289 + score += 30; 290 + } else if (sizeDiff <= expectedSize * 0.5) { 291 + score += 10; 292 + } 293 + } 294 + 295 + if (score > 0) { 296 + candidates.push({ format: id, score }); 297 + } 298 + } 299 + 300 + const results = candidates 301 + .sort((a, b) => b.score - a.score) 302 + .slice(0, 3) 303 + .map((c) => c.format); 304 + 305 + // Fall back to Kitchen Table if nothing else matches 306 + if (results.length === 0 && currentFormat !== "kitchentable") { 307 + return ["kitchentable"]; 308 + } 309 + 310 + return results; 311 + } 312 + 313 + /** 314 + * Format a list of format suggestions as a readable string. 315 + * e.g., ["brawl", "standardbrawl"] โ†’ "Brawl or Standard Brawl" 316 + */ 317 + export function formatSuggestionList(formats: string[]): string { 318 + if (formats.length === 0) return ""; 319 + if (formats.length === 1) return formatDisplayName(formats[0]); 320 + if (formats.length === 2) { 321 + return `${formatDisplayName(formats[0])} or ${formatDisplayName(formats[1])}`; 322 + } 323 + const last = formats[formats.length - 1]; 324 + const rest = formats.slice(0, -1); 325 + return `${rest.map(formatDisplayName).join(", ")}, or ${formatDisplayName(last)}`; 326 + }
+53 -26
src/routes/deck/import.tsx
··· 23 23 import { useCreateDeckMutation } from "@/lib/deck-queries"; 24 24 import type { Section } from "@/lib/deck-types"; 25 25 import { getPreset } from "@/lib/deck-validation/presets"; 26 - import { FORMAT_GROUPS, getFormatInfo } from "@/lib/format-utils"; 26 + import { 27 + FORMAT_GROUPS, 28 + formatSuggestionList, 29 + getFormatInfo, 30 + suggestFormats, 31 + } from "@/lib/format-utils"; 27 32 import type { Card } from "@/lib/scryfall-types"; 33 + import { isAlchemySetCode } from "@/lib/set-symbols"; 28 34 import { useDebounce } from "@/lib/useDebounce"; 29 35 30 36 export const Route = createFileRoute("/deck/import")({ ··· 294 300 const hasErrors = errorCount > 0; 295 301 const hasWarnings = warningCount > 0; 296 302 303 + // Check for alchemy cards (A- prefix or alchemy set codes) in errors 304 + const hasAlchemyCards = useMemo(() => { 305 + if (!hasErrors) return false; 306 + const allParsed = [ 307 + ...parsedDeck.commander, 308 + ...parsedDeck.mainboard, 309 + ...parsedDeck.sideboard, 310 + ...parsedDeck.maybeboard, 311 + ]; 312 + return allParsed.some( 313 + (card) => 314 + errorMap.has(card.raw) && 315 + (card.name.startsWith("A-") || 316 + (card.setCode && isAlchemySetCode(card.setCode))), 317 + ); 318 + }, [parsedDeck, errorMap, hasErrors]); 319 + 297 320 // Format suggestion hint 298 321 const formatHint = useMemo(() => { 299 322 const formatInfo = getFormatInfo(gameFormat); ··· 305 328 parsedDeck.mainboard.reduce((sum, c) => sum + c.quantity, 0) + 306 329 parsedDeck.commander.reduce((sum, c) => sum + c.quantity, 0); 307 330 308 - // Has commander section but not a commander format 309 - if (hasCommander && !isCommanderFormat) { 310 - return "Deck has a commander โ€” try a Commander format?"; 311 - } 331 + // Build a reason and get dynamic suggestions 332 + let reason: string | null = null; 312 333 313 - // Size mismatch heuristics 314 - const expectedSize = 315 - formatInfo.deckSize === "variable" ? null : formatInfo.deckSize; 316 - if (expectedSize && deckSize > 0) { 317 - // ~100 cards but format expects 60 318 - if (deckSize >= 90 && expectedSize === 60) { 319 - if (hasCommander) { 320 - return "Deck has ~100 cards โ€” try Commander or Brawl?"; 321 - } 322 - return "Deck has ~100 cards โ€” try Commander, Brawl, or Gladiator?"; 323 - } 324 - // ~60 cards but format expects 100 325 - if (deckSize >= 50 && deckSize <= 70 && expectedSize === 100) { 326 - if (hasCommander) { 327 - return "Deck has ~60 cards โ€” try Oathbreaker or Standard Brawl?"; 334 + if (hasAlchemyCards && !formatInfo.supportsAlchemy) { 335 + reason = "Alchemy cards found"; 336 + } else if (hasCommander && !isCommanderFormat) { 337 + reason = "Deck has a commander"; 338 + } else if (hasErrors) { 339 + reason = "Some cards not found"; 340 + } else { 341 + // Size mismatch heuristics 342 + const expectedSize = 343 + formatInfo.deckSize === "variable" ? null : formatInfo.deckSize; 344 + if (expectedSize && deckSize > 0) { 345 + if (deckSize >= 90 && expectedSize === 60) { 346 + reason = "Deck has ~100 cards"; 347 + } else if (deckSize >= 50 && deckSize <= 70 && expectedSize === 100) { 348 + reason = "Deck has ~60 cards"; 328 349 } 329 - return "Deck has ~60 cards โ€” try a 60-card format?"; 330 350 } 331 351 } 332 352 333 - // Cards not resolving 334 - if (hasErrors) { 335 - return "Some cards not found โ€” try changing the format?"; 353 + if (!reason) return null; 354 + 355 + // Get dynamic format suggestions 356 + const suggestions = suggestFormats( 357 + { deckSize, hasCommander, hasAlchemyCards }, 358 + gameFormat, 359 + ); 360 + 361 + if (suggestions.length > 0) { 362 + return `${reason} โ€” try ${formatSuggestionList(suggestions)}?`; 336 363 } 337 364 338 - return null; 339 - }, [gameFormat, parsedDeck, hasErrors]); 365 + return `${reason} โ€” try changing the format?`; 366 + }, [gameFormat, parsedDeck, hasErrors, hasAlchemyCards]); 340 367 341 368 const handleCreate = useCallback(() => { 342 369 if (!deckName.trim()) return;
+19
src/workers/__tests__/cards.worker.test.ts
··· 84 84 expect(results.length).toBeGreaterThan(0); 85 85 }); 86 86 }); 87 + 88 + describe("alchemy cards", () => { 89 + it("finds cards with A- prefix", () => { 90 + // Alchemy rebalanced cards have "A-" prefix 91 + const results = worker.searchCards( 92 + "A-Llanowar Loamspeaker", 93 + undefined, 94 + 10, 95 + ); 96 + expect(results.length).toBeGreaterThan(0); 97 + expect(results[0].name).toBe("A-Llanowar Loamspeaker"); 98 + }); 99 + 100 + it("finds A-Thran Portal", () => { 101 + const results = worker.searchCards("A-Thran Portal", undefined, 10); 102 + expect(results.length).toBeGreaterThan(0); 103 + expect(results[0].name).toBe("A-Thran Portal"); 104 + }); 105 + }); 87 106 });