// Mana symbol utility for Magic: The Gathering cards // Using Scryfall's symbology API: https://scryfall.com/docs/api/card-symbols/parse-mana // Cache for parsed mana costs to avoid repeated API calls const manaCache = new Map(); // Fallback symbol mapping for offline/error cases const FALLBACK_SYMBOLS = { // Basic mana costs '{W}': { symbol: 'W', color: '#FFFBD5', name: 'White' }, '{U}': { symbol: 'U', color: '#0E68AB', name: 'Blue' }, '{B}': { symbol: 'B', color: '#150B00', name: 'Black' }, '{R}': { symbol: 'R', color: '#D3202A', name: 'Red' }, '{G}': { symbol: 'G', color: '#00733E', name: 'Green' }, '{C}': { symbol: 'C', color: '#BEB9B2', name: 'Colorless' }, // Generic mana costs '{0}': { symbol: '0', color: '#BEB9B2', name: 'Zero' }, '{1}': { symbol: '1', color: '#BEB9B2', name: 'One' }, '{2}': { symbol: '2', color: '#BEB9B2', name: 'Two' }, '{3}': { symbol: '3', color: '#BEB9B2', name: 'Three' }, '{4}': { symbol: '4', color: '#BEB9B2', name: 'Four' }, '{5}': { symbol: '5', color: '#BEB9B2', name: 'Five' }, '{6}': { symbol: '6', color: '#BEB9B2', name: 'Six' }, '{7}': { symbol: '7', color: '#BEB9B2', name: 'Seven' }, '{8}': { symbol: '8', color: '#BEB9B2', name: 'Eight' }, '{9}': { symbol: '9', color: '#BEB9B2', name: 'Nine' }, '{10}': { symbol: '10', color: '#BEB9B2', name: 'Ten' }, '{X}': { symbol: 'X', color: '#BEB9B2', name: 'X' }, // Hybrid mana '{W/U}': { symbol: 'W/U', color: 'linear-gradient(135deg, #FFFBD5 50%, #0E68AB 50%)', name: 'White or Blue' }, '{W/B}': { symbol: 'W/B', color: 'linear-gradient(135deg, #FFFBD5 50%, #150B00 50%)', name: 'White or Black' }, '{U/B}': { symbol: 'U/B', color: 'linear-gradient(135deg, #0E68AB 50%, #150B00 50%)', name: 'Blue or Black' }, '{U/R}': { symbol: 'U/R', color: 'linear-gradient(135deg, #0E68AB 50%, #D3202A 50%)', name: 'Blue or Red' }, '{B/R}': { symbol: 'B/R', color: 'linear-gradient(135deg, #150B00 50%, #D3202A 50%)', name: 'Black or Red' }, '{B/G}': { symbol: 'B/G', color: 'linear-gradient(135deg, #150B00 50%, #00733E 50%)', name: 'Black or Green' }, '{R/G}': { symbol: 'R/G', color: 'linear-gradient(135deg, #D3202A 50%, #00733E 50%)', name: 'Red or Green' }, '{R/W}': { symbol: 'R/W', color: 'linear-gradient(135deg, #D3202A 50%, #FFFBD5 50%)', name: 'Red or White' }, '{G/W}': { symbol: 'G/W', color: 'linear-gradient(135deg, #00733E 50%, #FFFBD5 50%)', name: 'Green or White' }, '{G/U}': { symbol: 'G/U', color: 'linear-gradient(135deg, #00733E 50%, #0E68AB 50%)', name: 'Green or Blue' }, }; /** * Parse mana cost using Scryfall's API * @param {string} manaCost - Raw mana cost string (can be shorthand like "2WW" or formal like "{2}{W}{W}") * @returns {Promise} Parsed mana cost data from Scryfall */ export async function parseManaCostWithScryfall(manaCost) { if (!manaCost) return null; // Check cache first if (manaCache.has(manaCost)) { return manaCache.get(manaCost); } try { const response = await fetch( `https://api.scryfall.com/symbology/parse-mana?cost=${encodeURIComponent(manaCost)}` ); if (response.ok) { const data = await response.json(); manaCache.set(manaCost, data); return data; } else { console.warn('Scryfall API error:', response.status); return null; } } catch (error) { console.warn('Error parsing mana cost with Scryfall:', error); return null; } } /** * Parse mana cost string and return array of symbol components * Uses Scryfall API when possible, falls back to local parsing * @param {string} manaCost - Mana cost string * @returns {Promise} Array of mana symbol objects */ export async function parseManaSymbols(manaCost) { if (!manaCost) return []; // Try Scryfall API first const scryfallData = await parseManaCostWithScryfall(manaCost); if (scryfallData && scryfallData.cost) { // Parse the normalized cost from Scryfall const matches = scryfallData.cost.match(/\{[^}]+\}/g); if (matches) { return matches.map(match => { const symbol = FALLBACK_SYMBOLS[match]; if (symbol) { return { raw: match, symbol: symbol.symbol, color: symbol.color, name: symbol.name, scryfall_uri: `https://svgs.scryfall.io/card-symbols/${match.replace(/[{}]/g, '')}.svg` }; } // Fallback for unknown symbols const cleanSymbol = match.replace(/[{}]/g, ''); return { raw: match, symbol: cleanSymbol, color: '#BEB9B2', name: cleanSymbol, scryfall_uri: `https://svgs.scryfall.io/card-symbols/${cleanSymbol}.svg` }; }); } } // Fallback to local parsing if Scryfall fails return parseManaSymbolsLocal(manaCost); } /** * Local fallback mana cost parsing * @param {string} manaCost - Mana cost string * @returns {Array} Array of mana symbol objects */ function parseManaSymbolsLocal(manaCost) { if (!manaCost) return []; // Handle shorthand notation (like "2WW" -> "{2}{W}{W}") let normalizedCost = manaCost; // If it doesn't start with {, try to normalize it if (!normalizedCost.startsWith('{')) { normalizedCost = normalizedCost .replace(/(\d+)/g, '{$1}') // Numbers: 2 -> {2} .replace(/([WUBRG])/gi, '{$1}') // Colors: W -> {W} .replace(/([XYZ])/gi, '{$1}') // Variables: X -> {X} .toUpperCase(); } // Match all {symbol} patterns const matches = normalizedCost.match(/\{[^}]+\}/g); if (!matches) return []; return matches.map(match => { const symbol = FALLBACK_SYMBOLS[match]; if (symbol) { return { raw: match, symbol: symbol.symbol, color: symbol.color, name: symbol.name, scryfall_uri: `https://svgs.scryfall.io/card-symbols/${match.replace(/[{}]/g, '')}.svg` }; } // Fallback for unknown symbols const cleanSymbol = match.replace(/[{}]/g, ''); return { raw: match, symbol: cleanSymbol, color: '#BEB9B2', name: cleanSymbol, scryfall_uri: `https://svgs.scryfall.io/card-symbols/${cleanSymbol}.svg` }; }); } /** * Get color identity from mana cost using Scryfall data * @param {string} manaCost - Mana cost string * @returns {Promise} Array of color letters */ export async function getColorIdentity(manaCost) { if (!manaCost) return []; // Try Scryfall API first const scryfallData = await parseManaCostWithScryfall(manaCost); if (scryfallData && scryfallData.colors) { return scryfallData.colors; } // Fallback to local parsing const colors = new Set(); const symbols = await parseManaSymbols(manaCost); symbols.forEach(({ raw }) => { if (raw.includes('W')) colors.add('W'); if (raw.includes('U')) colors.add('U'); if (raw.includes('B')) colors.add('B'); if (raw.includes('R')) colors.add('R'); if (raw.includes('G')) colors.add('G'); }); return Array.from(colors); } /** * Convert single color letter to display symbol * @param {string} color - Single color letter (W, U, B, R, G) * @returns {Object} Symbol object with display properties */ export function getColorSymbol(color) { const symbolKey = `{${color}}`; const symbol = FALLBACK_SYMBOLS[symbolKey]; return symbol ? { ...symbol, scryfall_uri: `https://svgs.scryfall.io/card-symbols/${color}.svg` } : { symbol: color, color: '#BEB9B2', name: color, scryfall_uri: `https://svgs.scryfall.io/card-symbols/${color}.svg` }; } /** * Calculate converted mana cost using Scryfall data * @param {string} manaCost - Mana cost string * @returns {Promise} Converted mana cost */ export async function calculateCMC(manaCost) { if (!manaCost) return 0; // Try Scryfall API first for accurate CMC const scryfallData = await parseManaCostWithScryfall(manaCost); if (scryfallData && typeof scryfallData.cmc === 'number') { return scryfallData.cmc; } // Fallback to local calculation const symbols = await parseManaSymbols(manaCost); let cmc = 0; symbols.forEach(({ raw }) => { const clean = raw.replace(/[{}]/g, ''); // Numeric costs const numMatch = clean.match(/^\d+$/); if (numMatch) { cmc += parseInt(numMatch[0]); return; } // Single mana symbols (including hybrid) count as 1 if (clean.match(/^[WUBRG]$/) || clean.includes('/')) { cmc += 1; return; } // X, Y, Z count as 0 for CMC calculation if (clean.match(/^[XYZ]$/)) { return; } // Everything else counts as 1 cmc += 1; }); return cmc; } /** * Get mana cost analysis using Scryfall * @param {string} manaCost - Mana cost string * @returns {Promise} Complete mana cost analysis */ export async function getManaCostAnalysis(manaCost) { if (!manaCost) return { cost: '', cmc: 0, colors: [], colorless: true, monocolored: false, multicolored: false, symbols: [] }; const scryfallData = await parseManaCostWithScryfall(manaCost); const symbols = await parseManaSymbols(manaCost); return { cost: scryfallData?.cost || manaCost, cmc: scryfallData?.cmc || await calculateCMC(manaCost), colors: scryfallData?.colors || await getColorIdentity(manaCost), colorless: scryfallData?.colorless ?? (symbols.length === 0 || symbols.every(s => !['W', 'U', 'B', 'R', 'G'].some(c => s.raw.includes(c)))), monocolored: scryfallData?.monocolored ?? false, multicolored: scryfallData?.multicolored ?? false, symbols }; }