From dd0afac3a8459035827dac36986a690c8a9541ea Mon Sep 17 00:00:00 2001 From: nea Date: Mon, 29 May 2023 23:28:52 +0200 Subject: Add price checker --- build.gradle.kts | 2 + gradle/libs.versions.toml | 2 +- src/main/kotlin/moe/nea/firmament/Firmament.kt | 39 +++++++--- src/main/kotlin/moe/nea/firmament/commands/dsl.kt | 12 +++ src/main/kotlin/moe/nea/firmament/commands/rome.kt | 26 +++++++ .../events/HandledScreenKeyPressedEvent.kt | 8 +- .../moe/nea/firmament/events/TooltipEvent.kt | 15 ++++ .../moe/nea/firmament/keybindings/IKeyBinding.kt | 27 +++++++ .../moe/nea/firmament/rei/SBItemEntryDefinition.kt | 2 +- .../kotlin/moe/nea/firmament/repo/ItemCostData.kt | 86 ++++++++++++++++++++++ .../kotlin/moe/nea/firmament/util/SkyblockId.kt | 25 +++++++ .../kotlin/moe/nea/firmament/util/async/input.kt | 45 +++++++++++ .../resources/assets/firmament/lang/en_us.json | 25 ++----- 13 files changed, 285 insertions(+), 29 deletions(-) create mode 100644 src/main/kotlin/moe/nea/firmament/events/TooltipEvent.kt create mode 100644 src/main/kotlin/moe/nea/firmament/keybindings/IKeyBinding.kt create mode 100644 src/main/kotlin/moe/nea/firmament/repo/ItemCostData.kt create mode 100644 src/main/kotlin/moe/nea/firmament/util/async/input.kt diff --git a/build.gradle.kts b/build.gradle.kts index 0c92701..67cba5b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,6 +100,8 @@ dependencies { transInclude(nonModImplentation(ktor("client-java"))!!) transInclude(nonModImplentation(ktor("serialization-kotlinx-json"))!!) transInclude(nonModImplentation(ktor("client-content-negotiation"))!!) + transInclude(nonModImplentation(ktor("client-encoding"))!!) + transInclude(nonModImplentation(ktor("client-logging"))!!) // Dev environment preinstalled mods modRuntimeOnly(libs.bundles.runtime.required) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cda6e74..cb8975e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ modmenu = "6.2.1" ktor = "2.3.0" dbus_java = "4.2.1" architectury = "8.1.79" -neurepoparser = "0.0.1" +neurepoparser = "1.1.0" qolify = "1.2.2-1.19.4" citresewn = "1.1.3+1.19.4" ncr = "Fabric-1.19.4-v2.1.1" diff --git a/src/main/kotlin/moe/nea/firmament/Firmament.kt b/src/main/kotlin/moe/nea/firmament/Firmament.kt index 9786361..b3e2115 100644 --- a/src/main/kotlin/moe/nea/firmament/Firmament.kt +++ b/src/main/kotlin/moe/nea/firmament/Firmament.kt @@ -19,11 +19,14 @@ package moe.nea.firmament import com.mojang.brigadier.CommandDispatcher -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.serialization.kotlinx.json.* -import java.awt.Taskbar.Feature +import io.ktor.client.HttpClient +import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.cache.HttpCache +import io.ktor.client.plugins.compression.ContentEncoding +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.serialization.kotlinx.json.json import java.nio.file.Files import java.nio.file.Path import net.fabricmc.api.ClientModInitializer @@ -35,14 +38,21 @@ import net.fabricmc.loader.api.FabricLoader import net.fabricmc.loader.api.Version import net.fabricmc.loader.api.metadata.ModMetadata import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.Logger import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlin.coroutines.EmptyCoroutineContext import net.minecraft.command.CommandRegistryAccess import moe.nea.firmament.commands.registerFirmamentCommand import moe.nea.firmament.dbus.FirmamentDbusObject import moe.nea.firmament.features.FeatureManager +import moe.nea.firmament.repo.ItemCostData import moe.nea.firmament.repo.RepoManager import moe.nea.firmament.util.SBData import moe.nea.firmament.util.data.IDataHolder @@ -53,8 +63,10 @@ object Firmament : ModInitializer, ClientModInitializer { val DEBUG = System.getProperty("firmament.debug") == "true" val DATA_DIR: Path = Path.of(".firmament").also { Files.createDirectories(it) } val CONFIG_DIR: Path = Path.of("config/firmament").also { Files.createDirectories(it) } - val logger = LogManager.getLogger("Firmament") - val metadata: ModMetadata by lazy { FabricLoader.getInstance().getModContainer(MOD_ID).orElseThrow().metadata } + val logger: Logger = LogManager.getLogger("Firmament") + private val metadata: ModMetadata by lazy { + FabricLoader.getInstance().getModContainer(MOD_ID).orElseThrow().metadata + } val version: Version by lazy { metadata.version } val json = Json { @@ -68,9 +80,18 @@ object Firmament : ModInitializer, ClientModInitializer { install(ContentNegotiation) { json(json) } + install(ContentEncoding) { + gzip() + deflate() + } install(UserAgent) { agent = "Firmament/$version" } + if (DEBUG) + install(Logging) { + level = LogLevel.INFO + } + install(HttpCache) } } @@ -98,7 +119,7 @@ object Firmament : ModInitializer, ClientModInitializer { RepoManager.initialize() SBData.init() FeatureManager.autoload() - + ItemCostData.spawnPriceLoop() ClientCommandRegistrationCallback.EVENT.register(this::registerCommands) ClientLifecycleEvents.CLIENT_STOPPING.register(ClientLifecycleEvents.ClientStopping { runBlocking { diff --git a/src/main/kotlin/moe/nea/firmament/commands/dsl.kt b/src/main/kotlin/moe/nea/firmament/commands/dsl.kt index e2b9e8a..155e700 100644 --- a/src/main/kotlin/moe/nea/firmament/commands/dsl.kt +++ b/src/main/kotlin/moe/nea/firmament/commands/dsl.kt @@ -23,6 +23,7 @@ import com.mojang.brigadier.builder.ArgumentBuilder import com.mojang.brigadier.builder.LiteralArgumentBuilder import com.mojang.brigadier.builder.RequiredArgumentBuilder import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.suggestion.SuggestionProvider import java.lang.reflect.ParameterizedType import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource import moe.nea.firmament.util.iterate @@ -80,6 +81,17 @@ fun , AT : Any> T.thenArgument( block: RequiredArgumentBuilder.(TypeSafeArg) -> Unit ): T = then(argument(name, argument, block)) +fun > T.suggestsList(provider: () -> Iterable) { + suggests(SuggestionProvider { context, builder -> + provider() + .asSequence() + .filter { it.startsWith(builder.remaining, ignoreCase = true) } + .forEach { + builder.suggest(it) + } + builder.buildFuture() + }) +} fun > T.thenLiteral( name: String, diff --git a/src/main/kotlin/moe/nea/firmament/commands/rome.kt b/src/main/kotlin/moe/nea/firmament/commands/rome.kt index 55dce75..02b12d9 100644 --- a/src/main/kotlin/moe/nea/firmament/commands/rome.kt +++ b/src/main/kotlin/moe/nea/firmament/commands/rome.kt @@ -19,12 +19,16 @@ package moe.nea.firmament.commands import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.arguments.StringArgumentType.getString +import com.mojang.brigadier.arguments.StringArgumentType.string import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource import net.minecraft.text.Text import moe.nea.firmament.features.world.FairySouls import moe.nea.firmament.gui.config.AllConfigsGui +import moe.nea.firmament.repo.ItemCostData import moe.nea.firmament.repo.RepoManager import moe.nea.firmament.util.SBData +import moe.nea.firmament.util.SkyblockId fun firmamentCommand() = literal("firmament") { @@ -47,6 +51,28 @@ fun firmamentCommand() = literal("firmament") { } } } + thenLiteral("price") { + thenArgument("item", string()) { item -> + suggestsList { RepoManager.neuRepo.items.items.keys } + thenExecute { + val itemName = SkyblockId(getString(context, "item")) + source.sendFeedback(Text.translatable("firmament.price", itemName.neuItem)) + val bazaarData = ItemCostData.bazaarData[itemName] + if (bazaarData != null) { + source.sendFeedback(Text.translatable("firmament.price.bazaar")) + source.sendFeedback(Text.translatable("firmament.price.bazaar.productid", bazaarData.productId.bazaarId)) + source.sendFeedback(Text.translatable("firmament.price.bazaar.buy.price", bazaarData.quickStatus.buyPrice)) + source.sendFeedback(Text.translatable("firmament.price.bazaar.buy.order", bazaarData.quickStatus.buyOrders)) + source.sendFeedback(Text.translatable("firmament.price.bazaar.sell.price", bazaarData.quickStatus.sellPrice)) + source.sendFeedback(Text.translatable("firmament.price.bazaar.sell.order", bazaarData.quickStatus.sellOrders)) + } + val lowestBin = ItemCostData.lowestBin[itemName] + if (lowestBin != null) { + source.sendFeedback(Text.translatable("firmament.price.lowestbin", lowestBin)) + } + } + } + } thenLiteral("dev") { thenLiteral("config") { thenExecute { diff --git a/src/main/kotlin/moe/nea/firmament/events/HandledScreenKeyPressedEvent.kt b/src/main/kotlin/moe/nea/firmament/events/HandledScreenKeyPressedEvent.kt index 8d19f7e..3c19aa7 100644 --- a/src/main/kotlin/moe/nea/firmament/events/HandledScreenKeyPressedEvent.kt +++ b/src/main/kotlin/moe/nea/firmament/events/HandledScreenKeyPressedEvent.kt @@ -19,10 +19,16 @@ package moe.nea.firmament.events import net.minecraft.client.option.KeyBinding +import moe.nea.firmament.keybindings.IKeyBinding data class HandledScreenKeyPressedEvent(val keyCode: Int, val scanCode: Int, val modifiers: Int) : FirmamentEvent.Cancellable() { companion object : FirmamentEventBus() + fun matches(keyBinding: KeyBinding): Boolean { - return keyBinding.matchesKey(keyCode, scanCode) + return matches(IKeyBinding.minecraft(keyBinding)) + } + + fun matches(keyBinding: IKeyBinding): Boolean { + return keyBinding.matches(keyCode, scanCode, modifiers) } } diff --git a/src/main/kotlin/moe/nea/firmament/events/TooltipEvent.kt b/src/main/kotlin/moe/nea/firmament/events/TooltipEvent.kt new file mode 100644 index 0000000..3341534 --- /dev/null +++ b/src/main/kotlin/moe/nea/firmament/events/TooltipEvent.kt @@ -0,0 +1,15 @@ +package moe.nea.firmament.events + +import net.minecraft.client.gui.tooltip.Tooltip +import net.minecraft.client.item.TooltipContext +import net.minecraft.entity.player.PlayerEntity +import net.minecraft.item.ItemStack + +data class TooltipEvent( + val itemStack: ItemStack, + val tooltip: Tooltip, + val tooltipContext: TooltipContext, + val player: PlayerEntity? +) : FirmamentEvent() { + companion object : FirmamentEventBus() +} diff --git a/src/main/kotlin/moe/nea/firmament/keybindings/IKeyBinding.kt b/src/main/kotlin/moe/nea/firmament/keybindings/IKeyBinding.kt new file mode 100644 index 0000000..7cb2720 --- /dev/null +++ b/src/main/kotlin/moe/nea/firmament/keybindings/IKeyBinding.kt @@ -0,0 +1,27 @@ +package moe.nea.firmament.keybindings + +import net.minecraft.client.option.KeyBinding + +interface IKeyBinding { + fun matches(keyCode: Int, scanCode: Int, modifiers: Int): Boolean + + fun withModifiers(wantedModifiers: Int): IKeyBinding { + val old = this + return object : IKeyBinding { + override fun matches(keyCode: Int, scanCode: Int, modifiers: Int): Boolean { + return old.matches(keyCode, scanCode, modifiers) && (modifiers and wantedModifiers) == wantedModifiers + } + } + } + + companion object { + fun minecraft(keyBinding: KeyBinding) = object : IKeyBinding { + override fun matches(keyCode: Int, scanCode: Int, modifiers: Int) = + keyBinding.matchesKey(keyCode, scanCode) + } + + fun ofKeyCode(wantedKeyCode: Int) = object : IKeyBinding { + override fun matches(keyCode: Int, scanCode: Int, modifiers: Int): Boolean = keyCode == wantedKeyCode + } + } +} diff --git a/src/main/kotlin/moe/nea/firmament/rei/SBItemEntryDefinition.kt b/src/main/kotlin/moe/nea/firmament/rei/SBItemEntryDefinition.kt index 4420a24..a6d683d 100644 --- a/src/main/kotlin/moe/nea/firmament/rei/SBItemEntryDefinition.kt +++ b/src/main/kotlin/moe/nea/firmament/rei/SBItemEntryDefinition.kt @@ -106,7 +106,7 @@ object SBItemEntryDefinition : EntryDefinition { getEntry(SBItemStack(skyblockId, RepoManager.getNEUItem(skyblockId), count)) fun getEntry(ingredient: NEUIngredient): EntryStack = - getEntry(SkyblockId(ingredient.itemId), count = ingredient.amount) + getEntry(SkyblockId(ingredient.itemId), count = ingredient.amount.toInt()) } diff --git a/src/main/kotlin/moe/nea/firmament/repo/ItemCostData.kt b/src/main/kotlin/moe/nea/firmament/repo/ItemCostData.kt new file mode 100644 index 0000000..a1084b9 --- /dev/null +++ b/src/main/kotlin/moe/nea/firmament/repo/ItemCostData.kt @@ -0,0 +1,86 @@ +package moe.nea.firmament.repo + +import io.ktor.client.call.body +import io.ktor.client.request.get +import org.apache.logging.log4j.LogManager +import org.lwjgl.glfw.GLFW +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.minutes +import moe.nea.firmament.Firmament +import moe.nea.firmament.keybindings.IKeyBinding +import moe.nea.firmament.util.SkyblockId +import moe.nea.firmament.util.async.waitForInput + +object ItemCostData { + private val logger = LogManager.getLogger("Firmament.ItemCostData") + private val moulberryBaseUrl = "https://moulberry.codes" + private val hypixelApiBaseUrl = "https://api.hypixel.net" + var lowestBin: Map = mapOf() + private set + var bazaarData: Map = mapOf() + private set + + @Serializable + data class BazaarData( + @SerialName("product_id") + val productId: SkyblockId.BazaarStock, + @SerialName("quick_status") + val quickStatus: BazaarStatus, + ) + + @Serializable + data class BazaarStatus( + val sellPrice: Double, + val sellVolume: Long, + val sellMovingWeek: Long, + val sellOrders: Long, + val buyPrice: Double, + val buyVolume: Long, + val buyMovingWeek: Long, + val buyOrders: Long + ) + + @Serializable + private data class BazaarResponse( + val success: Boolean, + val products: Map = mapOf(), + ) + + fun getPriceOfItem(item: SkyblockId): Double? = bazaarData[item]?.quickStatus?.buyPrice ?: lowestBin[item] + + fun spawnPriceLoop() { + Firmament.coroutineScope.launch { + while (true) { + logger.info("Updating NEU prices") + updatePrices() + withTimeoutOrNull(10.minutes) { waitForInput(IKeyBinding.ofKeyCode(GLFW.GLFW_KEY_U)) } + } + } + } + + private suspend fun updatePrices() { + awaitAll( + Firmament.coroutineScope.async { fetchBazaarPrices() }, + Firmament.coroutineScope.async { fetchPricesFromMoulberry() }, + ) + } + + private suspend fun fetchPricesFromMoulberry() { + lowestBin = Firmament.httpClient.get("$moulberryBaseUrl/lowestbin.json") + .body>() + } + + private suspend fun fetchBazaarPrices() { + val response = Firmament.httpClient.get("$hypixelApiBaseUrl/skyblock/bazaar").body() + if (!response.success) { + logger.warn("Retrieved unsuccessful bazaar data") + } + bazaarData = response.products.mapKeys { it.key.toRepoId() } + } + +} diff --git a/src/main/kotlin/moe/nea/firmament/util/SkyblockId.kt b/src/main/kotlin/moe/nea/firmament/util/SkyblockId.kt index 6033577..f0998a7 100644 --- a/src/main/kotlin/moe/nea/firmament/util/SkyblockId.kt +++ b/src/main/kotlin/moe/nea/firmament/util/SkyblockId.kt @@ -27,11 +27,36 @@ import net.minecraft.item.ItemStack import net.minecraft.nbt.NbtCompound import net.minecraft.util.Identifier +/** + * A skyblock item id, as used by the NEU repo. + * This is not exactly the format used by HyPixel, but is mostly the same. + * Usually this id splits an id used by HyPixel into more sub items. For example `PET` becomes `$PET_ID;$PET_RARITY`, + * with those values extracted from other metadata. + */ @JvmInline +@Serializable value class SkyblockId(val neuItem: String) { val identifier get() = Identifier("skyblockitem", neuItem.lowercase().replace(";", "__")) + /** + * A bazaar stock item id, as returned by the HyPixel bazaar api endpoint. + * These are not equivalent to the in-game ids, or the NEU repo ids, and in fact, do not refer to items, but instead + * to bazaar stocks. The main difference from [SkyblockId]s is concerning enchanted books. There are probably more, + * but for now this holds. + */ + @JvmInline + @Serializable + value class BazaarStock(val bazaarId: String) { + fun toRepoId(): SkyblockId { + bazaarEnchantmentRegex.matchEntire(bazaarId)?.let { + return SkyblockId("${it.groupValues[1]};${it.groupValues[2]}") + } + return SkyblockId(bazaarId.replace(":", "-")) + } + } + companion object { + private val bazaarEnchantmentRegex = "ENCHANTMENT_(\\D*)_(\\d+)".toRegex() val NULL: SkyblockId = SkyblockId("null") } } diff --git a/src/main/kotlin/moe/nea/firmament/util/async/input.kt b/src/main/kotlin/moe/nea/firmament/util/async/input.kt new file mode 100644 index 0000000..ac1cfb4 --- /dev/null +++ b/src/main/kotlin/moe/nea/firmament/util/async/input.kt @@ -0,0 +1,45 @@ +package moe.nea.firmament.util.async + +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import moe.nea.firmament.events.HandledScreenKeyPressedEvent +import moe.nea.firmament.keybindings.IKeyBinding + +private object InputHandler { + data class KeyInputContinuation(val keybind: IKeyBinding, val onContinue: () -> Unit) + + private val activeContinuations = mutableListOf() + + fun registerContinuation(keyInputContinuation: KeyInputContinuation): () -> Unit { + synchronized(InputHandler) { + activeContinuations.add(keyInputContinuation) + } + return { + synchronized(this) { + activeContinuations.remove(keyInputContinuation) + } + } + } + + init { + HandledScreenKeyPressedEvent.subscribe { event -> + synchronized(InputHandler) { + val toRemove = activeContinuations.filter { + event.matches(it.keybind) + } + toRemove.forEach { it.onContinue() } + activeContinuations.removeAll(toRemove) + } + } + } +} + +suspend fun waitForInput(keybind: IKeyBinding): Unit = suspendCancellableCoroutine { cont -> + val unregister = + InputHandler.registerContinuation(InputHandler.KeyInputContinuation(keybind) { cont.resume(Unit) }) + cont.invokeOnCancellation { + unregister() + } +} + + diff --git a/src/main/resources/assets/firmament/lang/en_us.json b/src/main/resources/assets/firmament/lang/en_us.json index e372c7f..440d923 100644 --- a/src/main/resources/assets/firmament/lang/en_us.json +++ b/src/main/resources/assets/firmament/lang/en_us.json @@ -1,21 +1,12 @@ -/** - * Firmament is a Hypixel Skyblock mod for modern Minecraft versions - * Copyright (C) 2023 Linnea Gräf - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ { + "firmament.price": "Checking price for %s", + "firmament.price.bazaar": "Bazaar stats:", + "firmament.price.bazaar.productid": "Stock id: %s", + "firmament.price.bazaar.buy.price": "Buy Price: %.1f", + "firmament.price.bazaar.buy.order": "Buy orders: %d", + "firmament.price.bazaar.sell.price": "Sell Price: %.1f", + "firmament.price.bazaar.sell.order": "Sell orders: %d", + "firmament.price.lowestbin": "Lowest BIN: %.1f", "firmament.repo.reload.network": "Trying to redownload the repository", "firmament.repo.reload.disk": "Reloading repository from disk. This may lag a bit.", "firmament.repo.cache": "Recaching items", -- cgit