Skip to content

Instantly share code, notes, and snippets.

@pakoito
Last active January 29, 2026 12:09
Show Gist options
  • Select an option

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

Select an option

Save pakoito/5c7f9b8c35efee0126b2b874beb365db to your computer and use it in GitHub Desktop.
Manabase Optimizer Bundle - Standalone module for Tampermonkey
// Manabase Optimizer Bundle - 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