๐Ÿ‘๏ธ
5
fork

Configure Feed

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

suggust dynamic format based on legality

+166 -69
+60 -13
src/lib/__tests__/format-utils.test.ts
··· 24 24 }); 25 25 26 26 describe("suggestFormats", () => { 27 - it("suggests alchemy-supporting formats when hasAlchemyCards", () => { 27 + it("boosts formats where error cards are legal", () => { 28 + // Simulate error cards that are legal in alchemy formats 28 29 const suggestions = suggestFormats( 29 - { deckSize: 60, hasCommander: false, hasAlchemyCards: true }, 30 + { 31 + deckSize: 60, 32 + hasCommander: false, 33 + errorLegalFormats: ["alchemy", "historic", "timeless"], 34 + }, 30 35 "standard", 31 36 ); 32 37 33 38 expect(suggestions.length).toBeGreaterThan(0); 34 - for (const fmt of suggestions) { 35 - expect(getFormatInfo(fmt).supportsAlchemy).toBe(true); 36 - } 39 + // Should suggest formats from errorLegalFormats 40 + expect( 41 + suggestions.some((fmt) => 42 + ["alchemy", "historic", "timeless"].includes(fmt), 43 + ), 44 + ).toBe(true); 37 45 }); 38 46 39 - it("suggests commander + alchemy formats when both conditions", () => { 47 + it("suggests commander formats when both commander and error formats match", () => { 48 + // Simulate error cards legal in brawl (which is both alchemy and commander) 40 49 const suggestions = suggestFormats( 41 - { deckSize: 100, hasCommander: true, hasAlchemyCards: true }, 50 + { 51 + deckSize: 100, 52 + hasCommander: true, 53 + errorLegalFormats: ["brawl", "standardbrawl"], 54 + }, 42 55 "commander", 43 56 ); 44 57 45 58 expect(suggestions.length).toBeGreaterThan(0); 46 59 for (const fmt of suggestions) { 47 60 const info = getFormatInfo(fmt); 48 - expect(info.supportsAlchemy).toBe(true); 49 61 expect(info.commanderType).not.toBeNull(); 50 62 } 51 63 }); 52 64 53 65 it("suggests commander formats when hasCommander", () => { 54 66 const suggestions = suggestFormats( 55 - { deckSize: 100, hasCommander: true, hasAlchemyCards: false }, 67 + { deckSize: 100, hasCommander: true, errorLegalFormats: [] }, 56 68 "standard", 57 69 ); 58 70 ··· 64 76 65 77 it("excludes current format from suggestions", () => { 66 78 const suggestions = suggestFormats( 67 - { deckSize: 60, hasCommander: false, hasAlchemyCards: true }, 79 + { 80 + deckSize: 60, 81 + hasCommander: false, 82 + errorLegalFormats: ["alchemy", "historic"], 83 + }, 68 84 "alchemy", 69 85 ); 70 86 ··· 73 89 74 90 it("excludes cube when other suggestions exist", () => { 75 91 const suggestions = suggestFormats( 76 - { deckSize: 100, hasCommander: true, hasAlchemyCards: false }, 92 + { deckSize: 100, hasCommander: true, errorLegalFormats: [] }, 77 93 "standard", 78 94 ); 79 95 ··· 83 99 }); 84 100 85 101 it("falls back to kitchentable when nothing else matches", () => { 86 - // Tiny deck with no commander or alchemy - no format matches 102 + // Tiny deck with no commander or error formats - no format matches 87 103 const suggestions = suggestFormats( 88 - { deckSize: 5, hasCommander: false, hasAlchemyCards: false }, 104 + { deckSize: 5, hasCommander: false, errorLegalFormats: [] }, 89 105 "standard", 90 106 ); 91 107 92 108 expect(suggestions).toEqual(["kitchentable"]); 109 + }); 110 + 111 + it("ranks formats by frequency in errorLegalFormats", () => { 112 + // legacy appears twice, so it should be boosted more 113 + const suggestions = suggestFormats( 114 + { 115 + deckSize: 60, 116 + hasCommander: false, 117 + errorLegalFormats: ["legacy", "vintage", "legacy"], 118 + }, 119 + "standard", 120 + ); 121 + 122 + expect(suggestions.length).toBeGreaterThan(0); 123 + // legacy should be ranked higher due to more occurrences 124 + expect(suggestions[0]).toBe("legacy"); 125 + }); 126 + 127 + it("penalizes but does not exclude commander formats when no commander", () => { 128 + // 100-card deck with brawl-legal errors but no commander marked 129 + // Brawl should still appear (penalized, not excluded) 130 + const suggestions = suggestFormats( 131 + { 132 + deckSize: 100, 133 + hasCommander: false, 134 + errorLegalFormats: ["brawl", "brawl", "brawl"], 135 + }, 136 + "standard", 137 + ); 138 + 139 + expect(suggestions).toContain("brawl"); 93 140 }); 94 141 }); 95 142
+34
src/lib/deck-import.ts
··· 39 39 line: number; 40 40 raw: string; 41 41 error: string; 42 + /** If card exists but isn't legal in selected format, formats where it IS legal */ 43 + legalFormats?: string[]; 42 44 } 43 45 44 46 export interface ImportResult { ··· 151 153 } 152 154 153 155 /** 156 + * Extract formats where a card is legal from its legalities object 157 + */ 158 + function getLegalFormats( 159 + legalities: Record<string, string> | undefined, 160 + ): string[] { 161 + if (!legalities) return []; 162 + return Object.entries(legalities) 163 + .filter(([_, status]) => status === "legal" || status === "restricted") 164 + .map(([format]) => format); 165 + } 166 + 167 + /** 154 168 * Resolve parsed cards to Scryfall IDs 155 169 * 156 170 * Uses card data provider to find matching cards by name, 157 171 * then filters by set/collector number if provided. 172 + * 173 + * If lookupByNameUnrestricted is provided, cards not found with restrictions 174 + * will be checked without restrictions to distinguish "doesn't exist" from 175 + * "exists but not legal in this format". 158 176 */ 159 177 export async function resolveCards( 160 178 parsed: ParsedCardLine[], ··· 162 180 getPrintings: (oracleId: OracleId) => Promise<ScryfallId[]>, 163 181 getCardById: (id: ScryfallId) => Promise<Card | undefined>, 164 182 options?: ResolveOptions, 183 + lookupByNameUnrestricted?: (name: string) => Promise<Card[]>, 165 184 ): Promise<ImportResult> { 166 185 const resolved: ResolvedCard[] = []; 167 186 const errors: ImportError[] = []; ··· 175 194 const matches = await lookupByName(line.name); 176 195 177 196 if (matches.length === 0) { 197 + // Check if card exists at all (without format restrictions) 198 + if (lookupByNameUnrestricted) { 199 + const unrestrictedMatches = await lookupByNameUnrestricted(line.name); 200 + if (unrestrictedMatches.length > 0) { 201 + // Card exists but not legal in selected format 202 + const card = unrestrictedMatches[0]; 203 + errors.push({ 204 + line: lineNum, 205 + raw: line.raw, 206 + error: `"${card.name}" not legal in this format`, 207 + legalFormats: getLegalFormats(card.legalities), 208 + }); 209 + continue; 210 + } 211 + } 178 212 errors.push({ 179 213 line: lineNum, 180 214 raw: line.raw,
+34 -28
src/lib/format-utils.ts
··· 211 211 export interface DeckCharacteristics { 212 212 deckSize: number; 213 213 hasCommander: boolean; 214 - /** Deck contains alchemy cards (A- prefix or alchemy set codes) that failed to resolve */ 215 - hasAlchemyCards: boolean; 214 + /** Formats where error cards are legal (from card legalities). Used to boost matching formats. */ 215 + errorLegalFormats: string[]; 216 216 } 217 217 218 218 // Pre-computed format info for all formats (avoids repeated getFormatInfo calls) ··· 229 229 * Returns format IDs sorted by relevance (max 3). 230 230 * 231 231 * Scoring: 232 - * - +100 for alchemy support (when deck has alchemy cards) 232 + * - +20 per occurrence in errorLegalFormats (formats where error cards are legal) 233 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%) 234 + * - -20 for commander format when deck has no commander (might be missing markup) 235 + * - +50 for exact deck size match (within 5%) 236 + * - +30 for close deck size (within 20%) 237 + * - +10 for somewhat close deck size (within 50%) 236 238 * 237 239 * 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 + * - Formats without commander support when deck has commander 240 241 * - Cube (too specific, user knows if they're building a cube) 241 242 * 242 243 * Falls back to Kitchen Table if no other formats match. ··· 245 246 characteristics: DeckCharacteristics, 246 247 currentFormat: string, 247 248 ): string[] { 248 - const { deckSize, hasCommander, hasAlchemyCards } = characteristics; 249 + const { deckSize, hasCommander, errorLegalFormats } = characteristics; 250 + 251 + // Count occurrences of each format in error legalities 252 + const legalFormatCounts = new Map<string, number>(); 253 + for (const fmt of errorLegalFormats) { 254 + legalFormatCounts.set(fmt, (legalFormatCounts.get(fmt) || 0) + 1); 255 + } 249 256 250 257 const candidates: Array<{ format: string; score: number }> = []; 251 258 ··· 257 264 258 265 let score = 0; 259 266 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 - } 267 + // Boost formats where error cards are legal (+20 per card) 268 + const legalCount = legalFormatCounts.get(id) || 0; 269 + score += legalCount * 20; 268 270 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; 271 + // Commander handling: 272 + // - Deck HAS commander but format doesn't support: hard exclude 273 + // - Deck has NO commander but format expects one: penalty (might just be missing markup) 274 + // - Both match: boost 275 + if (hasCommander && info.commanderType === null) { 276 + continue; // Can't use commander in non-commander format 277 + } 278 278 279 279 if (hasCommander && info.commanderType !== null) { 280 280 score += 50; 281 + } else if (!hasCommander && info.commanderType !== null) { 282 + score -= 20; // Penalty for missing commander, but don't exclude 281 283 } 282 284 283 - // Deck size matching (within ~20% tolerance) 285 + // Deck size matching - bigger boost for exact match 284 286 const expectedSize = info.deckSize === "variable" ? null : info.deckSize; 285 287 if (expectedSize) { 286 288 const sizeDiff = Math.abs(deckSize - expectedSize); 287 - const tolerance = expectedSize * 0.2; 288 - if (sizeDiff <= tolerance) { 289 + if (sizeDiff <= expectedSize * 0.05) { 290 + // Exact or near-exact match (within 5%) 291 + score += 50; 292 + } else if (sizeDiff <= expectedSize * 0.2) { 293 + // Close match (within 20%) 289 294 score += 30; 290 295 } else if (sizeDiff <= expectedSize * 0.5) { 296 + // Somewhat close (within 50%) 291 297 score += 10; 292 298 } 293 299 }
+38 -28
src/routes/deck/import.tsx
··· 19 19 matchLinesToParsedCards, 20 20 parseDeck, 21 21 } from "@/lib/deck-formats"; 22 - import { type ResolvedCard, resolveCards } from "@/lib/deck-import"; 22 + import { 23 + type ImportError, 24 + type ResolvedCard, 25 + resolveCards, 26 + } from "@/lib/deck-import"; 23 27 import { useCreateDeckMutation } from "@/lib/deck-queries"; 24 28 import type { Section } from "@/lib/deck-types"; 25 29 import { getPreset } from "@/lib/deck-validation/presets"; ··· 30 34 suggestFormats, 31 35 } from "@/lib/format-utils"; 32 36 import type { Card } from "@/lib/scryfall-types"; 33 - import { isAlchemySetCode } from "@/lib/set-symbols"; 34 37 import { useDebounce } from "@/lib/useDebounce"; 35 38 36 39 export const Route = createFileRoute("/deck/import")({ ··· 171 174 const [resolvedMap, setResolvedMap] = useState< 172 175 Map<string, ResolvedCard & { cardData: Card }> 173 176 >(new Map()); 174 - const [errorMap, setErrorMap] = useState<Map<string, string>>(new Map()); 177 + const [errorMap, setErrorMap] = useState<Map<string, ImportError>>(new Map()); 175 178 const [isResolving, setIsResolving] = useState(false); 176 179 177 180 // Resolve cards when parsed deck changes ··· 210 213 (oracleId) => provider.getPrintingsByOracleId(oracleId), 211 214 (id) => provider.getCardById(id), 212 215 { format: debouncedParsed.format }, 216 + // Unrestricted lookup to detect "exists but not legal" vs "doesn't exist" 217 + restrictions 218 + ? async (name) => 219 + provider.searchCards 220 + ? provider.searchCards(name, undefined, 1) 221 + : [] 222 + : undefined, 213 223 ); 214 224 215 225 if (cancelled) return; 216 226 217 - const newErrors = new Map<string, string>(); 227 + const newErrors = new Map<string, ImportError>(); 218 228 for (const error of result.errors) { 219 - newErrors.set(error.raw, error.error); 229 + newErrors.set(error.raw, error); 220 230 } 221 231 222 232 const cardDataList = await Promise.all( ··· 260 270 261 271 const error = errorMap.get(trimmed); 262 272 if (error) { 263 - return { key, line: { type: "error", message: error } }; 273 + return { key, line: { type: "error", message: error.error } }; 264 274 } 265 275 266 276 const resolved = resolvedMap.get(trimmed); ··· 300 310 const hasErrors = errorCount > 0; 301 311 const hasWarnings = warningCount > 0; 302 312 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]); 313 + // Collect legal formats from error cards to inform suggestions 314 + const { errorLegalFormats, illegalCardCount } = useMemo(() => { 315 + if (!hasErrors) return { errorLegalFormats: [], illegalCardCount: 0 }; 316 + const formats: string[] = []; 317 + let count = 0; 318 + for (const error of errorMap.values()) { 319 + if (error.legalFormats) { 320 + formats.push(...error.legalFormats); 321 + count++; 322 + } 323 + } 324 + return { errorLegalFormats: formats, illegalCardCount: count }; 325 + }, [errorMap, hasErrors]); 319 326 320 327 // Format suggestion hint 321 328 const formatHint = useMemo(() => { ··· 331 338 // Build a reason and get dynamic suggestions 332 339 let reason: string | null = null; 333 340 334 - if (hasAlchemyCards && !formatInfo.supportsAlchemy) { 335 - reason = "Alchemy cards found"; 336 - } else if (hasCommander && !isCommanderFormat) { 341 + if (hasCommander && !isCommanderFormat) { 337 342 reason = "Deck has a commander"; 343 + } else if (illegalCardCount > 0) { 344 + reason = 345 + illegalCardCount === 1 346 + ? "1 card not legal in this format" 347 + : `${illegalCardCount} cards not legal in this format`; 338 348 } else if (hasErrors) { 339 349 reason = "Some cards not found"; 340 350 } else { ··· 352 362 353 363 if (!reason) return null; 354 364 355 - // Get dynamic format suggestions 365 + // Get dynamic format suggestions based on error legalities 356 366 const suggestions = suggestFormats( 357 - { deckSize, hasCommander, hasAlchemyCards }, 367 + { deckSize, hasCommander, errorLegalFormats }, 358 368 gameFormat, 359 369 ); 360 370 ··· 363 373 } 364 374 365 375 return `${reason} โ€” try changing the format?`; 366 - }, [gameFormat, parsedDeck, hasErrors, hasAlchemyCards]); 376 + }, [gameFormat, parsedDeck, hasErrors, errorLegalFormats, illegalCardCount]); 367 377 368 378 const handleCreate = useCallback(() => { 369 379 if (!deckName.trim()) return;