diff options
14 files changed, 986 insertions, 624 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 94fc9f8f..5ed35943 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,3 +9,4 @@ /.github/SUPPORT.md @IRONM00N /.idea/codeStyles/* @IRONM00N /.idea/copyright/* @IRONM00N +/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/* @DeDiamondPro diff --git a/src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java b/src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java index ce7b21b2..000158a4 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/NEUOverlay.java @@ -37,8 +37,8 @@ import io.github.moulberry.notenoughupdates.mbgui.MBGuiGroupAligned; import io.github.moulberry.notenoughupdates.mbgui.MBGuiGroupFloating; import io.github.moulberry.notenoughupdates.miscfeatures.EnchantingSolvers; import io.github.moulberry.notenoughupdates.miscfeatures.SunTzu; -import io.github.moulberry.notenoughupdates.miscgui.GuiPriceGraph; import io.github.moulberry.notenoughupdates.miscgui.NeuSearchCalculator; +import io.github.moulberry.notenoughupdates.miscgui.pricegraph.GuiPriceGraph; import io.github.moulberry.notenoughupdates.options.NEUConfigEditor; import io.github.moulberry.notenoughupdates.util.Constants; import io.github.moulberry.notenoughupdates.util.GuiTextures; diff --git a/src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java b/src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java index 14f75fb0..794df0f4 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/NotEnoughUpdates.java @@ -142,7 +142,8 @@ public class NotEnoughUpdates { //Stolen from Biscut and used for detecting whether in skyblock private static final Set<String> SKYBLOCK_IN_ALL_LANGUAGES = Sets.newHashSet("SKYBLOCK", "\u7A7A\u5C9B\u751F\u5B58", "\u7A7A\u5CF6\u751F\u5B58", - "SKIBLOCK"); // april fools language + "SKIBLOCK" + ); // april fools language public static NotEnoughUpdates INSTANCE = null; public static HashMap<String, String> petRarityToColourMap = new HashMap<String, String>() {{ put("UNKNOWN", EnumChatFormatting.RED.toString()); @@ -255,6 +256,9 @@ public class NotEnoughUpdates { if (config.apiData.moulberryCodesApi.isEmpty()) { config.apiData.moulberryCodesApi = "moulberry.codes"; } + if (config.ahGraph.serverUrl.trim().isEmpty()) { + config.ahGraph.serverUrl = "pricehistory.notenoughupdates.org"; + } saveConfig(); } diff --git a/src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java b/src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java index 329ab796..1faac488 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/auction/APIManager.java @@ -25,7 +25,7 @@ import com.google.gson.JsonObject; import io.github.moulberry.notenoughupdates.ItemPriceInformation; import io.github.moulberry.notenoughupdates.NEUManager; import io.github.moulberry.notenoughupdates.NotEnoughUpdates; -import io.github.moulberry.notenoughupdates.miscgui.GuiPriceGraph; +import io.github.moulberry.notenoughupdates.miscgui.pricegraph.LocalGraphDataProvider; import io.github.moulberry.notenoughupdates.recipes.Ingredient; import io.github.moulberry.notenoughupdates.recipes.ItemShopRecipe; import io.github.moulberry.notenoughupdates.recipes.NeuRecipe; @@ -306,7 +306,7 @@ public class APIManager { ItemPriceInformation.updateAuctionableItemsList(); didFirstUpdate = true; } - GuiPriceGraph.addToCache(lowestBins, false); + LocalGraphDataProvider.INSTANCE.savePrices(lowestBins, false); }); } @@ -778,7 +778,7 @@ public class APIManager { bazaarJson.add(transformHypixelBazaarToNEUItemId(entry.getKey()), productInfo); } } - GuiPriceGraph.addToCache(bazaarJson, true); + LocalGraphDataProvider.INSTANCE.savePrices(bazaarJson, true); }); } diff --git a/src/main/java/io/github/moulberry/notenoughupdates/miscgui/GuiPriceGraph.java b/src/main/java/io/github/moulberry/notenoughupdates/miscgui/GuiPriceGraph.java deleted file mode 100644 index c03a72a6..00000000 --- a/src/main/java/io/github/moulberry/notenoughupdates/miscgui/GuiPriceGraph.java +++ /dev/null @@ -1,611 +0,0 @@ -/* - * Copyright (C) 2022 NotEnoughUpdates contributors - * - * This file is part of NotEnoughUpdates. - * - * NotEnoughUpdates is free software: you can redistribute it - * and/or modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation, either - * version 3 of the License, or (at your option) any later version. - * - * NotEnoughUpdates is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>. - */ - -package io.github.moulberry.notenoughupdates.miscgui; - -import com.google.common.reflect.TypeToken; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import io.github.moulberry.notenoughupdates.NotEnoughUpdates; -import io.github.moulberry.notenoughupdates.util.SpecialColour; -import io.github.moulberry.notenoughupdates.util.Utils; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.Gui; -import net.minecraft.client.gui.GuiScreen; -import net.minecraft.client.renderer.GlStateManager; -import net.minecraft.item.ItemStack; -import net.minecraft.util.EnumChatFormatting; -import net.minecraft.util.ResourceLocation; -import org.lwjgl.opengl.GL11; - -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.lang.reflect.Type; -import java.nio.charset.StandardCharsets; -import java.text.DecimalFormat; -import java.text.NumberFormat; -import java.text.SimpleDateFormat; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.TreeMap; -import java.util.stream.Collectors; -import java.util.zip.GZIPInputStream; -import java.util.zip.GZIPOutputStream; - -public class GuiPriceGraph extends GuiScreen { - - private static final Gson GSON = new GsonBuilder().create(); - private static final SimpleDateFormat format = new SimpleDateFormat("dd-MM-yyyy"); - private final ResourceLocation TEXTURE; - private static final int X_SIZE = 364; - private static final int Y_SIZE = 215; - private ItemData itemData; - private double highestValue; - private long firstTime; - private long lastTime; - private double lowestValue; - private String itemName; - private final String itemId; - private int guiLeft; - private int guiTop; - private ItemStack itemStack = null; - private boolean loaded = false; - /** - * 0 = hour - * 1 = day - * 2 = week - * 3 = all - * 4 = custom - **/ - private int mode = NotEnoughUpdates.INSTANCE.config.ahGraph.defaultMode; - private long customStart = 0; - private long customEnd = 0; - private boolean customSelecting = false; - - public GuiPriceGraph(String itemId) { - switch (NotEnoughUpdates.INSTANCE.config.ahGraph.graphStyle) { - case 1: - TEXTURE = new ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_dark.png"); - break; - case 2: - TEXTURE = new ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_phqdark.png"); - break; - case 3: - TEXTURE = new ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_fsr.png"); - break; - default: - TEXTURE = new ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui.png"); - break; - } - this.itemId = itemId; - if (NotEnoughUpdates.INSTANCE.manager.getItemInformation().containsKey(itemId)) { - JsonObject itemInfo = NotEnoughUpdates.INSTANCE.manager.getItemInformation().get(itemId); - itemName = itemInfo.get("displayname").getAsString(); - itemStack = NotEnoughUpdates.INSTANCE.manager.jsonToStack(itemInfo); - } - loadData(); - } - - @Override - public void drawScreen(int mouseX, int mouseY, float partialTicks) { - guiLeft = (width - X_SIZE) / 2; - guiTop = (height - Y_SIZE) / 2; - - Minecraft.getMinecraft().getTextureManager().bindTexture(TEXTURE); - GlStateManager.color(1, 1, 1, 1); - Utils.drawTexturedRect(guiLeft, guiTop, X_SIZE, Y_SIZE, - 0, X_SIZE / 512f, 0, Y_SIZE / 512f, GL11.GL_NEAREST - ); - Utils.drawTexturedRect(guiLeft + 245, guiTop + 17, 16, 16, - 0, 16 / 512f, (mode == 0 ? 215 : 231) / 512f, (mode == 0 ? 231 : 247) / 512f, GL11.GL_NEAREST - ); - Utils.drawTexturedRect(guiLeft + 263, guiTop + 17, 16, 16, - 16 / 512f, 32 / 512f, (mode == 1 ? 215 : 231) / 512f, (mode == 1 ? 231 : 247) / 512f, GL11.GL_NEAREST - ); - Utils.drawTexturedRect(guiLeft + 281, guiTop + 17, 16, 16, - 32 / 512f, 48 / 512f, (mode == 2 ? 215 : 231) / 512f, (mode == 2 ? 231 : 247) / 512f, GL11.GL_NEAREST - ); - Utils.drawTexturedRect(guiLeft + 299, guiTop + 17, 16, 16, - 48 / 512f, 64 / 512f, (mode == 3 ? 215 : 231) / 512f, (mode == 3 ? 231 : 247) / 512f, GL11.GL_NEAREST - ); - - if (itemName != null && itemStack != null) { - Utils.drawItemStack(itemStack, guiLeft + 16, guiTop + 11); - Utils.drawStringScaledMax(itemName, guiLeft + 35, guiTop + 13, false, - 0xffffff, 1.77f, 208 - ); - } - - if (!loaded) - Utils.drawStringCentered("Loading...", guiLeft + 166, guiTop + 116, false, 0xffffff00); - else if ( - itemData == null || itemData.get() == null || itemData.get().size() <= 1) - Utils.drawStringCentered("No data found.", guiLeft + 166, guiTop + 116, false, 0xffff0000); - else { - int graphColor = SpecialColour.specialToChromaRGB(NotEnoughUpdates.INSTANCE.config.ahGraph.graphColor); - int graphColor2 = SpecialColour.specialToChromaRGB(NotEnoughUpdates.INSTANCE.config.ahGraph.graphColor2); - Integer lowestDist = null; - Long lowestDistTime = null; - HashMap<Integer, Integer> secondLineData = new HashMap<>(); - for (int i = (itemData.isBz() ? 1 : 0); i >= 0; i--) { - Utils.drawGradientRect(0, guiLeft + 17, guiTop + 35, guiLeft + 315, guiTop + 198, - changeAlpha(i == 0 ? graphColor : graphColor2, 120), changeAlpha(i == 0 ? graphColor : graphColor2, 10) - ); - Integer prevX = null; - Integer prevY = null; - for (Long time : itemData.get().keySet()) { - double price = itemData.isBz() - ? i == 0 ? itemData.bz.get(time).b : itemData.bz.get(time).s - : itemData.ah.get(time); - int xPos = (int) map(time, firstTime, lastTime, guiLeft + 17, guiLeft + 315); - int yPos = (int) map(price, highestValue + 10d, lowestValue - 10d, guiTop + 35, guiTop + 198); - if (prevX != null) { - Minecraft.getMinecraft().getTextureManager().bindTexture(TEXTURE); - GlStateManager.color(1, 1, 1, 1); - Utils.drawTexturedQuad( - prevX, - prevY, - xPos, - yPos, - xPos, - guiTop + 35, - prevX, - guiTop + 35, - 18 / 512f, - 19 / 512f, - 36 / 512f, - 37 / 512f, - GL11.GL_NEAREST - ); - if (i == 0) { - Utils.drawLine(prevX, prevY + 0.5f, xPos, yPos + 0.5f, 2, graphColor); - if (itemData.isBz()) - Utils.drawLine( - prevX, - secondLineData.get(prevX) + 0.5f, - xPos, - secondLineData.get(xPos) + 0.5f, - 2, - graphColor2 - ); - } - } - if (i == 1) - secondLineData.put(xPos, yPos); - if (mouseX >= guiLeft + 17 && mouseX <= guiLeft + 315 && mouseY >= guiTop + 35 && mouseY <= guiTop + 198) { - int dist = Math.abs(mouseX - xPos); - if (lowestDist == null || dist < lowestDist) { - lowestDist = dist; - lowestDistTime = time; - } - } - prevX = xPos; - prevY = yPos; - } - } - boolean showDays = lastTime - firstTime > 86400; - int prevNum = showDays ? Date.from(Instant.ofEpochSecond(firstTime)).getDate() : Date.from(Instant.ofEpochSecond( - firstTime)).getHours(); - long prevXPos = -100; - for (long time = firstTime; time <= lastTime; time += showDays ? 3600 : 60) { - int num = showDays - ? Date.from(Instant.ofEpochSecond(time)).getDate() - : Date.from(Instant.ofEpochSecond(time)).getHours(); - if (num != prevNum) { - int xPos = (int) map(time, firstTime, lastTime, guiLeft + 17, guiLeft + 315); - if (Math.abs(prevXPos - xPos) > 30) { - Utils.drawStringCentered(String.valueOf(num), xPos, guiTop + 206, false, 0x8b8b8b); - prevXPos = xPos; - } - prevNum = num; - } - } - for (int i = 0; i <= 6; i++) { - long price = (long) map(i, 0, 6, highestValue, lowestValue); - String formattedPrice = formatPrice(price); - Utils.drawStringF(formattedPrice, guiLeft + 320, - (float) map(i, 0, 6, guiTop + 35, guiTop + 198) - - Minecraft.getMinecraft().fontRendererObj.FONT_HEIGHT / 2f, false, 0x8b8b8b - ); - } - if (customSelecting) { - Utils.drawDottedLine(customStart, guiTop + 36, customStart, guiTop + 197, 2, 10, 0xFFc6c6c6); - Utils.drawDottedLine(customEnd, guiTop + 36, customEnd, guiTop + 197, 2, 10, 0xFFc6c6c6); - Utils.drawDottedLine(customStart, guiTop + 36, customEnd, guiTop + 36, 2, 10, 0xFFc6c6c6); - Utils.drawDottedLine(customStart, guiTop + 197, customEnd, guiTop + 197, 2, 10, 0xFFc6c6c6); - } - if (lowestDist != null && !customSelecting) { - double price = itemData.isBz() ? itemData.bz.get(lowestDistTime).b : itemData.ah.get(lowestDistTime); - Double price2 = itemData.isBz() ? itemData.bz.get(lowestDistTime).s : null; - int xPos = (int) map(lowestDistTime, firstTime, lastTime, guiLeft + 17, guiLeft + 315); - int yPos = (int) map(price, highestValue + 10d, lowestValue - 10d, guiTop + 35, guiTop + 198); - int yPos2 = price2 != null - ? (int) map(price2, highestValue + 10d, lowestValue - 10d, guiTop + 35, guiTop + 198) - : 0; - - Utils.drawLine(xPos, guiTop + 35, xPos, guiTop + 198, 2, 0x4D8b8b8b); - Minecraft.getMinecraft().getTextureManager().bindTexture(TEXTURE); - GlStateManager.color(1, 1, 1, 1); - Utils.drawTexturedRect(xPos - 2.5f, yPos - 2.5f, 5, 5, - 0, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST - ); - if (price2 != null) { - Utils.drawTexturedRect(xPos - 2.5f, yPos2 - 2.5f, 5, 5, - 0, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST - ); - } - - Date date = Date.from(Instant.ofEpochSecond(lowestDistTime)); - SimpleDateFormat displayFormat = new SimpleDateFormat("'§b'd MMMMM yyyy '§eat§b' HH:mm"); - NumberFormat nf = NumberFormat.getInstance(); - ArrayList<String> text = new ArrayList<>(); - text.add(displayFormat.format(date)); - if (itemData.isBz()) { - text.add(EnumChatFormatting.YELLOW + "" + EnumChatFormatting.BOLD + "Bazaar Insta-Buy: " + - EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + nf.format(price)); - text.add(EnumChatFormatting.YELLOW + "" + EnumChatFormatting.BOLD + "Bazaar Insta-Sell: " + - EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + nf.format(price2)); - } else { - text.add(EnumChatFormatting.YELLOW + "" + EnumChatFormatting.BOLD + "Lowest BIN: " + - EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + nf.format(price)); - } - drawHoveringText(text, xPos, yPos); - } - } - - if (mouseY >= guiTop + 17 && mouseY <= guiTop + 35 && mouseX >= guiLeft + 244 && mouseX <= guiLeft + 316) { - int index = (mouseX - guiLeft - 245) / 18; - switch (index) { - case 0: - Gui.drawRect(guiLeft + 245, guiTop + 17, guiLeft + 261, guiTop + 33, 0x80ffffff); - drawHoveringText(Collections.singletonList("Show 1 Hour"), mouseX, mouseY); - break; - case 1: - Gui.drawRect(guiLeft + 263, guiTop + 17, guiLeft + 279, guiTop + 33, 0x80ffffff); - drawHoveringText(Collections.singletonList("Show 1 Day"), mouseX, mouseY); - break; - case 2: - Gui.drawRect(guiLeft + 281, guiTop + 17, guiLeft + 297, guiTop + 33, 0x80ffffff); - drawHoveringText(Collections.singletonList("Show 1 Week"), mouseX, mouseY); - break; - case 3: - Gui.drawRect(guiLeft + 299, guiTop + 17, guiLeft + 315, guiTop + 33, 0x80ffffff); - drawHoveringText(Collections.singletonList("Show All"), mouseX, mouseY); - break; - } - } - } - - @Override - protected void mouseClicked(int mouseX, int mouseY, int mouseButton) throws IOException { - super.mouseClicked(mouseX, mouseY, mouseButton); - if (mouseY >= guiTop + 17 && mouseY <= guiTop + 35 && mouseX >= guiLeft + 244 && mouseX <= guiLeft + 316) { - mode = (mouseX - guiLeft - 245) / 18; - loadData(); - } else if (mouseY >= guiTop + 35 && mouseY <= guiTop + 198 && mouseX >= guiLeft + 17 && mouseX <= guiLeft + 315) { - customSelecting = true; - customStart = mouseX; - customEnd = mouseX; - } - } - - @Override - protected void mouseReleased(int mouseX, int mouseY, int state) { - super.mouseReleased(mouseX, mouseY, state); - if (customSelecting) { - customSelecting = false; - customStart = (int) map(customStart, guiLeft + 17, guiLeft + 315, firstTime, lastTime); - customEnd = (int) map(mouseX, guiLeft + 17, guiLeft + 315, firstTime, lastTime); - if (customStart > customEnd) { - long temp = customStart; - customStart = customEnd; - customEnd = temp; - } - if (customEnd - customStart != 0) { - mode = 4; - loadData(); - } - } - } - - @Override - protected void mouseClickMove(int mouseX, int mouseY, int clickedMouseButton, long timeSinceLastClick) { - super.mouseClickMove(mouseX, mouseY, clickedMouseButton, timeSinceLastClick); - if (customSelecting) { - customEnd = mouseX < guiLeft + 18 ? guiLeft + 18 : Math.min(mouseX, guiLeft + 314); - } - } - - private void loadData() { - itemData = null; - loaded = false; - new Thread(() -> { - File dir = new File("config/notenoughupdates/prices"); - if (!dir.exists()) { - loaded = true; - return; - } - File[] files = dir.listFiles(); - ItemData itemData = new ItemData(); - if (files == null) return; - for (File file : files) { - if (!file.getName().endsWith(".gz")) continue; - HashMap<String, ItemData> data2 = load(file); - if (data2 == null || !data2.containsKey(itemId)) continue; - if (data2.get(itemId).isBz()) { - if (itemData.bz == null) itemData.bz = data2.get(itemId).bz; - else itemData.bz.putAll(data2.get(itemId).bz); - } else if (itemData.ah == null) itemData.ah = data2.get(itemId).ah; - else itemData.ah.putAll(data2.get(itemId).ah); - } - if (itemData.get() != null && !itemData.get().isEmpty()) { - if (mode < 3) - itemData = new ItemData( - new TreeMap<>(itemData.get().entrySet().stream() - .filter(e -> e.getKey() > System.currentTimeMillis() / 1000 - - (mode == 0 ? 3600 : mode == 1 ? 86400 : 604800)) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))), - itemData.isBz() - ); - else if (mode == 4) - itemData = new ItemData( - new TreeMap<>(itemData.get().entrySet().stream() - .filter(e -> e.getKey() >= customStart && e.getKey() <= customEnd) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))), - itemData.isBz() - ); - if (itemData.get() == null || itemData.get().isEmpty()) { - loaded = true; - return; - } - this.itemData = trimData(itemData); - firstTime = this.itemData.get().firstKey(); - lastTime = this.itemData.get().lastKey(); - highestValue = this.itemData.isBz() ? this.itemData.bz.get(firstTime).b : this.itemData.ah.get(firstTime); - lowestValue = this.itemData.isBz() ? this.itemData.bz.get(firstTime).b : this.itemData.ah.get(firstTime); - for (long key : this.itemData.get().keySet()) { - double value1 = this.itemData.isBz() ? this.itemData.bz.get(key).b : this.itemData.ah.get(key); - Double value2 = this.itemData.isBz() ? this.itemData.bz.get(key).s : null; - if (value1 > highestValue) highestValue = value1; - if (value2 != null && value2 > highestValue) highestValue = value2; - if (value1 < lowestValue) lowestValue = value1; - if (value2 != null && value2 < lowestValue) lowestValue = value2; - } - } - loaded = true; - }).start(); - } - - public static void addToCache(JsonObject items, boolean bazaar) { - if (!NotEnoughUpdates.INSTANCE.config.ahGraph.graphEnabled) return; - try { - File dir = new File("config/notenoughupdates/prices"); - if (!dir.exists() && !dir.mkdir()) return; - File[] files = dir.listFiles(); - if (files != null) - for (File file : files) { - if (!file.getName().endsWith(".gz")) continue; - if (file.lastModified() < - System.currentTimeMillis() - NotEnoughUpdates.INSTANCE.config.ahGraph.dataRetention * 86400000L) - //noinspection ResultOfMethodCallIgnored - file.delete(); - } - Date date = new Date(); - Long epochSecond = date.toInstant().getEpochSecond(); - File file = new File(dir, "prices_" + format.format(date) + ".gz"); - HashMap<String, ItemData> prices = new HashMap<>(); - if (file.exists()) { - HashMap<String, ItemData> tempPrices = load(file); - if (tempPrices != null) prices = tempPrices; - } - for (Map.Entry<String, JsonElement> item : items.entrySet()) { - addOrUpdateItemPriceInfo(item, prices, epochSecond, bazaar); - } - //noinspection ResultOfMethodCallIgnored - file.createNewFile(); - try ( - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( - new GZIPOutputStream(new FileOutputStream(file)), - StandardCharsets.UTF_8 - )) - ) { - writer.write(GSON.toJson(prices)); - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - private static void addOrUpdateItemPriceInfo( - Map.Entry<String, JsonElement> item, - HashMap<String, ItemData> prices, - long timestamp, - boolean bazaar - ) { - String itemName = item.getKey(); - ItemData existingItemData = null; - if (prices.containsKey(itemName)) { - existingItemData = prices.get(itemName); - } - - // Handle transitions from ah to bz (the other direction typically doesn't happen) - if (existingItemData != null) { - if (existingItemData.isBz() && !bazaar) { - return; - } - - if (!existingItemData.isBz() && bazaar) { - prices.remove(itemName); - existingItemData = null; - } - } - - if (bazaar) { - if (!item.getValue().getAsJsonObject().has("curr_buy") || - !item.getValue().getAsJsonObject().has("curr_sell") - ) { - return; - } - - BzData bzData = new BzData( - item.getValue().getAsJsonObject().get("curr_buy").getAsFloat(), - item.getValue().getAsJsonObject().get("curr_sell").getAsFloat() - ); - - if (existingItemData != null) { - existingItemData.bz.put(timestamp, bzData); - } else { - TreeMap<Long, Object> mapData = new TreeMap<>(); - mapData.put(timestamp, bzData); - prices.put(item.getKey(), new ItemData(mapData, true)); - } - } else { - if (existingItemData != null) { - prices.get(item.getKey()).ah.put(timestamp, item.getValue().getAsBigDecimal().longValue()); - } else { - TreeMap<Long, Object> mapData = new TreeMap<>(); - mapData.put(timestamp, item.getValue().getAsLong()); - prices.put(item.getKey(), new ItemData(mapData, false)); - } - } - } - - private ItemData trimData(ItemData itemData) { - long first = itemData.get().firstKey(); - long last = itemData.get().lastKey(); - ItemData trimmed = new ItemData(); - if (itemData.isBz()) - trimmed.bz = new TreeMap<>(); - else - trimmed.ah = new TreeMap<>(); - int zones = NotEnoughUpdates.INSTANCE.config.ahGraph.graphZones; - Long[] dataArray = - !itemData.isBz() ? itemData.ah.keySet().toArray(new Long[0]) : itemData.bz.keySet().toArray(new Long[0]); - int prev = 0; - for (int i = 0; i < zones; i++) { - long lowest = (long) map(i, 0, zones, first, last); - long highest = (long) map(i + 1, 0, zones, first, last); - int amount = 0; - double sumBuy = 0; - double sumSell = 0; - for (int l = prev; l < dataArray.length; l++) { - if (dataArray[l] >= lowest && dataArray[l] <= highest) { - amount++; - sumBuy += itemData.isBz() ? itemData.bz.get(dataArray[l]).b : itemData.ah.get(dataArray[l]); - if (itemData.isBz()) sumSell += itemData.bz.get(dataArray[l]).s; - prev = l + 1; - } else if (dataArray[l] > highest) - break; - } - if (amount > 0) { - if (itemData.isBz()) - trimmed.bz.put(lowest, new BzData((float) (sumBuy / amount), (float) (sumSell / amount))); - else - trimmed.ah.put(lowest, (long) (sumBuy / amount)); - } - } - return trimmed; - } - - private static HashMap<String, ItemData> load(File file) { - Type type = new TypeToken<HashMap<String, ItemData>>() { - }.getType(); - if (file.exists()) { - try ( - BufferedReader reader = new BufferedReader(new InputStreamReader( - new GZIPInputStream(new FileInputStream(file)), - StandardCharsets.UTF_8 - )) - ) { - return GSON.fromJson(reader, type); - } catch (Exception e) { - System.out.println("Deleting " + file.getName() + " because it is probably corrupted."); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } - } - return null; - } - - private static double map(double x, double in_min, double in_max, double out_min, double out_max) { - return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; - } - - private static String formatPrice(long price) { - DecimalFormat df = new DecimalFormat("#.00"); - if (price >= 1000000000) { - return df.format(price / 1000000000f) + "B"; - } else if (price >= 1000000) { - return df.format(price / 1000000f) + "M"; - } else if (price >= 1000) { - return df.format(price / 1000f) + "K"; - } - return String.valueOf(price); - } - - private int changeAlpha(int origColor, int alpha) { - origColor = origColor & 0x00ffffff; //drop the previous alpha value - return (alpha << 24) | origColor; //add the one the user inputted - } -} - -class ItemData { - public TreeMap<Long, Long> ah = null; - public TreeMap<Long, BzData> bz = null; - - public ItemData() { - } - - public ItemData(TreeMap<Long, ?> map, boolean bz) { - if (bz) - this.bz = (TreeMap<Long, BzData>) map; - else - this.ah = (TreeMap<Long, Long>) map; - } - - public TreeMap<Long, ?> get() { - return !isBz() ? ah : bz; - } - - public boolean isBz() { - return bz != null && !bz.isEmpty(); - } -} - -class BzData { - double b; - double s; - - public BzData(double b, double s) { - this.b = b; - this.s = s; - } -} diff --git a/src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java b/src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java index 53906cd8..49053316 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/options/NEUConfig.java @@ -369,7 +369,7 @@ public class NEUConfig extends Config { @Expose @Category( - name = "AH/BZ Graph", + name = "Price Graph", desc = "Graph of auction and bazaar prices" ) public AHGraph ahGraph = new AHGraph(); diff --git a/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java b/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java index 58c58e3c..ff5bdf62 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/options/seperateSections/AHGraph.java @@ -25,6 +25,7 @@ import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditor import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditorDropdown; import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditorKeybind; import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditorSlider; +import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigEditorText; import io.github.moulberry.notenoughupdates.core.config.annotations.ConfigOption; import org.lwjgl.input.Keyboard; @@ -32,7 +33,7 @@ public class AHGraph { @Expose @ConfigOption( name = "Enable AH/BZ Price Graph", - desc = "Enable or disable the graph. Disabling this will also make it so that no price data is stored.", + desc = "Enable or disable the graph.", searchTags = {"auction", "bazaar"} ) @ConfigEditorBoolean @@ -58,6 +59,16 @@ public class AHGraph { @Expose @ConfigOption( + name = "Default Time", + desc = "Change the default time period for the graph." + ) + @ConfigEditorDropdown( + values = {"1 Hour", "1 Day", "1 Week", "All Time"} + ) + public int defaultMode = 1; + + @Expose + @ConfigOption( name = "Graph Colour", desc = "Set a custom colour for the graph.", searchTags = "color" @@ -76,13 +87,60 @@ public class AHGraph { @Expose @ConfigOption( - name = "Default Time", - desc = "Change the default time period for the graph." + name = "Moving Average", + desc = "Whether the graph should have a moving average line or not." + ) + @ConfigEditorBoolean + public boolean movingAverages = false; + // Disabled by default because it looks weird to people who don't know what it is + + @Expose + @ConfigOption( + name = "Moving Average Size (%)", + desc = "The percent of the time displayed that should be averaged." + ) + @ConfigEditorSlider( + minValue = 0.05f, + maxValue = 0.5f, + minStep = 0.05f + ) + public double movingAveragePercent = 0.2; + + @Expose + @ConfigOption( + name = "Moving Average Colour", + desc = "Set a custom colour for the graph's moving average line.", + searchTags = "color" + ) + @ConfigEditorColour + public String movingAverageColor = "0:255:0:255:171"; + + @Expose + @ConfigOption( + name = "Secondary Moving Average Colour", + desc = "Set a custom colour for the second graph's secondary moving average line line.", + searchTags = "color" + ) + @ConfigEditorColour + public String movingAverageColor2 = "0:255:255:109:0"; + + @Expose + @ConfigOption( + name = "Data Source", + desc = "Where NEU should get the data for the graph.\nPrices are only stored locally if this is set to 'Local'." ) @ConfigEditorDropdown( - values = {"1 Hour", "1 Day", "1 Week", "All Time"} + values = {"Server", "Local"} ) - public int defaultMode = 1; + public int dataSource = 0; + + @Expose + @ConfigOption( + name = "Price History API", + desc = "§4Do §lNOT §r§4change this, unless you know exactly what you are doing\n§fDefault: §apricehistory.notenoughupdates.org" + ) + @ConfigEditorText + public String serverUrl = "pricehistory.notenoughupdates.org"; @Expose @ConfigOption( diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt index a5ff48d2..93c4ab90 100644 --- a/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/commands/dev/DevTestCommand.kt @@ -29,8 +29,8 @@ import io.github.moulberry.notenoughupdates.events.RegisterBrigadierCommandEvent import io.github.moulberry.notenoughupdates.miscfeatures.FishingHelper import io.github.moulberry.notenoughupdates.miscfeatures.customblockzones.CustomBiomes import io.github.moulberry.notenoughupdates.miscfeatures.customblockzones.LocationChangeEvent -import io.github.moulberry.notenoughupdates.miscgui.GuiPriceGraph import io.github.moulberry.notenoughupdates.miscgui.minionhelper.MinionHelperManager +import io.github.moulberry.notenoughupdates.miscgui.pricegraph.GuiPriceGraph import io.github.moulberry.notenoughupdates.util.PronounDB import io.github.moulberry.notenoughupdates.util.SBInfo import io.github.moulberry.notenoughupdates.util.TabListUtils @@ -131,7 +131,8 @@ class DevTestCommand { thenLiteral("pricetest") { thenArgument("item", StringArgumentType.string()) { item -> thenExecute { - NotEnoughUpdates.INSTANCE.openGui = GuiPriceGraph(this[item]) + NotEnoughUpdates.INSTANCE.openGui = + GuiPriceGraph(this[item]) } }.withHelp("Display the price graph for an item by id") thenExecute { diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GraphDataProvider.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GraphDataProvider.kt new file mode 100644 index 00000000..575c34b9 --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GraphDataProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2023 NotEnoughUpdates contributors + * + * This file is part of NotEnoughUpdates. + * + * NotEnoughUpdates is free software: you can redistribute it + * and/or modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * NotEnoughUpdates is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>. + */ + +package io.github.moulberry.notenoughupdates.miscgui.pricegraph + +import java.time.Instant +import java.util.concurrent.CompletableFuture + +interface GraphDataProvider { + fun loadData(itemId: String,): CompletableFuture<Map<Instant, PriceObject>?> +} diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GuiPriceGraph.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GuiPriceGraph.kt new file mode 100644 index 00000000..0fe6d843 --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/GuiPriceGraph.kt @@ -0,0 +1,596 @@ +/* + * Copyright (C) 2023 NotEnoughUpdates contributors + * + * This file is part of NotEnoughUpdates. + * + * NotEnoughUpdates is free software: you can redistribute it + * and/or modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * NotEnoughUpdates is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>. + */ + +package io.github.moulberry.notenoughupdates.miscgui.pricegraph + +import io.github.moulberry.notenoughupdates.NotEnoughUpdates +import io.github.moulberry.notenoughupdates.util.SpecialColour +import io.github.moulberry.notenoughupdates.util.Utils +import io.github.moulberry.notenoughupdates.util.roundToDecimals +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.GuiScreen +import net.minecraft.client.renderer.GlStateManager +import net.minecraft.item.ItemStack +import net.minecraft.util.EnumChatFormatting +import net.minecraft.util.ResourceLocation +import org.lwjgl.opengl.GL11 +import java.text.DecimalFormat +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.time.Duration +import java.time.Instant +import java.util.* +import java.util.concurrent.CompletableFuture +import kotlin.math.abs + +private const val X_SIZE = 364 +private const val Y_SIZE = 215 +private val dateFormat = SimpleDateFormat("'§b'd MMMMM yyyy '§eat§b' HH:mm") +private val numberFormat = NumberFormat.getInstance() +private val config = NotEnoughUpdates.INSTANCE.config + +class GuiPriceGraph(itemId: String) : GuiScreen() { + private val TEXTURE: ResourceLocation = when (config.ahGraph.graphStyle) { + 1 -> ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_dark.png") + 2 -> ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_phqdark.png") + 3 -> ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui_fsr.png") + else -> ResourceLocation("notenoughupdates:price_graph_gui/price_information_gui.png") + } + private val dataProvider: GraphDataProvider = when (config.ahGraph.dataSource) { + 0 -> ServerGraphDataProvider + else -> LocalGraphDataProvider + } + private val rawData: CompletableFuture<Map<Instant, PriceObject>?> = dataProvider.loadData(itemId) + private var data: Map<Instant, PriceObject> = mapOf() + private var processedData = false + private var hasSellData = false + private var firstTime: Instant = Instant.now() + private var lastTime: Instant = Instant.now() + private var lowestPrice: Double = 0.0 + private var highestPrice: Double = 1.0 + private var guiLeft = 0 + private var guiTop = 0 + + /** + * 0 = hour + * 1 = day + * 2 = week + * 3 = all + * 4 = custom + */ + private var mode = config.ahGraph.defaultMode + + private var itemName: String? = null + private var itemStack: ItemStack? = null + + private var customSelecting = false + private var customSelectionStart = 0 + private var customSelectionEnd = 0 + + private val buyPoints = mutableMapOf<Double, Pair<Double, Double>>() + private val sellPoints = mutableMapOf<Double, Pair<Double, Double>>() + + private val buyMovingAverage = mutableMapOf<Double, Double>() + private val buyMovingAveragePoints = mutableMapOf<Double, Pair<Double, Double>>() + private val sellMovingAverage = mutableMapOf<Double, Double>() + private val sellMovingAveragePoints = mutableMapOf<Double, Pair<Double, Double>>() + + init { + if (NotEnoughUpdates.INSTANCE.manager.itemInformation.containsKey(itemId)) { + val itemInfo = NotEnoughUpdates.INSTANCE.manager.itemInformation[itemId] + itemName = itemInfo!!["displayname"].asString + itemStack = NotEnoughUpdates.INSTANCE.manager.jsonToStack(itemInfo) + } + } + + override fun drawScreen(mouseX: Int, mouseY: Int, partialTicks: Float) { + drawDefaultBackground() + + guiLeft = (width - X_SIZE) / 2 + guiTop = (height - Y_SIZE) / 2 + + if (customSelecting) customSelectionEnd = // Update custom selecting box + if (mouseX < guiLeft + 17) guiLeft + 17 else mouseX.coerceAtMost(guiLeft + 315) + + Minecraft.getMinecraft().textureManager.bindTexture(TEXTURE) + GlStateManager.color(1f, 1f, 1f, 1f) + Utils.drawTexturedRect( // Draw main background + guiLeft.toFloat(), guiTop.toFloat(), X_SIZE.toFloat(), Y_SIZE.toFloat(), + 0f, X_SIZE / 512f, 0f, Y_SIZE / 512f, GL11.GL_NEAREST + ) + for (i in 0..3) Utils.drawTexturedRect( // Draw buttons + (guiLeft + 245 + 18 * i).toFloat(), (guiTop + 17).toFloat(), 16f, 16f, + (0f + 16f * i) / 512f, (16 + 16f * i) / 512f, + (if (mode == i) 215 else 231) / 512f, + (if (mode == i) 231 else 247) / 512f, GL11.GL_NEAREST + ) + + if (itemName != null && itemStack != null) { // Draw item name and icon + Utils.drawItemStack(itemStack, guiLeft + 16, guiTop + 11) + Utils.drawStringScaledMax( + itemName, (guiLeft + 35).toFloat(), (guiTop + 13).toFloat(), false, + 0xffffff, 1.77f, 208 + ) + } + + if (!rawData.isDone) { + Utils.drawStringCentered( // Loading text + "Loading...", + (guiLeft + 166).toFloat(), (guiTop + 116).toFloat(), + false, -0x100 + ) + } else if (rawData.get() == null || rawData.get()!!.size < 2 || data.isEmpty() && processedData) { + Utils.drawStringCentered( // Error text + "No data found.", + (guiLeft + 166).toFloat(), + (guiTop + 116).toFloat(), + false, + -0x10000 + ) + } else if (data.isEmpty()) { + processData() // Process the data if needed, done here so no race conditions of any kind can occur + } else { + val buyColor = SpecialColour.specialToChromaRGB(config.ahGraph.graphColor) + val sellColor = SpecialColour.specialToChromaRGB(config.ahGraph.graphColor2) + val buyMovingAverageColor = SpecialColour.specialToChromaRGB(config.ahGraph.movingAverageColor) + val sellMovingAverageColor = SpecialColour.specialToChromaRGB(config.ahGraph.movingAverageColor2) + var prevX: Double? = null + var prevY: Double? = null + + if (hasSellData) { // Draw sell gradient + drawGradient(sellColor) + for (point in data) { + if (point.value.sellPrice == null) continue + val x = getX(point.key) + val y = getY(point.value.sellPrice!!) + + if (prevX != null && prevY != null) drawCoveringQuad(x, y, prevX, prevY) + + prevX = x + prevY = y + } + } + + prevX = null + prevY = null + var closestPoint = data.entries.first() + var closestDistance = Double.MAX_VALUE + + drawGradient(buyColor) // Draw buy gradient + for (point in data) { + val x = getX(point.key) + val y = getY(point.value.buyPrice) + + if (prevX != null && prevY != null) drawCoveringQuad(x, y, prevX, prevY) + + val distance = abs(mouseX - x) // Find the closest point to show tooltip + if (closestDistance > distance) { + closestPoint = point + closestDistance = distance + } + + prevX = x + prevY = y + } + prevX = null + + // Draw lines last to make sure nothing cuts into it + for (point in data) { + val x = getX(point.key) + if (prevX != null) { + if (config.ahGraph.movingAverages) drawLine( + x, prevX, + buyMovingAveragePoints[x], sellMovingAveragePoints[x], + buyMovingAverageColor, sellMovingAverageColor + ) + drawLine(x, prevX, buyPoints[x], sellPoints[x], buyColor, sellColor) + } + prevX = x + } + + // Draw axis + for (i in 0..6) { // Y-axis with price + val price = map(i.toDouble(), 0.0, 6.0, highestPrice, lowestPrice).toLong() + Utils.drawStringF( + formatPrice(price), guiLeft + 320f, + map(i.toDouble(), 0.0, 6.0, guiTop + 35.0, guiTop + 198.0).toFloat() + - Minecraft.getMinecraft().fontRendererObj.FONT_HEIGHT / 2f, false, 0x8b8b8b + ) + } + // X-axis with hour or date + val showDays = lastTime.epochSecond - firstTime.epochSecond > 86400 + val amountOfTime = (lastTime.epochSecond - firstTime.epochSecond) / (if (showDays) 86400.0 else 3600.0) + val pixelsPerTime = 298.0 / amountOfTime + var time = firstTime.plusSeconds( + if (showDays) (24 - Date.from(firstTime).hours) * 3600L + else (60 - Date.from(firstTime).minutes) * 60L + ) + var xPos = getX(time) + var lastX = -100.0 + while (xPos < guiLeft + 315) { + if (abs(xPos - lastX) > 30) { + Utils.drawStringCentered( + Date.from(time).let { if (showDays) it.date else it.hours }.toString(), + xPos.toFloat(), (guiTop + 206).toFloat(), + false, 0x8b8b8b + ) + lastX = xPos + } + time = time.plusSeconds(if (showDays) 86400L else 3600L) + xPos += pixelsPerTime + } + + if ( + mouseX >= guiLeft + 17 && mouseX <= guiLeft + 315 && + mouseY >= guiTop + 35 && mouseY <= guiTop + 198 && !customSelecting + ) { + // Draw tooltip with price info + val text = ArrayList<String>() + val x = getX(closestPoint.key) + val y = getY(closestPoint.value.buyPrice) + text.add(dateFormat.format(Date.from(closestPoint.key))) + if (closestPoint.value.sellPrice == null) { + text.add( + "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Lowest BIN: ${EnumChatFormatting.GOLD}" + + "${EnumChatFormatting.BOLD}${numberFormat.format(closestPoint.value.buyPrice)}" + ) + if (config.ahGraph.movingAverages && buyMovingAverage[x] != null) text.add( + "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Lowest BIN Moving Average: ${EnumChatFormatting.GOLD}" + + "${EnumChatFormatting.BOLD}${numberFormat.format(buyMovingAverage[x])}" + ) + } else { + text.add( + "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Bazaar Insta-Buy: ${EnumChatFormatting.GOLD}" + + "${EnumChatFormatting.BOLD}${numberFormat.format(closestPoint.value.buyPrice)}" + ) + text.add( + "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Bazaar Insta-Sell: ${EnumChatFormatting.GOLD}" + + "${EnumChatFormatting.BOLD}${numberFormat.format(closestPoint.value.sellPrice)}" + ) + if (config.ahGraph.movingAverages) { + if (buyMovingAverage[x] != null) text.add( + "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Bazaar Insta-Buy Moving Average: ${EnumChatFormatting.GOLD}${EnumChatFormatting.BOLD}" + + numberFormat.format(buyMovingAverage[x]) + ) + if (sellMovingAverage[x] != null) text.add( + "${EnumChatFormatting.YELLOW}${EnumChatFormatting.BOLD}Bazaar Insta-Sell Moving Average: ${EnumChatFormatting.GOLD}${EnumChatFormatting.BOLD}" + + numberFormat.format(sellMovingAverage[x]) + ) + } + } + Utils.drawLine( + x.toFloat(), (guiTop + 35).toFloat(), + x.toFloat(), (guiTop + 198).toFloat(), + 2, 0x4D8b8b8b + ) + Minecraft.getMinecraft().textureManager.bindTexture(TEXTURE) + GlStateManager.color(1f, 1f, 1f, 1f) + Utils.drawTexturedRect( + x.toFloat() - 2.5f, y.toFloat() - 2.5f, 5f, 5f, + 0f, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST + ) + if (closestPoint.value.sellPrice != null) Utils.drawTexturedRect( + x.toFloat() - 2.5f, getY(closestPoint.value.sellPrice!!).toFloat() - 2.5f, 5f, 5f, + 0f, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST + ) + if (config.ahGraph.movingAverages) { + val buyAverageY = buyMovingAveragePoints[x]?.second + if (buyAverageY != null) Utils.drawTexturedRect( + x.toFloat() - 2.5f, buyAverageY.toFloat() - 2.5f, 5f, 5f, + 0f, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST + ) + val sellAverageY = sellMovingAveragePoints[x]?.second + if (sellAverageY != null) Utils.drawTexturedRect( + x.toFloat() - 2.5f, sellAverageY.toFloat() - 2.5f, 5f, 5f, + 0f, 5 / 512f, 247 / 512f, 252 / 512f, GL11.GL_NEAREST + ) + } + + drawHoveringText(text, x.toInt(), y.toInt()) + } + + if (customSelecting) { // Draw selecting box + Utils.drawDottedLine( + customSelectionStart.toFloat(), guiTop + 36f, + customSelectionStart.toFloat(), guiTop + 197f, + 2, 10, -0x39393a + ) + Utils.drawDottedLine( + customSelectionEnd.toFloat(), guiTop + 36f, + customSelectionEnd.toFloat(), guiTop + 197f, + 2, 10, -0x39393a + ) + Utils.drawDottedLine( + customSelectionStart.toFloat(), guiTop + 36f, + customSelectionEnd.toFloat(), guiTop + 36f, + 2, 10, -0x39393a + ) + Utils.drawDottedLine( + customSelectionStart.toFloat(), guiTop + 197f, + customSelectionEnd.toFloat(), guiTop + 197f, + 2, 10, -0x39393a + ) + } + } + + // Draw item tooltips + if (mouseY >= guiTop + 17 && mouseY <= guiTop + 35 && mouseX >= guiLeft + 244 && mouseX <= guiLeft + 316) { + val index = (mouseX - guiLeft - 245) / 18 + drawRect( + guiLeft + 245 + 18 * index, + guiTop + 17, guiLeft + 261 + 18 * index, + guiTop + 33, -0x7f000001 + ) + drawHoveringText( + listOf( + when (index) { + 0 -> "Show 1 Hour" + 1 -> "Show 1 Day" + 2 -> "Show 1 Week" + else -> if (dataProvider is LocalGraphDataProvider) "Show All" else "Show 1 Month" + } + ), mouseX, mouseY + ) + } + } + + private fun getX(time: Instant) = map( + time.epochSecond.toDouble(), + firstTime.epochSecond.toDouble(), + lastTime.epochSecond.toDouble(), + guiLeft + 17.0, + guiLeft + 315.0 + ) + + private fun getY(price: Double) = map( + price, + highestPrice + 1, + lowestPrice - 1, + guiTop + 45.0, + guiTop + 188.0 + ) + + override fun mouseClicked(mouseX: Int, mouseY: Int, mouseButton: Int) { + super.mouseClicked(mouseX, mouseY, mouseButton) + if (mouseY >= guiTop + 17 && mouseY <= guiTop + 35 && mouseX >= guiLeft + 244 && mouseX <= guiLeft + 316) { + selectMode((mouseX - guiLeft - 245) / 18) + Utils.playPressSound() + } else if (mouseY >= guiTop + 35 && mouseY <= guiTop + 198 && mouseX >= guiLeft + 17 && mouseX <= guiLeft + 315) { + customSelecting = true + customSelectionStart = mouseX + customSelectionEnd = mouseX + } + } + + override fun mouseReleased(mouseX: Int, mouseY: Int, state: Int) { + super.mouseReleased(mouseX, mouseY, state) + if (customSelecting) { + customSelecting = false + customSelectionEnd = + if (mouseX < guiLeft + 17) guiLeft + 17 else mouseX.coerceAtMost(guiLeft + 315) + if (customSelectionStart > customSelectionEnd) { + val temp = customSelectionStart + customSelectionStart = customSelectionEnd + customSelectionEnd = temp + } + if (customSelectionStart - customSelectionEnd == 0) return + selectMode(4) + } + } + + private fun processData() { + processedData = true + // Filter based on time + val now = Instant.now() + val startTime = when (mode) { + 0 -> now.minus(Duration.ofHours(1)) + 1 -> now.minus(Duration.ofDays(1)) + 2 -> now.minus(Duration.ofDays(7)) + // The server only deletes old data every hour, so you could get 30 days and 1 hour, this is just for consistency + 3 -> if (dataProvider is ServerGraphDataProvider) now.minus(Duration.ofDays(30)) else null + 4 -> Instant.ofEpochSecond( + map( + customSelectionStart.toDouble(), + guiLeft + 17.0, + guiLeft + 315.0, + firstTime.epochSecond.toDouble(), + lastTime.epochSecond.toDouble() + ).toLong() + ) + + else -> error("$mode is not a valid mode!") + } + val endTime = when (mode) { + 4 -> Instant.ofEpochSecond( + map( + customSelectionEnd.toDouble(), + guiLeft + 17.0, + guiLeft + 315.0, + firstTime.epochSecond.toDouble(), + lastTime.epochSecond.toDouble() + ).toLong() + ) + + else -> null + } + val cutData = rawData.get()!!.filter { + (startTime == null || it.key >= startTime) && (endTime == null || it.key <= endTime) + } + if (cutData.isEmpty()) return + + // Smooth data + val zones = config.ahGraph.graphZones + val first = cutData.minOf { it.key } + val last = cutData.maxOf { it.key } + val trimmedData = mutableMapOf<Instant, PriceObject>() + for (i in 0..zones) { + val zoneStart = Instant.ofEpochSecond( + map( + i.toDouble(), 0.0, zones.toDouble(), + first.epochSecond.toDouble(), last.epochSecond.toDouble() + ).toLong() + ) + val zoneEnd = Instant.ofEpochSecond( + map( + i + 1.0, 0.0, zones.toDouble(), + first.epochSecond.toDouble(), last.epochSecond.toDouble() + ).toLong() + ) + val dataInZone = cutData.filter { it.key >= zoneStart && it.key < zoneEnd } + if (dataInZone.isEmpty()) { + continue + } else { + val averageTime = Instant.ofEpochSecond(dataInZone.keys.sumOf { it.epochSecond } / dataInZone.size) + val averageBuyPrice = (dataInZone.values.sumOf { it.buyPrice } / dataInZone.size).roundToDecimals(1) + val sellPoints = dataInZone.values.filter { it.sellPrice != null } + val averageSellPrice = if (sellPoints.isEmpty()) null + else (sellPoints.sumOf { it.sellPrice ?: 0.0 } / sellPoints.size).roundToDecimals(1) + trimmedData[averageTime] = PriceObject(averageBuyPrice, averageSellPrice) + } + } + data = trimmedData + if (data.isEmpty()) return + + // Populate variables required for graphs + firstTime = data.minOf { it.key } + lastTime = data.maxOf { it.key } + lowestPrice = data.minOf { + if (it.value.sellPrice != null) it.value.buyPrice.coerceAtMost(it.value.sellPrice!!) + else it.value.buyPrice + } + highestPrice = data.maxOf { + if (it.value.sellPrice != null) it.value.buyPrice.coerceAtLeast(it.value.sellPrice!!) + else it.value.buyPrice + } + + // Populate line variables + buyPoints.clear() + sellPoints.clear() + buyMovingAveragePoints.clear() + sellMovingAveragePoints.clear() + val movingAveragePeriod = (lastTime.epochSecond - firstTime.epochSecond) * config.ahGraph.movingAveragePercent + var prevBuyY: Double? = null + var prevSellY: Double? = null + var prevBuyAverageY: Double? = null + var prevSellAverageY: Double? = null + for (point in data) { + val x = getX(point.key) + val buyY = getY(point.value.buyPrice) + if (prevBuyY != null) buyPoints[x] = prevBuyY to buyY + prevBuyY = buyY + if (point.value.sellPrice != null) { + val sellY = getY(point.value.sellPrice!!) + if (prevSellY != null) sellPoints[x] = prevSellY to sellY + prevSellY = sellY + } + // Moving average stuff + if (!config.ahGraph.movingAverages) continue + val dataInPeriod = rawData.get()?.filterKeys { + it >= point.key.minusSeconds(movingAveragePeriod.toLong()) && it <= point.key + } + if (dataInPeriod.isNullOrEmpty()) continue + val buyAverage = dataInPeriod.values.sumOf { it.buyPrice } / dataInPeriod.size + buyMovingAverage[x] = buyAverage.roundToDecimals(1) + val buyAverageY = getY((buyAverage).coerceAtLeast(lowestPrice).coerceAtMost(highestPrice)) + if (prevBuyAverageY != null) buyMovingAveragePoints[x] = prevBuyAverageY to buyAverageY + prevBuyAverageY = buyAverageY + val sellData = dataInPeriod.filterValues { it.sellPrice != null } + if (sellData.isEmpty()) continue + val sellAverage = sellData.values.sumOf { it.sellPrice!! } / sellData.size + sellMovingAverage[x] = sellAverage.roundToDecimals(1) + val sellAverageY = getY((sellAverage).coerceAtLeast(lowestPrice).coerceAtMost(highestPrice)) + if (prevSellAverageY != null) sellMovingAveragePoints[x] = prevSellAverageY to sellAverageY + prevSellAverageY = sellAverageY + } + hasSellData = sellPoints.isNotEmpty() + } + + private fun selectMode(mode: Int) { + this.mode = mode + data = mapOf() + processedData = false + } + + private fun drawGradient(color: Int) { + Utils.drawGradientRect( + 0, + guiLeft + 17, + guiTop + 35, + guiLeft + 315, + guiTop + 198, + changeAlpha(color, 120), + changeAlpha(color, 10) + ) + } + + private fun drawCoveringQuad(x: Double, y: Double, prevX: Double, prevY: Double) { + Minecraft.getMinecraft().textureManager.bindTexture(TEXTURE) + GlStateManager.color(1f, 1f, 1f, 1f) + Utils.drawTexturedQuad( + prevX.toFloat(), prevY.toFloat(), + x.toFloat(), y.toFloat(), + x.toFloat(), guiTop + 35f, + prevX.toFloat(), guiTop + 35f, + 18 / 512f, 19 / 512f, + 36 / 512f, 37 / 512f, + GL11.GL_NEAREST + ) + } + + private fun drawLine( + x: Double, + prevX: Double, + buyLine: Pair<Double, Double>?, + sellLine: Pair<Double, Double>?, + buyColor: Int, + sellColor: Int + ) { + if (buyLine != null) Utils.drawLine( + prevX.toFloat(), buyLine.first.toFloat() + 0.5f, + x.toFloat(), buyLine.second.toFloat() + 0.5f, + 2, buyColor + ) + if (sellLine != null) Utils.drawLine( + prevX.toFloat(), sellLine.first.toFloat() + 0.5f, + x.toFloat(), sellLine.second.toFloat() + 0.5f, + 2, sellColor + ) + } + + private fun formatPrice(price: Long): String { + val df = DecimalFormat("#.00") + if (price >= 1000000000) { + return df.format((price / 1000000000f).toDouble()) + "B" + } else if (price >= 1000000) { + return df.format((price / 1000000f).toDouble()) + "M" + } else if (price >= 1000) { + return df.format((price / 1000f).toDouble()) + "K" + } + return price.toString() + } + + private fun changeAlpha(origColor: Int, alpha: Int): Int { + val color = origColor and 0x00ffffff //drop the previous alpha value + return alpha shl 24 or color //add the one the user inputted + } + + private fun map(x: Double, inMin: Double, inMax: Double, outMin: Double, outMax: Double): Double { + return (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin + } +} diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/LocalGraphDataProvider.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/LocalGraphDataProvider.kt new file mode 100644 index 00000000..be93a8d6 --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/LocalGraphDataProvider.kt @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2023 NotEnoughUpdates contributors + * + * This file is part of NotEnoughUpdates. + * + * NotEnoughUpdates is free software: you can redistribute it + * and/or modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * NotEnoughUpdates is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>. + */ + +package io.github.moulberry.notenoughupdates.miscgui.pricegraph + +import com.google.common.reflect.TypeToken +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.github.moulberry.notenoughupdates.NotEnoughUpdates +import java.io.* +import java.nio.charset.StandardCharsets +import java.text.SimpleDateFormat +import java.time.Instant +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +object LocalGraphDataProvider : GraphDataProvider { + private val priceDir = File("config/notenoughupdates/prices") + private val GSON = GsonBuilder().create() + private val format = SimpleDateFormat("dd-MM-yyyy") + private val config = NotEnoughUpdates.INSTANCE.config + private val fileLocked = AtomicBoolean(false) + + override fun loadData(itemId: String): CompletableFuture<Map<Instant, PriceObject>?> { + return CompletableFuture.supplyAsync { + if (!priceDir.exists() || !priceDir.isDirectory) return@supplyAsync null + if (fileLocked.get()) while (fileLocked.get()) { // Wait for file to become unlocked + Thread.sleep(100) + } + val response = mutableMapOf<Instant, PriceObject>() + val futures = mutableListOf<CompletableFuture<Map<Instant, PriceObject>?>>() + for (file in priceDir.listFiles { file -> file.extension == "gz" }!!) { + futures.add(CompletableFuture.supplyAsync { + val data = load(file)?.get(itemId) ?: return@supplyAsync null + (if (data.isBz()) data.bz?.map { + Instant.ofEpochSecond(it.key) to PriceObject(it.value.b, it.value.s) + } else data.ah?.map { + Instant.ofEpochSecond(it.key) to PriceObject(it.value.toDouble(), null) + })?.toMap() + }) + } + + for (future in futures) { + val result = future.get() + if (result != null) response.putAll(result) + } + + return@supplyAsync response + } + } + + private fun load(file: File): MutableMap<String, ItemData>? { + val type = object : TypeToken<MutableMap<String, ItemData>?>() {}.type + if (file.exists()) { + try { + BufferedReader( + InputStreamReader( + GZIPInputStream(FileInputStream(file)), + StandardCharsets.UTF_8 + ) + ).use { reader -> + return GSON.fromJson( + reader, + type + ) + } + } catch (e: Exception) { + println("Deleting " + file.name + " because it is probably corrupted.") + file.delete() + } + } + return null + } + + fun savePrices(items: JsonObject, bazaar: Boolean) { + try { + if (!priceDir.exists() && !priceDir.mkdir()) return + val files = priceDir.listFiles() + val dataRetentionTime = + System.currentTimeMillis() - config.ahGraph.dataRetention * 86400000L + files?.filter { it.extension == "gz" && it.lastModified() < dataRetentionTime }?.forEach { it.delete() } + + if (!config.ahGraph.graphEnabled || config.ahGraph.dataSource != 1) return + if (fileLocked.get()) while (fileLocked.get()) { // Wait for file to become unlocked + Thread.sleep(100) + } + fileLocked.set(true) + + val date = Date() + val epochSecond = date.toInstant().epochSecond + val file = File(priceDir, "prices_" + format.format(date) + ".gz") + var prices = load(file) ?: mutableMapOf() + if (file.exists()) { + val tempPrices = load(file) + if (tempPrices != null) prices = tempPrices + } + for (item in items.entrySet()) { + addOrUpdateItemPriceInfo(item, prices, epochSecond, bazaar) + } + file.createNewFile() + BufferedWriter( + OutputStreamWriter( + GZIPOutputStream(FileOutputStream(file)), + StandardCharsets.UTF_8 + ) + ).use { writer -> writer.write(GSON.toJson(prices)) } + fileLocked.set(false) + } catch (e: java.lang.Exception) { + e.printStackTrace() + fileLocked.set(false) + } + } + + private fun addOrUpdateItemPriceInfo( + item: Map.Entry<String, JsonElement>, + prices: MutableMap<String, ItemData>, + timestamp: Long, + bazaar: Boolean + ) { + val itemName = item.key + var existingItemData: ItemData? = null + if (prices.containsKey(itemName)) { + existingItemData = prices[itemName] + } + + // Handle transitions from ah to bz (the other direction typically doesn't happen) + if (existingItemData != null) { + if (existingItemData.isBz() && !bazaar) { + return + } + if (!existingItemData.isBz() && bazaar) { + prices.remove(itemName) + existingItemData = null + } + } + if (bazaar) { + if (!item.value.asJsonObject.has("curr_buy") || + !item.value.asJsonObject.has("curr_sell") + ) { + return + } + val bzData = BzData( + item.value.asJsonObject["curr_buy"].asDouble, + item.value.asJsonObject["curr_sell"].asDouble + ) + if (existingItemData != null) { + existingItemData.bz?.set(timestamp, bzData) + } else { + val mapData = mutableMapOf(timestamp to bzData) + prices[item.key] = ItemData(bz = mapData) + } + } else { + if (existingItemData != null) { + prices[item.key]!!.ah?.set(timestamp, item.value.asBigDecimal.toLong()) + } else { + val mapData = mutableMapOf(timestamp to item.value.asLong) + prices[item.key] = ItemData(ah = mapData) + } + } + } +} + + +private data class ItemData(val ah: MutableMap<Long, Long>? = null, val bz: MutableMap<Long, BzData>? = null) { + + fun get(): MutableMap<Long, *>? = if (!isBz()) ah else bz + + fun isBz() = !bz.isNullOrEmpty() +} + +private class BzData(val b: Double, val s: Double) + diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/PriceObject.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/PriceObject.kt new file mode 100644 index 00000000..41f1d02f --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/PriceObject.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2023 NotEnoughUpdates contributors + * + * This file is part of NotEnoughUpdates. + * + * NotEnoughUpdates is free software: you can redistribute it + * and/or modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * NotEnoughUpdates is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>. + */ + +package io.github.moulberry.notenoughupdates.miscgui.pricegraph + +import com.google.gson.annotations.SerializedName + +data class PriceObject(@SerializedName("b") val buyPrice: Double, @SerializedName("s") val sellPrice: Double? = null) diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/ServerGraphDataProvider.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/ServerGraphDataProvider.kt new file mode 100644 index 00000000..65c76bca --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/miscgui/pricegraph/ServerGraphDataProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 NotEnoughUpdates contributors + * + * This file is part of NotEnoughUpdates. + * + * NotEnoughUpdates is free software: you can redistribute it + * and/or modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * NotEnoughUpdates is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>. + */ + +package io.github.moulberry.notenoughupdates.miscgui.pricegraph + +import com.google.gson.GsonBuilder +import io.github.moulberry.notenoughupdates.NotEnoughUpdates +import java.time.Instant +import java.util.concurrent.CompletableFuture + +object ServerGraphDataProvider : GraphDataProvider { + private val gson = GsonBuilder().create() + + override fun loadData(itemId: String): CompletableFuture<Map<Instant, PriceObject>?> { + return CompletableFuture.supplyAsync { + val request = NotEnoughUpdates.INSTANCE.manager.apiUtils.request() + .url("https://${NotEnoughUpdates.INSTANCE.config.ahGraph.serverUrl}") + .queryArgument("item", itemId).requestJson().get()?.asJsonObject ?: return@supplyAsync null + + val response = mutableMapOf<Instant, PriceObject>() + for (element in request.entrySet()) response[Instant.parse(element.key)] = + gson.fromJson(element.value, PriceObject::class.java) + return@supplyAsync response + } + } +} diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/KotlinNumberUtils.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/KotlinNumberUtils.kt new file mode 100644 index 00000000..f20da4e4 --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/KotlinNumberUtils.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2023 NotEnoughUpdates contributors + * + * This file is part of NotEnoughUpdates. + * + * NotEnoughUpdates is free software: you can redistribute it + * and/or modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version. + * + * NotEnoughUpdates is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with NotEnoughUpdates. If not, see <https://www.gnu.org/licenses/>. + */ + +package io.github.moulberry.notenoughupdates.util + +import kotlin.math.pow +import kotlin.math.round + +fun Double.roundToDecimals(decimals: Int): Double { + val multiplier = 10.0.pow(decimals) + return round(this * multiplier) / multiplier +} |