👁️
5
fork

Configure Feed

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

resolve arena and mtgo setcodes

+263 -12
+2 -1
.claude/settings.local.json
··· 68 68 "Bash(npm run lexicons:all:*)", 69 69 "Bash(goat lex:*)", 70 70 "Bash(npm run test:a11y:*)", 71 - "Bash(git grep:*)" 71 + "Bash(git grep:*)", 72 + "WebFetch(domain:mtg.fandom.com)" 72 73 ], 73 74 "deny": [], 74 75 "ask": []
+216 -7
scripts/download-scryfall.ts
··· 987 987 setCount: number; 988 988 } 989 989 990 + interface SetCodeMappings { 991 + arenaToScryfall: Record<string, string>; 992 + shadowedCodes: string[]; 993 + alchemyYearSets: Record<string, string[]>; 994 + } 995 + 996 + /** 997 + * Fetch Scryfall sets API and build arena/mtgo code mappings 998 + * 999 + * Arena and MTGO use different set codes than Scryfall for some sets 1000 + * (e.g., Arena uses "dar" for Dominaria, but Scryfall uses "dom"). 1001 + * 1002 + * Also identifies "shadowed" codes - arena codes that are also valid Scryfall 1003 + * codes for different sets (e.g., "evg" is arena code for dd1, but also the 1004 + * Scryfall code for the Anthology version). 1005 + */ 1006 + async function fetchSetCodeMappings( 1007 + offline: boolean, 1008 + ): Promise<SetCodeMappings> { 1009 + const cachePath = join(TEMP_DIR, "scryfall-sets.json"); 1010 + 1011 + interface ScryfallSet { 1012 + code: string; 1013 + name: string; 1014 + arena_code?: string; 1015 + mtgo_code?: string; 1016 + set_type?: string; 1017 + block_code?: string; 1018 + icon_svg_uri?: string; 1019 + } 1020 + 1021 + let sets: ScryfallSet[]; 1022 + 1023 + if (!offline) { 1024 + console.log("Fetching Scryfall sets for code mappings..."); 1025 + const data = await fetchJSON<{ data: ScryfallSet[] }>( 1026 + "https://api.scryfall.com/sets", 1027 + ); 1028 + sets = data.data; 1029 + await mkdir(TEMP_DIR, { recursive: true }); 1030 + await writeFile(cachePath, JSON.stringify(sets)); 1031 + console.log(`Fetched ${sets.length} sets`); 1032 + } else { 1033 + try { 1034 + sets = JSON.parse(await readFile(cachePath, "utf-8")) as ScryfallSet[]; 1035 + console.log(`Using cached sets (${sets.length} sets)`); 1036 + } catch { 1037 + console.log("No cached sets found, using empty mappings"); 1038 + return { arenaToScryfall: {}, shadowedCodes: [], alchemyYearSets: {} }; 1039 + } 1040 + } 1041 + 1042 + // Build set of all Scryfall codes to detect shadowed arena codes 1043 + const allScryfallCodes = new Set(sets.map((s) => s.code)); 1044 + 1045 + const arenaToScryfall: Record<string, string> = {}; 1046 + const shadowedCodes: string[] = []; 1047 + const alchemyYearSets: Record<string, string[]> = {}; 1048 + 1049 + for (const set of sets) { 1050 + const { code, name, arena_code, mtgo_code, set_type, block_code } = set; 1051 + 1052 + // Verify our assumption: arena_code and mtgo_code are always identical 1053 + if (arena_code && mtgo_code && arena_code !== mtgo_code) { 1054 + throw new Error( 1055 + `Set code mismatch for ${name} (${code}): arena=${arena_code}, mtgo=${mtgo_code}. ` + 1056 + `Our assumption that these are always equal is broken - code needs updating.`, 1057 + ); 1058 + } 1059 + 1060 + const altCode = arena_code ?? mtgo_code; 1061 + if (altCode && altCode !== code) { 1062 + arenaToScryfall[altCode] = code; 1063 + 1064 + // Check if this arena code shadows a Scryfall code 1065 + if (allScryfallCodes.has(altCode)) { 1066 + shadowedCodes.push(altCode); 1067 + } 1068 + } 1069 + 1070 + // Build Alchemy year groupings from block_code (e.g., y22 → [ymid, yneo, ysnc, hbg]) 1071 + // Fallback to icon_svg_uri when block_code is null (Scryfall sometimes lags on this) 1072 + if (set_type === "alchemy") { 1073 + let yearCode = block_code; 1074 + if (!yearCode && set.icon_svg_uri) { 1075 + // Extract from URL like "https://svgs.scryfall.io/sets/y25.svg?..." 1076 + const match = set.icon_svg_uri.match(/\/sets\/(y\d+)\.svg/); 1077 + if (match) { 1078 + yearCode = match[1]; 1079 + } 1080 + } 1081 + if (yearCode) { 1082 + if (!alchemyYearSets[yearCode]) { 1083 + alchemyYearSets[yearCode] = []; 1084 + } 1085 + alchemyYearSets[yearCode].push(code); 1086 + } 1087 + } 1088 + } 1089 + 1090 + console.log( 1091 + `Set code mappings: ${Object.keys(arenaToScryfall).length} arena/mtgo → scryfall, ${shadowedCodes.length} shadowed, ${Object.keys(alchemyYearSets).length} alchemy year groups`, 1092 + ); 1093 + 1094 + return { arenaToScryfall, shadowedCodes, alchemyYearSets }; 1095 + } 1096 + 990 1097 /** 991 1098 * Downloads Keyrune set symbol font from GitHub release 992 1099 * https://github.com/andrewgioia/keyrune 993 1100 * 994 1101 * Font licensed under SIL OFL 1.1, CSS under MIT 995 1102 */ 996 - async function downloadKeyrune(offline: boolean): Promise<KeyruneResult> { 1103 + async function downloadKeyrune( 1104 + offline: boolean, 1105 + setCodeMappings: SetCodeMappings, 1106 + ): Promise<KeyruneResult> { 997 1107 const cssCachePath = join(TEMP_DIR, "keyrune.css"); 998 1108 const versionCachePath = join(TEMP_DIR, "keyrune-version.json"); 999 1109 ··· 1095 1205 1096 1206 // Generate TypeScript file 1097 1207 const tsContent = `/** 1098 - * Keyrune set symbol unicode mappings 1099 - * Auto-generated by scripts/download-scryfall.ts from Keyrune ${version} 1100 - * https://github.com/andrewgioia/keyrune 1208 + * Set symbol unicode mappings and set code utilities 1209 + * Auto-generated by scripts/download-scryfall.ts 1101 1210 * 1211 + * Keyrune ${version} - https://github.com/andrewgioia/keyrune 1102 1212 * Font licensed under SIL OFL 1.1 1103 1213 */ 1104 1214 ··· 1110 1220 }; 1111 1221 1112 1222 /** 1223 + * Arena/MTGO set code → Scryfall set code 1224 + * 1225 + * Arena and MTGO use different codes for some sets (e.g., "dar" for Dominaria 1226 + * instead of Scryfall's "dom"). This mapping allows normalizing those codes. 1227 + */ 1228 + const ARENA_TO_SCRYFALL: Record<string, string> = { 1229 + ${Object.entries(setCodeMappings.arenaToScryfall) 1230 + .sort(([a], [b]) => a.localeCompare(b)) 1231 + .map( 1232 + ([arena, scry]) => `\t${JSON.stringify(arena)}: ${JSON.stringify(scry)},`, 1233 + ) 1234 + .join("\n")} 1235 + }; 1236 + 1237 + /** 1238 + * Arena codes that shadow existing Scryfall codes. 1239 + * These should NOT be auto-mapped in search context (ambiguous). 1240 + * e.g., "evg" is arena code for dd1, but also Scryfall code for Anthology. 1241 + */ 1242 + const SHADOWED_ARENA_CODES = new Set([${setCodeMappings.shadowedCodes.map((c) => JSON.stringify(c)).join(", ")}]); 1243 + 1244 + /** 1245 + * Arena Alchemy year codes → Scryfall set codes 1246 + * 1247 + * Arena groups Alchemy sets by rotation year (Y22, Y23, Y24, Y25) in deck exports, 1248 + * but Scryfall has individual set codes (ymid, yneo, etc.). 1249 + * Maps Y-codes to the list of Scryfall sets they contain. 1250 + */ 1251 + const ALCHEMY_YEAR_SETS: Record<string, readonly string[]> = { 1252 + ${Object.entries(setCodeMappings.alchemyYearSets) 1253 + .sort(([a], [b]) => a.localeCompare(b)) 1254 + .map( 1255 + ([year, codes]) => 1256 + `\t${JSON.stringify(year)}: [${codes 1257 + .sort() 1258 + .map((c) => JSON.stringify(c)) 1259 + .join(", ")}],`, 1260 + ) 1261 + .join("\n")} 1262 + }; 1263 + 1264 + /** 1113 1265 * Get unicode character for a set symbol 1114 1266 * Returns empty string if set not found 1115 1267 */ ··· 1117 1269 const codepoint = SET_SYMBOLS[setCode.toLowerCase()]; 1118 1270 return codepoint ? String.fromCodePoint(codepoint) : ""; 1119 1271 } 1272 + 1273 + /** 1274 + * Normalize set code for search (Scryfall-compatible) 1275 + * 1276 + * Maps Arena/MTGO codes to Scryfall codes, but only for unambiguous mappings. 1277 + * Shadowed codes (${setCodeMappings.shadowedCodes.join(", ")}) are left as-is. 1278 + */ 1279 + export function normalizeSetCodeForSearch(code: string): string { 1280 + const lower = code.toLowerCase(); 1281 + if (SHADOWED_ARENA_CODES.has(lower)) return lower; 1282 + return ARENA_TO_SCRYFALL[lower] ?? lower; 1283 + } 1284 + 1285 + /** 1286 + * Convert Arena/MTGO set code to Scryfall code for import 1287 + * 1288 + * Use this when importing from Arena or MTGO format, where we know 1289 + * the user's intent and can safely map all codes including shadowed ones. 1290 + */ 1291 + export function arenaCodeToScryfall(code: string): string { 1292 + const lower = code.toLowerCase(); 1293 + return ARENA_TO_SCRYFALL[lower] ?? lower; 1294 + } 1295 + 1296 + /** 1297 + * Convert Scryfall set code to Arena/MTGO code for export 1298 + */ 1299 + export function scryfallCodeToArena(code: string): string { 1300 + const lower = code.toLowerCase(); 1301 + // Reverse lookup - find arena code that maps to this scryfall code 1302 + for (const [arena, scry] of Object.entries(ARENA_TO_SCRYFALL)) { 1303 + if (scry === lower) return arena; 1304 + } 1305 + return lower; 1306 + } 1307 + 1308 + /** 1309 + * Expand Arena Alchemy year code (Y22, Y23, etc.) to Scryfall set codes 1310 + * 1311 + * Returns the list of Scryfall set codes for that year, or undefined if not a Y-code. 1312 + * Used during import to search across all Alchemy sets in a rotation year. 1313 + */ 1314 + export function expandAlchemyYearCode(code: string): readonly string[] | undefined { 1315 + const lower = code.toLowerCase(); 1316 + return ALCHEMY_YEAR_SETS[lower]; 1317 + } 1120 1318 `; 1121 1319 1122 1320 const tsPath = join(__dirname, "../src/lib/set-symbols.ts"); 1123 1321 await writeFile(tsPath, tsContent); 1124 1322 console.log(`Wrote set symbols: ${tsPath}`); 1125 1323 1126 - console.log(`✓ Keyrune ${version}: ${setCount} set symbols`); 1324 + console.log( 1325 + `✓ Keyrune ${version}: ${setCount} set symbols, ${Object.keys(setCodeMappings.arenaToScryfall).length} arena mappings`, 1326 + ); 1127 1327 return { version, setCount }; 1128 1328 } 1129 1329 ··· 1133 1333 1134 1334 console.log("=== Scryfall Data Download ===\n"); 1135 1335 1336 + // Fetch set code mappings first (needed for keyrune file generation) 1337 + const setCodeMappings = await fetchSetCodeMappings(offline); 1338 + 1136 1339 if (offline) { 1137 1340 console.log("Running in offline mode (reprocessing only)\n"); 1138 1341 const [cards, cachedSymbols, keyrune] = await Promise.all([ 1139 1342 processBulkData(true), 1140 1343 getCachedSymbolNames(), 1141 - downloadKeyrune(true), 1344 + downloadKeyrune(true, setCodeMappings), 1142 1345 ]); 1143 1346 1144 1347 await writeManifest( ··· 1151 1354 console.log("\n=== Summary ==="); 1152 1355 console.log(`Cards: ${cards.data.cardCount.toLocaleString()}`); 1153 1356 console.log(`Set symbols: ${keyrune.setCount.toLocaleString()}`); 1357 + console.log( 1358 + `Arena code mappings: ${Object.keys(setCodeMappings.arenaToScryfall).length}`, 1359 + ); 1154 1360 console.log(`Version: ${cards.data.version}`); 1155 1361 console.log("\n✓ Done!"); 1156 1362 } else { ··· 1158 1364 processBulkData(false), 1159 1365 processMigrations(), 1160 1366 downloadSymbols(), 1161 - downloadKeyrune(false), 1367 + downloadKeyrune(false, setCodeMappings), 1162 1368 ]); 1163 1369 1164 1370 await writeManifest( ··· 1175 1381 ); 1176 1382 console.log(`Mana symbols: ${symbols.count.toLocaleString()}`); 1177 1383 console.log(`Set symbols: ${keyrune.setCount.toLocaleString()}`); 1384 + console.log( 1385 + `Arena code mappings: ${Object.keys(setCodeMappings.arenaToScryfall).length}`, 1386 + ); 1178 1387 console.log(`Version: ${cards.data.version}`); 1179 1388 console.log("\n✓ Done!"); 1180 1389 }
+38 -4
src/lib/deck-import.ts
··· 10 10 * 1 Sol Ring 11 11 */ 12 12 13 + import type { DeckFormat } from "./deck-formats/types"; 13 14 import type { Card, OracleId, ScryfallId } from "./scryfall-types"; 15 + import { 16 + arenaCodeToScryfall, 17 + expandAlchemyYearCode, 18 + normalizeSetCodeForSearch, 19 + } from "./set-symbols"; 14 20 15 21 export interface ParsedCardLine { 16 22 quantity: number; ··· 139 145 return parts.join(" "); 140 146 } 141 147 148 + export interface ResolveOptions { 149 + /** Deck format for set code normalization (arena/mtgo use full mapping) */ 150 + format?: DeckFormat; 151 + } 152 + 142 153 /** 143 154 * Resolve parsed cards to Scryfall IDs 144 155 * ··· 150 161 lookupByName: (name: string) => Promise<Card[]>, 151 162 getPrintings: (oracleId: OracleId) => Promise<ScryfallId[]>, 152 163 getCardById: (id: ScryfallId) => Promise<Card | undefined>, 164 + options?: ResolveOptions, 153 165 ): Promise<ImportResult> { 154 166 const resolved: ResolvedCard[] = []; 155 167 const errors: ImportError[] = []; ··· 183 195 if (line.setCode) { 184 196 const printings = await getPrintings(baseCard.oracle_id); 185 197 let found = false; 198 + 199 + // Check if this is an Alchemy year code (Y22, Y23, etc.) 200 + // These expand to multiple Scryfall set codes 201 + const alchemyYearSets = expandAlchemyYearCode(line.setCode); 202 + 203 + // Normalize arena/mtgo set codes to scryfall codes (e.g., "dar" → "dom") 204 + // Use full mapping for arena/mtgo formats, safe mapping for others 205 + const useFullMapping = 206 + options?.format === "arena" || options?.format === "mtgo"; 207 + const normalizedSetCode = useFullMapping 208 + ? arenaCodeToScryfall(line.setCode) 209 + : normalizeSetCodeForSearch(line.setCode); 186 210 187 211 for (const printingId of printings) { 188 212 const printing = await getCardById(printingId); 189 213 if (!printing) continue; 190 214 215 + // For Y-codes, check if printing is in any of the year's sets 216 + // Otherwise, check exact match against normalized code 191 217 const setMatches = 192 - printing.set?.toUpperCase() === line.setCode.toUpperCase(); 193 - const collectorMatches = 194 - !line.collectorNumber || 195 - printing.collector_number === line.collectorNumber; 218 + alchemyYearSets && printing.set 219 + ? alchemyYearSets.includes(printing.set) 220 + : printing.set === normalizedSetCode; 221 + 222 + // For alchemy cards, Arena exports "170" but Scryfall has "A-170" 223 + // Try both the raw collector number and with "A-" prefix 224 + let collectorMatches = !line.collectorNumber; 225 + if (line.collectorNumber && !collectorMatches) { 226 + collectorMatches = 227 + printing.collector_number === line.collectorNumber || 228 + printing.collector_number === `A-${line.collectorNumber}`; 229 + } 196 230 197 231 if (setMatches && collectorMatches) { 198 232 finalId = printingId;
+1
src/routes/deck/import.tsx
··· 203 203 : [], 204 204 (oracleId) => provider.getPrintingsByOracleId(oracleId), 205 205 (id) => provider.getCardById(id), 206 + { format: debouncedParsed.format }, 206 207 ); 207 208 208 209 if (cancelled) return;
+6
todos.md
··· 8 8 9 9 ## Bugs 10 10 11 + ### Scryfall: Missing block_code on recent Alchemy sets 12 + - **Sets affected**: ydsk, ydft, ytdm, yeoe (all have `block_code: null`) 13 + - **Workaround**: We parse `icon_svg_uri` which correctly shows y25.svg 14 + - **Report to**: Scryfall (Discord or GitHub) 15 + - **Note**: block_code is documented and works for older sets (ymid has y22, etc.) 16 + 11 17 ### Flaky property test for OR parsing 12 18 - **Location**: `src/lib/search/__tests__/parser.test.ts:276` ("parses OR combinations") 13 19 - **Issue**: fast-check property test occasionally finds edge cases that fail parsing