Skip to content

Instantly share code, notes, and snippets.

@pakoito
Last active March 14, 2026 23:05
Show Gist options
  • Select an option

  • Save pakoito/5c7f9b8c35efee0126b2b874beb365db to your computer and use it in GitHub Desktop.

Select an option

Save pakoito/5c7f9b8c35efee0126b2b874beb365db to your computer and use it in GitHub Desktop.
Manabase Optimizer Bundle - Standalone module for Tampermonkey
// 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 all cards
for (const card of [...deckList, ...commanders]) {
card.discount = 0;
card.ignore = false;
}
// 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:12:28.944Z"';
}
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