290 lines
9.4 KiB
JavaScript
290 lines
9.4 KiB
JavaScript
|
|
// 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<Object>} 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>} 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>} 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<number>} 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<Object>} 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
|
||
|
|
};
|
||
|
|
}
|