Add price checker

This commit is contained in:
nea
2023-05-29 23:28:52 +02:00
parent c133505e3b
commit dd0afac3a8
13 changed files with 285 additions and 29 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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 <T : ArgumentBuilder<DefaultSource, T>, AT : Any> T.thenArgument(
block: RequiredArgumentBuilder<DefaultSource, AT>.(TypeSafeArg<AT>) -> Unit
): T = then(argument(name, argument, block))
fun <T : RequiredArgumentBuilder<DefaultSource, String>> T.suggestsList(provider: () -> Iterable<String>) {
suggests(SuggestionProvider<DefaultSource> { context, builder ->
provider()
.asSequence()
.filter { it.startsWith(builder.remaining, ignoreCase = true) }
.forEach {
builder.suggest(it)
}
builder.buildFuture()
})
}
fun <T : ArgumentBuilder<DefaultSource, T>> T.thenLiteral(
name: String,

View File

@@ -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 {

View File

@@ -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<HandledScreenKeyPressedEvent>()
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)
}
}

View File

@@ -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<TooltipEvent>()
}

View File

@@ -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
}
}
}

View File

@@ -106,7 +106,7 @@ object SBItemEntryDefinition : EntryDefinition<SBItemStack> {
getEntry(SBItemStack(skyblockId, RepoManager.getNEUItem(skyblockId), count))
fun getEntry(ingredient: NEUIngredient): EntryStack<SBItemStack> =
getEntry(SkyblockId(ingredient.itemId), count = ingredient.amount)
getEntry(SkyblockId(ingredient.itemId), count = ingredient.amount.toInt())
}

View File

@@ -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<SkyblockId, Double> = mapOf()
private set
var bazaarData: Map<SkyblockId, BazaarData> = 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<SkyblockId.BazaarStock, BazaarData> = 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<Map<SkyblockId, Double>>()
}
private suspend fun fetchBazaarPrices() {
val response = Firmament.httpClient.get("$hypixelApiBaseUrl/skyblock/bazaar").body<BazaarResponse>()
if (!response.success) {
logger.warn("Retrieved unsuccessful bazaar data")
}
bazaarData = response.products.mapKeys { it.key.toRepoId() }
}
}

View File

@@ -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")
}
}

View File

@@ -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<KeyInputContinuation>()
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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
{
"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",