diff --git a/backend/analytics.js b/backend/analytics.js new file mode 100644 index 00000000..d9f71a72 --- /dev/null +++ b/backend/analytics.js @@ -0,0 +1,173 @@ +// const fs = require('fs'); +// const readline = require('readline'); +// const path = require('path'); + +// const rankingsPath = path.join(__dirname, "../data/picks/results.txt"); + +const CardColors = { + "W": 0, + "U": 1, + "B": 2, + "R": 3, + "G": 4, + "C": 5 +}; + +const CardColorNames = { + "White": 0, + "Blue": 1, + "Black": 2, + "Red": 3, + "Green": 4, + "Colorless": 5, + "Multicolor": 6 +}; + +// /** +// * @desc ... Searches our rankings file for a card and returns what rank it is. +// * @param {Object} card ... The card object. +// * @returns {Int} ... Returns the card rank, -1 if not found. +// */ +// async function pullCardRank(card) { +// const fileStream = fs.createReadStream(rankingsPath); + +// const rl = readline.createInterface({ +// input: fileStream, +// crlfDelay: Infinity +// }); + +// var rank = 0; + +// console.log(card.name); + +// for await (var line of rl) { +// var card_line; +// card_line = line.split(" "); +// card_line.shift(); +// card_line = card_line.join(" "); + +// if (card.name == card_line) { +// return rank; +// } + +// rank++ +// } + +// return -1; +// } + + +// async function getCardRank(card) { +// promise = pullCardRank(card); +// result = await promise; + +// return result; +// } + +/** + * @desc eatColorPips ... Separates out the colored pips {4}{W} --> W for bias analysis. + * @param {String} manaCost ... String of the manacost with generic and colored costs. + * @returns {Array} ... Returns an array of the color pip bias. + */ +function eatColorPips(manaCost) { + var colorBias = [0, 0, 0, 0, 0, 0]; // The last value refers to the total number of color pips. + + // Symbols to be removed from card mana costs. + const toBeRemoved = ["{", "}", "X", "/"]; + + for (var item in toBeRemoved) { + manaCost = manaCost.split(toBeRemoved[item]).join(""); + } + + for (var number = 0; number <= 9; number++) { + manaCost = manaCost.split(number).join(""); + } + + for (var char = 0; char < manaCost.length; char++) { + colorBias[CardColors[manaCost.charAt(char)]]++; + colorBias[colorBias.length - 1]++; // Increment the total number of color pips. + } + + return colorBias; +} + +/** + * @desc generatePackStats(pack) ... Examines and create an object for the statistics of a pack. + * @param {object} pack ... An object containing the name, UUID, CMC, and other relevant information about the pack. + * @returns {object} ... Returns an object containing information about the pack. + */ +function generatePackStats(packs) { + // colorBias, an array from [0, 1] reference enum CardColors, each pack is weighted by color. + var colorBias = [0, 0, 0, 0, 0, 0, 0]; + var colorPipBias = [0, 0, 0, 0, 0]; + var typeBias = {}; + var rarityBias = {}; + + var cmcBias = 0; + var totalCount = packs.length; + var nonLandCount = 0; + var colorPips = 0; + // var bestPick; + + for (var pack in packs) { + // packObj used to make my life easier regarding referencing. + var packObj = packs[pack]; + + var manaCost = packObj.manaCost; + var type = packObj.type; + var rarity = packObj.rarity; + var color = packObj.color; + var CMC = packObj.cmc; + // var rank = getCardRank(packObj); + + // console.log(rank); + + + if (type != "Land") { + nonLandCount++; + + colorBias[CardColorNames[color]]++; + + if (CMC > 0) { + cmcBias += CMC; + } + + if (color != "Colorless") { + var newColorBias = eatColorPips(manaCost); + + for (var val = 0; val < colorPipBias.length; val++) { + colorPipBias[val] += newColorBias[val]; + } + + colorPips += newColorBias[newColorBias.length - 1]; + } + } + + typeBias[type] = (typeBias[type] || 0) + 1; // Increase the number for whatever type it is or initialize the value. + rarityBias[rarity] = (rarityBias[rarity] || 0) + 1; + } + + // Adjust the weights of everything. + for (var pipVal = 0; pipVal < colorPipBias.length; pipVal++) { + colorPipBias[pipVal] /= colorPips; + } + + for (var biasVal = 0; biasVal < colorBias.length; biasVal++) { + colorBias[biasVal] /= nonLandCount; + } + + cmcBias /= nonLandCount; + + for (var types in typeBias) { + typeBias[types] /= totalCount; + } + + for (var rarities in rarityBias) { + rarityBias[rarities] /= totalCount; + } + + var packStats = {"colorBias": colorBias, "colorPipBias": colorPipBias, "typeBias": typeBias, "rarityBias": rarityBias, "cmcBias": cmcBias}; + return packStats; +} + +module.exports = generatePackStats; \ No newline at end of file diff --git a/backend/analytics.spec.js b/backend/analytics.spec.js new file mode 100644 index 00000000..30f32a9a --- /dev/null +++ b/backend/analytics.spec.js @@ -0,0 +1,106 @@ +const {describe, it} = require("mocha"); +const assert = require("assert"); +const cardStats = require("./analytics.js"); +const boosterGenerator = require("./boosterGenerator"); +const {range} = require("lodash"); + +// List of potentially problematic cards to test over for pack analysis. +const TestCard = { + uuid: "576e9e04-acd7-5d24-bc19-1dd765e9d1b8", + name: "Purphoros's Intervention", + names: [], + color: "Red", + colors: [ "R" ], + colorIdentity: [ "R" ], + setCode: "THB", + scryfallId: "ecc911ee-0e12-4b10-add7-9a9d63c29443", + cmc: 1, + number: "151", + type: "Sorcery", + manaCost: "{X}{R}", + rarity: "Rare", + url: "https://api.scryfall.com/cards/ecc911ee-0e12-4b10-add7-9a9d63c29443?format=image", + layout: "normal", + isDoubleFaced: false, + flippedCardURL: "", + supertypes: [], + subtypes: [], + text: "Choose one —\n" + + "• Create an X/1 red Elemental creature token with trample and haste. Sacrifice it at the beginning of the next end step.\n" + + "• Purphoros's Intervention deals twice X damage to target creature or planeswalker.", + foil: true +}; + +/** + * @desc withinRange ... Returns a boolean whether or not a number is within range of a set tolerance +/-. + * @param {float} val + * @param {float} tolerance + * @returns {boolean} ... Is the value within range. + */ +function withinRange(val, expected, tolerance) { + return (val <= tolerance + expected && val >= tolerance - expected); +} + +/** + * @desc Compares an arr/obj and returns if they're the same. + * @param {arr/obj} val0 ... val0 to compare. + * @param {arr/obj} val1 ... val1 to compare. + * @returns {boolean} ... Are the values the same? + */ +function compareArray(val0, val1) { + for (var val in val0) { + if (val0[val] != val1[val]) { + return false; + } + } + + return true; +} + +var monoGreenTest = []; + +for (var val = 0; val < 15; val++) { + monoGreenTest.push(TestCard); +} + +describe("Acceptance tests for card analytics generation", () => { + it("Should return the known bias of a single card", () => { + var stats = cardStats(monoGreenTest); + var statsLength = 0; + + for (var output in stats) { + if (stats[output] != undefined) { + statsLength++; + } + } + + assert(statsLength == 5); + assert(compareArray(stats.colorBias, [0, 0, 0, 1, 0, 0, 0])); + assert(compareArray(stats.colorPipBias, [0, 0, 0, 1, 0])); + assert(stats.cmcBias == 1); + }); + + it("Should return statistics near 100% +/- 2", () => { + range(20).forEach(() => { + var randomBooster = boosterGenerator("MH1"); + var stats = cardStats(randomBooster); + + var colorBias = stats.colorBias; + var colorPipBias = stats.colorPipBias; + + var colorBiasPercentage = 0; + var colorPipBiasPercentage = 0; + + for (var colorBiasVal in colorBias) { + colorBiasPercentage += colorBias[colorBiasVal]; + } + + for (var colorPipVal in colorPipBias) { + colorPipBiasPercentage += colorPipBias[colorPipVal]; + } + + assert(withinRange(colorBiasPercentage, 1, .02)); + assert(withinRange(colorPipBiasPercentage, 1, .02)); + }); + }); +}); \ No newline at end of file diff --git a/backend/bot.js b/backend/bot.js index b3b89c23..2f7ce0da 100644 --- a/backend/bot.js +++ b/backend/bot.js @@ -1,5 +1,6 @@ const {sample, pull} = require("lodash"); const Player = require("./player"); +const PackStats = require("./analytics"); module.exports = class extends Player { constructor() {