Last active
January 29, 2026 12:09
-
-
Save pakoito/5c7f9b8c35efee0126b2b874beb365db to your computer and use it in GitHub Desktop.
Manabase Optimizer Bundle - Standalone module for Tampermonkey
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Manabase Optimizer Bundle - Generated from manatool library | |
| (function (global, factory) { | |
| typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : | |
| typeof define === 'function' && define.amd ? define(['exports'], factory) : | |
| (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ManabaseOptimizer = {})); | |
| })(this, (function (exports) { 'use strict'; | |
| function findLandIndex(source) { | |
| let landIndex = 0; | |
| if (source.w) { | |
| landIndex += 1; | |
| } | |
| if (source.u) { | |
| landIndex += 2; | |
| } | |
| if (source.b) { | |
| landIndex += 4; | |
| } | |
| if (source.r) { | |
| landIndex += 8; | |
| } | |
| if (source.g) { | |
| landIndex += 16; | |
| } | |
| return landIndex; | |
| } | |
| function getColorCost(cost) { | |
| // | |
| let cc = { | |
| w: cost.split("{W}").length - 1, | |
| u: cost.split("{U}").length - 1, | |
| b: cost.split("{B}").length - 1, | |
| r: cost.split("{R}").length - 1, | |
| g: cost.split("{G}").length - 1, | |
| wu: cost.split("{W/U}").length - 1, | |
| wb: cost.split("{W/B}").length - 1, | |
| wr: cost.split("{R/W}").length - 1, | |
| wg: cost.split("{G/W}").length - 1, | |
| ub: cost.split("{U/B}").length - 1, | |
| ur: cost.split("{U/R}").length - 1, | |
| ug: cost.split("{G/U}").length - 1, | |
| br: cost.split("{B/R}").length - 1, | |
| bg: cost.split("{B/G}").length - 1, | |
| rg: cost.split("{R/G}").length - 1, | |
| x: cost.split("{X}").length - 1, | |
| c: parseInt(cost.substring(1, cost.length - 1).split('}{')[0]) || 0, | |
| t: 0 // storage value | |
| }; | |
| let xValue = 0; | |
| if (cc.x == 1) { | |
| xValue = 3; // one X, X = 3 | |
| } | |
| else if (cc.x == 2) { | |
| xValue = 2; // 2 Xs, X = 2 | |
| } | |
| else if (cc.x > 2) { | |
| xValue = 1; // 3+ Xs X = 1 | |
| } | |
| // 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); | |
| return cc; | |
| } | |
| function sum(arr) { | |
| return arr.reduce((a, b) => a + b, 0); | |
| } | |
| function getFetchEquivalent(source, sources) { | |
| const { white, blue, black, red, green, plainsTypes, islandTypes, swampTypes, mountainTypes, forestTypes } = sources; | |
| let myLandTypes = new Array(32).fill(0); | |
| let deckColors = Math.sign(white) + Math.sign(blue) * 2 + Math.sign(black) * 4 + Math.sign(red) * 8 + Math.sign(green) * 16; | |
| let landIndex = findLandIndex(source) & deckColors; | |
| // for fetches that get basic lands, just look at the colors | |
| if (source.fetch == "b") { | |
| myLandTypes[landIndex] += 0.5; | |
| let colors = Math.sign(landIndex & 1) + Math.sign(landIndex & 2) + Math.sign(landIndex & 4) + Math.sign(landIndex & 8) + Math.sign(landIndex & 16); | |
| if (source.w && white > 0) { | |
| myLandTypes[1] += 0.5 / colors; | |
| } | |
| if (source.u && blue > 0) { | |
| myLandTypes[2] += 0.5 / colors; | |
| } | |
| if (source.b && black > 0) { | |
| myLandTypes[4] += 0.5 / colors; | |
| } | |
| if (source.r && red > 0) { | |
| myLandTypes[8] += 0.5 / colors; | |
| } | |
| if (source.g && green > 0) { | |
| myLandTypes[16] += 0.5 / colors; | |
| } | |
| } | |
| // for fetches that get nonbasic lands, pay attention to what fetchable duals and triomes we have | |
| else if (source.fetch == "nb") { | |
| let canFetch = new Array(32).fill(false); | |
| new Array(32).fill(false); | |
| let myIndex = landIndex; | |
| 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]) { | |
| //if current options include a land that isn't part of my index, include it in my index | |
| myIndex = myIndex | i; | |
| //if current options include a land just like this with one less color, remove it | |
| canFetch[i & 30] = canFetch[i & 30] && !(i & 1); | |
| canFetch[i & 29] = canFetch[i & 29] && !(i & 2); | |
| canFetch[i & 27] = canFetch[i & 27] && !(i & 4); | |
| canFetch[i & 23] = canFetch[i & 23] && !(i & 8); | |
| canFetch[i & 15] = canFetch[i & 15] && !(i & 16); | |
| } | |
| } | |
| myLandTypes[myIndex] += 0.5; | |
| let numOptions = sum(canFetch); | |
| for (let i = 0; i < 32; i++) { | |
| if (canFetch[i]) { | |
| myLandTypes[i] += 0.5 / numOptions; | |
| } | |
| } | |
| } | |
| return myLandTypes; | |
| } | |
| // staples to automatically discount | |
| const commonDiscounts = { | |
| "Blasphemous Act": 5, | |
| "The Great Henge": 4, | |
| "Treasure Cruise": 4, | |
| "Ghalta, Primal Hunger": 6, | |
| "Dig Through Time": 4, | |
| "City On Fire": 2, | |
| "Hour of Reckoning": 3, | |
| "Vanquish the Horde": 4, | |
| "Thoughtcast": 2, | |
| "Thought Monitor": 3, | |
| "The Skullspore Nexus": 4, | |
| "Organic Extinction": 4, | |
| "Hoarding Broodlord": 3, | |
| "Metalwork Colossus": 6, | |
| "Excalibur, Sword of Eden": 7 | |
| }; | |
| /** | |
| * Pure function to load and process deck data | |
| * @param decklistText - Raw decklist text from user input | |
| * @param commanderName1 - First commander name (or empty string) | |
| * @param commanderName2 - Second commander/partner name (or empty string) | |
| * @param commanderName3 - Companion name (or empty string) | |
| * @param lastDecklist - Previously loaded decklist to avoid re-fetching | |
| * @param cachedTestDict - Cached test dictionary to avoid re-fetching | |
| * @returns Promise of LoadDictResult with all deck data | |
| */ | |
| async function loadDict(decklistText, commanderName1, commanderName2, commanderName3) { | |
| const errors = []; | |
| const warnings = []; | |
| const autoDiscounts = []; | |
| // sanitize input | |
| let sanitizedL = decklistText | |
| .replaceAll("&", "%26") // replace ampersands temporarily | |
| .replaceAll("+", "PLUS") // replace pluses temporarily | |
| .replaceAll(/^\s*/gm, "") // remove spaces at line starts | |
| .replaceAll(/\s*$/gm, "") // remove spaces at line ends | |
| .replaceAll(/^(\d+)x\s+/gm, "$1 ") // remove "x" from quantities at line starts | |
| .replaceAll(/\s*\[.*\]/gmu, "") // remove anything within squared brackets | |
| .replaceAll(/\s+\*F\*/gm, "") // remove " *F*" | |
| .replaceAll(/\s*#.*$/gm, ""); // remove anything after "#" | |
| // show error if no input was given | |
| if (sanitizedL == "") { | |
| errors.push("Error: no cards entered"); | |
| // Return minimal valid result | |
| return { | |
| testDict: {}, | |
| deckList: [], | |
| landTypes: new Array(32).fill(0), | |
| landCount: 0, | |
| deckSize: 0, | |
| sources: { | |
| white: 0, | |
| blue: 0, | |
| black: 0, | |
| red: 0, | |
| green: 0, | |
| plainsTypes: new Array(32).fill(false), | |
| islandTypes: new Array(32).fill(false), | |
| swampTypes: new Array(32).fill(false), | |
| mountainTypes: new Array(32).fill(false), | |
| forestTypes: new Array(32).fill(false), | |
| }, | |
| deckColors: 0, | |
| condLands: [], | |
| condNames: [], | |
| roundWUBRG: [], | |
| landAdded: new Array(32).fill(0), | |
| errors, | |
| warnings, | |
| autoDiscounts | |
| }; | |
| } | |
| // remove sideboard, empty lines, and add custom separator string | |
| let splitL = sanitizedL.split("\n"); | |
| let strippedL = ""; | |
| let inSideboard = false; | |
| for (const line of splitL) { | |
| if (line.toLowerCase().includes("sideboard")) { | |
| inSideboard = true; | |
| } | |
| if (!inSideboard && line != "") { | |
| let first = line.charAt(0); | |
| if (first >= "1" && first <= "9") { // is non-zero digit | |
| if (strippedL != "") { | |
| strippedL += "NEXT_CARD"; | |
| } | |
| strippedL += line; | |
| } | |
| else if (first != "0") { | |
| if (strippedL != "") { | |
| strippedL += "NEXT_CARD"; | |
| } | |
| // insert quantity "1" if none is provided | |
| strippedL += "1 " + line; | |
| } | |
| } | |
| if (line == "") { | |
| inSideboard = false; | |
| } | |
| } | |
| // send request to server (only if different from last load) | |
| const response = await fetch('https://api.salubrioussnail.com/?cards=' + strippedL); | |
| const testDict = await response.json(); // FIXME - trust me bro | |
| // Initialize state | |
| let deckList = []; | |
| let white = 0; | |
| let blue = 0; | |
| let black = 0; | |
| let red = 0; | |
| let green = 0; | |
| let plainsTypes = new Array(32).fill(false); | |
| let islandTypes = new Array(32).fill(false); | |
| let swampTypes = new Array(32).fill(false); | |
| let mountainTypes = new Array(32).fill(false); | |
| let forestTypes = new Array(32).fill(false); | |
| let landTypes = new Array(32).fill(0); | |
| let landCount = 0; | |
| let deckSize = 0; | |
| let commander1 = undefined; | |
| let commander2 = undefined; | |
| let commander3 = undefined; | |
| // Normalize commander names | |
| const normalizedCmdr1 = commanderName1 || "nocard"; | |
| const normalizedCmdr2 = commanderName2 || "nocard"; | |
| const normalizedCmdr3 = commanderName3 || "nocard"; | |
| let commander1found = 0; | |
| let commander2found = 0; | |
| let commander3found = 0; | |
| let condLands = []; | |
| let condNames = []; | |
| // sanitize card names | |
| let cardList = strippedL.split('NEXT_CARD'); | |
| for (let c = 0; c < cardList.length; c++) { | |
| cardList[c] = cardList[c].replaceAll("%26", "&").replaceAll("PLUS", "+"); // revert temporary replacements of ampersands and pluses | |
| cardList[c] = cardList[c].split(" (")[0]; // discard anything after opening round bracket | |
| cardList[c] = cardList[c].split(" *F*")[0]; // discard foiling | |
| } | |
| // read in cards from decklist, collect some preliminary information | |
| for (const cardLine of cardList) { | |
| const cut = cardLine.indexOf(" "); | |
| const count = parseInt(cardLine.substring(0, cut)); | |
| const name = cardLine.substring(cut + 1); | |
| let card = testDict[name]; | |
| // count up colors of deck | |
| if (card.mana_cost) { | |
| white += card.mana_cost.split("{W}").length - 1; | |
| blue += card.mana_cost.split("{U}").length - 1; | |
| black += card.mana_cost.split("{B}").length - 1; | |
| red += card.mana_cost.split("{R}").length - 1; | |
| green += card.mana_cost.split("{G}").length - 1; | |
| } | |
| // note basic land types | |
| if (card.card_type && card.card_type.includes("Land")) { | |
| let 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; | |
| } | |
| } | |
| deckSize += count; | |
| } | |
| // check to see if deck size lines up with common formats | |
| if (normalizedCmdr3 != "nocard") { // exclude companion from deck total, leave commanders in | |
| deckSize--; | |
| } | |
| console.log("Deck size: " + deckSize + " cards"); | |
| if (deckSize != 100 && deckSize != 60 && deckSize != 40) { | |
| warnings.push("Warning: This deck has " + deckSize + " cards in it. If this sounds wrong, there a few things you can do:<br>" | |
| + "- Make sure any commanders and companions are identified<br>" | |
| + "- If you exported from Moxfield using \"Copy for MTGA\", it will remove any cards that aren't on Arena. Use \"Copy for MTGO\" instead"); | |
| } | |
| if (normalizedCmdr1 != "nocard") { // remove commanders for the purposes of simulating draws | |
| deckSize--; | |
| } | |
| if (normalizedCmdr2 != "nocard") { | |
| deckSize--; | |
| } | |
| // process cards in decklist | |
| let uniqueCards = new Map(); // for decklists with multiple printings of a card, combine them into a single listing | |
| for (const cardLine of cardList) { | |
| const cut = cardLine.indexOf(" "); | |
| const count = parseInt(cardLine.substring(0, cut)); | |
| const name = cardLine.substring(cut + 1); | |
| let card = testDict[name]; | |
| // Handle exceptions | |
| if (card == "NOT FOUND") { | |
| errors.push("Error: \"" + name + "\" is not in our database or Scryfall. This may be due to a typo"); | |
| continue; | |
| } | |
| else if (card == "FOUND") { | |
| errors.push("Error: \"" + name + "\" is not in our database yet. Give us a few minutes to pull the latest data and try again"); | |
| continue; | |
| } | |
| else if (card == undefined) { | |
| errors.push("Error: \"" + name + "\" came back as undefined"); | |
| continue; | |
| } | |
| // check to make sure entry isn't duplicate | |
| if (uniqueCards.has(name)) { | |
| deckList[uniqueCards.get(name)].count += count; | |
| 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) { | |
| // break fetch lands down into pieces of lands they fetch for and add those to their relevant catefories | |
| let addLandTypes = getFetchEquivalent(card.mana_source, { | |
| white, blue, black, red, green, | |
| plainsTypes, islandTypes, swampTypes, mountainTypes, forestTypes | |
| }); | |
| for (let i = 0; i < 32; i++) { | |
| landTypes[i] += addLandTypes[i] * count; | |
| } | |
| landCount += count; | |
| } | |
| else if (card.mana_source.choose) { | |
| // "choose a color" type mana sources (thriving, CLB gates) | |
| // 50% any color, 50% split between all relevant pairs | |
| let landIndex = findLandIndex(card.mana_source) & deckColors; | |
| let chooseIndex = deckColors & (~landIndex); | |
| let numColors = Math.sign(chooseIndex & 1) + Math.sign(chooseIndex & 2) + Math.sign(chooseIndex & 4) + Math.sign(chooseIndex & 8) + Math.sign(chooseIndex & 16); | |
| landTypes[chooseIndex | landIndex] += 0.5; | |
| for (let i = 1; i < 32; i *= 2) { | |
| if (i & chooseIndex) { | |
| landTypes[i | landIndex] += 1 / (2 * numColors); | |
| } | |
| } | |
| landCount += count; | |
| } | |
| else { | |
| // normal lands | |
| let landIndex = findLandIndex(card.mana_source) & deckColors; | |
| landTypes[landIndex] += count; | |
| landCount += count; | |
| } | |
| // note if land has conditions (i.e. verge lands) | |
| if (card.mana_source.cond) { | |
| let index1 = findLandIndex(card.mana_source); | |
| let index2 = findLandIndex(card.mana_source.cond.colors) | index1; | |
| let condition = card.mana_source.cond.cond; | |
| condLands.push([index1, index2, condition]); | |
| condNames.push(name); | |
| } | |
| } | |
| } | |
| else { | |
| uniqueCards.set(name, deckList.length); | |
| card.name = name; | |
| let cost = card.mana_cost; | |
| let colorCost = getColorCost(cost); | |
| card.colorCost = colorCost; | |
| card.count = count; | |
| // check card name against common discounts | |
| if (commonDiscounts[name]) { | |
| card.discount = commonDiscounts[name]; | |
| autoDiscounts.push(card.discount + " " + name); | |
| } | |
| else { | |
| card.discount = 0; | |
| } | |
| card.ignore = false; | |
| // checking card names against commander names | |
| // 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) { | |
| // break fetch lands down into pieces of lands they fetch for and add those to their relevant catefories | |
| let addLandTypes = getFetchEquivalent(card.mana_source, { | |
| white, blue, black, red, green, | |
| plainsTypes, islandTypes, swampTypes, mountainTypes, forestTypes | |
| }); | |
| for (let i = 0; i < 32; i++) { | |
| landTypes[i] += addLandTypes[i] * count; | |
| } | |
| landCount += count; | |
| deckList.push(card); | |
| } | |
| else if (card.mana_source.choose) { | |
| // "choose a color" type mana sources (thriving, CLB gates) | |
| // 50% any color, 50% split between all relevant pairs | |
| let landIndex = findLandIndex(card.mana_source) & deckColors; | |
| let chooseIndex = deckColors & (~landIndex); | |
| let numColors = Math.sign(chooseIndex & 1) + Math.sign(chooseIndex & 2) + Math.sign(chooseIndex & 4) + Math.sign(chooseIndex & 8) + Math.sign(chooseIndex & 16); | |
| landTypes[chooseIndex | landIndex] += 0.5; | |
| for (let i = 1; i < 32; i *= 2) { | |
| if (i & chooseIndex) { | |
| landTypes[i | landIndex] += 1 / (2 * numColors); | |
| } | |
| } | |
| landCount += count; | |
| deckList.push(card); | |
| } | |
| else { | |
| // normal lands | |
| let landIndex = findLandIndex(card.mana_source) & deckColors; | |
| landTypes[landIndex] += count; | |
| landCount += count; | |
| deckList.push(card); | |
| } | |
| // note if land has conditions (i.e. verge lands) | |
| if (card.mana_source.cond) { | |
| let index1 = findLandIndex(card.mana_source); | |
| let index2 = findLandIndex(card.mana_source.cond.colors) | index1; | |
| let condition = card.mana_source.cond.cond; | |
| condLands.push([index1, index2, condition]); | |
| condNames.push(name); | |
| } | |
| } | |
| else { | |
| deckList.push(card); | |
| } | |
| } | |
| } | |
| console.log("lands: " + landCount); | |
| // check errors with commander recognition | |
| 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 { | |
| testDict, | |
| deckList, | |
| landTypes, | |
| landCount, | |
| deckSize, | |
| sources: { | |
| white, | |
| blue, | |
| black, | |
| red, | |
| green, | |
| plainsTypes, | |
| islandTypes, | |
| swampTypes, | |
| mountainTypes, | |
| forestTypes, | |
| }, | |
| deckColors, | |
| commander1, | |
| commander2, | |
| commander3, | |
| condLands, | |
| condNames, | |
| roundWUBRG: [], | |
| landAdded: new Array(32).fill(0), | |
| errors, | |
| warnings, | |
| autoDiscounts | |
| }; | |
| } | |
| function partialFact(y, x) { | |
| let z = 1; | |
| while (x > y && x > 1) { | |
| z = z * x; | |
| x = x - 1; | |
| } | |
| if (z == 0) { | |
| z = z + 1; | |
| } | |
| return z; | |
| } | |
| function fact(x) { | |
| let y = x; | |
| while (x > 2) { | |
| x = x - 1; | |
| y = y * x; | |
| } | |
| if (y == 0) { | |
| y = y + 1; | |
| } | |
| return y; | |
| } | |
| function quickChoose(x, y) { | |
| let z = partialFact(Math.max(y, x - y), x) / fact(Math.min(y, x - y)); | |
| return z; | |
| } | |
| function drawType(allTotal, typeTotal, allDrawn, typeDrawn) { | |
| let x = quickChoose(allTotal - typeTotal, allDrawn - typeDrawn) * quickChoose(typeTotal, typeDrawn) / quickChoose(allTotal, allDrawn); | |
| return x; | |
| } | |
| function calcMullLands(landCount, deckSize) { | |
| let myLandCount = Math.round(landCount); | |
| let openingDist = new Array(8); | |
| let mullDist = new Array(8); | |
| const mullLands = new Array(8).fill(0); | |
| let mullAmount = 0; | |
| // distribution for a random hand | |
| for (let i = 0; i <= 7; i++) { | |
| openingDist[i] = drawType(deckSize, myLandCount, 7, i); | |
| mullDist[i] = openingDist[i]; | |
| } | |
| //free mull, shoot for at least three if deck is a commander deck | |
| if (deckSize >= 90) { | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[2] + mullDist[6] + mullDist[7]; | |
| for (let i = 3; i < 6; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| } | |
| else { | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[6] + mullDist[7]; | |
| for (let i = 2; i < 6; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| } | |
| mullDist = new Array(8).fill(0); | |
| for (let i = 0; i <= 7; i++) { | |
| mullDist[i] += openingDist[i] * mullAmount; | |
| } | |
| // Second mull, accept 2 if needed | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[6] + mullDist[7]; | |
| for (let i = 2; i < 6; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| mullDist = new Array(8).fill(0); | |
| // shoot for 3 lands with our discards | |
| for (let i = 0; i <= 3; i++) { | |
| mullDist[i] += openingDist[i] * mullAmount; | |
| } | |
| for (let i = 4; i <= 7; i++) { | |
| mullDist[i - 1] += openingDist[i] * mullAmount; | |
| } | |
| // After we discard to 6, the 6 and 7 land hands are now 5 and 6 land hands | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[5] + mullDist[6]; | |
| for (let i = 2; i < 5; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| mullDist = new Array(8).fill(0); | |
| // discard 2, once again shooting for 3 lands | |
| for (let i = 0; i <= 3; i++) { | |
| mullDist[i] += openingDist[i] * mullAmount; | |
| } | |
| mullDist[3] += openingDist[4] * mullAmount; | |
| for (let i = 5; i <= 7; i++) { | |
| mullDist[i - 2] += openingDist[i] * mullAmount; | |
| } | |
| // After we discard to 5, we'll settle for at least one spell | |
| mullAmount = mullDist[0] + mullDist[1] + mullDist[5]; | |
| for (let i = 2; i < 5; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| mullDist = new Array(8).fill(0); | |
| // discard to 4, 3 lands again | |
| for (let i = 0; i <= 3; i++) { | |
| mullDist[i] += openingDist[i] * mullAmount; | |
| } | |
| for (let i = 4; i <= 6; i++) { | |
| mullDist[3] += openingDist[i] * mullAmount; | |
| } | |
| mullDist[4] += openingDist[7] * mullAmount; | |
| for (let i = 0; i < 7; i++) { | |
| mullLands[i] += mullDist[i]; | |
| } | |
| // only keep after 4 | |
| return mullLands; | |
| } | |
| // Debugged for correctness | |
| function colorReqs(cost) { | |
| return { | |
| w: cost.w, | |
| u: cost.u, | |
| b: cost.b, | |
| r: cost.r, | |
| g: cost.g, | |
| wu: cost.wu, | |
| wb: cost.wb, | |
| wr: cost.wr, | |
| wg: cost.wg, | |
| ub: cost.ub, | |
| ur: cost.ur, | |
| ug: cost.ug, | |
| br: cost.br, | |
| bg: cost.bg, | |
| rg: cost.rg | |
| }; | |
| } | |
| 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; | |
| } | |
| function numColors(cost) { | |
| return Math.sign(cost.w) + Math.sign(cost.u) + Math.sign(cost.b) + Math.sign(cost.r) + Math.sign(cost.g); | |
| } | |
| function sourcesFromLands(myLandTypes) { | |
| let sources = new Array(32).fill(0); | |
| // Iterate through the 32 subsets of the 5 mana colors and find the number of sources for each | |
| for (let i = 0; i < 32; i++) { | |
| // Iterate through the 32 subsets of the 5 mana colors and add the number of sources of each to the total of the relevant color combination | |
| for (let j = 0; j < 32; j++) { | |
| sources[i] += myLandTypes[j] * Math.sign(j & i); //limit the colors produced to the intersection between the colors needed and the actual production | |
| } | |
| } | |
| return sources; | |
| } | |
| function roundSearch(myRoundLands, myLandTypes, landsToAdd, i, error, landsToRound) { | |
| // searches for next unrounded value | |
| while (myLandTypes[i] <= myRoundLands[i] && i >= 0) { | |
| i--; | |
| } | |
| if (landsToAdd < 1 || i < 0) { | |
| // once it reaches the end of the array or runs out of extra lands to add, calculate error and return | |
| let sumSq = 0; | |
| for (let j = 0; j < 32; j++) { | |
| sumSq += error[j] ** 2; | |
| } | |
| return [myRoundLands, sumSq + landsToAdd * 100, error]; | |
| } | |
| if (landsToAdd >= landsToRound) { | |
| let newRoundLands = myRoundLands.slice(); | |
| newRoundLands[i] += 1; | |
| let newError = error.slice(); | |
| for (let j = 0; j < 32; j++) { | |
| newError[j] += Math.sign(j & i); | |
| } | |
| return roundSearch(newRoundLands, myLandTypes, landsToAdd - 1, i - 1, newError, landsToRound - 1); | |
| } | |
| // calculate two sets of lands, one with and without land i rounded up, run search on both of them, and return the one with less error | |
| let set1 = roundSearch(myRoundLands.slice(), myLandTypes, landsToAdd, i - 1, error, landsToRound - 1); | |
| let newRoundLands = myRoundLands.slice(); | |
| newRoundLands[i] += 1; | |
| let newError = error.slice(); | |
| for (let j = 0; j < 32; j++) { | |
| newError[j] += Math.sign(j & i); | |
| } | |
| let set2 = roundSearch(newRoundLands, myLandTypes, landsToAdd - 1, i - 1, newError, landsToRound - 1); | |
| if (set1[1] > set2[1]) { | |
| return set2; | |
| } | |
| return set1; | |
| } | |
| // Added parameters - TODO global state modification | |
| function roundLands(myLandTypes) { | |
| let myLandCount = sum(myLandTypes); | |
| let roundLandCount = Math.round(myLandCount); | |
| let floorLandTypes = new Array(32).fill(0); | |
| let floorLandCount = 0; | |
| let landsToRound = 0; | |
| // start with all lands rounded down, note how many to round back up. | |
| for (let i = 0; i < 32; i++) { | |
| floorLandTypes[i] = Math.floor(myLandTypes[i]); | |
| floorLandCount += floorLandTypes[i]; | |
| if (floorLandTypes[i] < myLandTypes[i]) { | |
| landsToRound += 1; | |
| } | |
| } | |
| // calculate error between output of rounded down lands and output of unrounded land totals. | |
| let benchmark = sourcesFromLands(myLandTypes); | |
| let floorSources = sourcesFromLands(floorLandTypes); | |
| let error = new Array(32).fill(0); | |
| for (let i = 0; i < 32; i++) { | |
| error[i] = floorSources[i] - benchmark[i]; | |
| } | |
| let result = roundSearch(floorLandTypes.slice(), myLandTypes, roundLandCount - floorLandCount, 31, error, landsToRound); | |
| let searchLandTypes = result[0]; | |
| result[1]; | |
| return searchLandTypes; | |
| } | |
| function processCost(cost) { | |
| return Math.sign(cost.w + cost.wu + cost.wb + cost.wr + cost.wg) | |
| + 2 * Math.sign(cost.u + cost.wu + cost.ub + cost.ur + cost.ug) | |
| + 4 * Math.sign(cost.b + cost.wb + cost.ub + cost.br + cost.bg) | |
| + 8 * Math.sign(cost.r + cost.wr + cost.ur + cost.br + cost.rg) | |
| + 16 * Math.sign(cost.g + cost.wg + cost.ug + cost.bg + cost.rg); | |
| } | |
| function landFilter(myLandTypes, cost) { | |
| let costCode = processCost(cost); | |
| let limitedLandTypes = new Array(32).fill(0); | |
| for (let i = 0; i < 32; i++) { | |
| limitedLandTypes[i & costCode] += myLandTypes[i]; | |
| } | |
| return limitedLandTypes; | |
| } | |
| function typeSearch(typeList, deckList) { | |
| let numFound = 0; | |
| for (let i = 0; i < deckList.length; i++) { | |
| let match = false; | |
| for (let j = 0; j < typeList.length && !match; j++) { | |
| match = deckList[i].card_type.toLowerCase().includes(typeList[j].toLowerCase()); | |
| } | |
| if (match) { | |
| numFound += deckList[i].count; | |
| } | |
| } | |
| return numFound; | |
| } | |
| function drawTypeMin(allTotal, typeTotal, allDrawn, typeDrawn) { | |
| let x = 0; | |
| for (let i = typeDrawn; i <= allDrawn && i <= typeTotal; i++) { | |
| x += drawType(allTotal, typeTotal, allDrawn, i); | |
| } | |
| return x; | |
| } | |
| function condAdjust(drawn, landTypes, landCount, condLands, deckList) { | |
| const myLandTypes = landTypes.slice(); | |
| const myLandCount = Math.round(landCount); | |
| // iterate through list of conditional lands, compute the likelihood of the condition being met, and adjust mana source count accordingly | |
| for (let i = 0; i < condLands.length; i++) { | |
| const matches = typeSearch(condLands[i][2].replaceAll("a ", "").replaceAll("an ", "").split(" or "), deckList); | |
| const odds = drawTypeMin(myLandCount - 1, matches, Math.max(0, drawn - 1), 1); | |
| myLandTypes[condLands[i][0]] -= odds; | |
| myLandTypes[condLands[i][1]] += odds; | |
| } | |
| return myLandTypes; | |
| } | |
| function getComboReqs(cost) { | |
| let comboReqs = new Array(32).fill(0); | |
| comboReqs[1] = cost.w; | |
| for (let i = 2; i < 4; i++) { | |
| comboReqs[i] = comboReqs[i - 2] + cost.u + cost.wu * Math.sign(i & 1); | |
| } | |
| for (let i = 4; i < 8; i++) { | |
| comboReqs[i] = comboReqs[i - 4] + cost.b + cost.wb * Math.sign(i & 1) + cost.ub * Math.sign(i & 2); | |
| } | |
| for (let i = 8; i < 16; i++) { | |
| comboReqs[i] = comboReqs[i - 8] + cost.r + cost.wr * Math.sign(i & 1) + cost.ur * Math.sign(i & 2) + cost.br * Math.sign(i & 4); | |
| } | |
| for (let i = 16; i < 32; i++) { | |
| comboReqs[i] = comboReqs[i - 16] + cost.g + cost.wg * Math.sign(i & 1) + cost.ug * Math.sign(i & 2) + cost.bg * Math.sign(i & 4) + cost.rg * Math.sign(i & 8); | |
| } | |
| return comboReqs; | |
| } | |
| function rLandTest(myLandTypes, numLands, myLandCount, i, scale, comboReqs) { | |
| // check if mana requirements are already satisfied | |
| if (Math.max(...comboReqs) <= 0) { | |
| return 1; | |
| } | |
| // check if mana requirements are unreachable | |
| if (Math.max(...comboReqs) > numLands) { | |
| return 0; | |
| } | |
| let success = 0.0; | |
| // scan forward through the land categories to find one thats in the deck | |
| while (i > 0 && myLandTypes[i] == 0) { | |
| i--; | |
| } | |
| // check point of no return for each color | |
| if (i < 16 && comboReqs[16] > 0) { | |
| return 0; | |
| } | |
| if (i < 8 && comboReqs[8] > 0) { | |
| return 0; | |
| } | |
| if (i < 4 && comboReqs[4] > 0) { | |
| return 0; | |
| } | |
| if (i < 2 && comboReqs[2] > 0) { | |
| return 0; | |
| } | |
| // run through the different numbers of the category of land that can be drawn, test their probability and recursively check with the remaining lands of each of them. | |
| for (let n = 0; n <= numLands && n <= myLandTypes[i]; n++) { | |
| let p = drawType(myLandCount, myLandTypes[i], numLands, n); | |
| if (p > 0) { | |
| const result = rLandTest(myLandTypes, numLands - n, myLandCount - myLandTypes[i], i - 1, p * scale, comboReqs.slice()); | |
| success += p * result; | |
| } | |
| for (let j = 0; j < 32; j++) { | |
| comboReqs[j] -= Math.sign(j & i); //if the color output overlaps with the requirement, decrement the requirement | |
| } | |
| } | |
| return success; | |
| } | |
| // 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, iterCount) { | |
| if (Math.floor(max) == Math.floor(min)) { | |
| // no ticks in range | |
| return 0; | |
| } | |
| // same as rLandTest except divides up space between max and min amongst branches | |
| if (Math.max(...comboReqs) <= 0) { | |
| return Math.floor(max) - Math.floor(min); | |
| } | |
| if (Math.max(...comboReqs) > numLands) { | |
| return 0; | |
| } | |
| let newI = i; | |
| while (newI > 0 && myLandTypes[newI] == 0) { | |
| newI--; | |
| } | |
| if (newI < 16 && comboReqs[16] > 0) { | |
| return 0; | |
| } | |
| if (newI < 8 && comboReqs[8] > 0) { | |
| return 0; | |
| } | |
| if (newI < 4 && comboReqs[4] > 0) { | |
| return 0; | |
| } | |
| if (newI < 2 && comboReqs[2] > 0) { | |
| return 0; | |
| } | |
| let success = 0; | |
| let span = max - min; | |
| let newMin = min; | |
| let newMax = min; | |
| for (let n = 0; n <= numLands && n <= myLandTypes[newI]; n++) { | |
| 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(), 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 (iterCount && iterCount.ref % 5000 === 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 | |
| const roundedLands = roundLands(landFilter(condAdjust(n, landTypes, landCount, condLands, deckList), cost)); | |
| origWUBRG_MUTATED.push(roundedLands); // FIXME - Mutable state | |
| } | |
| } | |
| const canCast = new Array(13); | |
| const comboReqs = getComboReqs(cost); | |
| const numCol = numColors(cost); | |
| // run 2 different versions of the analysis program, 1 that estimates for expensive cards, one that does the perfect computation for cheap cards. | |
| if (numCol < colors) { | |
| for (let n = 0; n <= 12; n++) { | |
| // filter and round lands for given color combination | |
| let roundedLands = []; | |
| if (numCol == 5) { | |
| roundedLands = origWUBRG_MUTATED[n].slice(); | |
| for (let i = 0; i < 32; i++) { | |
| roundedLands[i] += landAdded[i]; | |
| } | |
| } | |
| else { | |
| roundedLands = roundLands(landFilter(condAdjust(n, landTypes, landCount, condLands, deckList), cost)); | |
| } | |
| let roundedLandCount = sum(roundedLands); | |
| const result = rLandTest(roundedLands, n, roundedLandCount, 31, 1, structuredClone(comboReqs)); | |
| canCast[n] = result; | |
| } | |
| } | |
| else { | |
| for (let n = 0; n <= 12; n++) { | |
| // filter and round lands for given color combination | |
| let roundedLands; | |
| if (numCol == 5) { | |
| roundedLands = origWUBRG_MUTATED[n].slice(); | |
| for (let i = 0; i < 32; i++) { | |
| roundedLands[i] += landAdded[i]; | |
| } | |
| } | |
| else { | |
| 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), 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; | |
| } | |
| 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 = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypes, landCount, condLands, deckList); | |
| dict.set(pipCode, castDistro.slice()); | |
| } | |
| } | |
| // run the numbers for cards with a relevant cycling cost | |
| else if (deckList[i].mana_source.cycling && getColorCost(deckList[i].mana_source.cycling?.cost ?? "").t == x) { | |
| let pips = colorReqs(getColorCost(deckList[i].mana_source.cycling?.cost ?? "")); | |
| let pipCode = pipsToNum(pips); | |
| if (!dict.has(pipCode)) { | |
| const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypes, landCount, condLands, deckList); | |
| dict.set(pipCode, castDistro.slice()); | |
| } | |
| } | |
| } | |
| return dict; | |
| } | |
| function landsToCast(cost, discount, dropDicts, manaDict) { | |
| const { oneDropDict, twoDropDict, threeDropDict, fourDropDict } = dropDicts; | |
| let pipReqs; | |
| if (cost.t - discount == 1) { | |
| pipReqs = oneDropDict.get(pipsToNum(cost))?.slice(); | |
| } | |
| else if (cost.t - discount == 2) { | |
| pipReqs = twoDropDict.get(pipsToNum(cost))?.slice(); | |
| } | |
| else if (cost.t - discount == 3) { | |
| pipReqs = threeDropDict.get(pipsToNum(cost))?.slice(); | |
| } | |
| else if (cost.t - discount == 4) { | |
| pipReqs = fourDropDict.get(pipsToNum(cost))?.slice(); | |
| } | |
| else { | |
| let numfromPips = pipsToNum(cost); | |
| pipReqs = manaDict.get(numfromPips)?.slice(); | |
| } | |
| pipReqs = pipReqs ?? []; | |
| for (let i = 0; i < cost.t - discount; i++) { | |
| pipReqs[i] = 0; | |
| } | |
| return pipReqs; | |
| } | |
| function landsAtTurn(turn, landCount, deckSize, mullLands) { | |
| let myLandCount = Math.round(landCount); | |
| let landDist = new Array(13).fill(0); | |
| for (let startLands = 0; startLands < 7; startLands++) { | |
| for (let drawnLands = 0; drawnLands <= turn; drawnLands++) { | |
| landDist[Math.min(startLands + drawnLands, 12)] += mullLands[startLands] * drawType(deckSize - 7, myLandCount - startLands, turn, drawnLands); | |
| } | |
| } | |
| return landDist; | |
| } | |
| function turnsToCast(cost, discount, dropDicts, manaDict, landCount, deckSize, mullLands) { | |
| let landReqs = landsToCast(cost, discount, dropDicts, manaDict); | |
| let canCast = new Array(16); | |
| for (let i = 0; i < cost.t - discount; i++) { | |
| canCast[i] = 0; | |
| } | |
| // iterate through turns ranging from the first turn where it becomes possible to the last turn we consider | |
| for (let i = cost.t - discount; i <= 15; i++) { | |
| let pCast = 0; | |
| let landDist = landsAtTurn(i, landCount, deckSize, mullLands); | |
| for (let lands = cost.t - discount; lands <= 12; lands++) { | |
| pCast += landReqs[lands] * landDist[lands]; | |
| } | |
| canCast[i] = pCast; | |
| } | |
| let 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; | |
| } | |
| function scanForSources(x, deckList, landTypes, landCount, sources, dropDicts, manaDict, deckSize, mullLands) { | |
| // Create copies to avoid mutating inputs | |
| const newLandTypes = [...landTypes]; | |
| let newLandCount = landCount; | |
| for (let i = 0; i < deckList.length; i++) { | |
| // make sure mana value lines up | |
| if (deckList[i].colorCost.t - deckList[i].discount == x && !(deckList[i].card_type.includes("//") && deckList[i].card_type.includes("Land"))) { | |
| let landIndex = findLandIndex(deckList[i].mana_source); | |
| let typeMult = 1; | |
| // penalize more fragile permanent types | |
| if (deckList[i].card_type.includes("Artifact")) { | |
| typeMult = 0.75; | |
| } | |
| if (deckList[i].card_type.includes("Creature")) { | |
| typeMult = 0.5; | |
| } | |
| // handle fetches | |
| if (deckList[i].mana_source.fetch) { | |
| let addLandTypes = getFetchEquivalent(deckList[i].mana_source, sources); | |
| for (let j = 0; j < 32; j++) { | |
| newLandTypes[j] += addLandTypes[j] * deckList[i].count * typeMult; | |
| } | |
| newLandCount += deckList[i].count * typeMult; | |
| } | |
| // "choose a color" type mana sources (thriving, CLB gates) | |
| else if (deckList[i].mana_source.choose) { | |
| const { white, blue, black, red, green } = sources; | |
| let deckColors = Math.sign(white) + Math.sign(blue) * 2 + Math.sign(black) * 4 + Math.sign(red) * 8 + Math.sign(green) * 16; | |
| landIndex = findLandIndex(deckList[i].mana_source) & deckColors; | |
| let chooseIndex = deckColors & (~landIndex); | |
| let numColors = Math.sign(chooseIndex & 1) + Math.sign(chooseIndex & 2) + Math.sign(chooseIndex & 4) + Math.sign(chooseIndex & 8) + Math.sign(chooseIndex & 16); | |
| newLandTypes[chooseIndex | landIndex] += 0.5 * typeMult; | |
| for (let j = 1; j < 32; j *= 2) { | |
| if (j & chooseIndex) { | |
| newLandTypes[j | landIndex] += 1 / (2 * numColors) * typeMult; | |
| } | |
| } | |
| newLandCount += deckList[i].count * typeMult; | |
| } | |
| // handle more typical sources | |
| else if (landIndex != 0 || deckList[i].mana_source.amt > 0) { | |
| typeMult *= turnsToCast(deckList[i].colorCost, deckList[i].discount, dropDicts, manaDict, newLandCount, deckSize, mullLands)[x]; | |
| newLandTypes[landIndex] += deckList[i].count * typeMult; | |
| newLandCount += deckList[i].count * typeMult; | |
| } | |
| } | |
| // factor in landcycling | |
| else if (deckList[i].mana_source.cycling && getColorCost(deckList[i].mana_source.cycling?.cost ?? "").t == x) { | |
| const lands = deckList[i].mana_source.cycling?.lands; | |
| const cost = deckList[i].mana_source.cycling?.cost; | |
| if (lands && cost) { | |
| let typeMult = turnsToCast(getColorCost(cost), 0, dropDicts, manaDict, newLandCount, deckSize, mullLands)[x]; | |
| let addLandTypes = getFetchEquivalent(lands, sources); | |
| for (let j = 0; j < 32; j++) { | |
| newLandTypes[j] += addLandTypes[j] * deckList[i].count * typeMult; | |
| } | |
| newLandCount += deckList[i].count * typeMult; | |
| } | |
| } | |
| } | |
| return { landTypes: newLandTypes, landCount: newLandCount }; | |
| } | |
| function mean(pdf) { | |
| let avg = 0; | |
| for (let i = 0; i < pdf.length; i++) { | |
| avg += i * pdf[i]; | |
| } | |
| return avg; | |
| } | |
| function factorDiscount(colorCost, discount) { | |
| let pips = colorCost.substring(1, colorCost.length - 1).split("}{"); | |
| let costOut = ""; | |
| for (let i = 0; i < pips.length; i++) { | |
| let num = parseInt(pips[i]); | |
| if (isNaN(num)) { | |
| costOut += "{" + pips[i] + "}"; | |
| } | |
| else { | |
| costOut += "{" + (num - discount) + "}"; | |
| } | |
| } | |
| return costOut; | |
| } | |
| 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; | |
| // simulate mulligans to get a starting distribution of lands in the opening hand | |
| const initialMullLands = calcMullLands(landCount, deckSize); | |
| let dropDicts = { | |
| oneDropDict: new Map(), | |
| twoDropDict: new Map(), | |
| threeDropDict: new Map(), | |
| fourDropDict: new Map() | |
| }; | |
| // calculate for one drops based on mana from lands | |
| 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 = 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 = 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 = 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); | |
| 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); | |
| let pipCode = pipsToNum(pips); | |
| if (!manaDict.has(pipCode)) { | |
| const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList); | |
| manaDict.set(pipCode, castDistro.slice()); | |
| } | |
| await yieldABit(); | |
| } | |
| // calculate for commanders | |
| if (commander1) { | |
| let pips = colorReqs(commander1.colorCost); | |
| let pipCode = pipsToNum(pips); | |
| if (commander1.colorCost.t - commander1.discount == 1 && !oneDropDict.has(pipCode)) { | |
| 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 = 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 = 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 = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList); | |
| fourDropDict.set(pipCode, castDistro.slice()); | |
| } | |
| else if (!manaDict.has(pipCode)) { | |
| const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList); | |
| manaDict.set(pipCode, castDistro.slice()); | |
| } | |
| await yieldABit(); | |
| } | |
| if (commander2) { | |
| let pips = colorReqs(commander2.colorCost); | |
| let pipCode = pipsToNum(pips); | |
| if (commander2.colorCost.t - commander2.discount == 1 && !oneDropDict.has(pipCode)) { | |
| 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 = 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 = 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 = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList); | |
| fourDropDict.set(pipCode, castDistro.slice()); | |
| } | |
| else if (!manaDict.has(pipCode)) { | |
| const castDistro = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList); | |
| manaDict.set(pipCode, castDistro.slice()); | |
| } | |
| await yieldABit(); | |
| } | |
| if (commander3) { | |
| let pips = colorReqs(commander3.colorCost); | |
| let pipCode = pipsToNum(pips); | |
| if (commander3.colorCost.t - commander3.discount == 1 && !oneDropDict.has(pipCode)) { | |
| 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 = 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 = 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 = await pipDist(roundWUBRG, pips, approxColors, approxSamples, landAdded, landTypesAfterDrops4, landCountAfterDrops4, condLands, deckList); | |
| fourDropDict.set(pipCode, castDistro.slice()); | |
| } | |
| else if (!manaDict.has(pipCode)) { | |
| const castDistro = await 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(); | |
| let cmcOnCurve = 0; | |
| let totalCmcDelay = 0; | |
| let totalCmc = 0; | |
| let allCards = new Array(); | |
| // check how well deck can cast commanders | |
| if (commander1) { | |
| 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); | |
| // use it to calculate how often it's played on time, and how late it generally gets played | |
| let onCurveRate = turnCastDist[cmc]; | |
| let avgDelay = mean(turnCastDist) - commander1.colorCost.t + commander1.discount; | |
| cmcOnCurve += onCurveRate * cmc * weight; | |
| totalCmcDelay += avgDelay * cmc * weight; | |
| totalCmc += cmc * weight; | |
| if (!costsCovered.has(factorDiscount(commander1.mana_cost, commander1.discount))) { | |
| 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; | |
| let turnCastDist = turnsToCast(commander2.colorCost, commander2.discount, dropDicts, manaDict, landCountAfterDrops4, deckSize, mullLandsAfterDrops4); | |
| let cmc = Math.min(commander2.colorCost.t - commander2.discount, 12); | |
| let onCurveRate = turnCastDist[cmc]; | |
| let avgDelay = mean(turnCastDist) - commander2.colorCost.t + commander2.discount; | |
| cmcOnCurve += onCurveRate * cmc * weight; | |
| totalCmcDelay += avgDelay * cmc * weight; | |
| totalCmc += cmc * weight; | |
| if (!costsCovered.has(factorDiscount(commander2.mana_cost, commander2.discount))) { | |
| 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; | |
| let turnCastDist = turnsToCast(commander3.colorCost, commander3.discount, dropDicts, manaDict, landCountAfterDrops4, deckSize, mullLandsAfterDrops4); | |
| let cmc = Math.min(commander3.colorCost.t - commander3.discount, 12); | |
| let onCurveRate = turnCastDist[cmc]; | |
| let avgDelay = mean(turnCastDist) - commander3.colorCost.t + commander3.discount; | |
| cmcOnCurve += onCurveRate * cmc * weight; | |
| totalCmcDelay += avgDelay * cmc * weight; | |
| totalCmc += cmc * weight; | |
| if (!costsCovered.has(factorDiscount(commander3.mana_cost, commander3.discount))) { | |
| 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) { | |
| let turnCastDist = turnsToCast(deckList[i].colorCost, deckList[i].discount, dropDicts, manaDict, landCountAfterDrops4, deckSize, mullLandsAfterDrops4); | |
| let cmc = Math.min(deckList[i].colorCost.t - deckList[i].discount, 12); | |
| let onCurveRate = turnCastDist[cmc]; | |
| let avgDelay = mean(turnCastDist) - deckList[i].colorCost.t + deckList[i].discount; | |
| cmcOnCurve += onCurveRate * cmc; | |
| totalCmcDelay += avgDelay * cmc; | |
| totalCmc += cmc; | |
| if (!costsCovered.has(factorDiscount(deckList[i].mana_cost, deckList[i].discount))) { | |
| costsCovered.add(factorDiscount(deckList[i].mana_cost, deckList[i].discount)); | |
| allCards.push({ name: deckList[i].name, onCurveRate, avgDelay }); | |
| } | |
| await yieldABit(); | |
| } | |
| } | |
| // compute averages | |
| cmcOnCurve /= totalCmc; | |
| totalCmcDelay /= totalCmc; | |
| 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 | |
| * | |
| * This wrapper provides the optimizer interface expected by the userscript | |
| * while using the core calculation functions from the manatool library. | |
| */ | |
| /** | |
| * 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 | |
| return landTypes; | |
| } | |
| /** | |
| * Test a single land configuration | |
| */ | |
| 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 = { | |
| ...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]), | |
| 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 | |
| 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 | |
| */ | |
| 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 }]; | |
| 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 }); | |
| } | |
| 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 { | |
| 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' | |
| }; | |
| 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'; | |
| } | |
| /** | |
| * Generate recommendations | |
| */ | |
| 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; | |
| } | |
| /** | |
| * Main optimizer function | |
| */ | |
| 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 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'); | |
| } | |
| // 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, | |
| cmdr1Weight: options.calculatorOptions?.cmdr1Weight || 30, | |
| cmdr2Weight: options.calculatorOptions?.cmdr2Weight || 30, | |
| 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, | |
| context, | |
| cache, | |
| visited, | |
| stats, | |
| { maxIterations, signal } | |
| )) { | |
| if (onProgress) { | |
| const topNow = extractTopResults(cache, topN); | |
| 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: { | |
| tested: stats.tested, | |
| improved: stats.improved, | |
| duration: Date.now() - startTime | |
| }, | |
| recommendations: recommendations | |
| }; | |
| } | |
| exports.describeLandChanges = describeLandChanges; | |
| exports.extractTopResults = extractTopResults; | |
| exports.generateAllNeighbors = generateAllNeighbors; | |
| exports.generateNeighbors = generateAllNeighbors; | |
| exports.hashLands = hashLands; | |
| exports.optimizeLands = optimizeLands; | |
| })); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment