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.

Revisions

  1. pakoito revised this gist Mar 14, 2026. 1 changed file with 6 additions and 3 deletions.
    9 changes: 6 additions & 3 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -43,6 +43,7 @@
    br: cost.split("{B/R}").length - 1,
    bg: cost.split("{B/G}").length - 1,
    rg: cost.split("{R/G}").length - 1,
    cl: cost.split("{C}").length - 1,
    x: cost.split("{X}").length - 1,
    c: parseInt(cost.substring(1, cost.length - 1).split('}{')[0]) || 0,
    t: 0 // storage value
    @@ -58,7 +59,7 @@
    xValue = 1; // 3+ Xs X = 1
    }
    // sum
    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);
    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.cl + cc.c + (cc.x * xValue);
    return cc;
    }

    @@ -697,14 +698,16 @@
    ug: cost.ug,
    br: cost.br,
    bg: cost.bg,
    rg: cost.rg
    rg: cost.rg,
    cl: cost.cl,
    };
    }

    function pipsToNum(cost) {
    return 2 ** cost.w * 3 ** cost.u * 5 ** cost.b * 7 ** cost.r * 11 ** cost.g
    * 13 ** cost.wu * 17 ** cost.wb * 19 ** cost.wr * 23 ** cost.wg * 29 ** cost.ub
    * 31 ** cost.ur * 37 ** cost.ug * 41 ** cost.br * 43 ** cost.bg * 47 ** cost.rg;
    * 31 ** cost.ur * 37 ** cost.ug * 41 ** cost.br * 43 ** cost.bg * 47 ** cost.rg
    * 53 ** cost.cl;
    }

    function numColors(cost) {
  2. pakoito revised this gist Mar 7, 2026. 1 changed file with 1 addition and 0 deletions.
    1 change: 1 addition & 0 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -123,6 +123,7 @@
    // staples to automatically discount
    const commonDiscounts = {
    "Blasphemous Act": 5,
    "Bloodsoaked Insight": 3,
    "The Great Henge": 4,
    "Treasure Cruise": 4,
    "Ghalta, Primal Hunger": 6,
  3. pakoito revised this gist Feb 21, 2026. 1 changed file with 0 additions and 5 deletions.
    5 changes: 0 additions & 5 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1569,7 +1569,6 @@
    }
    }
    }
    console.log('generateAllNeighbors: lands =', lands, 'protectedColors =', [...protectedColors], 'activeColors =', activeColors);
    // Generate all swaps
    for (let i = 0; i < activeColors.length; i++) {
    const fromColor = activeColors[i];
    @@ -1602,7 +1601,6 @@
    neighborOptions.preserveOriginalTypes = true;
    neighborOptions.originalLands = opts.originalLands || baseline;
    }
    console.log('exploreIterative: neighborOptions =', neighborOptions);
    while (queue.length > 0) {
    if (stats.tested >= opts.maxIterations)
    break;
    @@ -1710,7 +1708,6 @@
    const onProgress = options.onProgress || null;
    const signal = options.signal || null;
    const preserveOriginalTypes = options.preserveOriginalTypes !== false; // Default to true
    console.log('Manatool optimizeLands: preserveOriginalTypes =', preserveOriginalTypes, 'startingLands =', startingLands);
    const startTime = Date.now();
    const cache = new Map();
    const visited = new Set();
    @@ -1744,7 +1741,6 @@
    const topNow = extractTopResults(cache, topN);
    onProgress(1, 0, 'Exploring configurations...', topNow);
    }
    console.log('About to call exploreIterative with preserveOriginalTypes =', preserveOriginalTypes);
    for await (const progress of exploreIterative(startingLands, startResult.castRate, context, cache, visited, stats, { maxIterations, signal, preserveOriginalTypes, originalLands: startingLands })) {
    if (onProgress) {
    const topNow = extractTopResults(cache, topN);
    @@ -1863,7 +1859,6 @@
    applyIgnoreDiscount(loadResult, ignoreDiscountEntries);

    // Call the library's optimizer
    console.log('Optimizer wrapper: preserveOriginalTypes =', options.preserveOriginalTypes);
    return optimizeLands$1({
    loadResult,
    startingLands,
  4. pakoito revised this gist Feb 21, 2026. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1569,6 +1569,7 @@
    }
    }
    }
    console.log('generateAllNeighbors: lands =', lands, 'protectedColors =', [...protectedColors], 'activeColors =', activeColors);
    // Generate all swaps
    for (let i = 0; i < activeColors.length; i++) {
    const fromColor = activeColors[i];
    @@ -1743,6 +1744,7 @@
    const topNow = extractTopResults(cache, topN);
    onProgress(1, 0, 'Exploring configurations...', topNow);
    }
    console.log('About to call exploreIterative with preserveOriginalTypes =', preserveOriginalTypes);
    for await (const progress of exploreIterative(startingLands, startResult.castRate, context, cache, visited, stats, { maxIterations, signal, preserveOriginalTypes, originalLands: startingLands })) {
    if (onProgress) {
    const topNow = extractTopResults(cache, topN);
  5. pakoito revised this gist Feb 21, 2026. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1601,6 +1601,7 @@
    neighborOptions.preserveOriginalTypes = true;
    neighborOptions.originalLands = opts.originalLands || baseline;
    }
    console.log('exploreIterative: neighborOptions =', neighborOptions);
    while (queue.length > 0) {
    if (stats.tested >= opts.maxIterations)
    break;
    @@ -1708,6 +1709,7 @@
    const onProgress = options.onProgress || null;
    const signal = options.signal || null;
    const preserveOriginalTypes = options.preserveOriginalTypes !== false; // Default to true
    console.log('Manatool optimizeLands: preserveOriginalTypes =', preserveOriginalTypes, 'startingLands =', startingLands);
    const startTime = Date.now();
    const cache = new Map();
    const visited = new Set();
  6. pakoito revised this gist Feb 21, 2026. 1 changed file with 216 additions and 168 deletions.
    384 changes: 216 additions & 168 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1473,44 +1473,52 @@
    }

    /**
    * Optimizer Wrapper for Manatool Library
    * Basic Land Optimizer
    *
    * This wrapper provides the optimizer interface expected by the userscript
    * while using the core calculation functions from the manatool library.
    * Hill-climbing optimization algorithm to find optimal basic land distributions
    * for a given deck and manabase.
    */


    const COLORS = ['w', 'u', 'b', 'r', 'g', 'c'];
    const COLOR_NAMES = {
    w: 'Plains',
    u: 'Island',
    b: 'Swamp',
    r: 'Mountain',
    g: 'Forest',
    c: 'Wastes'
    };
    /**
    * Hash land configuration for cache lookups
    */
    function hashLands$1(lands) {
    return COLORS.map(c => `${c.toUpperCase()}${lands[c] || 0}`).join('-');
    }
    /**
    * Build land types array from basic lands configuration using existing loadResult
    * This preserves all the non-basic lands and just replaces the basic counts
    */
    function buildLandTypesFromLoadResult(basicLands, loadResult, originalBasics) {
    // Start with a copy of the original land types (includes non-basics AND old basics)
    const landTypes = [...loadResult.landTypes];

    // Subtract original basics and add new basics
    // originalBasics is the starting configuration passed to optimizer
    landTypes[0] = landTypes[0] - (originalBasics.c || 0) + (basicLands.c || 0); // Wastes (colorless)
    landTypes[1] = landTypes[1] - (originalBasics.w || 0) + (basicLands.w || 0); // Plains
    landTypes[2] = landTypes[2] - (originalBasics.u || 0) + (basicLands.u || 0); // Island
    landTypes[4] = landTypes[4] - (originalBasics.b || 0) + (basicLands.b || 0); // Swamp
    landTypes[8] = landTypes[8] - (originalBasics.r || 0) + (basicLands.r || 0); // Mountain
    landTypes[16] = landTypes[16] - (originalBasics.g || 0) + (basicLands.g || 0); // Forest

    landTypes[0] = landTypes[0] - (originalBasics.c || 0) + (basicLands.c || 0); // Wastes (colorless)
    landTypes[1] = landTypes[1] - (originalBasics.w || 0) + (basicLands.w || 0); // Plains
    landTypes[2] = landTypes[2] - (originalBasics.u || 0) + (basicLands.u || 0); // Island
    landTypes[4] = landTypes[4] - (originalBasics.b || 0) + (basicLands.b || 0); // Swamp
    landTypes[8] = landTypes[8] - (originalBasics.r || 0) + (basicLands.r || 0); // Mountain
    landTypes[16] = landTypes[16] - (originalBasics.g || 0) + (basicLands.g || 0); // Forest
    return landTypes;
    }

    /**
    * Test a single land configuration
    */
    async function testConfiguration(basicLands, context, cache) {
    const hash = hashLands(basicLands);

    const hash = hashLands$1(basicLands);
    // Check cache
    if (cache.has(hash)) {
    return cache.get(hash);
    }

    // Build modified loadDictResult with new basic lands
    // Deep copy to prevent mutation of original loadResult by deepAnal
    const modifiedLoadResult = {
    @@ -1526,248 +1534,190 @@
    commander2: context.modifiedCommanders[1],
    commander3: context.modifiedCommanders[2]
    };

    // Run deep analysis using the modified loadResult
    const deepAnalResult = await deepAnal(modifiedLoadResult, context.options);

    // deepAnal already returns averaged values (divided by totalCmc)
    const castRate = deepAnalResult.cmcOnCurve;
    const avgDelay = deepAnalResult.totalCmcDelay;

    // Store in cache
    const entry = {
    lands: { ...basicLands },
    castRate: castRate,
    avgDelay: avgDelay
    };

    cache.set(hash, entry);
    return entry;
    }

    /**
    * Generate all ±1 swap neighbors
    *
    * @param lands - Current land configuration
    * @param options - Optional settings
    * @param options.originalLands - Original land configuration (for preserveOriginalTypes)
    * @param options.preserveOriginalTypes - If true, never reduce original land types below 1
    */
    function generateAllNeighbors(lands) {
    function generateAllNeighbors(lands, options = {}) {
    const neighbors = [];
    const colors = ['w', 'u', 'b', 'r', 'g', 'c'];

    // Find active colors
    const activeColors = colors.filter(c => (lands[c] || 0) > 0);

    const activeColors = COLORS.filter(c => (lands[c] || 0) > 0);
    // Determine which colors must stay >= 1 (original types that should be preserved)
    const protectedColors = new Set();
    if (options.preserveOriginalTypes && options.originalLands) {
    for (const c of COLORS) {
    if ((options.originalLands[c] || 0) > 0) {
    protectedColors.add(c);
    }
    }
    }
    // Generate all swaps
    for (let i = 0; i < activeColors.length; i++) {
    const fromColor = activeColors[i];

    // Skip if this would reduce a protected color below 1
    if (protectedColors.has(fromColor) && (lands[fromColor] || 0) <= 1) {
    continue;
    }
    for (let j = 0; j < activeColors.length; j++) {
    if (i === j) continue;
    if (i === j)
    continue;
    const toColor = activeColors[j];

    const neighbor = { ...lands };
    neighbor[fromColor] = (neighbor[fromColor] || 0) - 1;
    neighbor[toColor] = (neighbor[toColor] || 0) + 1;
    neighbors.push(neighbor);
    }
    }

    return neighbors;
    }

    /**
    * Iterative hill-climbing exploration
    */
    async function* exploreIterative(baseline, baselineCR, context, cache, visited, stats, opts) {
    const queue = [{ lands: baseline, parentCastRate: baselineCR }];

    const queue = [
    { lands: baseline, parentCastRate: baselineCR }
    ];
    // Build neighbor generation options
    const neighborOptions = {};
    if (opts.preserveOriginalTypes) {
    neighborOptions.preserveOriginalTypes = true;
    neighborOptions.originalLands = opts.originalLands || baseline;
    }
    while (queue.length > 0) {
    if (stats.tested >= opts.maxIterations) break;
    if (opts.signal?.aborted) break;

    if (stats.tested >= opts.maxIterations)
    break;
    if (opts.signal?.aborted)
    break;
    const current = queue.shift();
    const hash = hashLands(current.lands);

    if (visited.has(hash)) continue;
    const hash = hashLands$1(current.lands);
    if (visited.has(hash))
    continue;
    visited.add(hash);

    const currentResult = cache.get(hash);
    if (!currentResult) continue;

    const neighbors = generateAllNeighbors(current.lands);

    if (!currentResult)
    continue;
    const neighbors = generateAllNeighbors(current.lands, neighborOptions);
    for (const neighbor of neighbors) {
    if (stats.tested >= opts.maxIterations) break;
    if (opts.signal?.aborted) break;

    const neighborHash = hashLands(neighbor);

    if (stats.tested >= opts.maxIterations)
    break;
    if (opts.signal?.aborted)
    break;
    const neighborHash = hashLands$1(neighbor);
    let result;
    if (cache.has(neighborHash)) {
    result = cache.get(neighborHash);
    } else {
    }
    else {
    result = await testConfiguration(neighbor, context, cache);
    stats.tested++;
    }

    if (result.castRate > current.parentCastRate) {
    stats.improved++;
    queue.push({ lands: neighbor, parentCastRate: result.castRate });
    }

    yield { tested: stats.tested, improved: stats.improved };
    }
    }
    }

    /**
    * Hash land configuration for cache lookups
    */
    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
    */
    function extractTopResults(cache, n) {
    const results = Array.from(cache.values());

    results.sort((a, b) => {
    const rateDiff = b.castRate - a.castRate;
    if (Math.abs(rateDiff) > 0.001) {
    return rateDiff;
    } else {
    }
    else {
    return a.avgDelay - b.avgDelay;
    }
    });

    return results.slice(0, n);
    }

    /**
    * Describe changes between two land configurations
    */
    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'
    };

    function describeLandChanges$1(oldLands, newLands) {
    const changes = [];

    for (const color of colors) {
    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]}`);
    changes.push(`+${diff} ${COLOR_NAMES[color]}`);
    }
    else if (diff < 0) {
    changes.push(`${diff} ${COLOR_NAMES[color]}`);
    }
    }

    return changes.length > 0 ? changes.join(', ') : 'No changes';
    }

    /**
    * Generate recommendations
    * Generate recommendations from top results
    */
    function generateRecommendations(startingLands, topResults) {
    if (topResults.length === 0) return [];

    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 changes = describeLandChanges$1(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 {
    }
    else {
    rec = `${changes}${castRatePct}% (${castRateDiff}%), ${result.avgDelay.toFixed(2)} delay (+${delayDiff})`;
    }

    recommendations.push(rec);
    }

    return recommendations;
    }

    /**
    * Main optimizer function
    *
    * Takes a loaded deck and finds optimal basic land distributions using hill-climbing.
    */
    async function optimizeLands(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');
    }

    async function optimizeLands$1(input) {
    const { loadResult, startingLands, modifiedDeckList, modifiedCommanders, options = {} } = input;
    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;

    const preserveOriginalTypes = options.preserveOriginalTypes !== false; // Default to true
    const startTime = Date.now();
    const cache = new Map();
    const visited = new Set();
    const stats = {
    tested: 0,
    improved: 0};

    // Load cards - reuse testDict if available
    if (onProgress) {
    const msg = testDict ? 'Using loaded deck data...' : 'Loading cards from Scryfall...';
    onProgress(0, 0, msg);
    }

    const commanderNames = commanders.filter(c => c);
    const loadResult = await loadDict(
    deckList,
    commanderNames[0] || '',
    commanderNames[1] || '',
    commanderNames[2] || ''
    );

    if (signal?.aborted) {
    throw new Error('Optimization cancelled');
    }

    // Parse and apply ignore/discount settings
    const ignoreDiscountEntries = parseIgnoreDiscountList(ignoreList);
    const { deckList: modifiedDeckList, commanders: modifiedCommanders} =
    applyIgnoreDiscount(loadResult, ignoreDiscountEntries);

    // Build context with loadResult for testConfiguration
    // Build context for testConfiguration
    const context = {
    loadResult: loadResult,
    originalBasics: startingLands, // Store original basics to swap in/out
    originalBasics: startingLands,
    modifiedDeckList: modifiedDeckList,
    modifiedCommanders: modifiedCommanders,
    options: {
    @@ -1778,48 +1728,31 @@
    cmdr3Weight: options.calculatorOptions?.cmdr3Weight || 15
    }
    };

    // Test starting configuration
    if (onProgress) onProgress(0, 0, 'Testing 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
    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 }
    )) {
    for await (const progress of exploreIterative(startingLands, startResult.castRate, context, cache, visited, stats, { maxIterations, signal, preserveOriginalTypes, originalLands: startingLands })) {
    if (onProgress) {
    const topNow = extractTopResults(cache, topN);
    onProgress(progress.tested, maxIterations,
    `Tested ${progress.tested} configs, found ${progress.improved} improvements`,
    topNow);
    onProgress(progress.tested, maxIterations, `Tested ${progress.tested} configs, found ${progress.improved} improvements`, topNow);
    }

    // Yield to allow UI updates and check cancellation every iteration
    await new Promise(resolve => setTimeout(resolve, 0));
    }

    // Extract top results
    const topResults = extractTopResults(cache, topN);

    // Generate recommendations
    const recommendations = generateRecommendations(startingLands, topResults);

    return {
    results: topResults,
    statistics: {
    @@ -1831,10 +1764,125 @@
    };
    }

    /**
    * Optimizer Wrapper for Manatool Library
    *
    * This wrapper provides the optimizer interface expected by the userscript
    * while using the core calculation functions from the manatool library.
    *
    * The actual optimization logic lives in manatool/src/optimizer.ts.
    * This wrapper handles:
    * - Deck loading via loadDict
    * - Ignore/discount list parsing and application
    * - Providing a simplified API for the userscript
    */


    /**
    * @typedef {import('./manatool/src/index.ts').BasicLands} BasicLands
    * @typedef {import('./manatool/src/index.ts').OptimizeLandsResult} OptimizeLandsResult
    * @typedef {import('./manatool/src/index.ts').ConfigurationResult} ConfigurationResult
    * @typedef {import('./manatool/src/index.ts').ColorTestOptions} ColorTestOptions
    */

    /**
    * @typedef {Object} WrapperOptimizeLandsOptions
    * @property {number} [topN]
    * @property {number} [maxIterations]
    * @property {(current: number, total: number, status: string, topResults?: ConfigurationResult[]) => void} [onProgress]
    * @property {AbortSignal | null} [signal]
    * @property {boolean} [preserveOriginalTypes]
    * @property {Partial<ColorTestOptions>} [calculatorOptions]
    */

    /**
    * @typedef {Object} WrapperOptimizeLandsInput
    * @property {string} deckList
    * @property {string[]} [commanders]
    * @property {BasicLands} startingLands
    * @property {string} [ignoreList]
    * @property {WrapperOptimizeLandsOptions} [options]
    */

    /**
    * Main optimizer function - userscript entry point
    *
    * This wraps the library's optimizeLands with deck loading logic.
    *
    * @param {WrapperOptimizeLandsInput} input
    * @returns {Promise<OptimizeLandsResult>}
    */
    async function optimizeLands(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');
    }

    const onProgress = options.onProgress || null;
    const signal = options.signal || null;

    // Load cards
    if (onProgress) {
    onProgress(0, 0, 'Loading cards from Scryfall...');
    }

    const commanderNames = commanders.filter(c => c);
    const loadResult = await loadDict(
    deckList,
    commanderNames[0] || '',
    commanderNames[1] || '',
    commanderNames[2] || ''
    );

    if (signal?.aborted) {
    throw new Error('Optimization cancelled');
    }

    // Parse and apply ignore/discount settings
    const ignoreDiscountEntries = parseIgnoreDiscountList(ignoreList);
    const { deckList: modifiedDeckList, commanders: modifiedCommanders } =
    applyIgnoreDiscount(loadResult, ignoreDiscountEntries);

    // Call the library's optimizer
    console.log('Optimizer wrapper: preserveOriginalTypes =', options.preserveOriginalTypes);
    return optimizeLands$1({
    loadResult,
    startingLands,
    modifiedDeckList,
    modifiedCommanders,
    options: {
    topN: options.topN,
    maxIterations: options.maxIterations,
    onProgress: options.onProgress,
    signal: options.signal,
    preserveOriginalTypes: options.preserveOriginalTypes,
    calculatorOptions: options.calculatorOptions
    }
    });
    }

    /** @type {typeof Manatool.hashLands} */
    const hashLands = hashLands$1;

    /** @type {typeof Manatool.describeLandChanges} */
    const describeLandChanges = describeLandChanges$1;

    exports.describeLandChanges = describeLandChanges;
    exports.extractTopResults = extractTopResults;
    exports.generateAllNeighbors = generateAllNeighbors;
    exports.generateNeighbors = generateAllNeighbors;
    exports.hashLands = hashLands;
    exports.optimizeLands = optimizeLands;

  7. pakoito revised this gist Jan 29, 2026. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -949,7 +949,7 @@
    comboReqs[j] -= Math.sign(j & newI);
    }
    // Yield every 10k iterations to keep UI responsive
    if (iterCount && iterCount.ref % 10000 === 0) {
    if (iterCount && iterCount.ref % 5000 === 0) {
    await yieldABit();
    }
    }
  8. pakoito revised this gist Jan 29, 2026. 1 changed file with 13 additions and 5 deletions.
    18 changes: 13 additions & 5 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -902,7 +902,7 @@
    // This allows UI updates and event processing between heavy calculations
    const yieldABit = async () => new Promise(resolve => setTimeout(resolve, 0));

    async function landTestUniformSample(myLandTypes, numLands, myLandCount, i, min, max, comboReqs, iterations = 0) {
    async function landTestUniformSample(myLandTypes, numLands, myLandCount, i, min, max, comboReqs, iterCount) {
    if (Math.floor(max) == Math.floor(min)) {
    // no ticks in range
    return 0;
    @@ -935,26 +935,30 @@
    let newMin = min;
    let newMax = min;
    for (let n = 0; n <= numLands && n <= myLandTypes[newI]; n++) {
    iterations++;
    if (iterCount) {
    iterCount.ref++;
    }
    let p = drawType(myLandCount, myLandTypes[newI], numLands, n);
    if (p > 0) {
    newMin = newMax;
    newMax += p * span;
    const newSuccess = await landTestUniformSample(myLandTypes, numLands - n, myLandCount - myLandTypes[newI], newI - 1, newMin, newMax, comboReqs.slice(), iterations);
    const newSuccess = await landTestUniformSample(myLandTypes, numLands - n, myLandCount - myLandTypes[newI], newI - 1, newMin, newMax, comboReqs.slice(), iterCount);
    success += newSuccess;
    }
    for (let j = 0; j < 32; j++) {
    comboReqs[j] -= Math.sign(j & newI);
    }
    // Yield every 10k iterations to keep UI responsive
    if (iterations % 10000 === 0) {
    if (iterCount && iterCount.ref % 10000 === 0) {
    await yieldABit();
    }
    }
    return success;
    }

    async function pipDist(origWUBRG_MUTATED, cost, colors, samples, landAdded, landTypes, landCount, condLands, deckList) {
    // Create mutable reference for iteration counting across all landTestUniformSample calls
    const iterCount = { ref: 0 };
    if (numColors(cost) == 5 && origWUBRG_MUTATED.length == 0) {
    for (let n = 0; n <= 12; n++) {
    // filter and round lands for given color combination
    @@ -998,10 +1002,14 @@
    roundedLands = roundLands(landFilter(condAdjust(n, landTypes, landCount, condLands, deckList), cost));
    }
    const roundedLandCount = sum(roundedLands);
    const result = await landTestUniformSample(roundedLands, n, roundedLandCount, 31, 0, samples, structuredClone(comboReqs));
    const result = await landTestUniformSample(roundedLands, n, roundedLandCount, 31, 0, samples, structuredClone(comboReqs), iterCount);
    canCast[n] = (result / samples);
    }
    }
    // Log iteration count for performance monitoring
    if (iterCount.ref > 0) {
    console.log(`[pipDist] landTestUniformSample iterations: ${iterCount.ref.toLocaleString()}`);
    }
    return canCast;
    }

  9. pakoito revised this gist Jan 29, 2026. 1 changed file with 7 additions and 3 deletions.
    10 changes: 7 additions & 3 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -902,7 +902,7 @@
    // This allows UI updates and event processing between heavy calculations
    const yieldABit = async () => new Promise(resolve => setTimeout(resolve, 0));

    async function landTestUniformSample(myLandTypes, numLands, myLandCount, i, min, max, comboReqs) {
    async function landTestUniformSample(myLandTypes, numLands, myLandCount, i, min, max, comboReqs, iterations = 0) {
    if (Math.floor(max) == Math.floor(min)) {
    // no ticks in range
    return 0;
    @@ -935,17 +935,21 @@
    let newMin = min;
    let newMax = min;
    for (let n = 0; n <= numLands && n <= myLandTypes[newI]; n++) {
    iterations++;
    let p = drawType(myLandCount, myLandTypes[newI], numLands, n);
    if (p > 0) {
    newMin = newMax;
    newMax += p * span;
    const newSuccess = await landTestUniformSample(myLandTypes, numLands - n, myLandCount - myLandTypes[newI], newI - 1, newMin, newMax, comboReqs.slice());
    const newSuccess = await landTestUniformSample(myLandTypes, numLands - n, myLandCount - myLandTypes[newI], newI - 1, newMin, newMax, comboReqs.slice(), iterations);
    success += newSuccess;
    }
    for (let j = 0; j < 32; j++) {
    comboReqs[j] -= Math.sign(j & newI);
    }
    await yieldABit();
    // Yield every 10k iterations to keep UI responsive
    if (iterations % 10000 === 0) {
    await yieldABit();
    }
    }
    return success;
    }
  10. pakoito revised this gist Jan 29, 2026. 1 changed file with 98 additions and 1 deletion.
    99 changes: 98 additions & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1375,6 +1375,91 @@
    return { cmcOnCurve, totalCmcDelay, allCards, landCount: landCountAfterDrops4, landTypes: landTypesAfterDrops4, mullLands: mullLandsAfterDrops4 };
    }

    /**
    * Parse ignore/discount list from text input
    * Format:
    * "-CARDNAME" will ignore the card
    * "3 CARDNAME" will discount the card by 3 mana
    */
    function parseIgnoreDiscountList(text) {
    const lines = text.split('\n');
    const entries = [];
    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 (starts with number)
    else if (trimmed.includes(' ')) {
    const spaceIndex = trimmed.indexOf(' ');
    const discountStr = trimmed.substring(0, spaceIndex);
    const parsedDiscount = parseInt(discountStr);
    if (!isNaN(parsedDiscount)) {
    discount = parsedDiscount;
    cardName = trimmed.substring(spaceIndex + 1).trim();
    }
    }
    if (cardName) {
    entries.push({ cardName, ignore, discount });
    }
    }
    return entries;
    }

    /**
    * Apply ignore/discount settings to deck cards
    */
    function applyIgnoreDiscount(loadDictResult, entries) {
    const warnings = [];
    // Clone the deck list and commanders
    const deckList = loadDictResult.deckList.map(card => ({ ...card, ignore: false, discount: 0 }));
    const commander1 = loadDictResult.commander1 ? { ...loadDictResult.commander1, ignore: false, discount: 0 } : undefined;
    const commander2 = loadDictResult.commander2 ? { ...loadDictResult.commander2, ignore: false, discount: 0 } : undefined;
    const commander3 = loadDictResult.commander3 ? { ...loadDictResult.commander3, ignore: false, discount: 0 } : undefined;
    // Apply each entry
    for (const entry of entries) {
    const cardNameLower = entry.cardName.toLowerCase();
    // Apply to deck list
    for (const card of deckList) {
    if (card.name.toLowerCase().includes(cardNameLower)) {
    card.ignore = entry.ignore;
    if (entry.discount > 0) {
    if (card.colorCost.t >= entry.discount) {
    card.discount = entry.discount;
    }
    else {
    card.discount = card.colorCost.t;
    warnings.push(`Warning: ${entry.cardName} cannot be given a generic discount of ${entry.discount}`);
    }
    }
    }
    }
    // Apply to commanders
    for (const commander of [commander1, commander2, commander3]) {
    if (commander && commander.name.toLowerCase().includes(cardNameLower)) {
    commander.ignore = entry.ignore;
    if (entry.discount > 0) {
    if (commander.colorCost.t >= entry.discount) {
    commander.discount = entry.discount;
    }
    else {
    commander.discount = commander.colorCost.t;
    warnings.push(`Warning: ${entry.cardName} cannot be given a generic discount of ${entry.discount}`);
    }
    }
    }
    }
    }
    return { deckList, commanders: [commander1, commander2, commander3], warnings };
    }

    /**
    * Optimizer Wrapper for Manatool Library
    *
    @@ -1422,7 +1507,12 @@
    // Deep copy arrays that get mutated in deepAnal
    landAdded: [...context.loadResult.landAdded],
    condLands: context.loadResult.condLands.map(arr => [...arr]),
    roundWUBRG: context.loadResult.roundWUBRG.map(arr => [...arr])
    roundWUBRG: context.loadResult.roundWUBRG.map(arr => [...arr]),
    // Use the modified deckList and commanders with ignores/discounts applied
    deckList: context.modifiedDeckList,
    commander1: context.modifiedCommanders[0],
    commander2: context.modifiedCommanders[1],
    commander3: context.modifiedCommanders[2]
    };

    // Run deep analysis using the modified loadResult
    @@ -1657,10 +1747,17 @@
    throw new Error('Optimization cancelled');
    }

    // Parse and apply ignore/discount settings
    const ignoreDiscountEntries = parseIgnoreDiscountList(ignoreList);
    const { deckList: modifiedDeckList, commanders: modifiedCommanders} =
    applyIgnoreDiscount(loadResult, ignoreDiscountEntries);

    // Build context with loadResult for testConfiguration
    const context = {
    loadResult: loadResult,
    originalBasics: startingLands, // Store original basics to swap in/out
    modifiedDeckList: modifiedDeckList,
    modifiedCommanders: modifiedCommanders,
    options: {
    approxColors: options.calculatorOptions?.approxColors || 5,
    approxSamples: options.calculatorOptions?.approxSamples || 100000,
  11. pakoito revised this gist Jan 28, 2026. 1 changed file with 0 additions and 3 deletions.
    3 changes: 0 additions & 3 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -247,9 +247,6 @@
    const normalizedCmdr1 = commanderName1 || "nocard";
    const normalizedCmdr2 = commanderName2 || "nocard";
    const normalizedCmdr3 = commanderName3 || "nocard";
    console.log("DEBUG loadDict: normalizedCmdr1 =", normalizedCmdr1);
    console.log("DEBUG loadDict: normalizedCmdr2 =", normalizedCmdr2);
    console.log("DEBUG loadDict: normalizedCmdr3 =", normalizedCmdr3);
    let commander1found = 0;
    let commander2found = 0;
    let commander3found = 0;
  12. pakoito revised this gist Jan 28, 2026. 1 changed file with 81 additions and 1 deletion.
    82 changes: 81 additions & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -247,6 +247,9 @@
    const normalizedCmdr1 = commanderName1 || "nocard";
    const normalizedCmdr2 = commanderName2 || "nocard";
    const normalizedCmdr3 = commanderName3 || "nocard";
    console.log("DEBUG loadDict: normalizedCmdr1 =", normalizedCmdr1);
    console.log("DEBUG loadDict: normalizedCmdr2 =", normalizedCmdr2);
    console.log("DEBUG loadDict: normalizedCmdr3 =", normalizedCmdr3);
    let commander1found = 0;
    let commander2found = 0;
    let commander3found = 0;
    @@ -394,7 +397,41 @@
    }
    card.ignore = false;
    // checking card names against commander names
    if (card.card_type.includes("Land")) {
    // Use separate if statements (not else if) so each card can be checked against all commanders
    let commanderMatch = false;
    if (normalizedCmdr1 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr1.toLowerCase())) {
    if (commander1found > 0) {
    deckList.push(structuredClone(commander1));
    }
    commander1 = card;
    console.log("commander 1: " + card.name);
    commander1found += 1;
    commanderMatch = true;
    }
    if (normalizedCmdr2 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr2.toLowerCase())) {
    if (commander2found > 0) {
    deckList.push(structuredClone(commander2));
    }
    commander2 = card;
    console.log("commander 2: " + card.name);
    commander2found += 1;
    commanderMatch = true;
    }
    if (normalizedCmdr3 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr3.toLowerCase())) {
    if (commander3found > 0) {
    deckList.push(structuredClone(commander3));
    }
    commander3 = card;
    console.log("commander 3: " + card.name);
    commander3found += 1;
    commanderMatch = true;
    }
    if (commanderMatch) {
    // Skip to next card, don't process as land or regular card
    continue;
    }
    // categorizing lands and incrementing category totals
    else if (card.card_type.includes("Land")) {
    // handle fetches
    let deckColors = Math.sign(white) + Math.sign(blue) * 2 + Math.sign(black) * 4 + Math.sign(red) * 8 + Math.sign(green) * 16;
    if (card.mana_source.fetch) {
    @@ -450,14 +487,57 @@
    if (normalizedCmdr1 != "nocard" && commander1found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr1 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    // if multiple cards match with the commander, go back and check for exact matches, warn user if there aren't any
    if (commander1found > 1) {
    let exactMatch = (commander1.name.toLowerCase() == normalizedCmdr1.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr1.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander1);
    commander1 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr1 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // repeat for partner
    if (normalizedCmdr2 != "nocard" && commander2found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr2 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    if (commander2found > 1) {
    let exactMatch = (commander2.name.toLowerCase() == normalizedCmdr2.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr2.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander2);
    commander2 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr2 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // repeat for companion
    if (normalizedCmdr3 != "nocard" && commander3found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr3 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    if (commander3found > 1) {
    let exactMatch = (commander3.name.toLowerCase() == normalizedCmdr3.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr3.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander3);
    commander3 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr3 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // Calculate deck colors
    const deckColors = Math.sign(white) + Math.sign(blue) + Math.sign(black) + Math.sign(red) + Math.sign(green);
    return {
  13. pakoito revised this gist Jan 28, 2026. 1 changed file with 32 additions and 39 deletions.
    71 changes: 32 additions & 39 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -346,7 +346,6 @@
    landTypes[i] += addLandTypes[i] * count;
    }
    landCount += count;
    console.log("Added land", card.name, count, landCount);
    }
    else if (card.mana_source.choose) {
    // "choose a color" type mana sources (thriving, CLB gates)
    @@ -361,14 +360,12 @@
    }
    }
    landCount += count;
    console.log("Added land", card.name, count, landCount);
    }
    else {
    // normal lands
    let landIndex = findLandIndex(card.mana_source) & deckColors;
    landTypes[landIndex] += count;
    landCount += count;
    console.log("Added land", card.name, count, landCount);
    }
    // note if land has conditions (i.e. verge lands)
    if (card.mana_source.cond) {
    @@ -377,7 +374,6 @@
    let condition = card.mana_source.cond.cond;
    condLands.push([index1, index2, condition]);
    condNames.push(name);
    console.log([index1, index2, condition]);
    }
    }
    }
    @@ -411,7 +407,6 @@
    landTypes[i] += addLandTypes[i] * count;
    }
    landCount += count;
    console.log("Added land", card.name, count, landCount);
    deckList.push(card);
    }
    else if (card.mana_source.choose) {
    @@ -427,15 +422,13 @@
    }
    }
    landCount += count;
    console.log("Added land " + card.name + " " + count + " " + landCount);
    deckList.push(card);
    }
    else {
    // normal lands
    let landIndex = findLandIndex(card.mana_source) & deckColors;
    landTypes[landIndex] += count;
    landCount += count;
    console.log("Added land " + card.name + " " + count + " " + landCount);
    deckList.push(card);
    }
    // note if land has conditions (i.e. verge lands)
    @@ -445,7 +438,6 @@
    let condition = card.mana_source.cond.cond;
    condLands.push([index1, index2, condition]);
    condNames.push(name);
    console.log([index1, index2, condition]);
    }
    }
    else {
    @@ -829,7 +821,11 @@
    return success;
    }

    function landTestUniformSample(myLandTypes, numLands, myLandCount, i, min, max, comboReqs) {
    // Use setTimeout with a small delay to yield control back to the browser
    // This allows UI updates and event processing between heavy calculations
    const yieldABit = async () => new Promise(resolve => setTimeout(resolve, 0));

    async function landTestUniformSample(myLandTypes, numLands, myLandCount, i, min, max, comboReqs) {
    if (Math.floor(max) == Math.floor(min)) {
    // no ticks in range
    return 0;
    @@ -866,17 +862,18 @@
    if (p > 0) {
    newMin = newMax;
    newMax += p * span;
    const newSuccess = landTestUniformSample(myLandTypes, numLands - n, myLandCount - myLandTypes[newI], newI - 1, newMin, newMax, comboReqs.slice());
    const newSuccess = await landTestUniformSample(myLandTypes, numLands - n, myLandCount - myLandTypes[newI], newI - 1, newMin, newMax, comboReqs.slice());
    success += newSuccess;
    }
    for (let j = 0; j < 32; j++) {
    comboReqs[j] -= Math.sign(j & newI);
    }
    await yieldABit();
    }
    return success;
    }

    function pipDist(origWUBRG_MUTATED, cost, colors, samples, landAdded, landTypes, landCount, condLands, deckList) {
    async function pipDist(origWUBRG_MUTATED, cost, colors, samples, landAdded, landTypes, landCount, condLands, deckList) {
    if (numColors(cost) == 5 && origWUBRG_MUTATED.length == 0) {
    for (let n = 0; n <= 12; n++) {
    // filter and round lands for given color combination
    @@ -920,22 +917,22 @@
    roundedLands = roundLands(landFilter(condAdjust(n, landTypes, landCount, condLands, deckList), cost));
    }
    const roundedLandCount = sum(roundedLands);
    const result = landTestUniformSample(roundedLands, n, roundedLandCount, 31, 0, samples, structuredClone(comboReqs));
    const result = await landTestUniformSample(roundedLands, n, roundedLandCount, 31, 0, samples, structuredClone(comboReqs));
    canCast[n] = (result / samples);
    }
    }
    return canCast;
    }

    function xDropDict(x, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypes, landCount, condLands) {
    async function xDropDict(x, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypes, landCount, condLands) {
    const dict = new Map();
    for (let i = 0; i < deckList.length; i++) {
    // run the numbers for cards with a relevant mana cost
    if (deckList[i].colorCost.t - deckList[i].discount == x) {
    let pips = colorReqs(deckList[i].colorCost);
    let pipCode = pipsToNum(pips);
    if (!dict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypes, landCount, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypes, landCount, condLands, deckList);
    dict.set(pipCode, castDistro.slice());
    }
    }
    @@ -944,7 +941,7 @@
    let pips = colorReqs(getColorCost(deckList[i].mana_source.cycling?.cost ?? ""));
    let pipCode = pipsToNum(pips);
    if (!dict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypes, landCount, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypes, landCount, condLands, deckList);
    dict.set(pipCode, castDistro.slice());
    }
    }
    @@ -1099,10 +1096,6 @@
    return costOut;
    }

    // Use setTimeout with a small delay to yield control back to the browser
    // This allows UI updates and event processing between heavy calculations
    const yieldABit = async () => new Promise(resolve => setTimeout(resolve, 0));

    async function deepAnal(loadDict, options) {
    const manaDict = new Map(); // FIXME - Mutable state
    const { landCount, deckSize, deckList, commander1, commander2, commander3, roundWUBRG, landAdded, landTypes, condLands, sources } = loadDict;
    @@ -1116,27 +1109,27 @@
    fourDropDict: new Map()
    };
    // calculate for one drops based on mana from lands
    const oneDropDict = xDropDict(1, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypes, landCount, condLands);
    const oneDropDict = await xDropDict(1, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypes, landCount, condLands);
    dropDicts = { ...dropDicts, oneDropDict };
    // scan one drops for mana sources
    const { landTypes: landTypesAfterDrops1, landCount: landCountAfterDrops1 } = scanForSources(1, deckList, landTypes, landCount, sources, dropDicts, manaDict, deckSize, initialMullLands);
    // reflect newly identified sources in post-mulligan distribution
    const mullLandsAfterDrops1 = calcMullLands(landCountAfterDrops1, deckSize);
    await yieldABit();
    // calculate for two drops based on mana from lands and one drops
    const twoDropDict = xDropDict(2, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops1, landCountAfterDrops1, condLands);
    const twoDropDict = await xDropDict(2, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops1, landCountAfterDrops1, condLands);
    dropDicts = { ...dropDicts, twoDropDict };
    // scan two drops for mana sources
    const { landTypes: landTypesAfterDrops2, landCount: landCountAfterDrops2 } = scanForSources(2, deckList, landTypesAfterDrops1, landCountAfterDrops1, sources, dropDicts, manaDict, deckSize, mullLandsAfterDrops1);
    const mullLandsAfterDrops2 = calcMullLands(landCountAfterDrops2, deckSize);
    await yieldABit();
    // repeat for three and four drops
    const threeDropDict = xDropDict(3, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops2, landCountAfterDrops2, condLands);
    const threeDropDict = await xDropDict(3, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops2, landCountAfterDrops2, condLands);
    dropDicts = { ...dropDicts, threeDropDict };
    const { landTypes: landTypesAfterDrops3, landCount: landCountAfterDrops3 } = scanForSources(3, deckList, landTypesAfterDrops2, landCountAfterDrops2, sources, dropDicts, manaDict, deckSize, mullLandsAfterDrops2);
    const mullLandsAfterDrops3 = calcMullLands(landCountAfterDrops3, deckSize);
    await yieldABit();
    const fourDropDict = xDropDict(4, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops3, landCountAfterDrops3, condLands);
    const fourDropDict = await xDropDict(4, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops3, landCountAfterDrops3, condLands);
    dropDicts = { ...dropDicts, fourDropDict };
    const { landTypes: landTypesAfterDrops4, landCount: landCountAfterDrops4 } = scanForSources(4, deckList, landTypesAfterDrops3, landCountAfterDrops3, sources, dropDicts, manaDict, deckSize, mullLandsAfterDrops3);
    const mullLandsAfterDrops4 = calcMullLands(landCountAfterDrops4, deckSize);
    @@ -1146,7 +1139,7 @@
    let pips = colorReqs(deckList[i].colorCost);
    let pipCode = pipsToNum(pips);
    if (!manaDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    manaDict.set(pipCode, castDistro.slice());
    }
    await yieldABit();
    @@ -1156,23 +1149,23 @@
    let pips = colorReqs(commander1.colorCost);
    let pipCode = pipsToNum(pips);
    if (commander1.colorCost.t - commander1.discount == 1 && !oneDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    oneDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander1.colorCost.t - commander1.discount == 2 && !twoDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    twoDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander1.colorCost.t - commander1.discount == 3 && !threeDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    threeDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander1.colorCost.t - commander1.discount == 4 && !fourDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    fourDropDict.set(pipCode, castDistro.slice());
    }
    else if (!manaDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    manaDict.set(pipCode, castDistro.slice());
    }
    await yieldABit();
    @@ -1181,23 +1174,23 @@
    let pips = colorReqs(commander2.colorCost);
    let pipCode = pipsToNum(pips);
    if (commander2.colorCost.t - commander2.discount == 1 && !oneDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    oneDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander2.colorCost.t - commander2.discount == 2 && !twoDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    twoDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander2.colorCost.t - commander2.discount == 3 && !threeDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    threeDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander2.colorCost.t - commander2.discount == 4 && !fourDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    fourDropDict.set(pipCode, castDistro.slice());
    }
    else if (!manaDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    manaDict.set(pipCode, castDistro.slice());
    }
    await yieldABit();
    @@ -1206,23 +1199,23 @@
    let pips = colorReqs(commander3.colorCost);
    let pipCode = pipsToNum(pips);
    if (commander3.colorCost.t - commander3.discount == 1 && !oneDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    oneDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander3.colorCost.t - commander3.discount == 2 && !twoDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    twoDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander3.colorCost.t - commander3.discount == 3 && !threeDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    threeDropDict.set(pipCode, castDistro.slice());
    }
    else if (commander3.colorCost.t - commander3.discount == 4 && !fourDropDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    fourDropDict.set(pipCode, castDistro.slice());
    }
    else if (!manaDict.has(pipCode)) {
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    manaDict.set(pipCode, castDistro.slice());
    }
    await yieldABit();
  14. pakoito revised this gist Jan 28, 2026. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1320,7 +1320,7 @@
    function buildLandTypesFromLoadResult(basicLands, loadResult, originalBasics) {
    // Start with a copy of the original land types (includes non-basics AND old basics)
    const landTypes = [...loadResult.landTypes];

    // Subtract original basics and add new basics
    // originalBasics is the starting configuration passed to optimizer
    landTypes[0] = landTypes[0] - (originalBasics.c || 0) + (basicLands.c || 0); // Wastes (colorless)
    @@ -1329,7 +1329,7 @@
    landTypes[4] = landTypes[4] - (originalBasics.b || 0) + (basicLands.b || 0); // Swamp
    landTypes[8] = landTypes[8] - (originalBasics.r || 0) + (basicLands.r || 0); // Mountain
    landTypes[16] = landTypes[16] - (originalBasics.g || 0) + (basicLands.g || 0); // Forest

    return landTypes;
    }

  15. pakoito revised this gist Jan 28, 2026. 1 changed file with 5 additions and 2 deletions.
    7 changes: 5 additions & 2 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1099,6 +1099,8 @@
    return costOut;
    }

    // Use setTimeout with a small delay to yield control back to the browser
    // This allows UI updates and event processing between heavy calculations
    const yieldABit = async () => new Promise(resolve => setTimeout(resolve, 0));

    async function deepAnal(loadDict, options) {
    @@ -1354,7 +1356,7 @@
    };

    // Run deep analysis using the modified loadResult
    const deepAnalResult = deepAnal(modifiedLoadResult, context.options);
    const deepAnalResult = await deepAnal(modifiedLoadResult, context.options);

    // deepAnal already returns averaged values (divided by totalCmc)
    const castRate = deepAnalResult.cmcOnCurve;
    @@ -1629,7 +1631,8 @@
    topNow);
    }

    await new Promise(resolve => setTimeout(resolve, 1));
    // Yield to allow UI updates and check cancellation every iteration
    await new Promise(resolve => setTimeout(resolve, 0));
    }

    // Extract top results
  16. pakoito revised this gist Jan 28, 2026. 1 changed file with 18 additions and 71 deletions.
    89 changes: 18 additions & 71 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -398,32 +398,7 @@
    }
    card.ignore = false;
    // checking card names against commander names
    if (normalizedCmdr1 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr1.toLowerCase())) {
    if (commander1found > 0) {
    deckList.push(structuredClone(commander1));
    }
    commander1 = card;
    console.log("commander 1: " + card.name);
    commander1found += 1;
    }
    else if (normalizedCmdr2 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr2.toLowerCase())) {
    if (commander2found > 0) {
    deckList.push(structuredClone(commander2));
    }
    commander2 = card;
    console.log("commander 2: " + card.name);
    commander2found += 1;
    }
    else if (normalizedCmdr3 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr3.toLowerCase())) {
    if (commander3found > 0) {
    deckList.push(structuredClone(commander3));
    }
    commander3 = card;
    console.log("commander 3: " + card.name);
    commander3found += 1;
    }
    // categorizing lands and incrementing category totals
    else if (card.card_type.includes("Land")) {
    if (card.card_type.includes("Land")) {
    // handle fetches
    let deckColors = Math.sign(white) + Math.sign(blue) * 2 + Math.sign(black) * 4 + Math.sign(red) * 8 + Math.sign(green) * 16;
    if (card.mana_source.fetch) {
    @@ -483,57 +458,14 @@
    if (normalizedCmdr1 != "nocard" && commander1found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr1 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    // if multiple cards match with the commander, go back and check for exact matches, warn user if there aren't any
    if (commander1found > 1) {
    let exactMatch = (commander1.name.toLowerCase() == normalizedCmdr1.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr1.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander1);
    commander1 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr1 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // repeat for partner
    if (normalizedCmdr2 != "nocard" && commander2found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr2 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    if (commander2found > 1) {
    let exactMatch = (commander2.name.toLowerCase() == normalizedCmdr2.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr2.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander2);
    commander2 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr2 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // repeat for companion
    if (normalizedCmdr3 != "nocard" && commander3found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr3 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    if (commander3found > 1) {
    let exactMatch = (commander3.name.toLowerCase() == normalizedCmdr3.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr3.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander3);
    commander3 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr3 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // Calculate deck colors
    const deckColors = Math.sign(white) + Math.sign(blue) + Math.sign(black) + Math.sign(red) + Math.sign(green);
    return {
    @@ -1167,7 +1099,9 @@
    return costOut;
    }

    function deepAnal(loadDict, options) {
    const yieldABit = async () => new Promise(resolve => setTimeout(resolve, 0));

    async function deepAnal(loadDict, options) {
    const manaDict = new Map(); // FIXME - Mutable state
    const { landCount, deckSize, deckList, commander1, commander2, commander3, roundWUBRG, landAdded, landTypes, condLands, sources } = loadDict;
    const { approxColors, approxSamples, cmdr1Weight, cmdr2Weight, cmdr3Weight } = options;
    @@ -1186,21 +1120,25 @@
    const { landTypes: landTypesAfterDrops1, landCount: landCountAfterDrops1 } = scanForSources(1, deckList, landTypes, landCount, sources, dropDicts, manaDict, deckSize, initialMullLands);
    // reflect newly identified sources in post-mulligan distribution
    const mullLandsAfterDrops1 = calcMullLands(landCountAfterDrops1, deckSize);
    await yieldABit();
    // calculate for two drops based on mana from lands and one drops
    const twoDropDict = xDropDict(2, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops1, landCountAfterDrops1, condLands);
    dropDicts = { ...dropDicts, twoDropDict };
    // scan two drops for mana sources
    const { landTypes: landTypesAfterDrops2, landCount: landCountAfterDrops2 } = scanForSources(2, deckList, landTypesAfterDrops1, landCountAfterDrops1, sources, dropDicts, manaDict, deckSize, mullLandsAfterDrops1);
    const mullLandsAfterDrops2 = calcMullLands(landCountAfterDrops2, deckSize);
    await yieldABit();
    // repeat for three and four drops
    const threeDropDict = xDropDict(3, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops2, landCountAfterDrops2, condLands);
    dropDicts = { ...dropDicts, threeDropDict };
    const { landTypes: landTypesAfterDrops3, landCount: landCountAfterDrops3 } = scanForSources(3, deckList, landTypesAfterDrops2, landCountAfterDrops2, sources, dropDicts, manaDict, deckSize, mullLandsAfterDrops2);
    const mullLandsAfterDrops3 = calcMullLands(landCountAfterDrops3, deckSize);
    await yieldABit();
    const fourDropDict = xDropDict(4, deckList, approxColors, approxSamples, roundWUBRG, landAdded, landTypesAfterDrops3, landCountAfterDrops3, condLands);
    dropDicts = { ...dropDicts, fourDropDict };
    const { landTypes: landTypesAfterDrops4, landCount: landCountAfterDrops4 } = scanForSources(4, deckList, landTypesAfterDrops3, landCountAfterDrops3, sources, dropDicts, manaDict, deckSize, mullLandsAfterDrops3);
    const mullLandsAfterDrops4 = calcMullLands(landCountAfterDrops4, deckSize);
    await yieldABit();
    // calculate for 4 and above based on lands, 1, 2, and 3
    for (let i = 0; i < deckList.length; i++) {
    let pips = colorReqs(deckList[i].colorCost);
    @@ -1209,6 +1147,7 @@
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    manaDict.set(pipCode, castDistro.slice());
    }
    await yieldABit();
    }
    // calculate for commanders
    if (commander1) {
    @@ -1234,6 +1173,7 @@
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    manaDict.set(pipCode, castDistro.slice());
    }
    await yieldABit();
    }
    if (commander2) {
    let pips = colorReqs(commander2.colorCost);
    @@ -1258,6 +1198,7 @@
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    manaDict.set(pipCode, castDistro.slice());
    }
    await yieldABit();
    }
    if (commander3) {
    let pips = colorReqs(commander3.colorCost);
    @@ -1282,6 +1223,7 @@
    const castDistro = pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList);
    manaDict.set(pipCode, castDistro.slice());
    }
    await yieldABit();
    }
    // now that all of the mana values are calculated for and added to the dictionary, add up all the relevant info and return it
    let costsCovered = new Set();
    @@ -1291,7 +1233,7 @@
    let allCards = new Array();
    // check how well deck can cast commanders
    if (commander1) {
    let weight = cmdr1Weight ?? 30;
    let weight = cmdr1Weight;
    // get the distribution to afford the commander's mana cost
    let turnCastDist = turnsToCast(commander1.colorCost, commander1.discount, dropDicts, manaDict, landCountAfterDrops4, deckSize, mullLandsAfterDrops4);
    let cmc = Math.min(commander1.colorCost.t - commander1.discount, 12);
    @@ -1305,6 +1247,7 @@
    costsCovered.add(factorDiscount(commander1.mana_cost, commander1.discount));
    }
    allCards.push({ name: commander1.name, onCurveRate, avgDelay, isCommander: true });
    await yieldABit();
    }
    if (commander2) {
    let weight = cmdr2Weight ?? 30;
    @@ -1319,6 +1262,7 @@
    costsCovered.add(factorDiscount(commander2.mana_cost, commander2.discount));
    }
    allCards.push({ name: commander2.name, onCurveRate, avgDelay, isCommander: true });
    await yieldABit();
    }
    if (commander3) {
    let weight = cmdr3Weight ?? 30;
    @@ -1333,7 +1277,9 @@
    costsCovered.add(factorDiscount(commander3.mana_cost, commander3.discount));
    }
    allCards.push({ name: commander3.name, onCurveRate, avgDelay });
    await yieldABit();
    }
    await yieldABit();
    // do the same for every other card in the deck
    for (let i = 0; i < deckList.length; i++) {
    if ((!deckList[i].card_type.includes("Land") || (deckList[i].card_type.includes("//") && !(deckList[i].card_type == "Land // Land"))) && !deckList[i].ignore) {
    @@ -1348,6 +1294,7 @@
    costsCovered.add(factorDiscount(deckList[i].mana_cost, deckList[i].discount));
    allCards.push({ name: deckList[i].name, onCurveRate, avgDelay });
    }
    await yieldABit();
    }
    }
    // compute averages
  17. pakoito revised this gist Jan 28, 2026. 1 changed file with 69 additions and 1 deletion.
    70 changes: 69 additions & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -398,7 +398,32 @@
    }
    card.ignore = false;
    // checking card names against commander names
    if (card.card_type.includes("Land")) {
    if (normalizedCmdr1 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr1.toLowerCase())) {
    if (commander1found > 0) {
    deckList.push(structuredClone(commander1));
    }
    commander1 = card;
    console.log("commander 1: " + card.name);
    commander1found += 1;
    }
    else if (normalizedCmdr2 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr2.toLowerCase())) {
    if (commander2found > 0) {
    deckList.push(structuredClone(commander2));
    }
    commander2 = card;
    console.log("commander 2: " + card.name);
    commander2found += 1;
    }
    else if (normalizedCmdr3 !== "nocard" && card.name.toLowerCase().includes(normalizedCmdr3.toLowerCase())) {
    if (commander3found > 0) {
    deckList.push(structuredClone(commander3));
    }
    commander3 = card;
    console.log("commander 3: " + card.name);
    commander3found += 1;
    }
    // categorizing lands and incrementing category totals
    else if (card.card_type.includes("Land")) {
    // handle fetches
    let deckColors = Math.sign(white) + Math.sign(blue) * 2 + Math.sign(black) * 4 + Math.sign(red) * 8 + Math.sign(green) * 16;
    if (card.mana_source.fetch) {
    @@ -458,14 +483,57 @@
    if (normalizedCmdr1 != "nocard" && commander1found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr1 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    // if multiple cards match with the commander, go back and check for exact matches, warn user if there aren't any
    if (commander1found > 1) {
    let exactMatch = (commander1.name.toLowerCase() == normalizedCmdr1.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr1.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander1);
    commander1 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr1 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // repeat for partner
    if (normalizedCmdr2 != "nocard" && commander2found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr2 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    if (commander2found > 1) {
    let exactMatch = (commander2.name.toLowerCase() == normalizedCmdr2.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr2.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander2);
    commander2 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr2 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // repeat for companion
    if (normalizedCmdr3 != "nocard" && commander3found == 0) {
    errors.push("Error: The name \"" + normalizedCmdr3 + "\" did not match with any of the cards in your decklist. Double check the spelling and capitalization and try again");
    }
    if (commander3found > 1) {
    let exactMatch = (commander3.name.toLowerCase() == normalizedCmdr3.toLowerCase());
    for (let i = 0; i < deckList.length && !exactMatch; i++) {
    if (deckList[i].name.toLowerCase() == normalizedCmdr3.toLowerCase()) {
    let swap = structuredClone(deckList[i]);
    deckList[i] = structuredClone(commander3);
    commander3 = swap;
    exactMatch = true;
    }
    }
    if (!exactMatch) {
    errors.push("Error: The name \"" + normalizedCmdr3 + "\" matched with multiple cards in your decklist. Try entering the card's full name");
    }
    }
    // Calculate deck colors
    const deckColors = Math.sign(white) + Math.sign(blue) + Math.sign(black) + Math.sign(red) + Math.sign(green);
    return {
  18. pakoito revised this gist Jan 28, 2026. 1 changed file with 15 additions and 13 deletions.
    28 changes: 15 additions & 13 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1290,7 +1290,7 @@

    /**
    * Optimizer Wrapper for Manatool Library
    *
    *
    * This wrapper provides the optimizer interface expected by the userscript
    * while using the core calculation functions from the manatool library.
    */
    @@ -1300,18 +1300,19 @@
    * Build land types array from basic lands configuration using existing loadResult
    * This preserves all the non-basic lands and just replaces the basic counts
    */
    function buildLandTypesFromLoadResult(basicLands, loadResult) {
    // Start with a copy of the original land types (includes non-basics)
    function buildLandTypesFromLoadResult(basicLands, loadResult, originalBasics) {
    // Start with a copy of the original land types (includes non-basics AND old basics)
    const landTypes = [...loadResult.landTypes];

    // Replace basic land counts (indices 0, 1, 2, 4, 8, 16)
    landTypes[0] = basicLands.c || 0; // Wastes (colorless)
    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


    // Subtract original basics and add new basics
    // originalBasics is the starting configuration passed to optimizer
    landTypes[0] = landTypes[0] - (originalBasics.c || 0) + (basicLands.c || 0); // Wastes (colorless)
    landTypes[1] = landTypes[1] - (originalBasics.w || 0) + (basicLands.w || 0); // Plains
    landTypes[2] = landTypes[2] - (originalBasics.u || 0) + (basicLands.u || 0); // Island
    landTypes[4] = landTypes[4] - (originalBasics.b || 0) + (basicLands.b || 0); // Swamp
    landTypes[8] = landTypes[8] - (originalBasics.r || 0) + (basicLands.r || 0); // Mountain
    landTypes[16] = landTypes[16] - (originalBasics.g || 0) + (basicLands.g || 0); // Forest

    return landTypes;
    }

    @@ -1330,7 +1331,7 @@
    // Deep copy to prevent mutation of original loadResult by deepAnal
    const modifiedLoadResult = {
    ...context.loadResult,
    landTypes: buildLandTypesFromLoadResult(basicLands, context.loadResult),
    landTypes: buildLandTypesFromLoadResult(basicLands, context.loadResult, context.originalBasics),
    // Deep copy arrays that get mutated in deepAnal
    landAdded: [...context.loadResult.landAdded],
    condLands: context.loadResult.condLands.map(arr => [...arr]),
    @@ -1572,6 +1573,7 @@
    // Build context with loadResult for testConfiguration
    const context = {
    loadResult: loadResult,
    originalBasics: startingLands, // Store original basics to swap in/out
    options: {
    approxColors: options.calculatorOptions?.approxColors || 5,
    approxSamples: options.calculatorOptions?.approxSamples || 100000,
  19. pakoito revised this gist Jan 28, 2026. 1 changed file with 55 additions and 58 deletions.
    113 changes: 55 additions & 58 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1303,15 +1303,15 @@
    function buildLandTypesFromLoadResult(basicLands, loadResult) {
    // Start with a copy of the original land types (includes non-basics)
    const landTypes = [...loadResult.landTypes];

    // Replace basic land counts (indices 0, 1, 2, 4, 8, 16)
    landTypes[0] = basicLands.c || 0; // Wastes (colorless)
    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

    return landTypes;
    }

    @@ -1320,12 +1320,12 @@
    */
    async function testConfiguration(basicLands, context, cache) {
    const hash = hashLands(basicLands);

    // Check cache
    if (cache.has(hash)) {
    return cache.get(hash);
    }

    // Build modified loadDictResult with new basic lands
    // Deep copy to prevent mutation of original loadResult by deepAnal
    const modifiedLoadResult = {
    @@ -1336,24 +1336,21 @@
    condLands: context.loadResult.condLands.map(arr => [...arr]),
    roundWUBRG: context.loadResult.roundWUBRG.map(arr => [...arr])
    };

    // Recalculate landCount
    modifiedLoadResult.landCount = modifiedLoadResult.landTypes.reduce((sum, count) => sum + count, 0);


    // Run deep analysis using the modified loadResult
    const deepAnalResult = deepAnal(modifiedLoadResult, context.options);

    // deepAnal already returns averaged values (divided by totalCmc)
    const castRate = deepAnalResult.cmcOnCurve;
    const avgDelay = deepAnalResult.totalCmcDelay;

    // Store in cache
    const entry = {
    lands: { ...basicLands },
    castRate: castRate,
    avgDelay: avgDelay
    };

    cache.set(hash, entry);
    return entry;
    }
    @@ -1364,69 +1361,69 @@
    function generateAllNeighbors(lands) {
    const neighbors = [];
    const colors = ['w', 'u', 'b', 'r', 'g', 'c'];

    // Find active colors
    const activeColors = colors.filter(c => (lands[c] || 0) > 0);

    // Generate all swaps
    for (let i = 0; i < activeColors.length; i++) {
    const fromColor = activeColors[i];

    for (let j = 0; j < activeColors.length; j++) {
    if (i === j) continue;
    const toColor = activeColors[j];

    const neighbor = { ...lands };
    neighbor[fromColor] = (neighbor[fromColor] || 0) - 1;
    neighbor[toColor] = (neighbor[toColor] || 0) + 1;
    neighbors.push(neighbor);
    }
    }

    return neighbors;
    }

    /**
    * Iterative hill-climbing exploration
    */
    async function* exploreIterative(baseline, baselineCR, context, cache, visited, stats, opts) {
    const queue = [{lands: baseline, parentCastRate: baselineCR}];
    const queue = [{ lands: baseline, parentCastRate: baselineCR }];

    while (queue.length > 0) {
    if (stats.tested >= opts.maxIterations) break;
    if (opts.signal?.aborted) break;

    const current = queue.shift();
    const hash = hashLands(current.lands);

    if (visited.has(hash)) continue;
    visited.add(hash);

    const currentResult = cache.get(hash);
    if (!currentResult) continue;

    const neighbors = generateAllNeighbors(current.lands);

    for (const neighbor of neighbors) {
    if (stats.tested >= opts.maxIterations) break;
    if (opts.signal?.aborted) break;

    const neighborHash = hashLands(neighbor);

    let result;
    if (cache.has(neighborHash)) {
    result = cache.get(neighborHash);
    } else {
    result = await testConfiguration(neighbor, context, cache);
    stats.tested++;
    }

    if (result.castRate > current.parentCastRate) {
    stats.improved++;
    queue.push({lands: neighbor, parentCastRate: result.castRate});
    queue.push({ lands: neighbor, parentCastRate: result.castRate });
    }
    yield {tested: stats.tested, improved: stats.improved};

    yield { tested: stats.tested, improved: stats.improved };
    }
    }
    }
    @@ -1444,7 +1441,7 @@
    */
    function extractTopResults(cache, n) {
    const results = Array.from(cache.values());

    results.sort((a, b) => {
    const rateDiff = b.castRate - a.castRate;
    if (Math.abs(rateDiff) > 0.001) {
    @@ -1453,7 +1450,7 @@
    return a.avgDelay - b.avgDelay;
    }
    });

    return results.slice(0, n);
    }

    @@ -1470,21 +1467,21 @@
    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';
    }

    @@ -1493,27 +1490,27 @@
    */
    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;
    }

    @@ -1524,54 +1521,54 @@
    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');
    }

    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;

    const startTime = Date.now();
    const cache = new Map();
    const visited = new Set();
    const stats = {
    tested: 0,
    improved: 0};

    // Load cards - reuse testDict if available
    if (onProgress) {
    const msg = testDict ? 'Using loaded deck data...' : 'Loading cards from Scryfall...';
    onProgress(0, 0, msg);
    }

    const commanderNames = commanders.filter(c => c);
    const loadResult = await loadDict(
    deckList,
    commanderNames[0] || '',
    commanderNames[1] || '',
    commanderNames[2] || ''
    );

    if (signal?.aborted) {
    throw new Error('Optimization cancelled');
    }

    // Build context with loadResult for testConfiguration
    const context = {
    loadResult: loadResult,
    @@ -1583,22 +1580,22 @@
    cmdr3Weight: options.calculatorOptions?.cmdr3Weight || 15
    }
    };

    // 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
    if (onProgress) {
    const topNow = extractTopResults(cache, topN);
    onProgress(1, 0, 'Exploring configurations...', topNow);
    }

    for await (const progress of exploreIterative(
    startingLands,
    startResult.castRate,
    @@ -1610,20 +1607,20 @@
    )) {
    if (onProgress) {
    const topNow = extractTopResults(cache, topN);
    onProgress(progress.tested, maxIterations,
    onProgress(progress.tested, maxIterations,
    `Tested ${progress.tested} configs, found ${progress.improved} improvements`,
    topNow);
    }

    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: {
  20. pakoito revised this gist Jan 28, 2026. 1 changed file with 6 additions and 1 deletion.
    7 changes: 6 additions & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1327,9 +1327,14 @@
    }

    // Build modified loadDictResult with new basic lands
    // Deep copy to prevent mutation of original loadResult by deepAnal
    const modifiedLoadResult = {
    ...context.loadResult,
    landTypes: buildLandTypesFromLoadResult(basicLands, context.loadResult)
    landTypes: buildLandTypesFromLoadResult(basicLands, context.loadResult),
    // Deep copy arrays that get mutated in deepAnal
    landAdded: [...context.loadResult.landAdded],
    condLands: context.loadResult.condLands.map(arr => [...arr]),
    roundWUBRG: context.loadResult.roundWUBRG.map(arr => [...arr])
    };

    // Recalculate landCount
  21. pakoito revised this gist Jan 28, 2026. 1 changed file with 3 additions and 3 deletions.
    6 changes: 3 additions & 3 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1338,9 +1338,9 @@
    // Run deep analysis using the modified loadResult
    const deepAnalResult = deepAnal(modifiedLoadResult, context.options);

    // Calculate metrics from deepAnal result
    const castRate = deepAnalResult.cmcOnCurve / (deepAnalResult.cmcOnCurve + deepAnalResult.totalCmcDelay);
    const avgDelay = deepAnalResult.totalCmcDelay / deepAnalResult.allCards.length;
    // deepAnal already returns averaged values (divided by totalCmc)
    const castRate = deepAnalResult.cmcOnCurve;
    const avgDelay = deepAnalResult.totalCmcDelay;

    // Store in cache
    const entry = {
  22. pakoito revised this gist Jan 28, 2026. 1 changed file with 35 additions and 120 deletions.
    155 changes: 35 additions & 120 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1297,80 +1297,22 @@


    /**
    * Parse deck list into entries with quantities
    * Build land types array from basic lands configuration using existing loadResult
    * This preserves all the non-basic lands and just replaces the basic counts
    */
    function parseDeckList(deckListText) {
    const lines = deckListText.split('\n');
    const entries = [];
    function buildLandTypesFromLoadResult(basicLands, loadResult) {
    // Start with a copy of the original land types (includes non-basics)
    const landTypes = [...loadResult.landTypes];

    for (const line of lines) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('//')) continue;
    if (trimmed.toLowerCase() === 'sideboard') break;

    const match = trimmed.match(/^(\d+)x?\s+(.+)$/);
    if (match) {
    const quantity = parseInt(match[1]);
    const name = match[2].trim();
    entries.push({ quantity, name });
    }
    }

    return entries;
    }

    /**
    * Build land types array from basic lands configuration
    */
    function buildLandTypes(basicLands, deckList, deckColorCounts) {
    // Start with empty array
    const landTypes = new Array(32).fill(0);

    // Add basics from configuration
    // Replace basic land counts (indices 0, 1, 2, 4, 8, 16)
    landTypes[0] = basicLands.c || 0; // Wastes (colorless)
    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
    // Wastes (colorless) at index 0
    landTypes[0] = basicLands.c || 0;

    // Calculate fetch equivalents for non-basic lands in deck
    const sources = {
    white: deckColorCounts.white || 0,
    blue: deckColorCounts.blue || 0,
    black: deckColorCounts.black || 0,
    red: deckColorCounts.red || 0,
    green: deckColorCounts.green || 0,
    plainsTypes: new Array(32).fill(0),
    islandTypes: new Array(32).fill(0),
    swampTypes: new Array(32).fill(0),
    mountainTypes: new Array(32).fill(0),
    forestTypes: new Array(32).fill(0)
    };

    // Add non-basic lands from deck
    for (const card of deckList) {
    if (card.type_line && card.type_line.includes('Land')) {
    const source = {
    w: card.produces?.w || false,
    u: card.produces?.u || false,
    b: card.produces?.b || false,
    r: card.produces?.r || false,
    g: card.produces?.g || false,
    fetch: card.fetch || null
    };

    const fetchEquiv = getFetchEquivalent(source, sources);
    for (let i = 0; i < 32; i++) {
    landTypes[i] += fetchEquiv[i];
    }
    }
    }

    const landCount = landTypes.reduce((sum, count) => sum + count, 0);

    return { landTypes, landCount };
    return landTypes;
    }

    /**
    @@ -1384,28 +1326,27 @@
    return cache.get(hash);
    }

    // Build land types
    const { landTypes, landCount } = buildLandTypes(
    basicLands,
    context.deckList,
    context.deckColorCounts
    );
    // Build modified loadDictResult with new basic lands
    const modifiedLoadResult = {
    ...context.loadResult,
    landTypes: buildLandTypesFromLoadResult(basicLands, context.loadResult)
    };

    // Run deep analysis
    const result = await deepAnal(
    context.deckList,
    context.commanders,
    landTypes,
    landCount,
    context.deckSize,
    context.options
    );
    // Recalculate landCount
    modifiedLoadResult.landCount = modifiedLoadResult.landTypes.reduce((sum, count) => sum + count, 0);

    // Run deep analysis using the modified loadResult
    const deepAnalResult = deepAnal(modifiedLoadResult, context.options);

    // Calculate metrics from deepAnal result
    const castRate = deepAnalResult.cmcOnCurve / (deepAnalResult.cmcOnCurve + deepAnalResult.totalCmcDelay);
    const avgDelay = deepAnalResult.totalCmcDelay / deepAnalResult.allCards.length;

    // Store in cache
    const entry = {
    lands: { ...basicLands },
    castRate: result.castRate,
    avgDelay: result.avgDelay
    castRate: castRate,
    avgDelay: avgDelay
    };

    cache.set(hash, entry);
    @@ -1608,7 +1549,7 @@
    tested: 0,
    improved: 0};

    // Load cards
    // Load cards - reuse testDict if available
    if (onProgress) {
    const msg = testDict ? 'Using loaded deck data...' : 'Loading cards from Scryfall...';
    onProgress(0, 0, msg);
    @@ -1626,43 +1567,16 @@
    throw new Error('Optimization cancelled');
    }

    // Calculate deck size
    const entries = parseDeckList(deckList);
    const deckSize = entries.reduce((sum, e) => sum + e.quantity, 0);

    // Calculate deck color counts
    const deckColorCounts = {
    white: 0,
    blue: 0,
    black: 0,
    red: 0,
    green: 0
    };

    const allCards = [
    ...loadResult.deckList,
    loadResult.commander1,
    loadResult.commander2,
    loadResult.commander3
    ].filter(c => c);

    for (const card of allCards) {
    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
    // Build context with loadResult for testConfiguration
    const context = {
    deckList: loadResult.deckList,
    commanders: [loadResult.commander1, loadResult.commander2, loadResult.commander3].filter(c => c),
    deckSize: deckSize,
    deckColorCounts: deckColorCounts,
    options: options.calculatorOptions || {}
    loadResult: loadResult,
    options: {
    approxColors: options.calculatorOptions?.approxColors || 5,
    approxSamples: options.calculatorOptions?.approxSamples || 100000,
    cmdr1Weight: options.calculatorOptions?.cmdr1Weight || 30,
    cmdr2Weight: options.calculatorOptions?.cmdr2Weight || 30,
    cmdr3Weight: options.calculatorOptions?.cmdr3Weight || 15
    }
    };

    // Test starting configuration
    @@ -1719,6 +1633,7 @@
    exports.describeLandChanges = describeLandChanges;
    exports.extractTopResults = extractTopResults;
    exports.generateAllNeighbors = generateAllNeighbors;
    exports.generateNeighbors = generateAllNeighbors;
    exports.hashLands = hashLands;
    exports.optimizeLands = optimizeLands;

  23. pakoito revised this gist Jan 28, 2026. 1 changed file with 1271 additions and 1945 deletions.
    3,216 changes: 1,271 additions & 1,945 deletions manabase-optimizer-bundle.js
    1,271 additions, 1,945 deletions not shown because the diff is too large. Please use a local Git client to view these changes.
  24. pakoito revised this gist Jan 28, 2026. 1 changed file with 176 additions and 155 deletions.
    331 changes: 176 additions & 155 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -385,6 +385,42 @@
    return set1;
    }

    // ============================================================================
    // LAND CONFIGURATION
    // ============================================================================

    /**
    * Build landTypes array from basic land counts
    *
    * Converts simple land counts to the 32-element land type array used internally.
    *
    * Land type encoding (bitmask):
    * - landTypes[0] = Wastes (colorless)
    * - landTypes[1] = Plains (W)
    * - landTypes[2] = Island (U)
    * - landTypes[4] = Swamp (B)
    * - landTypes[8] = Mountain (R)
    * - landTypes[16] = Forest (G)
    * - landTypes[3] = W/U dual lands, etc.
    *
    * @param {Object} landCounts - Basic land counts {w, u, b, r, g, c}
    * @returns {number[]} Land types array (length 32)
    */
    /**
    * Calculate land index bitmask from mana source (matches website logic)
    * @param {Object} manaSource - Mana source with w, u, b, r, g flags
    * @returns {number} Bitmask index (0-31)
    */
    function findLandIndex(manaSource) {
    if (!manaSource || typeof manaSource !== 'object') return 0;

    return (manaSource.w ? 1 : 0) |
    (manaSource.u ? 2 : 0) |
    (manaSource.b ? 4 : 0) |
    (manaSource.r ? 8 : 0) |
    (manaSource.g ? 16 : 0);
    }

    /**
    * Card Loader Module
    *
    @@ -1196,7 +1232,7 @@
    * Based on website's getFetchEquivalent function
    */

    function getFetchEquivalent(source, deckColors, deckColorCounts) {
    function getFetchEquivalent(source, deckColors, deckColorCounts, landTypeArrays) {
    const myLandTypes = new Array(32).fill(0);

    // Calculate landIndex from source, masked to deck colors
    @@ -1234,12 +1270,42 @@
    myLandTypes[16] += 0.5 / colors;
    }
    }
    // Handle nonbasic fetches (like Marsh Flats, Polluted Delta)
    // Handle nonbasic fetches (like Marsh Flats, Polluted Delta) - website logic line 956+
    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;
    const { plainsTypes, islandTypes, swampTypes, mountainTypes, forestTypes } = landTypeArrays;

    const canFetch = new Array(32).fill(false);
    let myIndex = landIndex;

    // Determine which land types can be fetched
    for (let i = 0; i < 32; i++) {
    canFetch[i] = (plainsTypes[i] && source.w) ||
    (islandTypes[i] && source.u) ||
    (swampTypes[i] && source.b) ||
    (mountainTypes[i] && source.r) ||
    (forestTypes[i] && source.g);

    if (canFetch[i]) {
    // Include this land type in my index
    myIndex = myIndex | i;

    // Remove redundant options (if can fetch WR, don't count just W or just R)
    canFetch[i & 30] = canFetch[i & 30] && !(i & 1); // Remove if has W but not this one
    canFetch[i & 29] = canFetch[i & 29] && !(i & 2); // Remove if has U but not this one
    canFetch[i & 27] = canFetch[i & 27] && !(i & 4); // Remove if has B but not this one
    canFetch[i & 23] = canFetch[i & 23] && !(i & 8); // Remove if has R but not this one
    canFetch[i & 15] = canFetch[i & 15] && !(i & 16); // Remove if has G but not this one
    }
    }

    myLandTypes[myIndex] += 0.5;
    let numOptions = canFetch.filter(x => x).length;

    for (let i = 0; i < 32; i++) {
    if (canFetch[i]) {
    myLandTypes[i] += 0.5 / numOptions;
    }
    }
    }

    return myLandTypes;
    @@ -1258,6 +1324,56 @@
    * This matches the website's behavior exactly.
    */

    /**
    * Calculate turn distribution for casting a card (shared helper - matches website logic)
    */
    function calculateTurnsToCast(colorCost, discount, mullLands, deckSize, landCount, oneDropDict, twoDropDict, threeDropDict, fourDropDict, manaDict) {
    const effectiveCMC = colorCost.t - discount;
    const pips = colorReqs(colorCost);
    const pipCode = pipsToNum(pips);

    // Get pip requirements from appropriate dict
    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 if (manaDict) dict = manaDict;
    else return null;

    const pipReqs = dict.get(pipCode);
    if (!pipReqs) return null;

    // Clone and zero out impossible turns
    const landReqs = pipReqs.slice();
    for (let i = 0; i < effectiveCMC; i++) {
    landReqs[i] = 0;
    }

    // Calculate turn distribution (CDF → PDF)
    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;
    }

    /**
    * Scan cards of a given CMC for mana sources and add them to landTypes.
    @@ -1270,67 +1386,13 @@
    * @param {Object} options - Configuration options
    * @returns {number} Updated land count
    */
    function scanForSources(deckList, cmc, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict, options) {
    function scanForSources(deckList, cmc, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict, options, landTypeTracking) {
    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;
    };
    // Use shared calculateTurnsToCast function (no manaDict for passes 1-4)

    for (const card of deckList) {
    // Skip if wrong CMC
    @@ -1356,8 +1418,8 @@
    typeMult = 0.5;
    }

    // Get probability of casting this card on turn X using pre-calculated distributions
    const turnDist = turnsToCast(card.colorCost, card.discount || 0);
    // Get probability of casting this card on turn X using shared function
    const turnDist = calculateTurnsToCast(card.colorCost, card.discount || 0, mullLands, deckSize, landCount, oneDropDict, twoDropDict, threeDropDict, fourDropDict, null);

    if (turnDist) {
    // Weight by probability of having cast it by this turn
    @@ -1372,7 +1434,7 @@

    // Handle fetch sources (like Land Tax, fetch lands)
    if (card.mana_source.fetch) {
    const fetchLandTypes = getFetchEquivalent(card.mana_source, deckColors, deckColorCounts);
    const fetchLandTypes = getFetchEquivalent(card.mana_source, deckColors, deckColorCounts, landTypeTracking);
    for (let j = 0; j < 32; j++) {
    landTypes[j] += fetchLandTypes[j] * count * typeMult;
    }
    @@ -1421,8 +1483,6 @@
    newLandCount += addition;
    }
    }

    // TODO: Handle fetches, "choose a color", etc.
    }

    return newLandCount;
    @@ -1434,21 +1494,6 @@
    * @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.
    *
    @@ -1461,6 +1506,8 @@
    * @returns {Promise<Object>} {castRate, avgDelay, cardMetrics}
    */
    async function calculateDeckMetricsIterative(deckList, commanders, initialLandTypes, initialLandCount, deckSize, options = {}) {
    // Extract landTypeTracking if provided
    const landTypeTracking = options.landTypeTracking || null;
    // Start with just lands
    let landTypes = [...initialLandTypes];
    let landCount = initialLandCount;
    @@ -1546,7 +1593,7 @@
    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);
    landCount = scanForSources(deckList, 1, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict, options, landTypeTracking);
    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]);

    @@ -1555,22 +1602,22 @@
    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);
    landCount = scanForSources(deckList, 2, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict, options, landTypeTracking);
    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);
    landCount = scanForSources(deckList, 3, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict, options, landTypeTracking);
    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);
    landCount = scanForSources(deckList, 4, landTypes, landCount, deckSize, deckColors, deckColorCounts, oneDropDict, twoDropDict, threeDropDict, fourDropDict, options, landTypeTracking);
    console.log('After: landCount =', landCount);

    console.log('\n=== Final land count: ' + landCount + ' ===');
    @@ -1632,58 +1679,7 @@
    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;
    };
    // Use shared calculateTurnsToCast function (with manaDict for CMC 5+)

    // Process commanders
    for (const cmd of commanders) {
    @@ -1700,7 +1696,7 @@

    if (castDistro) {
    // Use pre-calculated pip distribution from dict + current mullLands (like website does)
    const turnDist = turnsToCast(cmd.colorCost, cmd.discount || 0, mullLands);
    const turnDist = calculateTurnsToCast(cmd.colorCost, cmd.discount || 0, mullLands, deckSize, landCount, oneDropDict, twoDropDict, threeDropDict, fourDropDict, manaDict);
    if (turnDist) {
    const onCurveRate = turnDist[cmc] || 0;
    const avgDelay = mean(turnDist) - cmd.colorCost.t + (cmd.discount || 0);
    @@ -1745,7 +1741,7 @@

    if (castDistro) {
    // Use pre-calculated pip distribution from dict + current mullLands (like website does)
    const turnDist = turnsToCast(card.colorCost, card.discount || 0, mullLands);
    const turnDist = calculateTurnsToCast(card.colorCost, card.discount || 0, mullLands, deckSize, landCount, oneDropDict, twoDropDict, threeDropDict, fourDropDict, manaDict);
    if (turnDist) {
    const onCurveRate = turnDist[cmc] || 0;
    const avgDelay = mean(turnDist) - card.colorCost.t + (card.discount || 0);
    @@ -1818,6 +1814,13 @@
    const landTypes = new Array(32).fill(0);
    let landCount = 0;

    // Track basic land types for nonbasic fetch logic (website logic line 1066+)
    const plainsTypes = new Array(32).fill(false);
    const islandTypes = new Array(32).fill(false);
    const swampTypes = new Array(32).fill(false);
    const mountainTypes = new Array(32).fill(false);
    const forestTypes = new Array(32).fill(false);

    // Add basic lands first (these are the counts being tested/used)
    landTypes[0] += basicLands.c || 0; // Wastes
    landTypes[1] += basicLands.w || 0; // Plains
    @@ -1829,24 +1832,33 @@
    landCount += (basicLands.w || 0) + (basicLands.u || 0) + (basicLands.b || 0) +
    (basicLands.r || 0) + (basicLands.g || 0) + (basicLands.c || 0);

    // Mark basic land types
    if (basicLands.w > 0) plainsTypes[1] = true;
    if (basicLands.u > 0) islandTypes[2] = true;
    if (basicLands.b > 0) swampTypes[4] = true;
    if (basicLands.r > 0) mountainTypes[8] = true;
    if (basicLands.g > 0) forestTypes[16] = true;

    // 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);
    };
    // First pass: note basic land types (website logic line 1113+)
    for (const card of deckList) {
    if (!card.card_type || !card.card_type.includes('Land')) continue;

    const landIndex = findLandIndex(card.mana_source);
    if (card.card_type.includes('Plains')) plainsTypes[landIndex] = true;
    if (card.card_type.includes('Island')) islandTypes[landIndex] = true;
    if (card.card_type.includes('Swamp')) swampTypes[landIndex] = true;
    if (card.card_type.includes('Mountain')) mountainTypes[landIndex] = true;
    if (card.card_type.includes('Forest')) forestTypes[landIndex] = true;
    }

    // Process non-basic lands (website logic from line 1173+)
    // Second pass: process non-basic lands (website logic from line 1173+)
    for (const card of deckList) {
    if (!card.card_type || !card.card_type.includes('Land')) continue;

    @@ -1858,7 +1870,12 @@

    // Handle fetch lands
    if (card.mana_source && card.mana_source.fetch) {
    const addLandTypes = getFetchEquivalent(card.mana_source, deckColorsBitmask, deckColors);
    const addLandTypes = getFetchEquivalent(
    card.mana_source,
    deckColorsBitmask,
    deckColors,
    { plainsTypes, islandTypes, swampTypes, mountainTypes, forestTypes }
    );
    for (let i = 0; i < 32; i++) {
    landTypes[i] += addLandTypes[i] * count;
    }
    @@ -1888,7 +1905,11 @@
    }
    }

    return { landTypes, landCount };
    return {
    landTypes,
    landCount,
    landTypeTracking: { plainsTypes, islandTypes, swampTypes, mountainTypes, forestTypes }
    };
    }

    /**
    @@ -2131,7 +2152,7 @@

    // Build landTypes using THE ONLY WAY
    // deckList contains only non-basics, basicLands are the test configuration
    const { landTypes, landCount } = buildInitialLandTypes(
    const { landTypes, landCount, landTypeTracking } = buildInitialLandTypes(
    context.deckList,
    basicLands,
    context.deckColorCounts
    @@ -2144,7 +2165,7 @@
    landTypes,
    landCount,
    context.deckSize,
    context.options
    { ...context.options, landTypeTracking }
    );

    // Store in cache
    @@ -2366,7 +2387,7 @@
    // 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:30:58.382Z"';
    ManabaseOptimizer.version = '"2026-01-28T16:13:54.748Z"';
    }

    exports.describeLandChanges = describeLandChanges;
  25. pakoito revised this gist Jan 26, 2026. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -2366,7 +2366,7 @@
    // 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:24:56.555Z"';
    ManabaseOptimizer.version = '"2026-01-26T18:30:58.382Z"';
    }

    exports.describeLandChanges = describeLandChanges;
  26. pakoito revised this gist Jan 26, 2026. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -2366,7 +2366,7 @@
    // 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:23:40.934Z"';
    ManabaseOptimizer.version = '"2026-01-26T18:24:56.555Z"';
    }

    exports.describeLandChanges = describeLandChanges;
  27. pakoito revised this gist Jan 26, 2026. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -2366,7 +2366,7 @@
    // 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:20:54.995Z"';
    ManabaseOptimizer.version = '"2026-01-26T18:23:40.934Z"';
    }

    exports.describeLandChanges = describeLandChanges;
  28. pakoito revised this gist Jan 26, 2026. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -2366,7 +2366,7 @@
    // 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"';
    ManabaseOptimizer.version = '"2026-01-26T18:20:54.995Z"';
    }

    exports.describeLandChanges = describeLandChanges;
  29. pakoito revised this gist Jan 26, 2026. 1 changed file with 3 additions and 3 deletions.
    6 changes: 3 additions & 3 deletions manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1911,10 +1911,10 @@
    function applyIgnoreList(deckList, commanders, ignoreListText) {
    const lines = ignoreListText.split('\n');

    // Reset all cards
    // Reset ignore flags only (keep existing discounts from card-loader)
    for (const card of [...deckList, ...commanders]) {
    card.discount = 0;
    card.ignore = false;
    // Don't reset discount - card-loader already set commonDiscounts
    }

    // Process each line in ignore list
    @@ -2366,7 +2366,7 @@
    // 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"';
    ManabaseOptimizer.version = '"2026-01-26T18:19:04.913Z"';
    }

    exports.describeLandChanges = describeLandChanges;
  30. pakoito revised this gist Jan 26, 2026. 1 changed file with 62 additions and 1 deletion.
    63 changes: 62 additions & 1 deletion manabase-optimizer-bundle.js
    Original file line number Diff line number Diff line change
    @@ -1901,6 +1901,61 @@
    */


    /**
    * 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.
    *
    @@ -1929,6 +1984,7 @@
    deckList,
    commanders = [],
    startingLands,
    ignoreList = '',
    options = {}
    } = input;

    @@ -1966,6 +2022,11 @@
    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);
    @@ -2305,7 +2366,7 @@
    // 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:07:54.628Z"';
    ManabaseOptimizer.version = '"2026-01-26T18:12:28.944Z"';
    }

    exports.describeLandChanges = describeLandChanges;