deckhearth/lib/mana-symbols.js

290 lines
9.4 KiB
JavaScript
Raw Permalink Normal View History

// 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
};
}