Last active
March 14, 2026 23:05
-
-
Save pakoito/5c7f9b8c35efee0126b2b874beb365db to your computer and use it in GitHub Desktop.
Manabase Optimizer Bundle - Standalone module for Tampermonkey
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Manabase Optimizer Bundle - Standalone module for Greasemonkey | |
| (function (global, factory) { | |
| typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : | |
| typeof define === 'function' && define.amd ? define(['exports'], factory) : | |
| (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ManabaseOptimizer = {})); | |
| })(this, (function (exports) { 'use strict'; | |
| /** | |
| * Color Calculator Utility Functions | |
| * | |
| * Pure mathematical and helper functions extracted from the Salubrious Snail Color Analyzer. | |
| * These functions have NO dependencies on global state or DOM and can be used standalone. | |
| * | |
| * Source: https://tappedout.net/mtg-deck-builder/mana-calculator/ | |
| * Extracted: 2026-01-26 | |
| */ | |
| // ============================================================================ | |
| // COMBINATORICS FUNCTIONS | |
| // ============================================================================ | |
| /** | |
| * Calculate factorial (x!) | |
| * @param {number} x - Input number | |
| * @returns {number} Factorial of x | |
| */ | |
| function fact(x) { | |
| let y = x; | |
| while (x > 2) { | |
| x = x - 1; | |
| y = y * x; | |
| } | |
| if (y == 0) { y = y + 1; } | |
| return y; | |
| } | |
| /** | |
| * Calculate partial factorial: x!/y! | |
| * More efficient than calculating both factorials separately. | |
| * @param {number} y - Lower bound | |
| * @param {number} x - Upper bound | |
| * @returns {number} x!/y! | |
| */ | |
| function partialFact(y, x) { | |
| let z = 1; | |
| while (x > y && x > 1) { | |
| z = z * x; | |
| x = x - 1; | |
| } | |
| if (z == 0) { z = z + 1; } | |
| return z; | |
| } | |
| /** | |
| * Calculate binomial coefficient: C(x,y) = x! / (y! * (x-y)!) | |
| * Uses partial factorials to optimize calculation. | |
| * @param {number} x - Total items | |
| * @param {number} y - Items to choose | |
| * @returns {number} Binomial coefficient | |
| */ | |
| function quickChoose(x, y) { | |
| let z = partialFact(Math.max(y, x - y), x) / fact(Math.min(y, x - y)); | |
| return z; | |
| } | |
| // ============================================================================ | |
| // ARRAY UTILITY FUNCTIONS | |
| // ============================================================================ | |
| /** | |
| * Sum all elements in an array | |
| * @param {number[]} arr - Array of numbers | |
| * @returns {number} Sum of all elements | |
| */ | |
| function sum(arr) { | |
| return arr.reduce((a, b) => a + b, 0); | |
| } | |
| /** | |
| * Calculate expected value of a probability distribution | |
| * @param {number[]} pdf - Probability density function (array of probabilities) | |
| * @returns {number} Expected value E[X] = Σ(i * P(X=i)) | |
| */ | |
| function mean(pdf) { | |
| let avg = 0; | |
| for (let i = 0; i < pdf.length; i++) { | |
| avg += i * pdf[i]; | |
| } | |
| return avg; | |
| } | |
| // ============================================================================ | |
| // PROBABILITY DISTRIBUTION FUNCTIONS | |
| // ============================================================================ | |
| /** | |
| * Hypergeometric PMF: P(drawing exactly typeDrawn of a type) | |
| * | |
| * Calculates the probability of drawing exactly `typeDrawn` cards of a specific | |
| * type when drawing `allDrawn` cards from a deck of `allTotal` cards containing | |
| * `typeTotal` cards of that type. | |
| * | |
| * Formula: C(typeTotal, typeDrawn) * C(allTotal-typeTotal, allDrawn-typeDrawn) / C(allTotal, allDrawn) | |
| * | |
| * @param {number} allTotal - Total cards in deck | |
| * @param {number} typeTotal - Cards of target type in deck | |
| * @param {number} allDrawn - Number of cards drawn | |
| * @param {number} typeDrawn - Number of target type to draw | |
| * @returns {number} Probability | |
| */ | |
| function drawType(allTotal, typeTotal, allDrawn, typeDrawn) { | |
| let x = quickChoose(allTotal - typeTotal, allDrawn - typeDrawn) * quickChoose(typeTotal, typeDrawn) / quickChoose(allTotal, allDrawn); | |
| return x; | |
| } | |
| // ============================================================================ | |
| // COLOR COST PROCESSING FUNCTIONS | |
| // ============================================================================ | |
| /** | |
| * Convert color cost object to bitmask (0-31) | |
| * | |
| * Bitmask encoding: | |
| * - Bit 0 (value 1): White | |
| * - Bit 1 (value 2): Blue | |
| * - Bit 2 (value 4): Black | |
| * - Bit 3 (value 8): Red | |
| * - Bit 4 (value 16): Green | |
| * | |
| * @param {Object} cost - Color cost object from getColorCost() | |
| * @returns {number} Bitmask 0-31 | |
| */ | |
| function processCost(cost) { | |
| return Math.sign(cost.w + cost.wu + cost.wb + cost.wr + cost.wg) | |
| + 2 * Math.sign(cost.u + cost.wu + cost.ub + cost.ur + cost.ug) | |
| + 4 * Math.sign(cost.b + cost.wb + cost.ub + cost.br + cost.bg) | |
| + 8 * Math.sign(cost.r + cost.wr + cost.ur + cost.br + cost.rg) | |
| + 16 * Math.sign(cost.g + cost.wg + cost.ug + cost.bg + cost.rg); | |
| } | |
| /** | |
| * Count number of colors in a mana cost | |
| * @param {Object} cost - Color cost object | |
| * @returns {number} Number of colors (0-5) | |
| */ | |
| function numColors(cost) { | |
| return Math.sign(cost.w) + Math.sign(cost.u) + Math.sign(cost.b) + Math.sign(cost.r) + Math.sign(cost.g); | |
| } | |
| /** | |
| * Extract colored pip requirements from a color cost | |
| * (Removes generic mana, keeps only colored requirements) | |
| * | |
| * @param {Object} cost - Color cost object | |
| * @returns {Object} Color requirements (no generic mana) | |
| */ | |
| function colorReqs(cost) { | |
| return { | |
| w: cost.w, | |
| u: cost.u, | |
| b: cost.b, | |
| r: cost.r, | |
| g: cost.g, | |
| wu: cost.wu, | |
| wb: cost.wb, | |
| wr: cost.wr, | |
| wg: cost.wg, | |
| ub: cost.ub, | |
| ur: cost.ur, | |
| ug: cost.ug, | |
| br: cost.br, | |
| bg: cost.bg, | |
| rg: cost.rg | |
| }; | |
| } | |
| /** | |
| * Encode color requirements as unique integer for caching | |
| * | |
| * Uses prime number encoding to create unique identifiers for pip patterns. | |
| * This allows using color requirements as Map keys. | |
| * | |
| * @param {Object} cost - Color requirements object | |
| * @returns {number} Unique integer encoding | |
| */ | |
| function pipsToNum(cost) { | |
| // Handle undefined/null as 0 (like website's .split().length-1 always returns a number) | |
| return 2 ** (cost.w || 0) * 3 ** (cost.u || 0) * 5 ** (cost.b || 0) * 7 ** (cost.r || 0) * 11 ** (cost.g || 0) | |
| * 13 ** (cost.wu || 0) * 17 ** (cost.wb || 0) * 19 ** (cost.wr || 0) * 23 ** (cost.wg || 0) * 29 ** (cost.ub || 0) | |
| * 31 ** (cost.ur || 0) * 37 ** (cost.ug || 0) * 41 ** (cost.br || 0) * 43 ** (cost.bg || 0) * 47 ** (cost.rg || 0); | |
| } | |
| /** | |
| * Parse mana cost string into color cost object | |
| * | |
| * Handles: | |
| * - Colored mana: {W}, {U}, {B}, {R}, {G} | |
| * - Hybrid mana: {W/U}, {U/B}, etc. (15 combinations) | |
| * - Generic mana: {1}, {2}, etc. | |
| * - X costs: {X}, {X}{X}, etc. | |
| * | |
| * X values: | |
| * - 1X: X=3 | |
| * - 2X: X=2 | |
| * - 3+X: X=1 | |
| * | |
| * @param {string} cost - Mana cost string (e.g., "{2}{W}{U}") | |
| * @returns {Object} Color cost object with fields: w, u, b, r, g, wu, wb, wr, wg, ub, ur, ug, br, bg, rg, x, c, t | |
| */ | |
| function getColorCost(cost) { | |
| let cc = { | |
| w: cost.split("{W}").length - 1, | |
| u: cost.split("{U}").length - 1, | |
| b: cost.split("{B}").length - 1, | |
| r: cost.split("{R}").length - 1, | |
| g: cost.split("{G}").length - 1, | |
| wu: cost.split("{W/U}").length - 1, | |
| wb: cost.split("{W/B}").length - 1, | |
| wr: cost.split("{R/W}").length - 1, | |
| wg: cost.split("{G/W}").length - 1, | |
| ub: cost.split("{U/B}").length - 1, | |
| ur: cost.split("{U/R}").length - 1, | |
| ug: cost.split("{G/U}").length - 1, | |
| br: cost.split("{B/R}").length - 1, | |
| bg: cost.split("{B/G}").length - 1, | |
| rg: cost.split("{R/G}").length - 1, | |
| x: cost.split("{X}").length - 1, | |
| c: parseInt(cost.substring(1, cost.length - 1).split('}{')[0]) || 0 | |
| }; | |
| let xValue = 0; | |
| if (cc.x == 1) { | |
| xValue = 3; // one X, X = 3 | |
| } | |
| else if (cc.x == 2) { | |
| xValue = 2; // 2 Xs, X = 2 | |
| } | |
| else if (cc.x > 2) { | |
| xValue = 1; // 3+ Xs X = 1 | |
| } | |
| // Calculate total CMC | |
| cc.t = cc.w + cc.u + cc.b + cc.r + cc.g + cc.wu + cc.wb + cc.wr + cc.wg + cc.ub + cc.ur + cc.ug + cc.br + cc.bg + cc.rg + cc.c + (cc.x * xValue); | |
| return cc; | |
| } | |
| // ============================================================================ | |
| // LAND PROCESSING FUNCTIONS | |
| // ============================================================================ | |
| /** | |
| * Filter land types to only those relevant for a given color cost | |
| * | |
| * Uses bitwise AND to filter lands. For example, if a spell only needs W/U, | |
| * lands that produce B/R/G are irrelevant and get collapsed. | |
| * | |
| * @param {number[]} myLandTypes - Land type array (length 32) | |
| * @param {Object} cost - Color cost object | |
| * @returns {number[]} Filtered land types array | |
| */ | |
| function landFilter(myLandTypes, cost) { | |
| let costCode = processCost(cost); | |
| let limitedLandTypes = new Array(32).fill(0); | |
| for (let i = 0; i < 32; i++) { | |
| limitedLandTypes[i & costCode] += myLandTypes[i]; | |
| } | |
| return limitedLandTypes; | |
| } | |
| /** | |
| * Calculate how many sources of each color combination exist | |
| * | |
| * For each of the 32 color combinations, count how many land types can produce it. | |
| * | |
| * @param {number[]} myLandTypes - Land type array (length 32) | |
| * @returns {number[]} Sources array (length 32) | |
| */ | |
| function sourcesFromLands(myLandTypes) { | |
| let sources = new Array(32).fill(0); | |
| // Iterate through the 32 subsets of the 5 mana colors and find the number of sources for each | |
| for (let i = 0; i < 32; i++) { | |
| // Iterate through the 32 subsets of the 5 mana colors and add the number of sources of each to the total of the relevant color combination | |
| for (let j = 0; j < 32; j++) { | |
| sources[i] += myLandTypes[j] * Math.sign(j & i); // limit the colors produced to the intersection between the colors needed and the actual production | |
| } | |
| } | |
| return sources; | |
| } | |
| /** | |
| * Round fractional land counts to whole numbers | |
| * | |
| * Some land processing (like fetch lands, conditional lands) results in fractional | |
| * land counts. This function rounds them to whole numbers while preserving the | |
| * overall color production profile as closely as possible. | |
| * | |
| * @param {number[]} myLandTypes - Land type array (may have fractional values) | |
| * @returns {number[]} Rounded land type array (all integers) | |
| */ | |
| function roundLands(myLandTypes) { | |
| let myLandCount = sum(myLandTypes); | |
| let roundLandCount = Math.round(myLandCount); | |
| let floorLandTypes = new Array(32).fill(0); | |
| let floorLandCount = 0; | |
| let landsToRound = 0; | |
| // Start with all lands rounded down, note how many to round back up | |
| for (let i = 0; i < 32; i++) { | |
| floorLandTypes[i] = Math.floor(myLandTypes[i]); | |
| floorLandCount += floorLandTypes[i]; | |
| if (floorLandTypes[i] < myLandTypes[i]) { | |
| landsToRound += 1; | |
| } | |
| } | |
| // Calculate error between output of rounded down lands and output of unrounded land totals | |
| let benchmark = sourcesFromLands(myLandTypes); | |
| let floorSources = sourcesFromLands(floorLandTypes); | |
| let error = new Array(32).fill(0); | |
| for (let i = 0; i < 32; i++) { | |
| error[i] = floorSources[i] - benchmark[i]; | |
| } | |
| let result = roundSearch(floorLandTypes.slice(), myLandTypes, roundLandCount - floorLandCount, 31, error, landsToRound); | |
| let searchLandTypes = result[0]; | |
| return searchLandTypes; | |
| } | |
| /** | |
| * Recursive search function for roundLands() | |
| * | |
| * Searches for the optimal combination of rounding decisions to minimize error | |
| * in color production. | |
| * | |
| * @param {number[]} myRoundLands - Current rounded land types | |
| * @param {number[]} myLandTypes - Target (unrounded) land types | |
| * @param {number} landsToAdd - Lands remaining to round up | |
| * @param {number} i - Current index in search | |
| * @param {number[]} error - Current error vector | |
| * @param {number} landsToRound - Total lands that need rounding | |
| * @returns {Array} [rounded land types, error metric, error vector] | |
| */ | |
| function roundSearch(myRoundLands, myLandTypes, landsToAdd, i, error, landsToRound) { | |
| // Search for next unrounded value | |
| while (myLandTypes[i] <= myRoundLands[i] && i >= 0) { | |
| i--; | |
| } | |
| if (landsToAdd < 1 || i < 0) { | |
| // Once it reaches the end of the array or runs out of extra lands to add, calculate error and return | |
| let sumSq = 0; | |
| for (let j = 0; j < 32; j++) { | |
| sumSq += error[j] ** 2; | |
| } | |
| return [myRoundLands, sumSq + landsToAdd * 100, error]; | |
| } | |
| if (landsToAdd >= landsToRound) { | |
| let newRoundLands = myRoundLands.slice(); | |
| newRoundLands[i] += 1; | |
| let newError = error.slice(); | |
| for (let j = 0; j < 32; j++) { | |
| newError[j] += Math.sign(j & i); | |
| } | |
| return roundSearch(newRoundLands, myLandTypes, landsToAdd - 1, i - 1, newError, landsToRound - 1); | |
| } | |
| // Calculate two sets of lands, one with and without land i rounded up | |
| // Run search on both of them, and return the one with less error | |
| let set1 = roundSearch(myRoundLands.slice(), myLandTypes, landsToAdd, i - 1, error, landsToRound - 1); | |
| let newRoundLands = myRoundLands.slice(); | |
| newRoundLands[i] += 1; | |
| let newError = error.slice(); | |
| for (let j = 0; j < 32; j++) { | |
| newError[j] += Math.sign(j & i); | |
| } | |
| let set2 = roundSearch(newRoundLands, myLandTypes, landsToAdd - 1, i - 1, newError, landsToRound - 1); | |
| if (set1[1] > set2[1]) { | |
| return set2; | |
| } | |
| return set1; | |
| } | |
| /** | |
| * Card Loader Module | |
| * | |
| * Loads card data from Scryfall API and parses deck lists. | |
| * Extracted and adapted from Salubrious Snail Color Analyzer. | |
| * | |
| * Source: https://tappedout.net/mtg-deck-builder/mana-calculator/ | |
| * Extracted: 2026-01-26 | |
| */ | |
| // Common discounts for cards with variable costs (from website) | |
| const commonDiscounts = { | |
| "Blasphemous Act": 5, | |
| "The Great Henge": 4, | |
| "Treasure Cruise": 4, | |
| "Ghalta, Primal Hunger": 6, | |
| "Dig Through Time": 4, | |
| "City On Fire": 2, | |
| "Hour of Reckoning": 3, | |
| "Vanquish the Horde": 4, | |
| "Thoughtcast": 2, | |
| "Thought Monitor": 3, | |
| "The Skullspore Nexus": 4, | |
| "Organic Extinction": 4, | |
| "Hoarding Broodlord": 3, | |
| "Metalwork Colossus": 6, | |
| "Excalibur, Sword of Eden": 7 | |
| }; | |
| // Version logging | |
| console.log('Card Loader Module v2.0.0 - Using Snail API'); | |
| // Salubrious Snail backend API (batches card requests) | |
| const SNAIL_API = 'https://api.salubrioussnail.com/'; | |
| // Scryfall API endpoint (fallback) | |
| const SCRYFALL_API = 'https://api.scryfall.com/cards/named'; | |
| // Rate limiting for Scryfall API (50-100ms between requests) | |
| const RATE_LIMIT_MS = 100; | |
| /** | |
| * Fetch multiple cards from Salubrious Snail backend API (batched) | |
| * | |
| * @param {Array<Object>} entries - Parsed deck list entries [{name, quantity}] | |
| * @returns {Promise<Object>} Dictionary of card data {cardName: cardData} | |
| */ | |
| async function fetchCardsFromSnailAPI(entries) { | |
| // Build card list in Snail API format: "1 CardName1NEXT_CARD1 CardName2NEXT_CARD..." | |
| const cardList = entries.map(entry => { | |
| const quantity = entry.quantity || 1; | |
| return `${quantity} ${entry.name}`; | |
| }).join('NEXT_CARD'); | |
| const url = `${SNAIL_API}?cards=${encodeURIComponent(cardList)}`; | |
| try { | |
| const response = await fetch(url); | |
| if (!response.ok) { | |
| throw new Error(`Snail API error: ${response.status}`); | |
| } | |
| const cardDict = await response.json(); | |
| return cardDict; | |
| } catch (error) { | |
| console.error('Failed to fetch from Snail API:', error.message); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Fetch a single card from Scryfall API (fallback) | |
| * | |
| * @param {string} cardName - Card name to fetch | |
| * @returns {Promise<Object>} Card data from Scryfall | |
| */ | |
| async function fetchCardFromScryfall(cardName) { | |
| const url = `${SCRYFALL_API}?fuzzy=${encodeURIComponent(cardName)}`; | |
| try { | |
| const response = await fetch(url); | |
| if (!response.ok) { | |
| if (response.status === 404) { | |
| throw new Error(`Card not found: ${cardName}`); | |
| } | |
| throw new Error(`Scryfall API error: ${response.status}`); | |
| } | |
| return await response.json(); | |
| } catch (error) { | |
| console.error(`Failed to fetch ${cardName}:`, error.message); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Parse Scryfall card data into our format | |
| * | |
| * @param {Object} scryfallCard - Raw card data from Scryfall | |
| * @returns {Object} Parsed card data | |
| */ | |
| function parseCardData(scryfallCard) { | |
| let card = { | |
| name: scryfallCard.name, | |
| mana_cost: '', | |
| card_type: '', | |
| type_line: scryfallCard.type_line || '', | |
| colorCost: null | |
| }; | |
| // Handle double-faced cards (DFCs) | |
| if (scryfallCard.card_faces && scryfallCard.card_faces.length > 0) { | |
| // For MDFCs (modal double-faced cards), use front face | |
| const frontFace = scryfallCard.card_faces[0]; | |
| card.mana_cost = frontFace.mana_cost || ''; | |
| card.card_type = frontFace.type_line || scryfallCard.type_line || ''; | |
| } else { | |
| // Normal single-faced card | |
| card.mana_cost = scryfallCard.mana_cost || ''; | |
| card.card_type = scryfallCard.type_line || ''; | |
| } | |
| // Parse mana cost | |
| if (card.mana_cost) { | |
| card.colorCost = getColorCost(card.mana_cost); | |
| } else { | |
| // No mana cost (e.g., lands) | |
| card.colorCost = { | |
| w: 0, u: 0, b: 0, r: 0, g: 0, | |
| wu: 0, wb: 0, wr: 0, wg: 0, | |
| ub: 0, ur: 0, ug: 0, | |
| br: 0, bg: 0, | |
| rg: 0, | |
| x: 0, c: 0, t: 0 | |
| }; | |
| } | |
| return card; | |
| } | |
| /** | |
| * Parse deck list text into array of card entries | |
| * | |
| * Format examples: | |
| * - "4 Lightning Bolt" | |
| * - "1x Counterspell" | |
| * - "Sol Ring" | |
| * | |
| * @param {string} deckListText - Raw deck list text | |
| * @returns {Array<{quantity: number, name: string}>} Parsed entries | |
| */ | |
| function parseDeckList(deckListText) { | |
| const lines = deckListText.split('\n').map(line => line.trim()); | |
| const entries = []; | |
| let inSideboard = false; | |
| for (const line of lines) { | |
| // Empty lines reset sideboard state | |
| if (line.length === 0) { | |
| inSideboard = false; | |
| continue; | |
| } | |
| // Check for sideboard marker | |
| if (line.toLowerCase().includes('sideboard')) { | |
| inSideboard = true; | |
| continue; | |
| } | |
| // Skip if in sideboard | |
| if (inSideboard) { | |
| continue; | |
| } | |
| // Skip comments | |
| if (line.startsWith('#') || line.startsWith('//')) { | |
| continue; | |
| } | |
| // Match patterns like "4 Lightning Bolt" or "4x Lightning Bolt" | |
| const match = line.match(/^(\d+)x?\s+(.+)$/); | |
| if (match) { | |
| const quantity = parseInt(match[1], 10); | |
| let name = match[2].trim(); | |
| // Remove set codes like "(STA)" or "[MOM]" | |
| name = name.replace(/\s*[\(\[].*?[\)\]]/g, ''); | |
| // Remove "*F*" foil markers | |
| name = name.replace(/\s*\*F\*/g, ''); | |
| entries.push({ quantity, name }); | |
| } else { | |
| // No quantity specified, assume 1 | |
| let name = line; | |
| name = name.replace(/\s*[\(\[].*?[\)\]]/g, ''); | |
| name = name.replace(/\s*\*F\*/g, ''); | |
| entries.push({ quantity: 1, name }); | |
| } | |
| } | |
| return entries; | |
| } | |
| /** | |
| * Load cards from deck list using Salubrious Snail API (fast) or Scryfall fallback | |
| * | |
| * @param {string} deckListText - Raw deck list text | |
| * @param {Object} options - Options | |
| * @param {Array<string>} options.commanders - Commander names (optional) | |
| * @param {Function} options.onProgress - Progress callback (optional) | |
| * @param {boolean} options.useSnailAPI - Use Snail API (default: true) | |
| * @returns {Promise<Object>} Result with cardDatabase and deckList | |
| */ | |
| async function loadCards(deckListText, options = {}) { | |
| const { commanders = [], onProgress = null, useSnailAPI = true, testDict = null } = options; | |
| // Parse deck list | |
| const entries = parseDeckList(deckListText); | |
| if (entries.length === 0) { | |
| throw new Error('No cards found in deck list'); | |
| } | |
| let cardDatabase = {}; | |
| // Use provided testDict if available | |
| if (testDict && typeof testDict === 'object') { | |
| console.log('Using provided testDict (', Object.keys(testDict).length, 'cards)'); | |
| // Convert testDict to our format | |
| let count = 0; | |
| const names = Object.keys(testDict); | |
| for (const name of names) { | |
| const snailCard = testDict[name]; | |
| // Handle error responses | |
| if (snailCard === 'NOT FOUND' || snailCard === 'FOUND' || !snailCard || typeof snailCard !== 'object') { | |
| continue; | |
| } | |
| cardDatabase[name] = { | |
| name: name, | |
| mana_cost: snailCard.mana_cost || '', | |
| card_type: snailCard.card_type || '', | |
| colorCost: getColorCost(snailCard.mana_cost || ''), | |
| mana_source: snailCard.mana_source, | |
| snail: snailCard | |
| }; | |
| count++; | |
| // Yield to event loop after EVERY card to keep UI responsive | |
| // Use setTimeout(1) to actually release control to browser | |
| await new Promise(resolve => setTimeout(resolve, 1)); | |
| if (onProgress && count % 5 === 0) { | |
| onProgress({ current: count, total: names.length, status: 'Processing deck data...' }); | |
| } | |
| } | |
| if (onProgress) { | |
| onProgress({ current: names.length, total: names.length, status: 'Deck data loaded' }); | |
| } | |
| } | |
| // Fetch from Snail API if testDict not provided or empty | |
| if (Object.keys(cardDatabase).length === 0 && useSnailAPI) { | |
| // Use Snail API - single batch request (FAST!) | |
| console.log('Using Snail API to fetch', entries.length, 'cards'); | |
| if (onProgress) { | |
| onProgress({ current: 0, total: entries.length, status: 'Loading all cards from Snail API...' }); | |
| } | |
| try { | |
| const snailDict = await fetchCardsFromSnailAPI(entries); | |
| console.log('Snail API SUCCESS! Returned:', Object.keys(snailDict).length, 'cards'); | |
| console.log('Sample card:', Object.values(snailDict)[0]); | |
| // Convert Snail API format to our format | |
| let processedCount = 0; | |
| const snailNames = Object.keys(snailDict); | |
| for (const name of snailNames) { | |
| const snailCard = snailDict[name]; | |
| // Handle error responses from Snail API | |
| if (snailCard === 'NOT FOUND' || snailCard === 'FOUND' || !snailCard || typeof snailCard !== 'object') { | |
| console.warn(`Snail API: Card "${name}" not found or invalid`); | |
| continue; | |
| } | |
| cardDatabase[name] = { | |
| name: name, // Name is the key, not in the card object | |
| mana_cost: snailCard.mana_cost || '', | |
| card_type: snailCard.card_type || '', | |
| colorCost: getColorCost(snailCard.mana_cost || ''), | |
| mana_source: snailCard.mana_source, // Keep mana_source for land analysis | |
| snail: snailCard // Keep original Snail data | |
| }; | |
| processedCount++; | |
| // Yield after every card | |
| await new Promise(resolve => setTimeout(resolve, 1)); | |
| if (onProgress && processedCount % 5 === 0) { | |
| onProgress({ current: processedCount, total: snailNames.length, status: 'Converting card data...' }); | |
| } | |
| } | |
| if (onProgress) { | |
| onProgress({ current: entries.length, total: entries.length, status: 'Cards loaded!' }); | |
| } | |
| } catch (error) { | |
| console.warn('Snail API failed, falling back to Scryfall:', error); | |
| // Fall through to Scryfall fallback | |
| cardDatabase = {}; | |
| } | |
| } | |
| // Scryfall fallback if Snail API disabled or failed | |
| if (Object.keys(cardDatabase).length === 0) { | |
| const uniqueNames = [...new Set(entries.map(e => e.name))]; | |
| let fetched = 0; | |
| for (const name of uniqueNames) { | |
| if (onProgress) { | |
| onProgress({ current: fetched + 1, total: uniqueNames.length, cardName: name }); | |
| } | |
| try { | |
| const scryfallCard = await fetchCardFromScryfall(name); | |
| const parsedCard = parseCardData(scryfallCard); | |
| cardDatabase[name] = parsedCard; | |
| } catch (error) { | |
| console.error(`Failed to load ${name}:`, error.message); | |
| // Add placeholder for failed cards | |
| cardDatabase[name] = { | |
| name: name, | |
| mana_cost: '', | |
| card_type: 'FAILED_TO_LOAD', | |
| colorCost: getColorCost(''), | |
| error: error.message | |
| }; | |
| } | |
| // Rate limiting | |
| await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_MS)); | |
| fetched++; | |
| } | |
| } | |
| // Build deck list with quantities | |
| const deckList = []; | |
| const commanderCards = []; | |
| const basicLands = { w: 0, u: 0, b: 0, r: 0, g: 0, c: 0 }; // Track original basics | |
| let builtCount = 0; | |
| // Basic lands that Snail API doesn't return - we track these separately | |
| const basicLandInfo = { | |
| 'Plains': { color: 'w', mana_source: { w: true, u: false, b: false, r: false, g: false, fetch: false, cycling: false, cond: false, choose: false, fast: true, prod: 1, ramp: 0 } }, | |
| 'Island': { color: 'u', mana_source: { w: false, u: true, b: false, r: false, g: false, fetch: false, cycling: false, cond: false, choose: false, fast: true, prod: 1, ramp: 0 } }, | |
| 'Swamp': { color: 'b', mana_source: { w: false, u: false, b: true, r: false, g: false, fetch: false, cycling: false, cond: false, choose: false, fast: true, prod: 1, ramp: 0 } }, | |
| 'Mountain': { color: 'r', mana_source: { w: false, u: false, b: false, r: true, g: false, fetch: false, cycling: false, cond: false, choose: false, fast: true, prod: 1, ramp: 0 } }, | |
| 'Forest': { color: 'g', mana_source: { w: false, u: false, b: false, r: false, g: true, fetch: false, cycling: false, cond: false, choose: false, fast: true, prod: 1, ramp: 0 } }, | |
| 'Wastes': { color: 'c', mana_source: { w: false, u: false, b: false, r: false, g: false, fetch: false, cycling: false, cond: false, choose: false, fast: true, prod: 1, ramp: 0 } } | |
| }; | |
| for (const entry of entries) { | |
| // Check if this is a basic land FIRST (before looking in cardDatabase) | |
| if (basicLandInfo[entry.name]) { | |
| const basicInfo = basicLandInfo[entry.name]; | |
| basicLands[basicInfo.color] += entry.quantity; | |
| // Skip adding to deckList - basics are handled separately | |
| continue; | |
| } | |
| let card = cardDatabase[entry.name]; | |
| if (!card) continue; | |
| // Check if this is a commander | |
| const isCommander = commanders.some(cmdr => | |
| card.name.toLowerCase().includes(cmdr.toLowerCase()) | |
| ); | |
| // Check for common discounts | |
| const discount = commonDiscounts[card.name] || 0; | |
| // IMPORTANT: Do NOT auto-set choose:true! | |
| // The website's database has choose:false for cards like Arcane Signet | |
| // Even though they produce all 5 colors, they go through "typical sources" branch | |
| // and get added to index[31] WITHOUT masking | |
| // | |
| // NOTE: Originally we set choose=true for WUBRG sources | |
| // But testing shows the website treats them as choose=false | |
| // Keep them as-is from Snail API | |
| if (isCommander) { | |
| commanderCards.push({ | |
| ...card, | |
| count: entry.quantity, | |
| discount: discount | |
| }); | |
| } else { | |
| deckList.push({ | |
| ...card, | |
| count: entry.quantity, | |
| discount: discount | |
| }); | |
| } | |
| builtCount++; | |
| // Yield every 5 entries | |
| if (builtCount % 5 === 0) { | |
| await new Promise(resolve => setTimeout(resolve, 1)); | |
| } | |
| } | |
| return { | |
| cardDatabase, | |
| deckList, // Does NOT include basics | |
| commanders: commanderCards, | |
| basicLands // Original basic land counts from deck | |
| }; | |
| } | |
| /** | |
| * Color Calculator Core Module | |
| * | |
| * Core probability calculation functions extracted from Salubrious Snail Color Analyzer. | |
| * These functions implement the mathematical/probabilistic algorithm for calculating | |
| * cast rates and average delays. | |
| * | |
| * Source: https://tappedout.net/mtg-deck-builder/mana-calculator/ | |
| * Extracted: 2026-01-26 | |
| */ | |
| // ============================================================================ | |
| // MULLIGAN CALCULATION | |
| // ============================================================================ | |
| /** | |
| * Calculate mulligan distribution using Frank Karsten's policy | |
| * | |
| * Implements mulligan strategy from Karsten's 2022 "How many sources?" article: | |
| * - EDH (90+ cards): Mulligan 0-2 or 6-7 lands | |
| * - 60-card: Mulligan 0-1 or 6-7 lands | |
| * - Uses London mulligan (bottom cards after drawing) | |
| * | |
| * @param {number} deckSize - Total cards in deck | |
| * @param {number} landCount - Total lands in deck | |
| * @returns {number[]} Probability distribution of lands in opening hand (length 8) | |
| */ | |
| function calcMullLands(deckSize, landCount) { | |
| let myLandCount = Math.round(landCount); | |
| let openingDist = new Array(8); | |
| let mullDist = new Array(8); | |
| let mullLands = new Array(8).fill(0); | |
| let mullAmount = 0; | |
| // Distribution for a random 7-card hand | |
| for (let i = 0; i <= 7; i++) { | |
| openingDist[i] = drawType(deckSize, myLandCount, 7, i); | |
| mullDist[i] = openingDist[i]; | |
| } | |
| // Free mulligan - shoot for at least three if deck is a commander deck | |
| if (deckSize >= 90) { | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[2] + mullDist[6] + mullDist[7]; | |
| for (let i = 3; i < 6; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| } else { | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[6] + mullDist[7]; | |
| for (let i = 2; i < 6; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| } | |
| mullDist = new Array(8).fill(0); | |
| for (let i = 0; i <= 7; i++) { | |
| mullDist[i] += openingDist[i] * mullAmount; | |
| } | |
| // Second mulligan - accept 2 if needed | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[6] + mullDist[7]; | |
| for (let i = 2; i < 6; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| mullDist = new Array(8).fill(0); | |
| // Shoot for 3 lands with our discards (London mulligan) | |
| for (let i = 0; i <= 3; i++) { | |
| mullDist[i] += openingDist[i] * mullAmount; | |
| } | |
| for (let i = 4; i <= 7; i++) { | |
| mullDist[i - 1] += openingDist[i] * mullAmount; | |
| } | |
| // After we discard to 6, the 6 and 7 land hands are now 5 and 6 land hands | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[5] + mullDist[6]; | |
| for (let i = 2; i < 5; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| mullDist = new Array(8).fill(0); | |
| // Discard 2, once again shooting for 3 lands | |
| for (let i = 0; i <= 3; i++) { | |
| mullDist[i] += openingDist[i] * mullAmount; | |
| } | |
| mullDist[3] += openingDist[4] * mullAmount; | |
| for (let i = 5; i <= 7; i++) { | |
| mullDist[i - 2] += openingDist[i] * mullAmount; | |
| } | |
| // After we discard to 5, we'll settle for at least one spell | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[5]; | |
| for (let i = 2; i < 5; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| mullDist = new Array(8).fill(0); | |
| // Discard to 4, 3 lands again | |
| for (let i = 0; i <= 3; i++) { | |
| mullDist[i] += openingDist[i] * mullAmount; | |
| } | |
| for (let i = 4; i <= 6; i++) { | |
| mullDist[3] += openingDist[i] * mullAmount; | |
| } | |
| mullDist[4] += openingDist[7] * mullAmount; | |
| for (let i = 0; i < 7; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| return mullLands; | |
| } | |
| // ============================================================================ | |
| // LAND DISTRIBUTION BY TURN | |
| // ============================================================================ | |
| /** | |
| * Find distribution of lands drawn by a given turn, factoring in mulligans | |
| * | |
| * Convolves mulligan distribution with draw distribution. | |
| * | |
| * @param {number} turn - Turn number | |
| * @param {number[]} mullLands - Mulligan distribution from calcMullLands() | |
| * @param {number} deckSize - Total cards in deck | |
| * @param {number} landCount - Total lands in deck | |
| * @returns {number[]} Probability of having N lands on this turn (length 13) | |
| */ | |
| function landsAtTurn(turn, mullLands, deckSize, landCount) { | |
| let myLandCount = Math.round(landCount); | |
| let landDist = new Array(13).fill(0); | |
| for (let startLands = 0; startLands < 7; startLands++) { | |
| for (let drawnLands = 0; drawnLands <= turn; drawnLands++) { | |
| landDist[Math.min(startLands + drawnLands, 12)] += | |
| mullLands[startLands] * drawType(deckSize - 7, myLandCount - startLands, turn, drawnLands); | |
| } | |
| } | |
| return landDist; | |
| } | |
| // ============================================================================ | |
| // COMBO REQUIREMENTS CALCULATION | |
| // ============================================================================ | |
| /** | |
| * Calculate requirements for each color combination | |
| * | |
| * For a card costing {1}{U}{W}, this calculates: | |
| * - 1 blue source required | |
| * - 1 white source required | |
| * - 2 sources that can produce either blue or white | |
| * | |
| * Uses bitwise operations to encode all 32 color combinations. | |
| * | |
| * @param {Object} cost - Color cost object from getColorCost() | |
| * @returns {number[]} Requirements array (length 32) | |
| */ | |
| function getComboReqs(cost) { | |
| let comboReqs = new Array(32).fill(0); | |
| comboReqs[1] = cost.w; | |
| for (let i = 2; i < 4; i++) { | |
| comboReqs[i] = comboReqs[i - 2] + cost.u + cost.wu * Math.sign(i & 1); | |
| } | |
| for (let i = 4; i < 8; i++) { | |
| comboReqs[i] = comboReqs[i - 4] + cost.b + cost.wb * Math.sign(i & 1) + cost.ub * Math.sign(i & 2); | |
| } | |
| for (let i = 8; i < 16; i++) { | |
| comboReqs[i] = comboReqs[i - 8] + cost.r + cost.wr * Math.sign(i & 1) + cost.ur * Math.sign(i & 2) + cost.br * Math.sign(i & 4); | |
| } | |
| for (let i = 16; i < 32; i++) { | |
| comboReqs[i] = comboReqs[i - 16] + cost.g + cost.wg * Math.sign(i & 1) + cost.ug * Math.sign(i & 2) + cost.bg * Math.sign(i & 4) + cost.rg * Math.sign(i & 8); | |
| } | |
| return comboReqs; | |
| } | |
| // ============================================================================ | |
| // CORE RECURSIVE ALGORITHM | |
| // ============================================================================ | |
| /** | |
| * "This is The Algorithm right here" - Recursive land testing | |
| * | |
| * Recursively tests different combinations of lands to calculate exact probability | |
| * of being able to cast a spell given N lands in play. | |
| * | |
| * Moves through the list of possible land types. If one is present in the deck, | |
| * calculates the different possibilities for how many are drawn, then recursively | |
| * tests with the remaining lands. | |
| * | |
| * @param {Object} cost - Color cost object | |
| * @param {number[]} myLandTypes - Land type array (length 32) | |
| * @param {number} numLands - Number of lands drawn | |
| * @param {number} myLandCount - Total land count | |
| * @param {number} i - Current index in land types (31 down to 0) | |
| * @param {number} scale - Probability scale factor | |
| * @param {number[]} comboReqs - Color requirements array (modified in place) | |
| * @returns {number} Probability of being able to cast | |
| */ | |
| function rLandTest(cost, myLandTypes, numLands, myLandCount, i, scale, comboReqs) { | |
| // Check if mana requirements are already satisfied | |
| if (Math.max(...comboReqs) <= 0) { | |
| return 1; | |
| } | |
| // Check if mana requirements are unreachable | |
| if (Math.max(...comboReqs) > numLands) { | |
| return 0; | |
| } | |
| let success = 0.0; | |
| // Scan forward through the land categories to find one that's in the deck | |
| while (i > 0 && myLandTypes[i] == 0) { | |
| i--; | |
| } | |
| // Check point of no return for each color | |
| if (i < 16 && comboReqs[16] > 0) { | |
| return 0; // Green | |
| } | |
| if (i < 8 && comboReqs[8] > 0) { | |
| return 0; // Red | |
| } | |
| if (i < 4 && comboReqs[4] > 0) { | |
| return 0; // Black | |
| } | |
| if (i < 2 && comboReqs[2] > 0) { | |
| return 0; // Blue | |
| } | |
| // Run through the different numbers of this category of land that can be drawn | |
| for (let n = 0; n <= numLands && n <= myLandTypes[i]; n++) { | |
| let p = drawType(myLandCount, myLandTypes[i], numLands, n); | |
| if (p > 0) { | |
| let result = rLandTest( | |
| cost, | |
| myLandTypes, | |
| numLands - n, | |
| myLandCount - myLandTypes[i], | |
| i - 1, | |
| p * scale, | |
| comboReqs.slice() | |
| ); | |
| success += p * result; | |
| } | |
| // Decrement color requirements based on what this land produces | |
| for (let j = 0; j < 32; j++) { | |
| comboReqs[j] -= Math.sign(j & i); | |
| } | |
| } | |
| return success; | |
| } | |
| /** | |
| * Approximation version of rLandTest using uniform sampling | |
| * | |
| * Like rLandTest, but divides the probability space over a number line of a given length. | |
| * Instead of counting exact proportions, it counts "ticks" on the line within slices. | |
| * | |
| * Gives near-perfect estimations for probabilities well above the "resolution", | |
| * and functions like random sampling for probabilities below it. | |
| * | |
| * @param {Object} cost - Color cost object | |
| * @param {number[]} myLandTypes - Land type array | |
| * @param {number} numLands - Number of lands drawn | |
| * @param {number} myLandCount - Total land count | |
| * @param {number} i - Current index | |
| * @param {number} min - Minimum sample range | |
| * @param {number} max - Maximum sample range | |
| * @param {number[]} comboReqs - Color requirements | |
| * @returns {number} Sample count (not probability) | |
| */ | |
| function landTestUniformSample(cost, myLandTypes, numLands, myLandCount, i, min, max, comboReqs) { | |
| if (Math.floor(max) == Math.floor(min)) { | |
| return 0; // No ticks in range | |
| } | |
| if (Math.max(...comboReqs) <= 0) { | |
| return Math.floor(max) - Math.floor(min); | |
| } | |
| if (Math.max(...comboReqs) > numLands) { | |
| return 0; | |
| } | |
| while (i > 0 && myLandTypes[i] == 0) { | |
| i--; | |
| } | |
| if (i < 16 && comboReqs[16] > 0) return 0; | |
| if (i < 8 && comboReqs[8] > 0) return 0; | |
| if (i < 4 && comboReqs[4] > 0) return 0; | |
| if (i < 2 && comboReqs[2] > 0) return 0; | |
| let success = 0; | |
| let span = max - min; | |
| let newMin = min; | |
| let newMax = min; | |
| for (let n = 0; n <= numLands && n <= myLandTypes[i]; n++) { | |
| let p = drawType(myLandCount, myLandTypes[i], numLands, n); | |
| if (p > 0) { | |
| newMin = newMax; | |
| newMax += p * span; | |
| success += landTestUniformSample( | |
| cost, | |
| myLandTypes, | |
| numLands - n, | |
| myLandCount - myLandTypes[i], | |
| i - 1, | |
| newMin, | |
| newMax, | |
| comboReqs.slice() | |
| ); | |
| } | |
| for (let j = 0; j < 32; j++) { | |
| comboReqs[j] -= Math.sign(j & i); | |
| } | |
| } | |
| return success; | |
| } | |
| // ============================================================================ | |
| // PIP DISTRIBUTION CALCULATION | |
| // ============================================================================ | |
| /** | |
| * Calculate probability distribution of casting a spell with N lands | |
| * | |
| * For each N lands (0-12), calculates the probability of being able to cast | |
| * the spell given the deck's land configuration. | |
| * | |
| * Uses exact calculation (rLandTest) for < 5 colors, approximation for 5+ colors. | |
| * | |
| * @param {Object} cost - Color requirements from colorReqs() | |
| * @param {number[]} landTypes - Land type array (length 32) | |
| * @param {number} landCount - Total lands | |
| * @param {Object} options - Configuration options | |
| * @returns {number[]} Probability with 0-12 lands (length 13) | |
| */ | |
| function pipDist(cost, landTypes, landCount, options = {}) { | |
| const approxColors = options.approxColors || 5; | |
| const approxSamples = options.approxSamples || 100000; | |
| let canCast = new Array(13); | |
| let comboReqs = getComboReqs(cost); | |
| // Use exact computation for < 5 colors, approximation for 5+ colors | |
| if (numColors(cost) < approxColors) { | |
| // Exact mode | |
| for (let n = 0; n <= 12; n++) { | |
| let roundedLands = roundLands(landFilter(landTypes, cost)); | |
| let roundedLandCount = sum(roundedLands); | |
| let result = rLandTest(cost, roundedLands, n, roundedLandCount, 31, 1, [...comboReqs]); | |
| canCast[n] = result; | |
| } | |
| } else { | |
| // Approximation mode (5+ colors) | |
| for (let n = 0; n <= 12; n++) { | |
| let roundedLands = roundLands(landFilter(landTypes, cost)); | |
| let roundedLandCount = sum(roundedLands); | |
| let result = landTestUniformSample(cost, roundedLands, n, roundedLandCount, 31, 0, approxSamples, [...comboReqs]); | |
| canCast[n] = result / approxSamples; | |
| } | |
| } | |
| return canCast; | |
| } | |
| /** | |
| * Calculate the equivalent land types for a fetch source | |
| * Based on website's getFetchEquivalent function | |
| */ | |
| function getFetchEquivalent(source, deckColors, deckColorCounts) { | |
| const myLandTypes = new Array(32).fill(0); | |
| // Calculate landIndex from source, masked to deck colors | |
| let landIndex = (source.w ? 1 : 0) | | |
| (source.u ? 2 : 0) | | |
| (source.b ? 4 : 0) | | |
| (source.r ? 8 : 0) | | |
| (source.g ? 16 : 0); | |
| landIndex = landIndex & deckColors; | |
| // Handle basic land fetches (like Land Tax, basic fetch lands) | |
| if (source.fetch === "b") { | |
| // Add 50% to the combined color index | |
| myLandTypes[landIndex] += 0.5; | |
| // Count how many colors this fetch can get | |
| const colors = Math.sign(landIndex & 1) + Math.sign(landIndex & 2) + | |
| Math.sign(landIndex & 4) + Math.sign(landIndex & 8) + | |
| Math.sign(landIndex & 16); | |
| // Split remaining 50% among individual colors (if that color is in deck) | |
| if (source.w && deckColorCounts.white > 0) { | |
| myLandTypes[1] += 0.5 / colors; | |
| } | |
| if (source.u && deckColorCounts.blue > 0) { | |
| myLandTypes[2] += 0.5 / colors; | |
| } | |
| if (source.b && deckColorCounts.black > 0) { | |
| myLandTypes[4] += 0.5 / colors; | |
| } | |
| if (source.r && deckColorCounts.red > 0) { | |
| myLandTypes[8] += 0.5 / colors; | |
| } | |
| if (source.g && deckColorCounts.green > 0) { | |
| myLandTypes[16] += 0.5 / colors; | |
| } | |
| } | |
| // Handle nonbasic fetches (like Marsh Flats, Polluted Delta) | |
| else if (source.fetch === "nb") { | |
| // TODO: Implement nonbasic fetch logic | |
| // Requires tracking which land types are fetchable (plainsTypes, islandTypes, etc.) | |
| // For now, treat like basic fetch | |
| myLandTypes[landIndex] += 1.0; | |
| } | |
| return myLandTypes; | |
| } | |
| /** | |
| * Iterative Color Calculator (Website Algorithm) | |
| * | |
| * Implements the website's deepAnal() algorithm: | |
| * 1. Calculate mana from lands | |
| * 2. Scan 1-drops for mana sources → update landTypes | |
| * 3. Scan 2-drops for mana sources → update landTypes | |
| * 4. Scan 3-drops and 4-drops | |
| * 5. Calculate all remaining spells with full ramp included | |
| * | |
| * This matches the website's behavior exactly. | |
| */ | |
| /** | |
| * Scan cards of a given CMC for mana sources and add them to landTypes. | |
| * | |
| * @param {Array} deckList - All cards in deck | |
| * @param {number} cmc - CMC to scan for | |
| * @param {number[]} landTypes - Land types array (mutated) | |
| * @param {number} landCount - Current land count | |
| * @param {number} deckSize - Deck size | |
| * @param {Object} options - Configuration options | |
| * @returns {number} Updated land count | |
| */ | |
| function scanForSources(deckList, cmc, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict, options) { | |
| let newLandCount = landCount; | |
| // Calculate mulligan distribution with CURRENT land count | |
| const mullLands = calcMullLands(deckSize, landCount); | |
| // Helper to get landsToCast using pre-calculated dicts | |
| const landsToCast = (colorCost, discount) => { | |
| const effectiveCMC = colorCost.t - discount; | |
| const pips = colorReqs(colorCost); | |
| const pipCode = pipsToNum(pips); | |
| let dict; | |
| if (effectiveCMC === 1) dict = oneDropDict; | |
| else if (effectiveCMC === 2) dict = twoDropDict; | |
| else if (effectiveCMC === 3) dict = threeDropDict; | |
| else if (effectiveCMC === 4) dict = fourDropDict; | |
| else return null; // CMC 5+ not yet calculated when scanning 1-4 | |
| const pipReqs = dict.get(pipCode); | |
| if (!pipReqs) return null; | |
| // Clone and zero out impossible turns | |
| const result = pipReqs.slice(); | |
| for (let i = 0; i < effectiveCMC; i++) { | |
| result[i] = 0; | |
| } | |
| return result; | |
| }; | |
| // Helper to calculate turnsToCast using pre-calculated pip requirements | |
| const turnsToCast = (colorCost, discount) => { | |
| const landReqs = landsToCast(colorCost, discount); | |
| if (!landReqs) return null; | |
| const effectiveCMC = colorCost.t - discount; | |
| const canCast = new Array(16); | |
| for (let i = 0; i < effectiveCMC; i++) { | |
| canCast[i] = 0; | |
| } | |
| for (let i = effectiveCMC; i <= 15; i++) { | |
| let pCast = 0; | |
| const landDist = landsAtTurn(i, mullLands, deckSize, landCount); | |
| for (let lands = effectiveCMC; lands <= 12; lands++) { | |
| pCast += landReqs[lands] * landDist[lands]; | |
| } | |
| canCast[i] = pCast; | |
| } | |
| const pdf = new Array(16); | |
| pdf[0] = canCast[0]; | |
| for (let i = 1; i < 15; i++) { | |
| pdf[i] = canCast[i] - canCast[i - 1]; | |
| } | |
| pdf[15] = 1 - canCast[14]; | |
| return pdf; | |
| }; | |
| for (const card of deckList) { | |
| // Skip if wrong CMC | |
| const effectiveCMC = card.colorCost.t - (card.discount || 0); | |
| if (effectiveCMC !== cmc) continue; | |
| // Skip MDFCs with Land (website logic: skip if has "//" AND "Land") | |
| if (card.card_type && card.card_type.includes("//") && card.card_type.includes("Land")) { | |
| continue; | |
| } | |
| // Check if card has mana_source | |
| if (!card.mana_source) continue; | |
| const count = card.count || 1; | |
| let typeMult = 1; | |
| // Penalty for fragile permanent types | |
| if (card.card_type && card.card_type.includes("Artifact")) { | |
| typeMult = 0.75; | |
| } | |
| if (card.card_type && card.card_type.includes("Creature")) { | |
| typeMult = 0.5; | |
| } | |
| // Get probability of casting this card on turn X using pre-calculated distributions | |
| const turnDist = turnsToCast(card.colorCost, card.discount || 0); | |
| if (turnDist) { | |
| // Weight by probability of having cast it by this turn | |
| typeMult *= turnDist[cmc] || 0; | |
| } else { | |
| // If no turn distribution calculated, skip this card | |
| continue; | |
| } | |
| // Get land index from mana_source | |
| let landIndex = findLandIndex(card.mana_source); | |
| // Handle fetch sources (like Land Tax, fetch lands) | |
| if (card.mana_source.fetch) { | |
| const fetchLandTypes = getFetchEquivalent(card.mana_source, deckColors, deckColorCounts); | |
| for (let j = 0; j < 32; j++) { | |
| landTypes[j] += fetchLandTypes[j] * count * typeMult; | |
| } | |
| newLandCount += count * typeMult; | |
| console.log(` Adding fetch source: ${card.name}, typeMult=${typeMult.toFixed(4)}, addition=${(count * typeMult).toFixed(4)}`); | |
| continue; | |
| } | |
| // Handle "choose a color" sources (like Arcane Signet, Fellwar Stone) | |
| else if (card.mana_source.choose) { | |
| landIndex = landIndex & deckColors; // Mask to deck colors | |
| const chooseIndex = deckColors & (~landIndex); | |
| const numColors = Math.sign(chooseIndex & 1) + Math.sign(chooseIndex & 2) + | |
| Math.sign(chooseIndex & 4) + Math.sign(chooseIndex & 8) + | |
| Math.sign(chooseIndex & 16); | |
| // Add 50% to combination of all choosable colors | |
| landTypes[chooseIndex | landIndex] += 0.5 * typeMult * count; | |
| // Split remaining 50% among individual colors | |
| for (let j = 1; j < 32; j *= 2) { | |
| if (j & chooseIndex) { | |
| landTypes[j | landIndex] += (1 / (2 * numColors)) * typeMult * count; | |
| } | |
| } | |
| newLandCount += count * typeMult; | |
| console.log(` Adding ${card.name} (choose): typeMult=${typeMult.toFixed(4)}, addition=${(count * typeMult).toFixed(4)}`); | |
| } | |
| // Handle typical mana sources | |
| else { | |
| // IMPORTANT: Do NOT mask landIndex here! | |
| // The website does NOT mask in the "typical sources" branch | |
| // So WUBRG sources like Arcane Signet (with choose:false) get added to index[31] | |
| // The website uses the 'amt' field, which the Snail API doesn't provide | |
| // Only cards with landIndex != 0 (i.e., producing colored mana) get added | |
| // This means Sol Ring (landIndex=0) is NOT counted as ramp! | |
| const amt = card.mana_source.amt; // undefined from Snail API | |
| if (landIndex !== null && (landIndex !== 0 || amt > 0)) { | |
| const addition = count * typeMult; | |
| if (addition > 0.01) { | |
| console.log(` Adding ${card.name}: landIndex=${landIndex}, typeMult=${typeMult.toFixed(4)}, addition=${addition.toFixed(4)}`); | |
| } | |
| landTypes[landIndex] += addition; | |
| newLandCount += addition; | |
| } | |
| } | |
| // TODO: Handle fetches, "choose a color", etc. | |
| } | |
| return newLandCount; | |
| } | |
| /** | |
| * Convert mana_source object to land type index (0-31). | |
| * | |
| * @param {Object} manaSource - mana_source from card data | |
| * @returns {number|null} Land type index or null | |
| */ | |
| function findLandIndex(manaSource) { | |
| if (!manaSource || typeof manaSource !== 'object') return null; | |
| let index = 0; | |
| // Build index from color flags (bits) - lowercase like website | |
| if (manaSource.w) index |= 1; // 0b00001 | |
| if (manaSource.u) index |= 2; // 0b00010 | |
| if (manaSource.b) index |= 4; // 0b00100 | |
| if (manaSource.r) index |= 8; // 0b01000 | |
| if (manaSource.g) index |= 16; // 0b10000 | |
| return index; | |
| } | |
| /** | |
| * Calculate deck metrics using iterative mana source scanning. | |
| * | |
| * @param {Array} deckList - Non-land, non-commander cards | |
| * @param {Array} commanders - Commander cards | |
| * @param {number[]} initialLandTypes - Starting land types (from basics) | |
| * @param {number} initialLandCount - Starting land count | |
| * @param {number} deckSize - Total deck size | |
| * @param {Object} options - Configuration options | |
| * @returns {Promise<Object>} {castRate, avgDelay, cardMetrics} | |
| */ | |
| async function calculateDeckMetricsIterative(deckList, commanders, initialLandTypes, initialLandCount, deckSize, options = {}) { | |
| // Start with just lands | |
| let landTypes = [...initialLandTypes]; | |
| let landCount = initialLandCount; | |
| // Calculate deck colors bitmask and color counts | |
| let deckColors = 0; | |
| const deckColorCounts = { white: 0, blue: 0, black: 0, red: 0, green: 0 }; | |
| const countPips = (colorCost) => { | |
| return (colorCost.w || 0) + (colorCost.u || 0) + (colorCost.b || 0) + | |
| (colorCost.r || 0) + (colorCost.g || 0); | |
| }; | |
| for (const card of deckList) { | |
| if (countPips(card.colorCost) > 0) { | |
| if (card.colorCost.w > 0) { deckColors |= 1; deckColorCounts.white += card.colorCost.w; } | |
| if (card.colorCost.u > 0) { deckColors |= 2; deckColorCounts.blue += card.colorCost.u; } | |
| if (card.colorCost.b > 0) { deckColors |= 4; deckColorCounts.black += card.colorCost.b; } | |
| if (card.colorCost.r > 0) { deckColors |= 8; deckColorCounts.red += card.colorCost.r; } | |
| if (card.colorCost.g > 0) { deckColors |= 16; deckColorCounts.green += card.colorCost.g; } | |
| } | |
| } | |
| for (const cmd of commanders) { | |
| if (countPips(cmd.colorCost) > 0) { | |
| if (cmd.colorCost.w > 0) { deckColors |= 1; deckColorCounts.white += cmd.colorCost.w; } | |
| if (cmd.colorCost.u > 0) { deckColors |= 2; deckColorCounts.blue += cmd.colorCost.u; } | |
| if (cmd.colorCost.b > 0) { deckColors |= 4; deckColorCounts.black += cmd.colorCost.b; } | |
| if (cmd.colorCost.r > 0) { deckColors |= 8; deckColorCounts.red += cmd.colorCost.r; } | |
| if (cmd.colorCost.g > 0) { deckColors |= 16; deckColorCounts.green += cmd.colorCost.g; } | |
| } | |
| } | |
| console.log('Deck colors bitmask:', deckColors.toString(2).padStart(5, '0'), '=', { | |
| W: !!(deckColors & 1), | |
| U: !!(deckColors & 2), | |
| B: !!(deckColors & 4), | |
| R: !!(deckColors & 8), | |
| G: !!(deckColors & 16) | |
| }); | |
| // Maps to store cast distributions by CMC | |
| const oneDropDict = new Map(); | |
| const twoDropDict = new Map(); | |
| const threeDropDict = new Map(); | |
| const fourDropDict = new Map(); | |
| const manaDict = new Map(); | |
| // Helper to calculate for a given CMC with current landTypes and landCount | |
| // IMPORTANT: Uses current landCount to recalculate mullLands each time (like website does) | |
| const calculateForCMC = (cmc, dict) => { | |
| calcMullLands(deckSize, landCount); // Recalculate with current land count! | |
| for (const card of deckList) { | |
| const effectiveCMC = card.colorCost.t - (card.discount || 0); | |
| if (effectiveCMC !== cmc) continue; | |
| const pips = colorReqs(card.colorCost); | |
| const pipCode = pipsToNum(pips); | |
| if (!dict.has(pipCode)) { | |
| const castDistro = pipDist(pips, landTypes, landCount, options); | |
| dict.set(pipCode, castDistro.slice()); // Clone array | |
| } | |
| } | |
| // Also check commanders for this CMC | |
| for (const cmd of commanders) { | |
| const effectiveCMC = cmd.colorCost.t - (cmd.discount || 0); | |
| if (effectiveCMC !== cmc) continue; | |
| const pips = colorReqs(cmd.colorCost); | |
| const pipCode = pipsToNum(pips); | |
| if (!dict.has(pipCode)) { | |
| const castDistro = pipDist(pips, landTypes, landCount, options); | |
| dict.set(pipCode, castDistro.slice()); // Clone array | |
| } | |
| } | |
| }; | |
| // Pass 1: Calculate 1-drops with just lands | |
| console.log('\n=== Pass 1: CMC 1 (lands only) ==='); | |
| console.log('Before: landCount =', landCount); | |
| console.log('Before: landTypes[0]=%d, [4]=%d, [8]=%d, [12]=%d', landTypes[0], landTypes[4], landTypes[8], landTypes[12]); | |
| calculateForCMC(1, oneDropDict); | |
| landCount = scanForSources(deckList, 1, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict); | |
| console.log('After: landCount =', landCount); | |
| console.log('After: landTypes[0]=%d, [4]=%d, [8]=%d, [12]=%d', landTypes[0], landTypes[4], landTypes[8], landTypes[12]); | |
| // Pass 2: Calculate 2-drops with lands + 1-drop ramp | |
| console.log('\n=== Pass 2: CMC 2 (lands + 1-drop ramp) ==='); | |
| console.log('Before: landCount =', landCount); | |
| console.log('Before: landTypes[0]=%d, [4]=%d, [8]=%d, [12]=%d', landTypes[0], landTypes[4], landTypes[8], landTypes[12]); | |
| calculateForCMC(2, twoDropDict); | |
| landCount = scanForSources(deckList, 2, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict); | |
| console.log('After: landCount =', landCount); | |
| console.log('After: landTypes[0]=%d, [4]=%d, [8]=%d, [12]=%d', landTypes[0], landTypes[4], landTypes[8], landTypes[12]); | |
| // Pass 3: Calculate 3-drops with lands + 1-2 drop ramp | |
| console.log('\n=== Pass 3: CMC 3 (lands + 1-2 drop ramp) ==='); | |
| console.log('Before: landCount =', landCount); | |
| calculateForCMC(3, threeDropDict); | |
| landCount = scanForSources(deckList, 3, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict); | |
| console.log('After: landCount =', landCount); | |
| // Pass 4: Calculate 4-drops with lands + 1-3 drop ramp | |
| console.log('\n=== Pass 4: CMC 4 (lands + 1-3 drop ramp) ==='); | |
| console.log('Before: landCount =', landCount); | |
| calculateForCMC(4, fourDropDict); | |
| landCount = scanForSources(deckList, 4, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict); | |
| console.log('After: landCount =', landCount); | |
| console.log('\n=== Final land count: ' + landCount + ' ==='); | |
| console.log('Final landTypes[0]=%d, [4]=%d, [8]=%d, [12]=%d', landTypes[0], landTypes[4], landTypes[8], landTypes[12]); | |
| // Pass 5: Calculate manaDict for ALL cards (not just CMC 5+) | |
| // Website populates manaDict for everything, even though CMC 1-4 will use their specific dicts | |
| const mullLands = calcMullLands(deckSize, landCount); | |
| console.log('Final mullLands:', mullLands.map(x => x.toFixed(6)).join(', ')); | |
| for (const card of deckList) { | |
| const pips = colorReqs(card.colorCost); | |
| const pipCode = pipsToNum(pips); | |
| if (!manaDict.has(pipCode)) { | |
| const castDistro = pipDist(pips, landTypes, landCount, options); | |
| manaDict.set(pipCode, castDistro.slice()); // Clone array | |
| } | |
| } | |
| // Also calculate for commanders and add to appropriate dict based on CMC | |
| for (const cmd of commanders) { | |
| const effectiveCMC = cmd.colorCost.t - (cmd.discount || 0); | |
| const pips = colorReqs(cmd.colorCost); | |
| const pipCode = pipsToNum(pips); | |
| // Website adds commanders to their specific dict based on CMC | |
| if (effectiveCMC === 1 && !oneDropDict.has(pipCode)) { | |
| const castDistro = pipDist(pips, landTypes, landCount, options); | |
| oneDropDict.set(pipCode, castDistro.slice()); | |
| } else if (effectiveCMC === 2 && !twoDropDict.has(pipCode)) { | |
| const castDistro = pipDist(pips, landTypes, landCount, options); | |
| twoDropDict.set(pipCode, castDistro.slice()); | |
| } else if (effectiveCMC === 3 && !threeDropDict.has(pipCode)) { | |
| const castDistro = pipDist(pips, landTypes, landCount, options); | |
| threeDropDict.set(pipCode, castDistro.slice()); | |
| } else if (effectiveCMC === 4 && !fourDropDict.has(pipCode)) { | |
| const castDistro = pipDist(pips, landTypes, landCount, options); | |
| fourDropDict.set(pipCode, castDistro.slice()); | |
| } else if (!manaDict.has(pipCode)) { | |
| const castDistro = pipDist(pips, landTypes, landCount, options); | |
| manaDict.set(pipCode, castDistro.slice()); | |
| } | |
| } | |
| // Now aggregate results (same as website) | |
| let cmcOnCurve = 0; | |
| let totalCmcDelay = 0; | |
| let totalCmc = 0; | |
| const cardMetrics = []; | |
| const costsCovered = new Set(); | |
| // Helper to get the right dictionary for a CMC | |
| const getDict = (cmc) => { | |
| if (cmc === 1) return oneDropDict; | |
| if (cmc === 2) return twoDropDict; | |
| if (cmc === 3) return threeDropDict; | |
| if (cmc === 4) return fourDropDict; | |
| return manaDict; | |
| }; | |
| // Helper to get pip requirements from dict (like website's landsToCast) | |
| const landsToCast = (colorCost, discount) => { | |
| const effectiveCMC = colorCost.t - discount; | |
| const pips = colorReqs(colorCost); | |
| const pipCode = pipsToNum(pips); | |
| const dict = getDict(effectiveCMC); | |
| const pipReqs = dict.get(pipCode); | |
| if (!pipReqs) return null; | |
| // Clone and zero out impossible turns (before CMC) | |
| const result = pipReqs.slice(); | |
| for (let i = 0; i < effectiveCMC; i++) { | |
| result[i] = 0; | |
| } | |
| return result; | |
| }; | |
| // Helper to calculate turn distribution using pre-calculated pip requirements | |
| const turnsToCast = (colorCost, discount, mullLands) => { | |
| const landReqs = landsToCast(colorCost, discount); | |
| if (!landReqs) return null; | |
| const effectiveCMC = colorCost.t - discount; | |
| const canCast = new Array(16); | |
| // Initialize impossible turns | |
| for (let i = 0; i < effectiveCMC; i++) { | |
| canCast[i] = 0; | |
| } | |
| // Calculate cumulative probability for each turn | |
| for (let i = effectiveCMC; i <= 15; i++) { | |
| let pCast = 0; | |
| const landDist = landsAtTurn(i, mullLands, deckSize, landCount); | |
| for (let lands = effectiveCMC; lands <= 12; lands++) { | |
| pCast += landReqs[lands] * landDist[lands]; | |
| } | |
| canCast[i] = pCast; | |
| } | |
| // Convert CDF to PDF | |
| const pdf = new Array(16); | |
| pdf[0] = canCast[0]; | |
| for (let i = 1; i < 15; i++) { | |
| pdf[i] = canCast[i] - canCast[i - 1]; | |
| } | |
| pdf[15] = 1 - canCast[14]; | |
| return pdf; | |
| }; | |
| // Process commanders | |
| for (const cmd of commanders) { | |
| const weight = cmd.weight || 10; | |
| const effectiveCMC = cmd.colorCost.t - (cmd.discount || 0); | |
| const cmc = Math.min(effectiveCMC, 12); | |
| const pips = colorReqs(cmd.colorCost); | |
| const pipCode = pipsToNum(pips); | |
| const dict = getDict(effectiveCMC); | |
| const castDistro = dict.get(pipCode); | |
| console.log(`Commander ${cmd.name}: CMC ${effectiveCMC}, pipCode ${pipCode}, dict has entry: ${dict.has(pipCode)}`); | |
| if (castDistro) { | |
| // Use pre-calculated pip distribution from dict + current mullLands (like website does) | |
| const turnDist = turnsToCast(cmd.colorCost, cmd.discount || 0, mullLands); | |
| if (turnDist) { | |
| const onCurveRate = turnDist[cmc] || 0; | |
| const avgDelay = mean(turnDist) - cmd.colorCost.t + (cmd.discount || 0); | |
| console.log(` ${cmd.name}: onCurve=${(onCurveRate*100).toFixed(1)}%, delay=${avgDelay.toFixed(3)}`); | |
| cmcOnCurve += onCurveRate * cmc * weight; | |
| totalCmcDelay += avgDelay * cmc * weight; | |
| totalCmc += cmc * weight; | |
| cardMetrics.push({ | |
| name: cmd.name, | |
| castRate: onCurveRate, | |
| avgDelay: avgDelay, | |
| isCommander: true | |
| }); | |
| } | |
| } else { | |
| console.log(` ${cmd.name}: NO CAST DISTRO!`); | |
| } | |
| } | |
| // Process deck cards (skip lands, skip duplicates) | |
| for (const card of deckList) { | |
| // Skip pure lands (but include MDFCs like "Instant // Land") | |
| // Match website logic: process if (!isLand || (hasMDFC && !isLandLand)) | |
| const isLand = card.card_type && card.card_type.includes('Land'); | |
| const hasMDFC = card.card_type && card.card_type.includes('//'); | |
| const isLandLand = card.card_type === 'Land // Land'; | |
| if (isLand && (!hasMDFC || isLandLand)) { | |
| continue; // Skip pure lands and "Land // Land" MDFCs | |
| } | |
| const effectiveCMC = card.colorCost.t - (card.discount || 0); | |
| const cmc = Math.min(effectiveCMC, 12); | |
| const pips = colorReqs(card.colorCost); | |
| const pipCode = pipsToNum(pips); | |
| const dict = getDict(effectiveCMC); | |
| const castDistro = dict.get(pipCode); | |
| if (castDistro) { | |
| // Use pre-calculated pip distribution from dict + current mullLands (like website does) | |
| const turnDist = turnsToCast(card.colorCost, card.discount || 0, mullLands); | |
| if (turnDist) { | |
| const onCurveRate = turnDist[cmc] || 0; | |
| const avgDelay = mean(turnDist) - card.colorCost.t + (card.discount || 0); | |
| // IMPORTANT: Add to totals for EVERY card (website doesn't check duplicates here!) | |
| cmcOnCurve += onCurveRate * cmc; | |
| totalCmcDelay += avgDelay * cmc; | |
| totalCmc += cmc; | |
| // Only add to cardMetrics once per unique cost | |
| const costKey = `${card.mana_cost}-${card.discount || 0}`; | |
| if (!costsCovered.has(costKey)) { | |
| costsCovered.add(costKey); | |
| cardMetrics.push({ | |
| name: card.name, | |
| castRate: onCurveRate, | |
| avgDelay: avgDelay, | |
| isCommander: false | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // Calculate overall metrics | |
| const castRate = totalCmc > 0 ? cmcOnCurve / totalCmc : 0; | |
| const avgDelay = totalCmc > 0 ? totalCmcDelay / totalCmc : 0; | |
| console.log(`\nAggregation summary:`); | |
| console.log(` Commanders: ${commanders.length}`); | |
| console.log(` Unique deck costs: ${costsCovered.size}`); | |
| console.log(` Total cardMetrics: ${cardMetrics.length}`); | |
| console.log(` cmcOnCurve (raw): ${cmcOnCurve.toFixed(6)}`); | |
| console.log(` totalCmc: ${totalCmc.toFixed(6)}`); | |
| console.log(` Cast Rate: ${castRate.toFixed(10)} = ${(castRate * 100).toFixed(1)}%`); | |
| console.log(` Avg Delay: ${avgDelay.toFixed(10)}`); | |
| console.log(`\nFinal landTypes:`, landTypes); | |
| console.log(`Final landCount:`, landCount); | |
| console.log(`\nWebsite shows:`); | |
| console.log(` curve rate: 0.88131451126018`); | |
| console.log(` average delay: 0.37620613943046777`); | |
| console.log(` landTypes: Array(32) [ 1, 0, 0, 0, 12, 0, 0, 0, 15, 0, … ]`); | |
| return { | |
| castRate, | |
| avgDelay, | |
| cardMetrics | |
| }; | |
| } | |
| /** | |
| * Build initial landTypes array from deck | |
| * | |
| * This is the ONE AND ONLY place where landTypes is built. | |
| * Replicates website's loadDict() logic exactly (line 1072-1300). | |
| * | |
| * Used everywhere: tests, optimizer, calculator, UI. | |
| */ | |
| /** | |
| * Build initial landTypes array from deck list | |
| * | |
| * @param {Array} deckList - Non-basic lands from deck | |
| * @param {Object} basicLands - Basic land counts {w, u, b, r, g, c} | |
| * @param {Object} deckColors - Color counts {white, blue, black, red, green} | |
| * @returns {Object} { landTypes, landCount } | |
| */ | |
| function buildInitialLandTypes(deckList, basicLands, deckColors) { | |
| const landTypes = new Array(32).fill(0); | |
| let landCount = 0; | |
| // Add basic lands first (these are the counts being tested/used) | |
| landTypes[0] += basicLands.c || 0; // Wastes | |
| landTypes[1] += basicLands.w || 0; // Plains | |
| landTypes[2] += basicLands.u || 0; // Island | |
| landTypes[4] += basicLands.b || 0; // Swamp | |
| landTypes[8] += basicLands.r || 0; // Mountain | |
| landTypes[16] += basicLands.g || 0; // Forest | |
| landCount += (basicLands.w || 0) + (basicLands.u || 0) + (basicLands.b || 0) + | |
| (basicLands.r || 0) + (basicLands.g || 0) + (basicLands.c || 0); | |
| // Calculate deck colors bitmask from color counts | |
| const deckColorsBitmask = Math.sign(deckColors.white) + | |
| Math.sign(deckColors.blue) * 2 + | |
| Math.sign(deckColors.black) * 4 + | |
| Math.sign(deckColors.red) * 8 + | |
| Math.sign(deckColors.green) * 16; | |
| // Helper to find land index | |
| const findLandIndex = (manaSource) => { | |
| if (!manaSource) return 0; | |
| return (manaSource.w ? 1 : 0) | | |
| (manaSource.u ? 2 : 0) | | |
| (manaSource.b ? 4 : 0) | | |
| (manaSource.r ? 8 : 0) | | |
| (manaSource.g ? 16 : 0); | |
| }; | |
| // Process non-basic lands (website logic from line 1173+) | |
| for (const card of deckList) { | |
| if (!card.card_type || !card.card_type.includes('Land')) continue; | |
| // Skip basics - they're already added above | |
| const isBasic = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes'].includes(card.name); | |
| if (isBasic) continue; | |
| const count = card.count || 1; | |
| // Handle fetch lands | |
| if (card.mana_source && card.mana_source.fetch) { | |
| const addLandTypes = getFetchEquivalent(card.mana_source, deckColorsBitmask, deckColors); | |
| for (let i = 0; i < 32; i++) { | |
| landTypes[i] += addLandTypes[i] * count; | |
| } | |
| landCount += count; | |
| } | |
| // Handle "choose" lands | |
| else if (card.mana_source && card.mana_source.choose) { | |
| let landIndex = findLandIndex(card.mana_source) & deckColorsBitmask; | |
| const chooseIndex = deckColorsBitmask & (~landIndex); | |
| const numColors = Math.sign(chooseIndex & 1) + Math.sign(chooseIndex & 2) + | |
| Math.sign(chooseIndex & 4) + Math.sign(chooseIndex & 8) + | |
| Math.sign(chooseIndex & 16); | |
| landTypes[chooseIndex | landIndex] += 0.5 * count; | |
| for (let i = 1; i < 32; i *= 2) { | |
| if (i & chooseIndex) { | |
| landTypes[i | landIndex] += count / (2 * numColors); | |
| } | |
| } | |
| landCount += count; | |
| } | |
| // Normal lands | |
| else { | |
| let landIndex = findLandIndex(card.mana_source) & deckColorsBitmask; | |
| landTypes[landIndex] += count; | |
| landCount += count; | |
| } | |
| } | |
| return { landTypes, landCount }; | |
| } | |
| /** | |
| * Basic Land Optimizer | |
| * | |
| * Hill-climbing optimizer for MTG basic land distribution. | |
| * Uses the color calculator to find optimal basic land ratios. | |
| * | |
| * @module optimizer | |
| */ | |
| /** | |
| * Apply ignore/discount list to deck cards (matching website logic) | |
| * | |
| * @param {Array} deckList - Deck list array | |
| * @param {Array} commanders - Commander array | |
| * @param {string} ignoreListText - Ignore/discount list text | |
| */ | |
| function applyIgnoreList(deckList, commanders, ignoreListText) { | |
| const lines = ignoreListText.split('\n'); | |
| // Reset ignore flags only (keep existing discounts from card-loader) | |
| for (const card of [...deckList, ...commanders]) { | |
| card.ignore = false; | |
| // Don't reset discount - card-loader already set commonDiscounts | |
| } | |
| // Process each line in ignore list | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed) continue; | |
| let cardName = ''; | |
| let ignore = false; | |
| let discount = 0; | |
| // Check for ignored cards (starts with -) | |
| if (trimmed.charAt(0) === '-') { | |
| cardName = trimmed.substring(1).trim(); | |
| ignore = true; | |
| } | |
| // Check for discounted cards (number followed by space) | |
| else if (trimmed.includes(' ')) { | |
| const spaceIndex = trimmed.indexOf(' '); | |
| const discountStr = trimmed.substring(0, spaceIndex); | |
| if (!isNaN(discountStr)) { | |
| discount = parseInt(discountStr); | |
| cardName = trimmed.substring(spaceIndex + 1).trim(); | |
| } | |
| } | |
| if (!cardName) continue; | |
| // Apply to all matching cards (partial match, case-insensitive) | |
| const nameLower = cardName.toLowerCase(); | |
| for (const card of [...deckList, ...commanders]) { | |
| if (card.name.toLowerCase().includes(nameLower)) { | |
| card.ignore = ignore; | |
| if (discount > 0 && card.colorCost && card.colorCost.t >= discount) { | |
| card.discount = discount; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Optimize basic land distribution using hill-climbing algorithm. | |
| * | |
| * @param {Object} input - Optimization configuration | |
| * @param {string} input.deckList - Raw deck list text | |
| * @param {string[]} input.commanders - Commander names | |
| * @param {Object} input.startingLands - Initial land distribution {w, u, b, r, g, c} | |
| * @param {Object} [input.options] - Optional configuration | |
| * @param {number} [input.options.topN=3] - Number of top results to return | |
| * @param {number} [input.options.maxIterations=1000] - Maximum configurations to test | |
| * @param {Function} [input.options.onProgress] - Progress callback(current, total, status) | |
| * @param {Object} [input.options.signal] - AbortSignal for cancellation | |
| * | |
| * @returns {Promise<Object>} Optimization results | |
| * @returns {Array} results.results - Top N configurations [{lands, castRate, avgDelay, improvement}] | |
| * @returns {Object} results.statistics - Stats {tested, improved, duration} | |
| * @returns {string[]} results.recommendations - Human-readable suggestions | |
| */ | |
| async function optimizeLands(input) { | |
| // Validate input | |
| if (!input || typeof input !== 'object') { | |
| throw new Error('Input must be an object'); | |
| } | |
| const { | |
| deckList, | |
| commanders = [], | |
| startingLands, | |
| ignoreList = '', | |
| options = {} | |
| } = input; | |
| if (!deckList || typeof deckList !== 'string') { | |
| throw new Error('deckList must be a non-empty string'); | |
| } | |
| if (!startingLands || typeof startingLands !== 'object') { | |
| throw new Error('startingLands must be an object'); | |
| } | |
| // Extract options | |
| const topN = options.topN || 3; | |
| const maxIterations = options.maxIterations || 1000; | |
| const onProgress = options.onProgress || null; | |
| const signal = options.signal || null; | |
| const testDict = options.testDict || null; | |
| // Initialize | |
| const startTime = Date.now(); | |
| const cache = new Map(); | |
| const visited = new Set(); | |
| const stats = { | |
| tested: 0, | |
| improved: 0}; | |
| // Load cards | |
| if (onProgress) { | |
| const msg = testDict ? 'Using loaded deck data...' : 'Loading cards from Scryfall...'; | |
| onProgress(0, 0, msg); | |
| } | |
| const loadResult = await loadCards(deckList, { commanders, testDict }); | |
| if (signal?.aborted) { | |
| throw new Error('Optimization cancelled'); | |
| } | |
| // Apply ignore/discount list to loaded cards | |
| if (ignoreList) { | |
| applyIgnoreList(loadResult.deckList, loadResult.commanders, ignoreList); | |
| } | |
| // Calculate deck size | |
| const entries = parseDeckList(deckList); | |
| const deckSize = entries.reduce((sum, e) => sum + e.quantity, 0); | |
| // Calculate deck color counts for buildInitialLandTypes | |
| const deckColorCounts = { | |
| white: 0, | |
| blue: 0, | |
| black: 0, | |
| red: 0, | |
| green: 0 | |
| }; | |
| for (const card of [...loadResult.deckList, ...loadResult.commanders]) { | |
| if (card.colorCost) { | |
| deckColorCounts.white += card.colorCost.w || 0; | |
| deckColorCounts.blue += card.colorCost.u || 0; | |
| deckColorCounts.black += card.colorCost.b || 0; | |
| deckColorCounts.red += card.colorCost.r || 0; | |
| deckColorCounts.green += card.colorCost.g || 0; | |
| } | |
| } | |
| // Build context for calculator | |
| const context = { | |
| deckList: loadResult.deckList, | |
| commanders: loadResult.commanders, | |
| cardDatabase: loadResult.cardDatabase, | |
| deckSize: deckSize, | |
| deckColorCounts: deckColorCounts, | |
| options: options.calculatorOptions || {} | |
| }; | |
| // Test starting configuration | |
| if (onProgress) onProgress(0, 0, 'Testing starting configuration...'); | |
| const startResult = await testConfiguration(startingLands, context, cache); | |
| stats.tested++; | |
| if (signal?.aborted) { | |
| throw new Error('Optimization cancelled'); | |
| } | |
| // Run hill-climbing using async generator | |
| if (onProgress) { | |
| const topNow = extractTopResults(cache, topN); | |
| onProgress(1, 0, 'Exploring configurations...', topNow); | |
| } | |
| for await (const progress of exploreIterative( | |
| startingLands, | |
| startResult.castRate, | |
| context, | |
| cache, | |
| visited, | |
| stats, | |
| { maxIterations, signal } | |
| )) { | |
| // Report progress with current top results | |
| if (onProgress) { | |
| const topNow = extractTopResults(cache, topN); | |
| onProgress(progress.tested, maxIterations, | |
| `Tested ${progress.tested} configs, found ${progress.improved} improvements`, | |
| topNow); | |
| } | |
| // Yield to event loop after each progress update | |
| await new Promise(resolve => setTimeout(resolve, 1)); | |
| } | |
| // Extract top results | |
| const topResults = extractTopResults(cache, topN); | |
| // Generate recommendations | |
| const recommendations = generateRecommendations(startingLands, topResults); | |
| return { | |
| results: topResults, | |
| statistics: { | |
| tested: stats.tested, | |
| improved: stats.improved, | |
| duration: Date.now() - startTime | |
| }, | |
| recommendations: recommendations | |
| }; | |
| } | |
| /** | |
| * Test a single land configuration. | |
| * | |
| * @param {Object} basicLands - Basic land distribution {w, u, b, r, g, c} | |
| * @param {Object} context - Calculator context | |
| * @param {Map} cache - Result cache | |
| * @returns {Promise<Object>} Result with {lands, castRate, avgDelay, weakestColor} | |
| */ | |
| async function testConfiguration(basicLands, context, cache) { | |
| const hash = hashLands(basicLands); | |
| // Check cache | |
| if (cache.has(hash)) { | |
| return cache.get(hash); | |
| } | |
| // Build landTypes using THE ONLY WAY | |
| // deckList contains only non-basics, basicLands are the test configuration | |
| const { landTypes, landCount } = buildInitialLandTypes( | |
| context.deckList, | |
| basicLands, | |
| context.deckColorCounts | |
| ); | |
| // Run calculator using iterative algorithm directly | |
| const result = await calculateDeckMetricsIterative( | |
| context.deckList, | |
| context.commanders, | |
| landTypes, | |
| landCount, | |
| context.deckSize, | |
| context.options | |
| ); | |
| // Store in cache | |
| const entry = { | |
| lands: { ...basicLands }, | |
| castRate: result.castRate, | |
| avgDelay: result.avgDelay | |
| }; | |
| cache.set(hash, entry); | |
| return entry; | |
| } | |
| /** | |
| * Iterative hill-climbing exploration using async generator. | |
| * Yields progress after each neighbor test to keep UI responsive. | |
| * Mutates cache, visited, and stats as side effects (unavoidable for performance). | |
| * | |
| * @param {Object} baseline - Starting land configuration | |
| * @param {number} baselineCR - Baseline cast rate | |
| * @param {Object} context - Calculator context | |
| * @param {Map} cache - Result cache (mutated) | |
| * @param {Set} visited - Visited configurations (mutated) | |
| * @param {Object} stats - Statistics tracker (mutated) | |
| * @param {Object} opts - Options {maxIterations, signal} | |
| * @yields {Object} Progress object {tested, improved} | |
| */ | |
| async function* exploreIterative(baseline, baselineCR, context, cache, visited, stats, opts) { | |
| // Queue of configurations to explore: {lands, parentCastRate} | |
| const queue = [{lands: baseline, parentCastRate: baselineCR}]; | |
| while (queue.length > 0) { | |
| // Check limits | |
| if (stats.tested >= opts.maxIterations) break; | |
| if (opts.signal?.aborted) break; | |
| // Get next config to explore | |
| const current = queue.shift(); | |
| const hash = hashLands(current.lands); | |
| // Skip if already visited | |
| if (visited.has(hash)) continue; | |
| visited.add(hash); | |
| // Get result for current config | |
| const currentResult = cache.get(hash); | |
| if (!currentResult) continue; | |
| // Generate all ±1 swap neighbors (no need for weak color calculation) | |
| const neighbors = generateAllNeighbors(current.lands); | |
| // Test each neighbor | |
| for (const neighbor of neighbors) { | |
| // Check limits | |
| if (stats.tested >= opts.maxIterations) break; | |
| if (opts.signal?.aborted) break; | |
| const neighborHash = hashLands(neighbor); | |
| // Get or test neighbor | |
| let result; | |
| if (cache.has(neighborHash)) { | |
| // Already tested (possibly during weak color calculation) | |
| result = cache.get(neighborHash); | |
| } else { | |
| // Test neighbor | |
| result = await testConfiguration(neighbor, context, cache); | |
| stats.tested++; | |
| } | |
| // If improvement, add to queue for further exploration (even if already in cache) | |
| if (result.castRate > current.parentCastRate) { | |
| stats.improved++; | |
| queue.push({lands: neighbor, parentCastRate: result.castRate}); | |
| } | |
| // Yield progress after each neighbor test to keep UI responsive | |
| console.log('About to yield, tested:', stats.tested, 'improved:', stats.improved); | |
| yield {tested: stats.tested, improved: stats.improved}; | |
| console.log('Resumed after yield'); | |
| } | |
| } | |
| } | |
| /** | |
| * Generate all ±1 swap neighbors. | |
| * Only generates swaps between colors actually in the deck. | |
| * | |
| * @param {Object} lands - Current land distribution | |
| * @returns {Array<Object>} Array of neighbor configurations | |
| */ | |
| function generateAllNeighbors(lands) { | |
| const neighbors = []; | |
| const colors = ['w', 'u', 'b', 'r', 'g', 'c']; | |
| // Find which colors are in the deck | |
| const activeColors = colors.filter(c => (lands[c] || 0) > 0); | |
| // Generate all possible ±1 swaps between active colors | |
| for (let i = 0; i < activeColors.length; i++) { | |
| const fromColor = activeColors[i]; | |
| for (let j = 0; j < activeColors.length; j++) { | |
| if (i === j) continue; // Can't swap with self | |
| const toColor = activeColors[j]; | |
| const neighbor = { ...lands }; | |
| neighbor[fromColor] = (neighbor[fromColor] || 0) - 1; | |
| neighbor[toColor] = (neighbor[toColor] || 0) + 1; | |
| neighbors.push(neighbor); | |
| } | |
| } | |
| return neighbors; | |
| } | |
| /** | |
| * Hash land configuration for cache lookups. | |
| * | |
| * @param {Object} lands - Land distribution | |
| * @returns {string} Hash string | |
| */ | |
| function hashLands(lands) { | |
| const colors = ['w', 'u', 'b', 'r', 'g', 'c']; | |
| return colors.map(c => `${c.toUpperCase()}${lands[c] || 0}`).join('-'); | |
| } | |
| /** | |
| * Extract top N results from cache. | |
| * | |
| * @param {Map} cache - Result cache | |
| * @param {number} n - Number of results to return | |
| * @returns {Array} Top N results sorted by cast rate (desc) then avg delay (asc) | |
| */ | |
| function extractTopResults(cache, n) { | |
| const results = Array.from(cache.values()); | |
| // Sort by cast rate (desc), then avg delay (asc) | |
| results.sort((a, b) => { | |
| const rateDiff = b.castRate - a.castRate; | |
| if (Math.abs(rateDiff) > 0.001) { | |
| return rateDiff; | |
| } else { | |
| return a.avgDelay - b.avgDelay; | |
| } | |
| }); | |
| return results.slice(0, n); | |
| } | |
| /** | |
| * Generate human-readable recommendations. | |
| * | |
| * @param {Object} startingLands - Starting configuration | |
| * @param {Array} topResults - Top N results | |
| * @returns {string[]} Array of recommendation strings | |
| */ | |
| function generateRecommendations(startingLands, topResults) { | |
| if (topResults.length === 0) return []; | |
| const recommendations = []; | |
| const best = topResults[0]; | |
| for (let i = 0; i < topResults.length; i++) { | |
| const result = topResults[i]; | |
| const changes = describeLandChanges(startingLands, result.lands); | |
| const castRatePct = (result.castRate * 100).toFixed(1); | |
| const castRateDiff = ((result.castRate - best.castRate) * 100).toFixed(1); | |
| const delayDiff = (result.avgDelay - best.avgDelay).toFixed(2); | |
| let rec = ''; | |
| if (i === 0) { | |
| rec = `Best: ${changes} → ${castRatePct}% cast rate, ${result.avgDelay.toFixed(2)} avg delay`; | |
| } else { | |
| rec = `${changes} → ${castRatePct}% (${castRateDiff}%), ${result.avgDelay.toFixed(2)} delay (+${delayDiff})`; | |
| } | |
| recommendations.push(rec); | |
| } | |
| return recommendations; | |
| } | |
| /** | |
| * Describe the changes between two land configurations. | |
| * | |
| * @param {Object} oldLands - Original configuration | |
| * @param {Object} newLands - New configuration | |
| * @returns {string} Description of changes | |
| */ | |
| function describeLandChanges(oldLands, newLands) { | |
| const colors = ['w', 'u', 'b', 'r', 'g', 'c']; | |
| const colorNames = { | |
| w: 'Plains', | |
| u: 'Island', | |
| b: 'Swamp', | |
| r: 'Mountain', | |
| g: 'Forest', | |
| c: 'Wastes' | |
| }; | |
| const changes = []; | |
| for (const color of colors) { | |
| const oldCount = oldLands[color] || 0; | |
| const newCount = newLands[color] || 0; | |
| const diff = newCount - oldCount; | |
| if (diff > 0) { | |
| changes.push(`+${diff} ${colorNames[color]}`); | |
| } else if (diff < 0) { | |
| changes.push(`${diff} ${colorNames[color]}`); | |
| } | |
| } | |
| return changes.length > 0 ? changes.join(', ') : 'No changes'; | |
| } | |
| // Version for debugging (replaced at build time) | |
| // Only set in UMD build, not when imported as ES module | |
| if (typeof ManabaseOptimizer !== 'undefined') { | |
| ManabaseOptimizer.version = '"2026-01-26T18:19:04.913Z"'; | |
| } | |
| exports.describeLandChanges = describeLandChanges; | |
| exports.extractTopResults = extractTopResults; | |
| exports.generateAllNeighbors = generateAllNeighbors; | |
| exports.hashLands = hashLands; | |
| exports.optimizeLands = optimizeLands; | |
| })); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment